diff options
| -rw-r--r-- | src/ark/manager.js | 173 | ||||
| -rw-r--r-- | src/ark/proto.js | 1 | ||||
| -rw-r--r-- | src/ark/refresh.js | 8 | ||||
| -rw-r--r-- | src/features/ark.js | 45 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | tools/ark/manager-test.js | 4 | ||||
| -rw-r--r-- | tools/ark/scheduled-refresh-test.js | 151 |
7 files changed, 378 insertions, 5 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 15714b7..4dd39e4 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -60,6 +60,7 @@ const EMPTY_STATE = () => ({ vtxos: [], // { id, bytes, keyIndex, amountSat, expiryHeight, state } actions: [], // { id, type, step, ... } movements: [], // { id, type, amountSat, ts, status, detail } + scheduled: [], // standing-order refreshes: { unlockHash, inputIds, scheduledHeight, ... } }); // Local state must not grow forever. Two things did: a spent coin kept its @@ -135,6 +136,8 @@ export class ArkManager { this.state = null; this.info = null; this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push + this._refreshDriving = new Set(); // same, for round claims: sync loop vs mailbox breadcrumb + this._schedPollAt = new Map(); // last status poll per standing-order unlock hash // A derived key for an index never changes for this wallet, but computing // it costs a BIP32 derive + a secp256k1 getPublicKey (a full point mul). // reconcile() alone calls _keyForVtxo for every spendable coin, and decode/ @@ -417,6 +420,9 @@ export class ArkManager { } if (changed) this._save(); await this.resumePending(); + // Standing-order refreshes live beside the actions, not among them. + await this._driveScheduled().catch((e) => + console.warn('ark: scheduled refresh check failed —', e.message)); // The display is only as honest as the last reconcile: state restored // from a snapshot (or mirrored by the NWC worker) can show coins the // server already saw spent — a balance you can look at but not spend. @@ -483,7 +489,14 @@ export class ArkManager { // on the server until someone acts on it (cooney's 28,833 sats did, // for 8 days). Adopt it; rescueParticipation no-ops on inputs we don't // hold and the server side is idempotent. - if (m.unlockHash + // A standing order's round announces itself the same way, and this is + // the earliest word of it — claim on the breadcrumb instead of waiting + // for the next scheduled-height check. + const standing = (this.state.scheduled || []).find((s) => s.unlockHash === m.unlockHash); + if (standing) { + this._promoteScheduled(standing).catch((e) => + console.warn('ark: scheduled refresh not claimed —', e.message)); + } else if (m.unlockHash && !this.state.actions.some((a) => a.unlockHash === m.unlockHash && a.step !== 'failed')) { this.rescueParticipation(m.unlockHash).catch((e) => console.warn('ark: stranded participation not adopted —', e.message)); @@ -1791,7 +1804,18 @@ export class ArkManager { this._save(); } + // A refresh claim can be reached from two directions at once — the sync + // loop resuming the action, and a mailbox breadcrumb claiming the moment + // the round is announced. Both are safe server-side (cosign and forfeit are + // idempotent), but two winners would write two "Renewed" rows. async _driveRefresh(action) { + if (this._refreshDriving.has(action.id)) return; + this._refreshDriving.add(action.id); + try { await this._driveRefreshSteps(action); } + finally { this._refreshDriving.delete(action.id); } + } + + async _driveRefreshSteps(action) { const outputs = this._refreshOutputs(action); const inputRecs = action.inputIds.map((id) => this._vtxo(id)); @@ -1872,6 +1896,149 @@ export class ArkManager { } } + // ---- scheduled refresh: the renewal that happens without us ---- + // + // A participation registered now but held by the server until a future + // block height, then run in a round with nobody online (the round tree + // branch is signed by the server — that is what makes a delegated refresh + // delegated). The output waits 'unclaimed' until this wallet returns and + // forfeits the inputs, which nothing about expiry blocks: by then the money + // already sits in a fresh tree, anchored on-chain, with a new lifetime. + // + // This is the backstop under maybeAutoRefresh, whose whole premise is that + // someone opens the app inside the last few hours of a coin's life. Many + // people don't. + // + // Deliberately NOT an action: the coins stay spendable, nothing treats the + // wallet as busy, and no history row appears until there is something to + // say. Spend a coin and its standing order simply dies — the server drops a + // participation whose input is gone, and so do we. + async scheduleRefresh(vtxoIds, scheduledHeight) { + const inputs = vtxoIds.map((id) => this._vtxo(id)); + if (!inputs.length || inputs.some((v) => !v || v.state !== 'spendable')) { + throw new Error('no spendable vtxos to schedule'); + } + if (inputs.some((v) => !v.expiryHeight || v.expiryHeight <= scheduledHeight)) { + throw new Error('scheduled height is not before every input expiry'); + } + // Priced at the height it will RUN at — which is what the server checks, + // and cheaper than today: the refresh brackets fall as a coin's remaining + // lifetime does, and the last one is free. + const totalSat = inputs.reduce((n, v) => n + v.amountSat, 0); + const feeSat = this.refreshFee(inputs, scheduledHeight); + if (totalSat - feeSat < 330) { + throw new Error(`balance too small to refresh: ${totalSat} sat minus ${feeSat} sat fee is under the 330 sat minimum`); + } + const entry = { + unlockHash: null, + inputIds: inputs.map((v) => v.id), + outKeyIndex: this.state.nextKeyIndex++, + outAmountSat: totalSat - feeSat, feeSat, scheduledHeight, ts: Date.now(), + }; + this._save(); // burn the key index before it can be used, as every flow does + await registerVtxoTransactions(this.arkUrl, inputs.map((v) => vtxoBytesFromStr(v.bytes))); + const unlockHash = await submitRoundParticipation(this.arkUrl, { + inputs: inputs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })), + outputs: this._refreshOutputs(entry), + mailboxId: this._mailboxKey().pubkey, + scheduledHeight, + }); + entry.unlockHash = hex.encode(unlockHash); + (this.state.scheduled ||= []).push(entry); + this._save(); + return entry; + } + + scheduledRefreshes() { return (this.state.scheduled || []).slice(); } + + // Keep every coin covered by a standing order, `marginBlocks` before its + // 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) { + const tip = await this._tipMemo(); + if (!tip) return 0; + const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); + // 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) => v.state === 'spendable' && v.expiryHeight && !covered.has(v.id) + && !this._expired(v, tip) && v.expiryHeight - marginBlocks > 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++; } + catch (e) { console.warn('ark: no backstop refresh for', v.id, '—', e.message); } + } + return n; + } + + _dropScheduled(entry) { + this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); + this._save(); + } + + // The round ran. From here it is an ordinary refresh claim — leaf cosign, + // forfeit, adopt the output — and the inputs stop being spendable, because + // the server has already issued their replacement. + async _promoteScheduled(entry) { + this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); + const action = { + id: `refresh-${Date.now()}`, type: 'refresh', step: 'submitted', + inputIds: [...entry.inputIds], outKeyIndex: entry.outKeyIndex, + outAmountSat: entry.outAmountSat, feeSat: entry.feeSat, + unlockHash: entry.unlockHash, scheduledHeight: entry.scheduledHeight, + }; + for (const id of entry.inputIds) { + const v = this._vtxo(id); + if (v && v.state === 'spendable') v.state = 'pending'; + } + this.state.actions.push(action); + this._save(); + await this._driveRefresh(action); + return action; + } + + // Advance the standing orders: drop the dead ones, claim the ones whose + // round has run. Costs an RPC only once a scheduled height is behind us. + async _driveScheduled() { + for (const entry of [...(this.state.scheduled || [])]) { + const inputs = entry.inputIds.map((id) => this._vtxo(id)); + // A spent input ends the order — the server drops its copy for the same + // reason. 'pending' is not that: an action holding the coin may yet + // fail and hand it back, and re-registering costs a round-trip. + if (inputs.some((v) => !v || v.state === 'spent')) { this._dropScheduled(entry); continue; } + if (inputs.some((v) => v.state !== 'spendable')) continue; + const tip = this._tipH || 0; + if (tip && tip < entry.scheduledHeight) continue; + // Past the scheduled height the round can be hours out (it runs when + // the server runs one), and sync comes round every few seconds. The + // mailbox breadcrumb is what actually wakes the claim; this poll is the + // fallback for a wallet that missed it, so it can be slow. + const now = Date.now(); + if (now - (this._schedPollAt.get(entry.unlockHash) || 0) < 120_000) continue; + this._schedPollAt.set(entry.unlockHash, now); + let status; + try { + status = await roundParticipationStatus(this.arkUrl, hex.decode(entry.unlockHash)); + } catch (e) { + // The server deletes a participation it can't run (an input spent + // between rounds, a collision with another process holding the coin), + // so an unknown unlock hash ends this standing order rather than + // being retried forever. Anything else is transient. + if (/unknown|not found|no such|doesn't exist/i.test(e.message || '')) this._dropScheduled(entry); + continue; + } + if (status.status === 0 || !status.fundingTx) continue; // round hasn't run yet + await this._promoteScheduled(entry); + } + } + // Rebuild a refresh claim from its unlock hash — for a participation whose // action was lost (snapshot rollback, device wipe, crash mid-claim) after // its round ran, leaving the output stranded 'unclaimed' on the server. @@ -1884,6 +2051,10 @@ export class ArkManager { if (this.state.actions.some((a) => a.unlockHash === unlockHashHex && a.step !== 'failed')) { throw new Error('an action for this participation already exists — sync drives it'); } + // One of ours, waiting on a scheduled height: it has its inputs and key + // index already, so promote rather than rediscover them. + const standing = (this.state.scheduled || []).find((s) => s.unlockHash === unlockHashHex); + if (standing) return this._promoteScheduled(standing); const status = await roundParticipationStatus(this.arkUrl, hex.decode(unlockHashHex)); if (status.status === 0 || !status.fundingTx) throw new Error('the round for this participation has not run'); if (!inputVtxoIds?.length) inputVtxoIds = status.inputVtxoIds; diff --git a/src/ark/proto.js b/src/ark/proto.js index 829eba4..eb89d5e 100644 --- a/src/ark/proto.js +++ b/src/ark/proto.js @@ -410,6 +410,7 @@ export async function getArkInfo(ark) { if (field === 6) info.vtxoExpiryDelta = Number(value); if (field === 7) info.htlcSendExpiryDelta = Number(value); if (field === 8) info.htlcExpiryDelta = Number(value); + if (field === 9) info.maxVtxoAmountSat = Number(value); if (field === 10) info.requiredBoardConfirmations = Number(value); if (field === 11) info.maxUserInvoiceCltvDelta = Number(value); if (field === 13) info.minBoardAmountSat = Number(value); diff --git a/src/ark/refresh.js b/src/ark/refresh.js index 4bd74f2..6e4453d 100644 --- a/src/ark/refresh.js +++ b/src/ark/refresh.js @@ -311,7 +311,12 @@ export function participationAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { // mailboxId: when given, the server posts a recovery breadcrumb (the unlock // hash) to that mailbox once the round runs — any device of this wallet can // then claim the output even if the action that submitted it is lost. -export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId }) { +// scheduledHeight: when given, the server holds the participation out of +// every round until that block height and then runs it with nobody online +// (and prices the refresh fee at that height, not today's). Omitted, the +// participation joins the next round, which is what an interactive refresh +// wants. +export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId, scheduledHeight }) { const w = pbWriter(); for (const { vtxo, keys } of inputs) { const iv = pbWriter(); @@ -326,6 +331,7 @@ export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId w.bytesField(3, vr.finish()); } if (mailboxId) w.bytesField(4, mailboxId); + if (scheduledHeight) w.varintField(5, scheduledHeight); const resp = await grpcCall(ark, 'bark_server.ArkService/SubmitRoundParticipation', w.finish()); for (const { field, value } of pbFields(resp)) if (field === 1) return value; // unlock_hash throw new Error('no unlock hash in response'); diff --git a/src/features/ark.js b/src/features/ark.js index 764affd..222b312 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2631,7 +2631,16 @@ export function arkFeature(ctx) { // 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 = () => (getNetwork() === 'regtest' ? 6 : 36); let arkAutoRefreshAt = 0; + let arkBackstopAt = 0; // Set when the balance is too small to renew itself (total minus round fee // under the server's 330-sat output minimum) AND a vtxo is inside the // renewal window: { deadlineMs, sat }. The wallet-screen notice reads this. @@ -2663,8 +2672,22 @@ export function arkFeature(ctx) { async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; - const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable' && !v.expiryRejected); + // 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 + // did nothing at all: no renewal attempt, and no warning either. + const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable'); if (!spendables.length) { arkRenewWarn = null; return; } + // Register the server-side backstop for anything not covered yet. Ahead + // of the in-flight guard below on purpose: a standing order takes no coin + // and starts no round, so a send in progress is no reason to skip the one + // renewal that survives the app never being opened again. Stamped on its + // own clock — the guard below can return before the refresh throttle is + // 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(() => {}); + } // 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(); @@ -3404,6 +3427,26 @@ export function arkFeature(ctx) { amount: fmtAmount(arkRenewWarn.sat) + ' ' + unitLabel(), date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }))); + // Coins past their expiry. The balance already leaves these out, and + // until now it did so in silence — money apparently gone, with the one + // page that could bring it back reachable only through an advisory + // that most wallets never see (it wants four hops of depth) or a + // renewal forecast that only appears when the renewal COSTS something, + // which is no longer the normal case. + // + // An expired vtxo can still be renewed — that is the whole of the fix, + // 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) { + let expiredSat = 0; + try { expiredSat = ark.balance().expiredSat || 0; } catch {} + if (expiredSat > 0) out.push(h('div', { class: 'notice err', style: 'margin:12px 0 0' }, + t('arkExpiredNotice', { amount: fmtAmount(expiredSat) + ' ' + unitLabel() }), + ' ', + h('button', { class: 'linklike small', onClick: () => { ui.arkCoinsPage = true; render(); } }, + t('arkDepthBtn')))); + } const depthNotice = exitDepthNotice(); if (depthNotice) out.push(depthNotice); // An upcoming renewal that will COST something — stated before it diff --git a/src/i18n.js b/src/i18n.js index bb787b0..274b2ed 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -268,6 +268,7 @@ const STR = { arkDepthNotice: 'Uncooperative exit costs about {fee} sats.', arkDepthFree: 'free', arkDepthBtn: 'Manage', + arkExpiredNotice: '{amount} has passed its expiry. The server can still renew it — until then it can\u2019t be spent or exited.', arkDepthRenewed: 'Renewal requested', arkDepthRenewBtn: 'Renew now', arkCoinsTitle: 'Spending coins', diff --git a/tools/ark/manager-test.js b/tools/ark/manager-test.js index 902d757..74a1c5c 100644 --- a/tools/ark/manager-test.js +++ b/tools/ark/manager-test.js @@ -15,7 +15,7 @@ import { HDKey } from '@scure/bip32'; import { hex } from '@scure/base'; import { ArkManager } from '../../src/ark/manager.js'; -import { decodeVtxo } from '../../src/ark/proto.js'; +import { decodeVtxo, vtxoBytesFromStr } from '../../src/ark/proto.js'; import { validateVtxo } from '../../src/ark/validate.js'; const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535'; @@ -59,7 +59,7 @@ check('movement logged', mgr.movements().some((m) => m.type === 'receive' && m.s // tampered vtxo must fail validation const rec = mgr.vtxos()[0]; -const tampered = hex.decode(rec.bytes); +const tampered = vtxoBytesFromStr(rec.bytes); // stored base64 since the IndexedDB move tampered[tampered.length - 100] ^= 0xff; // corrupt inside the genesis data let rejected = false; try { diff --git a/tools/ark/scheduled-refresh-test.js b/tools/ark/scheduled-refresh-test.js new file mode 100644 index 0000000..bb030c4 --- /dev/null +++ b/tools/ark/scheduled-refresh-test.js @@ -0,0 +1,151 @@ +// End-to-end test of the server-side backstop renewal (scheduled delegated +// refresh) on regtest: +// +// receive -> register a standing order -> server HOLDS it out of rounds +// -> scheduled height passes -> server runs the round with the wallet +// offline -> wallet returns and claims -> spending the coin instead kills +// its standing order -> a claim still works after the input has EXPIRED +// +// Usage: bun tools/ark/scheduled-refresh-test.js +// Needs the ark-regtest stack (bc, ark-electrs, captaind on :3535). + +import { execSync } from 'node:child_process'; +import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { HDKey } from '@scure/bip32'; + +import { ArkManager } from '../../src/ark/manager.js'; + +const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535'; +const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002'; +const BARK = `${process.env.HOME}/bark/target/debug/bark`; +const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; +const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); +const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`); +const tipHeight = () => Number(sh(`${BCLI} getblockcount`)); + +let ok = true; +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!cond) ok = false; +}; + +const store = { raw: null }; +const storage = { + load: () => (store.raw ? JSON.parse(store.raw) : null), + save: (s) => { store.raw = JSON.stringify(s); }, +}; + +const mnemonic = generateMnemonic(wordlist); +const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'"); +const mgr = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init(); + +// This test mines a coin's whole lifetime in step 5, which also expires the +// funding wallet's coins — an expired vtxo can be refreshed but not sent, so +// re-running against a chain that already ran it would fund nothing. +const fundingReady = () => { + const alice = JSON.parse(sh(`${ALICE} -q vtxos`)); + const tip = tipHeight(); + if (!alice.some((v) => v.expiry_height <= tip)) return; + console.log(' … refreshing the funding wallet\'s expired coins first'); + sh(`${ALICE} refresh --all >/dev/null`); + mine(3); + sh(`${ALICE} -q balance >/dev/null`); +}; +fundingReady(); + +const receive = async (sat) => { + const before = mgr.balance().spendableSat; + sh(`${ALICE} -q send ${mgr.address()} "${sat} sat"`); + for (let i = 0; i < 15 && mgr.balance().spendableSat < before + sat; i++) { + await mgr.sync(); + await sleep(1000); + } + return mgr.balance().spendableSat - before; +}; + +// --- 1. register a standing order --- +console.log('\n[1] register'); +check('received 20000 sat', (await receive(20000)) === 20000, JSON.stringify(mgr.balance())); + +const coin = mgr.vtxos().find((v) => v.state === 'spendable'); +// aim the backstop a few blocks out so the test doesn't have to mine a month +const runAt = tipHeight() + 3; +const n = await mgr.ensureScheduledRefreshes(coin.expiryHeight - runAt); +const entry = mgr.scheduledRefreshes()[0]; +check('one standing order registered', n === 1 && mgr.scheduledRefreshes().length === 1); +check('has an unlock hash', !!entry?.unlockHash, entry?.unlockHash?.slice(0, 16)); +check('scheduled at the asked height', entry?.scheduledHeight === runAt, `${entry?.scheduledHeight} vs ${runAt}`); +check('fee priced at the scheduled height', + entry?.feeSat === mgr.refreshFee([coin], runAt), `${entry?.feeSat} sat`); +check('coin still spendable', mgr._vtxo(coin.id).state === 'spendable'); +check('balance untouched', mgr.balance().spendableSat === 20000, JSON.stringify(mgr.balance())); +check('wallet is not busy', mgr.pendingActions().length === 0); + +// --- 2. the server holds it until the scheduled height --- +console.log('\n[2] held out of rounds'); +await sleep(25_000); // two-plus regtest rounds +await mgr.sync(); +check('not claimed early', mgr.scheduledRefreshes().length === 1 && mgr.balance().spendableSat === 20000, + JSON.stringify(mgr.balance())); +check('coin still spendable while held', mgr._vtxo(coin.id).state === 'spendable'); + +// --- 3. the round runs with the wallet offline, then the wallet returns --- +console.log('\n[3] runs offline, claimed on return'); +mine(3 + 2); // past the scheduled height, plus confirmations for the round funding tx +await sleep(15_000); // let a round include it — no sync() in between: the wallet is "closed" +check('still nothing local while away', mgr.scheduledRefreshes().length === 1); +const claimed = () => mgr.state.actions.some((a) => a.type === 'refresh' && a.step === 'done'); +for (let i = 0; i < 25 && !claimed(); i++) { await mgr.sync(); mine(1); await sleep(1500); } +const renewed = mgr.vtxos().find((v) => v.state === 'spendable' && v.id !== coin.id); +if (!renewed) console.log(' actions:', JSON.stringify(mgr.state.actions.map((a) => [a.type, a.step, a.lastError]))); +check('standing order consumed', mgr.scheduledRefreshes().length === 0); +check('old coin spent', mgr._vtxo(coin.id).state === 'spent'); +check('fresh coin adopted', !!renewed, renewed && `${renewed.amountSat} sat`); +check('balance is the coin minus the scheduled fee', + mgr.balance().spendableSat === 20000 - entry.feeSat, JSON.stringify(mgr.balance())); +check('expiry clock restarted', !!renewed && renewed.expiryHeight > coin.expiryHeight, + renewed && `${coin.expiryHeight} -> ${renewed.expiryHeight}`); +check('renewal in history', mgr.movements().some((m) => m.type === 'refresh' && m.status === 'complete')); + +// --- 4. spending the coin kills its standing order --- +console.log('\n[4] spend voids the standing order'); +const runAt2 = tipHeight() + 50; // far enough out that the round never runs during the test +await mgr.ensureScheduledRefreshes(renewed.expiryHeight - runAt2); +check('standing order for the fresh coin', mgr.scheduledRefreshes().length === 1); +await mgr.send(sh(`${ALICE} -q address`), 5000); +await mgr.sync(); +check('dropped once the input is gone', mgr.scheduledRefreshes().length === 0); +check('no orphan action left behind', mgr.pendingActions().length === 0, + JSON.stringify(mgr.pendingActions().map((a) => [a.type, a.step]))); + +// --- 5. a claim still works after the input has expired --- +console.log('\n[5] claim after the input expires'); +const change = mgr.vtxos().find((v) => v.state === 'spendable'); +const runAt3 = tipHeight() + 3; +await mgr.ensureScheduledRefreshes(change.expiryHeight - runAt3); +check('standing order for the change coin', mgr.scheduledRefreshes().length === 1); +mine(5); +await sleep(15_000); // the round runs — still no sync, the wallet is away +mine(change.expiryHeight - tipHeight() + 10); // ...and stays away past the coin's expiry +check('input is expired now', tipHeight() > change.expiryHeight, `tip ${tipHeight()} > ${change.expiryHeight}`); +const claimedAgain = () => mgr.state.actions.filter((a) => a.type === 'refresh' && a.step === 'done').length > 1; +for (let i = 0; i < 25 && !claimedAgain(); i++) { await mgr.sync(); mine(1); await sleep(1500); } +if (!claimedAgain()) console.log(' actions:', JSON.stringify(mgr.state.actions.map((a) => [a.type, a.step, a.lastError]))); +const rescued = mgr.vtxos().find((v) => v.id !== change.id && v.state === 'spendable'); +check('expired input forfeited, output claimed', mgr.scheduledRefreshes().length === 0 && !!rescued, + rescued ? `${rescued.amountSat} sat, expiry ${rescued.expiryHeight}` : JSON.stringify(mgr.balance())); +check('expired input is spent, not stuck', mgr._vtxo(change.id).state === 'spent'); +// The replacement's lifetime runs from the round, so it outlives the coin it +// replaced — by margin blocks less than a full term. (This test compresses a +// month into minutes, so the tip can already be past even the new expiry; +// what matters is that the claim reached a coin with a later clock.) +check('replacement carries a later expiry', !!rescued && rescued.expiryHeight > change.expiryHeight, + rescued && `${change.expiryHeight} -> ${rescued.expiryHeight}`); +check('nothing left pending', mgr.balance().pendingSat === 0, JSON.stringify(mgr.balance())); + +console.log(ok ? '\nALL GREEN\n' : '\nFAILURES\n'); +process.exit(ok ? 0 : 1); |
