diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 21:06:32 -0700 |
| commit | 648a5503089d33232855925a24ff60f78cab6df4 (patch) | |
| tree | 1280073d797653d3ed86f0bb7b6c26b5d2271804 | |
| parent | 8257a98e8d1891d0795f6b8b0623678245cc8e33 (diff) | |
| download | coinosv3-648a5503089d33232855925a24ff60f78cab6df4.tar.gz coinosv3-648a5503089d33232855925a24ff60f78cab6df4.zip | |
Custody is a choice, and coinos holding the coins is the default: nothing renews on its own and no renewal fee is paid unless the user opts in
Why: renewal fees were "money passively draining" to people who thought they had a custodial wallet (Stacker News, 2026-10-22). Measured over Sep–Oct: 84% of what users paid came from the client's own consolidation round at 20 coins (0.2% + 300 on every fresh coin), the rest from 300-sat rounds that each cohort of Lightning-received coins (30-day pool lifetime) triggered on its own schedule. The ASP itself honors a coin past its timelock — rounds and offboards never checked expiry, and captaind's allow_expired_arkoor (now on at ark.coinos.io) lets arkoor spends through too — so expiry only ever meant "the exit is gone", which most people coming from v2 never had.
Settings → Spending gets a Custody card with three tiers:
- coinos holds my coins (default): no renewals, no fees; a coin past its timelock shows as "held by coinos", stays spendable and receivable, and is left out of a unilateral exit (there is nothing on-chain for it to take).
- Keep my own exit: renew in the last hours (free bracket + base), the server backstop registered 36 blocks out and snapped to a 48-block grid so every wallet's orders share one round.
- Renew a week early: 1008 blocks out on a 144-block grid (0.1% + base).
renewPolicy() in ark/manager.js is the one table; the app, the service worker (the mirror carries the tier) and the Manage page all read it. Only shown on servers that honor expired coins (presets carry honorsExpired); elsewhere renewal stays automatic.
Manager: honorsExpired makes a past-timelock coin ordinary money (balance, selection, receive validation via acceptExpired) while a server refusal (expiryRejected) still excludes it; such coins spend LAST so an older build on the receiving side almost never meets one. A once-per-wallet rescan re-reads the mailbox and adopts coins older builds refused as expired, un-forgets expired dust, and clears stale refusals. The refresh gate that retired a renewal of expired inputs is gone (the server accepts them; the Manage page's Renew of exactly those coins was failing silently). Automatic consolidation is gone; the send cap goes 24 → 48 coins; small change (<1,000 sats) folds into a renewal that happens anyway.
Server (cs:~/ark/captaind-main.toml, restarted): allow_expired_arkoor = true; pool vtxo_lifetime 4320 → 8640 so Lightning-received coins live as long as the rest.
Tests: tools/ark/custody-test.js (policies, honoring, ordering, grid snap, validator), tools/ark/custody-ui-test.js (the card, held coins, exit count, switching), nwc-sw-test updated for the tier the worker reads.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -rw-r--r-- | src/api.js | 11 | ||||
| -rw-r--r-- | src/ark/manager.js | 189 | ||||
| -rw-r--r-- | src/ark/validate.js | 8 | ||||
| -rw-r--r-- | src/features/ark.js | 218 | ||||
| -rw-r--r-- | src/i18n.js | 16 | ||||
| -rw-r--r-- | src/nwc-bg.js | 5 | ||||
| -rw-r--r-- | src/nwc-respond.js | 12 | ||||
| -rw-r--r-- | tools/ark/custody-test.js | 98 | ||||
| -rw-r--r-- | tools/ark/custody-ui-test.js | 122 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 19 |
10 files changed, 576 insertions, 122 deletions
@@ -232,7 +232,7 @@ export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deri const ARK_PRESETS_BY_NET = { mainnet: [ { id: 'off', label: 'Off', ark: '', esplora: '' }, - { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api' }, + { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api', honorsExpired: true }, { id: 'second', label: 'Second (ark.second.tech)', ark: 'https://ark.second.tech', esplora: 'https://mempool.second.tech/api' }, { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, ], @@ -249,12 +249,12 @@ const ARK_PRESETS_BY_NET = { ], mutinynet: [ { id: 'off', label: 'Off', ark: '', esplora: '' }, - { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora' }, + { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora', honorsExpired: true }, { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, ], regtest: [ { id: 'off', label: 'Off', ark: '', esplora: '' }, - { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002' }, + { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002', honorsExpired: true }, { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, ], }; @@ -294,7 +294,10 @@ export function getArkConfig(net = getNetwork()) { const id = getArkProviderId(net); if (id === 'off') return null; const p = arkPresets(net).find((x) => x.id === id); - const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora }; + // honorsExpired: the coinos servers keep cosigning spends of a coin past + // its timelock (captaind allow_expired_arkoor) — the basis of the default + // "coinos holds my coins" custody tier. Other servers refuse such a coin. + const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora, honorsExpired: !!p.honorsExpired }; return cfg.ark && cfg.esplora ? cfg : null; } diff --git a/src/ark/manager.js b/src/ark/manager.js index d5ae2b0..2b7a5a7 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -177,9 +177,35 @@ export function pruneArkState(state, now = Date.now()) { // Shared by the app (features/ark.js) and the worker tending a closed wallet. export const backstopMargin = (network) => (network === 'regtest' ? 6 : 36); +// Custody tiers — what a coin's timelock means to this wallet: +// custodial nothing renews. The coinos server keeps honoring a coin past +// its timelock (it has swept the tree; the coin is its IOU), so +// the balance works like a v2 one, and no renewal is ever paid. +// self renew in the last hours before expiry — the free ppm bracket, +// one base fee per round — so a unilateral exit is always there. +// early renew a week ahead (the 0.1% bracket + base) for whoever wants +// a margin against the server being unreachable near the end. +// A policy is { window, margin, grid } in blocks: the app renews a coin with +// under `window` blocks left; the server-side backstop is registered `margin` +// blocks before expiry, snapped DOWN to a multiple of `grid` so everyone's +// orders land in the same round (one round's mining fee shared, not one per +// coin). The snapped height stays inside the same fee bracket: self runs +// 36–83 blocks out (free under 96), early 1008–1151 (0.1% under 2160). +export const CUSTODY_TIERS = ['custodial', 'self', 'early']; +export const MAX_SEND_INPUTS = 48; +export function renewPolicy(tier, network) { + const rt = network === 'regtest'; + if (tier === 'early') return { window: rt ? 60 : 1008, margin: rt ? 60 : 1008, grid: rt ? 1 : 144 }; + if (tier === 'self') return { window: rt ? 24 : 96, margin: backstopMargin(network), grid: rt ? 1 : 48 }; + return null; +} + export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint }) { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false }) { this.account = account; // HDKey node; ark keys derived beneath it + // the server cosigns spends of a coin past its timelock (coinos): such a + // coin stays money here — spendable, receivable — just not exitable + this.honorsExpired = !!honorsExpired; this.storage = storage; // { load(): obj|null, save(obj): void } this.arkUrl = arkUrl; this.esploraUrl = esploraUrl; @@ -417,7 +443,9 @@ export class ArkManager { // the exception — they have no unilateral exit to protect and the coinos ASP // keeps honoring them off-chain (mirrors validateVtxo's carve-out). _expired(v, tip) { - return !!v.expiryRejected || (!!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip); + if (v.expiryRejected) return true; // the server said no to this one + if (this.honorsExpired) return false; + return !!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip; } // The dust exemption is not a promise that every old coin will be @@ -508,6 +536,10 @@ export class ArkManager { this._lnRescanned = true; try { if (await this.rescanLnReceives()) await this._save(); } catch (e) { this._lnRescanned = false; } } + if (!this._expiredRescanned) { + this._expiredRescanned = true; + try { if (await this.rescanRejectedExpired()) this._save(); } catch (e) { this._expiredRescanned = false; } + } const baselining = !!this.state.baselinePending; // keep the cached tip fresh: balance() and input selection judge coin // expiry against it synchronously, and a stale tip hides dead coins @@ -570,6 +602,59 @@ export class ArkManager { } } + // On a server that honors coins past their timelock, the ones this wallet + // wrote off before it knew that are still its money: + // - receives an older build refused at pickup ("vtxo expired at …"), + // recorded as rejected and left behind the mailbox cursor — the + // forwards stranded that way before 2026-08-24 among them; + // - coins the server once refused to spend (expiryRejected, from before + // allow_expired_arkoor), and expired dust the user gave up (Forget). + // Once per wallet: re-read the mailbox from the start and adopt what the + // server still reports spendable; clear the old verdicts the same way. The + // cursor is left where it is. Returns whether anything changed. + async rescanRejectedExpired() { + if (!this.honorsExpired || this.state.expiredRescanDone) return false; + let changed = false; + const alive = async (v) => { + try { + return (await getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey)) === VTXO_STATE_SPENDABLE; + } catch { return false; } + }; + for (const v of this.state.vtxos) { + if (!(v.expiryRejected || (v.forgotten && v.state === 'spent'))) continue; + if (!(await alive(v))) continue; + delete v.expiryRejected; delete v.forgotten; v.state = 'spendable'; + changed = true; + } + const rejected = (this.state.movements || []).filter((m) => m.type === 'receive' && m.status === 'rejected' && /expired at/i.test(m.detail || '')); + if (rejected.length) { + const mailbox = this._mailboxKey(); + let checkpoint = 0; + for (let page = 0; page < 50; page++) { + const { messages, haveMore } = await readMailbox(this.arkUrl, mailbox, checkpoint); + for (const m of messages) { + checkpoint = Math.max(checkpoint, m.checkpoint || 0); + if (m.kind !== 'arkoor') continue; + const fresh = []; + for (const v of m.vtxos) { + if (this._vtxo(v.id)) continue; + // a coin another device of this wallet adopted and spent since + // is not money here; only what the server still holds for us is + let status = 0; + try { status = await getVtxoStatus(this.arkUrl, v.point.raw, this._key(0).privkey); } catch { status = 0; } + if (status === VTXO_STATE_SPENDABLE) fresh.push(v); + } + if (fresh.length && await this._handleMailboxMessage({ ...m, vtxos: fresh })) changed = true; + } + if (!haveMore || !messages.length) break; + } + this.state.movements = this.state.movements.filter((m) => !rejected.includes(m)); + changed = true; + } + this.state.expiredRescanDone = true; + return true; + } + async _handleMailboxMessage(m) { let changed = false; if (m.kind === 'lnSendFinished') { @@ -640,7 +725,7 @@ export class ArkManager { continue; } try { - await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys }); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys, acceptExpired: this.honorsExpired }); } catch (e) { if (e instanceof VtxoValidationError) { this._movement({ type: 'receive', amountSat: v.amountSat, status: 'rejected', detail: e.message }); @@ -962,22 +1047,45 @@ export class ArkManager { // that covers it, else largest-first until the sum does. A balance scattered // by many small receives stays spendable — the package cosign spends all // the inputs atomically and the recipient just receives several vtxos. + // A coin past its timelock spends LAST. The server honors it, and so does + // this build at pickup — but a receiver on an older build still refuses + // one as expired and strands it. Reaching for such a coin only when + // nothing else covers the amount narrows that to almost no payment. + _pastExpiry(v, tip) { return !!tip && !!v.expiryHeight && v.expiryHeight <= tip; } + _spendOrder(list, tip, cmp) { + const live = list.filter((v) => !this._pastExpiry(v, tip)).sort(cmp); + const past = list.filter((v) => this._pastExpiry(v, tip)).sort(cmp); + return [...live, ...past]; + } + // largest-first until `need` is covered; the coins still inside their + // timelock alone if they can carry it, else everything + _accumulate(spendable, tip, need) { + const desc = (a, b) => b.amountSat - a.amountSat; + const live = spendable.filter((v) => !this._pastExpiry(v, tip)).sort(desc); + for (const pool of [live, [...spendable].sort(desc)]) { + const picked = []; + let sum = 0; + for (const v of pool) { + picked.push(v); sum += v.amountSat; + if (need(picked, sum)) return picked; + } + } + return null; + } + _selectInputs(amountSat, tip = this._tipH || 0) { const busy = this._remoteBusy(); const spendable = this.state.vtxos .filter((v) => this._free(v, busy) && !this._expired(v, tip)); - const single = spendable - .filter((v) => v.amountSat >= amountSat) - .sort((a, b) => a.amountSat - b.amountSat)[0]; + const single = this._spendOrder(spendable.filter((v) => v.amountSat >= amountSat), tip, (a, b) => a.amountSat - b.amountSat)[0]; if (single) return [single]; - const picked = []; - let sum = 0; - for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) { - picked.push(v); sum += v.amountSat; - if (sum >= amountSat) break; - } - if (sum < amountSat) throw new Error(this._insufficientMsg(tip)); - if (picked.length > 24) throw new Error('balance is spread over too many coins for one send'); + const picked = this._accumulate(spendable, tip, (set, sum) => sum >= amountSat); + if (!picked) throw new Error(this._insufficientMsg(tip)); + // The cap is this wallet's, not the server's (which caps offboard and + // board inputs only). It used to be 24, with an automatic consolidation + // round at 20 coins to stay under it — a round that cost 0.2% + base on + // every coin and was 84% of what users paid in fees. Wider, no round. + if (picked.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one send'); return picked; } @@ -1326,20 +1434,18 @@ export class ArkManager { const spendable = this.state.vtxos .filter((v) => this._free(v, busy) && !this._expired(v, tip)); let inputs = null, feeSat = 0; - for (const v of [...spendable].sort((a, b) => a.amountSat - b.amountSat)) { + for (const v of this._spendOrder(spendable, tip, (a, b) => a.amountSat - b.amountSat)) { const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } } if (!inputs) { - const picked = []; - let sum = 0; - for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) { - picked.push(v); sum += v.amountSat; - feeSat = lnSendFee(amountSat, this.info.lnSendFees, picked, tip) + routing; - if (sum >= amountSat + feeSat) { inputs = picked; break; } - } + const picked = this._accumulate(spendable, tip, (set, sum) => { + feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; + return sum >= amountSat + feeSat; + }); + if (picked) inputs = picked; if (!inputs) throw new Error(this._insufficientMsg(tip)); - if (inputs.length > 24) throw new Error('balance is spread over too many coins for one payment'); + if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); } // per-input HTLC share: first n-1 consumed whole, the last carries change let remainingLock = amountSat + feeSat; @@ -1771,7 +1877,7 @@ export class ArkManager { if (v.policy.userPubkey !== hex.encode(keys.pubkey)) throw new Error('HTLC vtxo pubkey mismatch'); if (v.policy.htlcExpiry < htlcRecvExpiry) throw new Error('HTLC vtxo expiry lower than requested'); try { - await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] }); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'], acceptExpired: this.honorsExpired }); } catch (e) { // a granted vtxo can anchor to a round the chain hasn't confirmed // yet — park (no preimage revealed, nothing at risk) and retry on @@ -1987,7 +2093,7 @@ export class ArkManager { if (v.policy.type !== 'pubkey' || v.policy.userPubkey !== expect) { throw new Error('refund vtxo is not paid to our refund key'); } - await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect] }); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect], acceptExpired: this.honorsExpired }); if (this._addVtxo(v, bytes, refundIndex)) total += v.amountSat; } if (total) { @@ -2250,19 +2356,20 @@ export class ArkManager { if (action.step === 'created') { // A set the round would refuse anyway is retired here, not resubmitted - // every sync: an input past its expiry (only the server's backstop can - // renew it now), or an output the fee leaves under the 330-sat dust - // floor (a 21-sat coin with a 150-sat fee sat "in progress" for days). - // Nothing was spent, so the inputs go back to spendable. - const tip = this._tipH || (await this.chain.tipHeight().catch(() => 0)); + // every sync: an output the fee leaves under the 330-sat dust floor (a + // 21-sat coin with a 150-sat fee sat "in progress" for days), or an + // input no longer held. Nothing was spent, so the inputs go back to + // spendable. An input PAST ITS EXPIRY is not a reason: the server's + // round-input check has no expiry clause (a coin 3,127 blocks past its + // height was renewed on mainnet, 2026-10-14) — renewal is the one + // thing an expired coin can always do, and this gate used to retire + // the Manage page's own "Renew" of exactly those coins, silently. const inSat = inputRecs.reduce((n, v) => n + (v ? v.amountSat : 0), 0); - const expired = inputRecs.some((v) => v && v.expiryHeight && tip && v.expiryHeight <= tip); - if (expired || inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) { + if (inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) { for (const v of inputRecs) if (v && v.state === 'pending') v.state = 'spendable'; action.step = 'failed'; action.superseded = true; // no row: the renewal never began - action.lastError = expired ? 'an input has expired: only the server can renew it now' - : inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum'; + action.lastError = inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum'; this._save(); return; } @@ -2437,24 +2544,32 @@ export class ArkManager { // expiry. Coins arrive, get spent, get renewed — each new one needs its own // backstop while it still has the lifetime to register one. Caller owns the // policy (how much margin, how often to ask); this owns the bookkeeping. - async ensureScheduledRefreshes(marginBlocks, maxCoins = 8) { + // grid: the order's height is snapped DOWN to a multiple of it, so orders + // from every wallet fall into the same round instead of one round (one + // on-chain fee) per coin. The snap only ever moves EARLIER, by under one + // grid step — the caller picks a margin+grid that stays in one fee bracket. + async ensureScheduledRefreshes(marginBlocks, maxCoins = 8, grid = 1) { const tip = await this._tipMemo(); if (!tip) return 0; const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); + const runAt = (v) => { + const h = v.expiryHeight - marginBlocks; + return grid > 1 ? h - (h % grid) : h; + }; // A round output over the server's cap is refused when the round runs — // long after registration, with nobody there to hear it. Don't register // an order that is going to die that quietly. const cap = this.info.maxVtxoAmountSat || Infinity; const coins = this.state.vtxos .filter((v) => this._free(v) && v.expiryHeight && !covered.has(v.id) - && !this._expired(v, tip) && v.expiryHeight - marginBlocks > tip && v.amountSat <= cap) + && !this._expired(v, tip) && v.expiryHeight > tip && runAt(v) > tip && v.amountSat <= cap) .sort((a, b) => b.amountSat - a.amountSat) .slice(0, maxCoins); let n = 0; for (const v of coins) { // One participation per coin. A shared one dies whole the moment any // single input is spent, taking the untouched coins' cover with it. - try { await this.scheduleRefresh([v.id], v.expiryHeight - marginBlocks); n++; } + try { await this.scheduleRefresh([v.id], runAt(v)); n++; } catch (e) { // priced from a stale schedule: re-read it so the next pass registers if (/insufficient refresh fee/i.test(e.message || '')) this.info = await getArkInfo(this.arkUrl).catch(() => this.info); diff --git a/src/ark/validate.js b/src/ark/validate.js index fae3465..9cb1751 100644 --- a/src/ark/validate.js +++ b/src/ark/validate.js @@ -24,7 +24,11 @@ const fail = (msg) => { throw new VtxoValidationError(msg); }; // plus getTxStatus(txid) -> {confirmed, block_height} (esplora semantics) // allowPolicies: acceptable policy types (default plain pubkey only; lightning // flows pass ['serverHtlcSend'] / ['serverHtlcRecv'] for their HTLC vtxos) -export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'] }) { +// acceptExpired: the server keeps honoring coins past their timelock (coinos: +// captaind allow_expired_arkoor) — the coin is then an IOU the server has +// already swept the tree of, which is exactly what the holder chose when they +// left custody to coinos; refuse only on servers that would refuse to spend it +export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'], acceptExpired = false }) { const serverHex = hex.encode(serverPubkey); // -- structural -- @@ -41,7 +45,7 @@ export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, a // and the coinos ASP keeps honoring them off-chain after sweeping their // round — an explicit IOU where the trustless path never existed anyway. const tip = await chain.tipHeight(); - if (vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) { + if (!acceptExpired && vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) { fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`); } diff --git a/src/features/ark.js b/src/features/ark.js index 3ef6cea..7a77d2f 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -8,7 +8,7 @@ import * as btc from '@scure/btc-signer'; import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; -import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, backstopMargin } from '../ark/manager.js'; +import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js'; export { remoteRenewalsInFlight }; // Coins this device may offer: spendable, and not held by a renewal another @@ -18,6 +18,12 @@ function freeCoins(st) { const busy = new Set(remoteRenewalsInFlight(st).flatMap((r) => r.inputIds || [])); return (st.vtxos || []).filter((v) => v.state === 'spendable' && !busy.has(v.id)); } +// The coins a unilateral exit can still take on-chain. Past its timelock a +// coin's tree is the server's to sweep (and it has): only the server can +// honor it now, and it does — but no exit transaction will ever confirm. +function exitableCoins(st, tip) { + return freeCoins(st).filter((v) => !(tip && v.expiryHeight && v.expiryHeight <= tip)); +} import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee, lnShortfall } from '../ark/lightning.js'; @@ -541,7 +547,7 @@ export function arkFeature(ctx) { adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, p.ark), save: (s) => { wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(p.ark)), p.ark); }, }, - arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(), + arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(), honorsExpired: !!p.honorsExpired, }); await mgr.init(); const done = () => (mgr.state.movements || []).filter((m) => m.type === 'ln-receive' && m.status === 'complete'); @@ -623,6 +629,7 @@ export function arkFeature(ctx) { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + honorsExpired: !!cfg.honorsExpired, // the wallet's own tx table as a second witness for confirmations — // its data source is user-configurable and often healthier than the // ark preset's esplora from this device @@ -931,8 +938,9 @@ export function arkFeature(ctx) { if (offer === undefined) offer = (prev && prev.offer) || null; if (prefs === undefined) prefs = (prev && prev.prefs) || undefined; const rec = buildBg({ - ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }, + ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired }, mgrState: ark.state, + custody: getCustody(), keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), connections, offer, @@ -987,7 +995,7 @@ export function arkFeature(ctx) { const pm = await new ArkManager({ account: shim, storage: { load: () => state, save: (s2) => { state = s2; } }, - arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired, }).init(); const dest = mgr.address(); for (let guard = 0; guard < 30; guard++) { @@ -2314,6 +2322,10 @@ export function arkFeature(ctx) { const inRound = new Set([...renewing, ...elsewhere].flatMap((a) => a.inputIds || [])); const pendingCoins = ((st && st.vtxos) || []).filter((v) => (v.state === 'pending' || v.state === 'spendable') && inRound.has(v.id)); const tip = (mgr && mgr._tipH) || 0; + // the coinos servers keep honoring a coin past its timelock: it is held + // by coinos, not expired — still money, just not exitable on its own + const honors = !!(mgr && mgr.honorsExpired); + const exitable = exitableCoins(st, tip); const back = embedded ? () => { ui.arkCoinsSel = null; ui.arkRenewConfirm = null; ctx.goHome(); } : () => ctx.goBack(() => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkRenewConfirm = null; }); // the page is a history entry now @@ -2335,7 +2347,7 @@ export function arkFeature(ctx) { const expiresOf = (v) => { if (!v.expiryHeight || !tip) return '—'; const blocks = v.expiryHeight - tip; - if (blocks <= 0) return t('arkCoinsExpired'); + if (blocks <= 0) return honors ? t('arkCoinsHeld') : t('arkCoinsExpired'); return blocks >= 144 ? Math.round(blocks / 144) + 'd' : Math.max(1, Math.round(blocks / 6)) + 'h'; }; // what pulling THIS coin on-chain alone would cost: every hop's fee @@ -2463,13 +2475,13 @@ export function arkFeature(ctx) { h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')), h('div', { class: 'col', style: 'gap:4px' }, - row(t('arkExitConfirmCoins'), String(spend.length)), - row(t('arkExitConfirmAmount'), fmtSats(totalSat) + ' sats'), + row(t('arkExitConfirmCoins'), String(exitable.length)), + row(t('arkExitConfirmAmount'), fmtSats(exitable.reduce((n, v) => n + v.amountSat, 0)) + ' sats'), row(t('arkExitConfirmFee'), fmtSats(exitFee) + ' sats'), row(t('arkExitConfirmSavings'), fmtSats(savings) + ' sats', short ? 'err' : '')), short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(exitFee), have: fmtSats(savings) })) : null, h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmWait')), - h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !spend.length, onClick: () => { + h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !exitable.length, onClick: () => { ui.arkExitConfirm = null; doArkExit(); ui.arkCoinsSel = null; @@ -2522,7 +2534,8 @@ export function arkFeature(ctx) { h('tbody', {}, ...spend.map((v) => { const f = feeNowOf(v); const xf = exitFeeOf(v); - const due = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0) || !!v.expiryRejected; + const past = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0); + const due = (past && !honors) || !!v.expiryRejected; // expired and under the floor: nothing can renew it, so the // last column offers to give it up const lost = due && v.amountSat < 330; @@ -2551,9 +2564,11 @@ export function arkFeature(ctx) { h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')), // the round cadence comes from the server (it has been an hour and // is two minutes as of September 2026) — never a number baked in here - h('p', { class: 'small muted', style: 'margin:0' }, baseSat - ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost }) - : t('arkCoinsRenewDesc', { every: roundEvery })), + h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors + ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') }) + : baseSat + ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost }) + : t('arkCoinsRenewDesc', { every: roundEvery })), schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' }, ...schedule.map((r) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, r.label), @@ -2577,11 +2592,50 @@ export function arkFeature(ctx) { h('div', { class: 'card col', style: 'gap:8px' }, h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')), h('p', { class: 'small muted', style: 'margin:0' }, - t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })), - h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !spend.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))), + exitable.length ? t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) }) : t('arkExitNoneExitable')), + h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !exitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))), embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } + // Who holds the coins once their timelock runs out — the user's call, made + // here, with what each option costs in the server's own numbers. The + // default is coinos: most people come from v2, where coinos held + // everything, and the server honors a coin past its timelock anyway. The + // two renewing tiers are the opt-ins, for those who want the exit kept + // (self) or kept with a week's margin (early). Only shown on a server that + // honors expired coins; elsewhere renewal is the only way to keep money. + function custodyCard() { + if (!arkAvailable() || wallet.watchOnly) return null; + const cfg = getArkConfig(); + const info = (ark && ark.info) || null; + const honors = !!(cfg && cfg.honorsExpired); + const cur = honors ? getCustody() : 'self'; + const base = (info && info.refreshFees && info.refreshFees.baseFeeSat) || 0; + const table = (info && info.refreshFees && info.refreshFees.ppmExpiryTable) || []; + const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0; + const lifeDays = Math.max(1, Math.round(((info && info.vtxoExpiryDelta) || 8640) / 144)); + const baseCost = base ? fmtSats(base) + ' sats' : t('arkDepthFree'); + const earlyPct = pct(ppmAt(1008)); + const tiers = [ + { id: 'custodial', label: t('arkCustodyCustodial'), desc: t('arkCustodyCustodialDesc'), cost: t('arkCustodyCostNone') }, + { id: 'self', label: t('arkCustodySelf'), desc: t('arkCustodySelfDesc', { days: lifeDays }), cost: t('arkCustodyCostSelf', { base: baseCost }) }, + { id: 'early', label: t('arkCustodyEarly'), desc: t('arkCustodyEarlyDesc'), cost: t('arkCustodyCostEarly', { pct: earlyPct, base: baseCost }) }, + ].filter((x) => honors || x.id !== 'custodial'); + return h('div', { class: 'card col ark-custody', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('arkCustodyTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkCustodyDesc', { days: lifeDays })), + h('div', { class: 'col', style: 'gap:8px' }, ...tiers.map((x) => h('label', { + class: 'row gap6 ark-custody-opt' + (x.id === cur ? ' active' : ''), style: 'align-items:flex-start;cursor:pointer', + 'data-tier': x.id, + }, + h('input', { type: 'radio', name: 'arkCustody', value: x.id, checked: x.id === cur, disabled: !honors, style: 'flex:0 0 auto;margin:3px 0 0', + onChange: () => applyCustody(x.id) }), + h('div', { class: 'col', style: 'gap:2px;flex:1' }, + h('div', { class: 'row between gap6', style: 'align-items:baseline' }, h('span', { style: 'white-space:nowrap' }, x.label), h('span', { class: 'small' + (x.id === 'custodial' ? ' faint' : ''), style: 'text-align:right' }, x.cost)), + h('div', { class: 'small muted' }, x.desc))))), + honors ? null : h('p', { class: 'small faint', style: 'margin:0' }, t('arkCustodyNotHere'))); + } + // What each server charges and promises, from its own published info — // laid side by side under the switcher so the choice is an informed one. const serverInfos = new Map(); // url -> info | null (failed) | undefined (loading) @@ -2801,7 +2855,7 @@ export function arkFeature(ctx) { let _exitFeeMemo = { key: null, val: 0 }; function estimateExitFeeSat(mgr) { const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); - const coins = freeCoins(mgr.state); + const coins = exitableCoins(mgr.state, mgr._tipH || 0); const key = feeRate + '|' + coins.map((v) => v.id).join(','); if (_exitFeeMemo.key === key) return _exitFeeMemo.val; let total = 0; @@ -2820,8 +2874,8 @@ export function arkFeature(ctx) { ui.arkBusy = 'exit'; ui.arkError = ''; render(); try { const mgr = await connectArk(); - const spendables = freeCoins(mgr.state); - if (!spendables.length) throw new Error(t('arkNotConnected')); + const spendables = exitableCoins(mgr.state, mgr._tipH || 0); + if (!spendables.length) throw new Error(freeCoins(mgr.state).length ? t('arkExitNoneExitable') : t('arkNotConnected')); // Fees are paid hop by hop from Savings as the exit unrolls — an exit // that can't fund its hops just stalls at step zero. Refuse to start // one Savings can't carry, with the number on the table. @@ -2983,7 +3037,7 @@ export function arkFeature(ctx) { load: () => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }, save: (s) => { try { localStorage.setItem(key, JSON.stringify(s)); } catch {} }, }, - arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired, }); await mgr.init(); return mgr; @@ -3199,25 +3253,48 @@ export function arkFeature(ctx) { ui.arkBusy = null; render(); } - // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount - // (fragmentation breaks coin selection) and every vtxo expires at - // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than - // exposing a refresh button, consolidate-and-renew automatically when a - // vtxo is inside the renewal window or fragmentation builds up. The round - // fee this spends is the cost of keeping the money spendable at all. - // The ASP's refresh fee brackets reward renewing LATE — under 96 blocks of - // remaining life a renewal is free, and this ASP keeps honoring (and freely - // rescuing) a coin even past its expiry, so aiming inside the free window - // risks nothing. ~16 hours of margin on mainnet. - const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96); - // How close to expiry the SERVER's own backstop renewal is aimed. The - // window above only helps a wallet someone opens inside it; this one is a - // participation registered far in advance that the ASP runs on its own, - // whether or not anybody is looking. It sits well inside renewWindow so an - // app that IS open still renews first (and the untouched backstop is - // dropped), and far enough from expiry — 36 blocks, six hourly rounds — for - // the round to actually run in time. Both are in the free fee bracket. - const backstopWindow = () => backstopMargin(getNetwork()); + // VTXO upkeep, by the user's choice of custody (Settings → Spending): + // custodial (the default) — nothing renews. A coin past its timelock is + // coinos's IOU: the server sweeps the tree and keeps cosigning spends of + // the coin (captaind allow_expired_arkoor), so the balance works as a v2 + // one did, and no renewal fee is ever paid. The holder trusts coinos. + // self — renew in the last hours before expiry, the free ppm bracket, one + // base fee per round (~300 sats): a unilateral exit is always possible. + // early — renew a week ahead at 0.1% + base, for whoever wants a margin + // against coinos being unreachable right at the end. + // The policy's window is when THIS app renews a coin (blocks of life left); + // margin/grid aim the SERVER's own backstop order — a participation the ASP + // runs on its own, registered far in advance, snapped to a grid so every + // wallet's orders share one round. renewPolicy() in ark/manager.js. + // + // Consolidation is gone from here: it used to trigger at 20 coins, bill the + // top ppm bracket on every fresh coin, and was 84% of all renewal fees paid + // in Sep–Oct 2026 — the "money draining" people saw. Multi-input sends take + // up to MAX_SEND_INPUTS coins; small change folds into a renewal that is + // happening anyway, for next to nothing; the Manage page consolidates on + // request. + const CUSTODY_DEFAULT = 'custodial'; + function getCustody() { + const v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null; + return CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT; + } + function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); } + const custodyPolicy = () => renewPolicy(getCustody(), getNetwork()); + // Switching: a renewing tier starts covering coins right away (the throttles + // are reset so the next sync registers backstops and renews what is due), + // and the worker's mirror learns the tier so closed-app renewals follow it. + // Orders already registered with the server keep running whatever the tier + // (there is no cancel; a spent input voids one) — they were priced when + // registered and renew one coin each, once. + function applyCustody(tier) { + if (!CUSTODY_TIERS.includes(tier) || tier === getCustody()) return; + setCustody(tier); + arkAutoRefreshAt = 0; arkBackstopAt = 0; arkRenewWarn = null; + if (ark && ark.state) maybeAutoRefresh(ark).catch(() => {}); + writeBgMirror().catch(() => {}); + toast(t('arkCustodySaved')); + render(); + } let arkAutoRefreshAt = 0; let arkBackstopAt = 0; // Set when the balance is too small to renew itself (total minus round fee @@ -3251,9 +3328,13 @@ export function arkFeature(ctx) { async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; + const policy = custodyPolicy(); + // "coinos holds my coins": nothing renews on its own and nothing warns. + // The server honors a coin past its timelock, and the holder chose this. + if (!policy) { arkRenewWarn = null; return; } // NOT filtered by expiryRejected. A coin the server refused as expired // can still be REFRESHED — that is the one thing an expired vtxo can - // still do — and excluding it here meant a wallet holding nothing else + // always do — and excluding it here meant a wallet holding nothing else // did nothing at all: no renewal attempt, and no warning either. const spendables = freeCoins(mgr.state); if (!spendables.length) { arkRenewWarn = null; return; } @@ -3265,25 +3346,18 @@ export function arkFeature(ctx) { // stamped, and this must not then run on every five-second sync. if (Date.now() - arkBackstopAt > 30 * 60_000) { arkBackstopAt = Date.now(); - await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {}); + await mgr.ensureScheduledRefreshes(policy.margin, 8, policy.grid).catch(() => {}); } // never start a round while any other action is still in flight if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; arkAutoRefreshAt = Date.now(); try { const tip = await mgr.chain.tipHeight(); - const RENEW_BLOCKS = renewWindow(); - const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS); - const expiring = expiringCoins.length > 0; - // Fragmentation alone is no longer worth paying for: multi-input sends - // handle up to 24 coins, and consolidating FRESH coins bills the round - // fee's top ppm bracket (a burst of refund coins after failed payments - // once cost a user ~1% overnight). Consolidate only when the coin count - // itself approaches the 24-input send ceiling. - const fragmented = spendables.length >= 20; + const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < policy.window); + if (!expiringCoins.length) { arkRenewWarn = null; return; } const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0); const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330; - if (expiring && !renewable) { + if (!renewable) { // Can't save these coins ourselves — tell the user while receiving // any amount (or spending) can still beat the clock. const minExpiry = Math.min(...spendables.map((v) => v.expiryHeight || Infinity)); @@ -3294,26 +3368,24 @@ export function arkFeature(ctx) { return; } arkRenewWarn = null; - if (!expiring && !fragmented) return; // Renew ONLY the coins that need it: dragging far-from-expiry coins // into the round would bill their (higher) fee bracket for nothing. - // The rare fragmentation consolidation still sweeps everything. - // - // But the subset must clear the server's 330-sat output floor BY - // ITSELF — the renewable check above prices the whole balance. Two - // dust coins at expiry (133 sats between them) failed their renewal - // round silently every 30 minutes forever. Pull in the smallest - // healthy coins until the renewed output is mintable. - let renewSet = expiringCoins; - if (!fragmented) { - const extras = spendables - .filter((v) => !expiringCoins.includes(v)) - .sort((a, b) => a.amountSat - b.amountSat); - const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip); - while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()]; - if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it - } - await mgr.refresh(fragmented ? undefined : renewSet.map((v) => v.id)); + // Small change rides along, though: a coin under 1,000 sats costs at + // most 2 sats of ppm in this round, and folding it in now spares it a + // whole base fee's worth of round of its own when its turn comes. + let renewSet = [...expiringCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && v.amountSat < 1000)]; + // The set must clear the server's 330-sat output floor BY ITSELF — the + // renewable check above prices the whole balance. Two dust coins at + // expiry (133 sats between them) failed their renewal round silently + // every 30 minutes forever. Pull in the smallest healthy coins until the + // renewed output is mintable. + const extras = spendables + .filter((v) => !renewSet.includes(v)) + .sort((a, b) => a.amountSat - b.amountSat); + const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip); + while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()]; + if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it + await mgr.refresh(renewSet.map((v) => v.id)); render(); } catch {} // transient — the next throttled attempt retries } @@ -3595,7 +3667,7 @@ export function arkFeature(ctx) { const b = arkBalance() || { spendableSat: 0, pendingSat: 0 }; const spendable = b.spendableSat; const total = b.spendableSat + b.pendingSat; - const nSpend = freeCoins(arkStateNow()).length; + const nSpend = exitableCoins(arkStateNow(), (ark && ark._tipH) || 0).length; const exitingSat = ((arkStateNow() && arkStateNow().actions) || []) .filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + a.amountSat, 0); @@ -3650,10 +3722,10 @@ export function arkFeature(ctx) { new Promise((r) => setTimeout(r, 800)), ]); }, - // the Spending face's settings page leads with its coins + // the Spending face's settings page: the custody choice, then its coins spendingSettings() { if (wallet.watchOnly) return null; // a locked wallet can't renew or exit - return arkCoinsPage({ embedded: true }); + return h('div', { class: 'col', style: 'gap:16px' }, custodyCard(), arkCoinsPage({ embedded: true })); }, // the wallet's own pages keep the top nav: these are pages, not takeovers screenView() { @@ -4113,7 +4185,11 @@ export function arkFeature(ctx) { // and it is one tap away on the Manage page. What it cannot do is be // spent over Ark or exited unilaterally, so saying nothing is the one // option that isn't honest. - if (!wallet.watchOnly && ark && ark.balance) { + // On the coinos servers a coin past its timelock is still money (the + // server keeps cosigning its spends) — the balance counts it and + // there is nothing to warn about; the custody tier the user chose + // says what expiry means to them. Elsewhere, the old warning. + if (!wallet.watchOnly && ark && ark.balance && !ark.honorsExpired) { let expiredSat = 0; try { expiredSat = ark.balance().expiredSat || 0; } catch {} // Expired dust (under the 330-sat floor) cannot be renewed or exited diff --git a/src/i18n.js b/src/i18n.js index a07cba4..6d36f49 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -777,6 +777,22 @@ const STR = { arkFeeChain: 'On-chain fee', arkFeeService: 'Service fee', arkCoinsExpired: 'expired', + arkCoinsHeld: 'held by coinos', + arkCustodyTitle: 'Custody', + arkCustodyDesc: 'Each Spending coin carries a timelock of about {days} days. Renewing a coin before it runs out keeps your right to bring it on-chain without coinos, and costs a share of a round’s mining fee. coinos keeps honoring coins past their timelock, so renewing is a choice:', + arkCustodyCustodial: 'coinos holds my coins', + arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. Past its timelock a coin is held by coinos, like a balance on the old coinos: paying, receiving and withdrawing keep working, but bringing it on-chain needs coinos’s cooperation.', + arkCustodySelf: 'Keep my own exit', + arkCustodySelfDesc: 'Coins renew in the last hours before their timelock ends — the cheapest moment — so you can always exit on your own. Roughly once every {days} days per coin.', + arkCustodyEarly: 'Renew a week early', + arkCustodyEarlyDesc: 'Coins renew about a week before their timelock ends, a margin in case coinos is unreachable right at the end.', + arkCustodyCostNone: 'no fees', + arkCustodyCostSelf: '{base} per renewal', + arkCustodyCostEarly: '{pct} + {base} per renewal', + arkCustodySaved: 'Custody choice saved', + arkCustodyNotHere: 'This server does not honor coins past their timelock, so they renew automatically here.', + arkExitNoneExitable: 'Every coin has passed its timelock, so there is nothing a unilateral exit can take on-chain: coinos holds them now. Renewing restores your own exit; withdrawing with coinos’s cooperation works as before.', + arkCoinsRenewDescCustodial: 'With “coinos holds my coins”, nothing renews on its own. A renewal replaces the selected coins with a single fresh one in the next round ({every}) and restores your own exit for them. Every renewal costs a flat {base} toward the round’s on-chain fee, plus a share that depends on how close each coin is to its timelock. The closer, the cheaper:', noteForeignKind: 'A kind {kind} event this app doesn’t show.', zapRowText: 'zapped {n} sats', pollVoter: '{n} vote', diff --git a/src/nwc-bg.js b/src/nwc-bg.js index 3bdef08..3349126 100644 --- a/src/nwc-bg.js +++ b/src/nwc-bg.js @@ -84,7 +84,7 @@ export const BG_KEY_LOOKAHEAD = 25; // module). Chain 3 covers coins + change, chain 4/0 the mailbox, chain 5 a // window of receive preimages — everything the worker needs to pay AND to // mint invoices while the app is closed. -export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw }) { +export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw, custody }) { const keys = {}; const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex)); for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i); @@ -112,6 +112,9 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra // { on, threshold, keep, target: { kind, address, url?, spkHex? } } — the // destination is resolved by the app, so the worker never needs a relay autowithdraw: autowithdraw || null, + // the custody tier ('custodial' | 'self' | 'early'): whether the worker + // registers renewal orders for bare coins, and how far ahead + custody: custody || null, // The budget's spent counter rides along (stamped with its window) so the // worker counts what the app spent; a revoked one rides along flagged, to // be refused rather than ignored. diff --git a/src/nwc-respond.js b/src/nwc-respond.js index ec19923..e6ee563 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -21,7 +21,7 @@ import { ArkManager } from './ark/manager.js'; import { maybeBolt11 } from './ark/lightning.js'; import { allBgs, saveBg } from './nwc-bg.js'; import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js'; -import { backstopMargin } from './ark/manager.js'; +import { renewPolicy } from './ark/manager.js'; const MAX_AGE_SEC = 120; const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin @@ -69,6 +69,7 @@ export async function bgManager(rec, walletKey, saveFn) { return new ArkManager({ account: shimAccount(rec), storage, arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network, + honorsExpired: !!rec.ark.honorsExpired, }).init(); } @@ -460,11 +461,14 @@ export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log = if (a.step === 'done') { n++; log('claimed a renewal while closed'); } } // coins without an order: register one, as long as the output key the - // order burns is inside the mirrored window (the worker can't derive) + // order burns is inside the mirrored window (the worker can't derive). + // Only under a custody tier that renews at all — the default lets coins + // run past their timelock in coinos's hands, by choice, and pays nothing. + const policy = renewPolicy(rec.custody || 'custodial', rec.ark.network); let room = 0; while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++; - if (room) { - const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? backstopMargin(rec.ark.network), room)).catch((e) => { log('renewal order: ' + e.message); return 0; }); + if (room && policy) { + const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? policy.margin, room, policy.grid)).catch((e) => { log('renewal order: ' + e.message); return 0; }); if (got) { n += got; log(`registered ${got} renewal order(s) while closed`); } } await saveFn(walletKey, rec); diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js new file mode 100644 index 0000000..da1079c --- /dev/null +++ b/tools/ark/custody-test.js @@ -0,0 +1,98 @@ +// The custody tiers, without a network: what each policy aims for, how the +// manager treats a coin past its timelock on a server that honors it, how +// backstop orders snap to a grid so wallets share rounds, and that the +// receive-side validator can be told to accept such a coin. +// Run: bun tools/ark/custody-test.js +import assert from 'node:assert/strict'; +import { hex } from '@scure/base'; +import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../src/ark/manager.js'; +import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js'; + +// policies +assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'early']); +assert.equal(renewPolicy('custodial', 'mainnet'), null, 'the default tier renews nothing'); +assert.equal(renewPolicy('nonsense', 'mainnet'), null); +assert.deepEqual(renewPolicy('self', 'mainnet'), { window: 96, margin: 36, grid: 48 }); +assert.deepEqual(renewPolicy('early', 'mainnet'), { window: 1008, margin: 1008, grid: 144 }); +assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 }); +// the snap never leaves the bracket: self stays under 96 blocks out, early under 2160 +for (let h = 100000; h < 100200; h++) { + const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); + const e = h - 1008; assert.ok(h - (e - (e % 144)) < 2160 && h - (e - (e % 144)) >= 199); +} +console.log('✓ renewal policies: custodial renews nothing; self and early aim inside one fee bracket'); + +const mk = (honorsExpired) => { + const mgr = new ArkManager({ account: null, storage: { load: () => null, save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', honorsExpired }); + mgr.state = { v: 1, nextKeyIndex: 1, vtxos: [], actions: [], movements: [], scheduled: [] }; + mgr.info = { maxVtxoAmountSat: 5_000_000, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] } }; + mgr._tipH = 1000; + return mgr; +}; +const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expiryHeight, state: 'spendable', policy: 'pubkey', keyIndex: 1, ...extra }); + +// a coin past its timelock: unusable on a server that refuses it, money on one that honors it +{ + const strict = mk(false), lenient = mk(true); + for (const m of [strict, lenient]) m.state.vtxos = [coin('a', 5000, 900), coin('b', 7000, 1200), coin('c', 100, 900)]; + assert.equal(strict._expired(strict.state.vtxos[0], 1000), true); + assert.equal(strict._expired(strict.state.vtxos[2], 1000), false, 'sub-dust was always honored'); + assert.deepEqual(strict.balance(), { spendableSat: 7100, pendingSat: 0, boardingSat: 0, expiredSat: 5000 }); + assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), false); + assert.deepEqual(lenient.balance(), { spendableSat: 12100, pendingSat: 0, boardingSat: 0, expiredSat: 0 }); + assert.deepEqual(lenient._selectInputs(5000).map((v) => v.id), ['b'], 'a coin still inside its timelock is preferred…'); + assert.deepEqual(lenient._selectInputs(7001).map((v) => v.id), ['b', 'a'], '…and the expired one joins only when needed'); + lenient.state.vtxos.push(coin('d', 9000, 900)); + assert.deepEqual(lenient._selectInputs(8000).map((v) => v.id), ['d'], 'a single expired coin beats a many-coin package'); + lenient.state.vtxos.pop(); + assert.deepEqual(strict._selectInputs(5000).map((v) => v.id), ['b']); + // the server's word still wins: a refusal naming the coin excludes it + lenient.state.vtxos[0].expiryRejected = true; + assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), true); + assert.equal(lenient.balance().spendableSat, 7100); + console.log('✓ honorsExpired: a coin past its timelock stays spendable; a server refusal still excludes it'); +} + +// the send cap is wider now that nothing consolidates on its own +{ + const m = mk(true); + m.state.vtxos = Array.from({ length: 40 }, (_, i) => coin('c' + i, 100, 2000)); + assert.equal(m._selectInputs(3500).length, 35, 'a 35-coin send is fine'); + m.state.vtxos = Array.from({ length: MAX_SEND_INPUTS + 5 }, (_, i) => coin('d' + i, 100, 2000)); + assert.throws(() => m._selectInputs(100 * (MAX_SEND_INPUTS + 1)), /too many coins/); + console.log(`✓ a send takes up to ${MAX_SEND_INPUTS} coins`); +} + +// backstop orders snap down to the grid, and skip what the snap would put in the past +{ + const m = mk(true); + const orders = []; + m._tipMemo = async () => 1000; + m.scheduleRefresh = async (ids, height) => { orders.push([ids[0], height]); }; + m.state.vtxos = [ + coin('x', 10000, 1100), // 1100-36 = 1064 → 1056 + coin('y', 20000, 1041), // 1005 → 960 ≤ tip: skipped (the app itself renews it) + coin('z', 30000, 1200), // 1164 → 1152 + coin('w', 40000, 1200, { state: 'pending' }), + ]; + assert.equal(await m.ensureScheduledRefreshes(36, 8, 48), 2); + assert.deepEqual(orders, [['z', 1152], ['x', 1056]], 'largest first, snapped to multiples of 48'); + assert.ok(orders.every(([, h]) => h % 48 === 0)); + orders.length = 0; + m.state.scheduled = []; + assert.equal(await m.ensureScheduledRefreshes(36, 8, 1), 3, 'grid 1 = the old exact heights'); + assert.deepEqual(orders, [['z', 1164], ['y', 1005], ['x', 1064]]); + console.log('✓ backstop orders snap to the grid (and never into the past)'); +} + +// the validator: an expired coin is refused by default, accepted when told the server honors it +{ + const serverPub = new Uint8Array(33).fill(2); + const v = { policy: { type: 'pubkey', userPubkey: 'aa' }, serverPubkey: hex.encode(serverPub), genesis: [{ transition: {} }], + expiryHeight: 500, amountSat: 1000, anchorPoint: { txid: 'ab'.repeat(32), vout: 0 }, point: { txid: 'cd'.repeat(32), vout: 0 }, id: 'x' }; + const chain = { tipHeight: async () => 600, getTxStatus: async () => ({ confirmed: false }), getTxHex: async () => null }; + await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain }), (e) => e instanceof VtxoValidationError && /expired at 500/.test(e.message)); + await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor'); + console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check'); +} +console.log('\n✅ custody tiers behave'); diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js new file mode 100644 index 0000000..d85cd36 --- /dev/null +++ b/tools/ark/custody-ui-test.js @@ -0,0 +1,122 @@ +// The custody card on the Spending settings page: three tiers, coinos holding +// the coins by default, a coin past its timelock shown as held (not expired), +// and switching tiers persists and reprices the page. Real app with a local +// Ark manager stub; nothing is sent anywhere. +// Run: bun tools/ark/custody-ui-test.js +import assert from 'node:assert/strict'; +import puppeteer from '../puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, + plugins: [{ name: 'custody-test', setup(build) { + build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` +window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; }, + setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } }; +` })); + build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents: + (await Bun.file(path).text()).replace(" return {\n id: 'ark',", ` + window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, autoRefreshCalls: [] }; + return {\n id: 'ark',`) })); + build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); + } }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/' + ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(), errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + const cdp = await page.createCDPSession(); + await cdp.send('Network.enable'); + await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // the local preset honors expired coins + }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.coopApp?.ready); + await page.evaluate(async (mn) => { + await coopApp.enterWallet(mn, '', { fresh: true, generated: true }); + coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step'); + coopApp.wallet.scan = async () => {}; + const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 97, ppm: 250 }, { thresholdBlocks: 199, ppm: 1000 }, { thresholdBlocks: 2161, ppm: 2000 }]; + window.coopManager = { + honorsExpired: true, + state: { vtxos: [ + { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921 }, // 29 days left + { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000 }, // past its timelock + ], actions: [], movements: [], scheduled: [] }, + info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751, + chain: { tipHeight: async () => 969751 }, + vtxos() { return this.state.vtxos; }, + _decoded() { throw new Error('no exit transaction fixture'); }, + balance() { return { spendableSat: 25000, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; }, + refreshFee(inputs, tip) { + let u = 0; + for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0); + return 300 + Math.ceil(u / 1e6); + }, + async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; }, + async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; }, + unseenReceives() { return []; }, ackReceives() {}, + address() { return 'test-ark-address'; }, + }; + coopFeature.setManager(coopManager); + coopApp.setSpending(true); + coopApp.openWalletSettings('spending'); + }, generateMnemonic(wordlist)); + await page.waitForSelector('.ark-custody'); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const text = () => page.evaluate(() => document.body.innerText); + + let tx = await text(); + assert.match(tx, /Custody/); + assert.match(tx, /coinos holds my coins[\s\S]*no fees/); + assert.match(tx, /Keep my own exit[\s\S]*300 sats per renewal/); + assert.match(tx, /Renew a week early[\s\S]*0\.1% \+ 300 sats per renewal/); + const checked = await page.$$eval('.ark-custody input[type=radio]', (els) => els.filter((e) => e.checked).map((e) => e.value)); + assert.deepEqual(checked, ['custodial'], 'coinos holds the coins by default'); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial'); + assert.match(tx, /held by coinos/, 'the coin past its timelock is held, not "expired"'); + assert.doesNotMatch(tx, /\bexpired\b/); + assert.match(tx, /With “coinos holds my coins”, nothing renews on its own/); + if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true }); + console.log('✓ the custody card: three tiers with their prices, coinos holding by default, a past-timelock coin shown as held'); + + // the unilateral exit only counts the coin still inside its timelock + assert.match(tx, /Exit now/); + const exitBtn = await page.$$eval('button', (bs) => bs.filter((b) => b.textContent === 'Exit now').map((b) => b.disabled)); + assert.deepEqual(exitBtn, [false]); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Exit now').click()); await sleep(200); + tx = await text(); + assert.match(tx, /Coins\s*1/); assert.match(tx, /Amount\s*20,000 sats/); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); + console.log('✓ a unilateral exit counts only the coin still inside its timelock'); + + // opt into keeping the exit: persisted, the page reprices, renewal starts covering + await page.click('.ark-custody-opt[data-tier=self] input'); await sleep(400); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self'); + assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet'); + tx = await text(); + assert.match(tx, /Custody choice saved/); + assert.doesNotMatch(tx, /nothing renews on its own/); + assert.match(tx, /renews coins automatically once they are close to expiring/); + const calls = await page.evaluate(() => coopFeature.autoRefreshCalls); + assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)'); + assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin past its timelock is renewed now; the healthy one is left alone'); + console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing'); + + await page.click('.ark-custody-opt[data-tier=early] input'); await sleep(300); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'early'); + await page.click('.ark-custody-opt[data-tier=custodial] input'); await sleep(300); + assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial'); + assert.match(await text(), /nothing renews on its own/); + console.log('✓ the other tiers switch the same way, and back'); + assert.deepEqual(errors, [], 'no page errors'); + console.log('\n✅ custody is a choice, made on the Spending page'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index e3bdb7b..2d07963 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -332,16 +332,17 @@ console.log('\n[tending renewals while closed]'); if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash }); }; ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; }; - ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max) { calls.push(['ensure', margin, max]); return 1; }; + ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max, grid) { calls.push(['ensure', margin, max, grid]); return 1; }; try { const rec = baseRec(); rec.key4 = 'ff'.repeat(32); + rec.custody = 'self'; // a renewing tier: the worker covers bare coins rec.mgr.nextKeyIndex = 5; rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }]; const saved = []; const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} }); - check('claims what ran, then covers what is bare: orders limited to the key window', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2]]), JSON.stringify(calls)); + check('claims what ran, then covers what is bare: orders limited to the key window, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), JSON.stringify(calls)); check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1')); check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done'); // a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next @@ -351,8 +352,20 @@ console.log('\n[tending renewals while closed]'); const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} }); check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls)); + // the default tier — coinos holds the coins — registers no order at all, + // however many keys the window holds (the claim of an order that already + // ran still happens: it was priced and promised when registered) calls.length = 0; - const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key + const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} }); + check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); + calls.length = 0; + const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'early'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' }; + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} }); + check('early tier → a week ahead on the daily grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 1008, 1, 144]]), JSON.stringify(calls)); + + calls.length = 0; + const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} }); check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); |
