diff options
| author | Adam Soltys <[email protected]> | 2026-09-13 23:52:39 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-13 23:52:39 -0700 |
| commit | 4131e5ee24c4891caffdd8f4ca903ee973d104f7 (patch) | |
| tree | 759df7cebc4de890fa397bdd407857f954200228 /tools/ark/scheduled-refresh-test.js | |
| parent | 4c09f4de3cddc6927a6108baaa4be3a11eef49c4 (diff) | |
| download | coinosv3-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
Diffstat (limited to 'tools/ark/scheduled-refresh-test.js')
| -rw-r--r-- | tools/ark/scheduled-refresh-test.js | 151 |
1 files changed, 151 insertions, 0 deletions
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); |
