aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js173
-rw-r--r--src/ark/proto.js1
-rw-r--r--src/ark/refresh.js8
-rw-r--r--src/features/ark.js45
-rw-r--r--src/i18n.js1
-rw-r--r--tools/ark/manager-test.js4
-rw-r--r--tools/ark/scheduled-refresh-test.js151
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);