aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-13 23:52:39 -0700
committerAdam Soltys <[email protected]>2026-09-13 23:52:39 -0700
commit4131e5ee24c4891caffdd8f4ca903ee973d104f7 (patch)
tree759df7cebc4de890fa397bdd407857f954200228
parent4c09f4de3cddc6927a6108baaa4be3a11eef49c4 (diff)
downloadcoinosv3-4131e5ee24c4891caffdd8f4ca903ee973d104f7.tar.gz
coinosv3-4131e5ee24c4891caffdd8f4ca903ee973d104f7.zip
The renewal happens whether or not anyone opens the app.
Every coin expires, and until now the only thing standing between a balance and its expiry was maybeAutoRefresh: it renews when the wallet is opened inside the last 96 blocks, roughly sixteen hours. Bark's own developers say the same premise fails on mobile — people don't reliably open a wallet at the right hour, which is why every mobile Ark client they know of has moved to delegated refreshes and why they are building server-driven renewal. captaind already has it, and ark.coinos.io has been carrying the column unused: SubmitRoundParticipation takes a scheduled_height, and the server holds that participation out of every round until the height arrives, then runs it with nobody online — the round tree branch is signed by the server, which is the whole point of a delegated refresh. The fee is priced at the height it will run at, so a standing order lands in the free bracket rather than today's. So each spendable coin now gets one registered 36 blocks before its expiry, six hourly rounds of slack. It is deliberately not an action: the coin stays spendable, the wallet never reads as busy, and spending it simply kills the order — the server drops a participation whose input is gone, and _driveScheduled drops ours. The claim comes later, whenever the wallet next opens: the mailbox breadcrumb we already post wakes it, and the leaf cosign and forfeit are the ordinary refresh path from there. Nothing about the input's expiry blocks that claim, which is what makes this worth having. A wallet that goes quiet for a month comes back to a coin already renewed and anchored in a fresh tree, instead of one the ASP has swept and would have to honor out of goodwill. One cycle per visit is all it can buy — the replacement's id doesn't exist yet, so its own backstop waits for the next time the app is opened. tools/ark/scheduled-refresh-test.js covers the five behaviours on regtest: the server holds the order until its height, runs it while the wallet is closed, the wallet claims on return, a spend voids the order, and a claim still works after the input has EXPIRED (the last one mines a coin's whole lifetime, so it also re-refreshes the funding wallet first — an expired vtxo can be renewed but not sent). Riding along: the expired-coin notice that was already sitting in the working tree, and manager-test's tampered-vtxo check, which had been reading base64 vtxo bytes as hex since they moved to IndexedDB. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019fMdLypdg5JaU7bdeUFF8v
-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);