diff options
Diffstat (limited to 'tools/ark')
| -rw-r--r-- | tools/ark/bg-renewal-test.js | 135 |
1 files changed, 135 insertions, 0 deletions
diff --git a/tools/ark/bg-renewal-test.js b/tools/ark/bg-renewal-test.js new file mode 100644 index 0000000..0a85816 --- /dev/null +++ b/tools/ark/bg-renewal-test.js @@ -0,0 +1,135 @@ +// The service worker tends a closed wallet's renewals, on regtest, end to end: +// +// the app receives a coin and writes the background mirror -> "closes" -> +// the worker (tendRenewals over the mirror, keys from the mirror alone) +// registers a standing order for the coin -> the worker SPENDS from the +// mirror (an NWC pay) which voids that order; the next wake covers the +// change coin -> the scheduled height passes, the server runs the round +// with nobody online -> the next wake claims the renewed coin into the +// mirror -> the app returns and merges the worker's state: fresh coin, +// old one spent, the order gone. +// +// Usage: bun tools/ark/bg-renewal-test.js +// Needs the ark-regtest stack (bc, ark-electrs, captaind on :3535). + +import { execSync } from 'node:child_process'; +import { hex } from '@scure/base'; +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'; +import { buildBg } from '../../src/nwc-bg.js'; +import { tendRenewals, bgManager } from '../../src/nwc-respond.js'; +import { mergeArkStates } from '../../src/features/ark.js'; +import { ALICE, aliceReady } from './alice.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 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; +}; + +// --- the app's wallet --- +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 app = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init(); +await aliceReady(); + +const receive = async (sat) => { + const before = app.balance().spendableSat; + sh(`${ALICE} -q send ${app.address()} "${sat} sat"`); + for (let i = 0; i < 15 && app.balance().spendableSat < before + sat; i++) { await app.sync(); await sleep(1000); } + return app.balance().spendableSat - before; +}; + +// --- the mirror the worker answers from (what writeBgMirror writes) --- +let rec = null; +const writeMirror = () => { + rec = buildBg({ + ark: { arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest', serverPubkey: app.info.serverPubkey }, + mgrState: JSON.parse(JSON.stringify(app.state)), + keyFor: (chain, i) => hex.encode(account.deriveChild(chain).deriveChild(i).privateKey), + connections: [], + }); +}; +const recs = async () => [{ walletKey: 'w1', rec }]; +const saveFn = async (k, r) => { rec = r; }; +const log = (m) => console.log(' [sw-renew] ' + m); +const tend = (margin) => tendRenewals({ recordsFn: recs, saveFn, log, deadline: Date.now() + 60_000, margin }); +const mirrorCoins = () => (rec.mgr.vtxos || []).filter((v) => v.state === 'spendable'); + +console.log('\n[1] the app receives a coin, mirrors it, and closes'); +check('received 20000 sat', (await receive(20000)) === 20000, JSON.stringify(app.balance())); +writeMirror(); +const coin = mirrorCoins()[0]; +check('the mirror holds the coin and its key', !!coin && !!rec.keys[String(coin.keyIndex)] && !!rec.key4); +check('no standing order yet, on either side', !(app.state.scheduled || []).length && !(rec.mgr.scheduled || []).length); + +console.log('\n[2] a wake: the worker registers a standing order from the mirror alone'); +// aim the order a few blocks out so the test needn't mine a month +const n1 = await tend(coin.expiryHeight - (tipHeight() + 3)); +const order1 = (rec.mgr.scheduled || [])[0]; +check('one order registered', n1 === 1 && (rec.mgr.scheduled || []).length === 1); +check('with the server\'s unlock hash', !!order1?.unlockHash, order1?.unlockHash?.slice(0, 16)); +check('the coin stays spendable in the mirror', mirrorCoins().some((v) => v.id === coin.id)); +check('the output key it burned is in the mirror', !!rec.keys[String(order1?.outKeyIndex)]); +check('the app knows nothing of it yet', !(app.state.scheduled || []).length); +check('a second wake registers nothing more', (await tend(coin.expiryHeight - (tipHeight() + 3))) === 0 && rec.mgr.scheduled.length === 1); + +console.log('\n[3] the worker spends from the mirror (an NWC pay): the order dies, the change gets its own'); +const worker = await bgManager(rec, 'w1', saveFn); +await worker.send(sh(`${ALICE} -q address`), 5000); +await worker.sync(); +const change = mirrorCoins().find((v) => v.id !== coin.id); +check('change coin in the mirror', !!change, change && `${change.amountSat} sat`); +check('the spent coin\'s order is gone', !(rec.mgr.scheduled || []).some((s) => s.inputIds.includes(coin.id))); +const n2 = await tend(change.expiryHeight - (tipHeight() + 3)); +const order2 = (rec.mgr.scheduled || []).find((s) => s.inputIds.includes(change.id)); +check('the next wake covers the change coin', n2 === 1 && !!order2, JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds))); +check('scheduled a few blocks out', order2?.scheduledHeight === tipHeight() + 3, `${order2?.scheduledHeight} vs ${tipHeight() + 3}`); + +console.log('\n[4] the round runs with nobody online; the next wake claims it into the mirror'); +mine(3 + 2); +await sleep(15_000); +const claimed = () => (rec.mgr.actions || []).some((a) => a.type === 'refresh' && a.step === 'done'); +for (let i = 0; i < 25 && !claimed(); i++) { await tend(change.expiryHeight - (tipHeight() + 3)); mine(1); await sleep(1500); } +if (!claimed()) console.log(' actions:', JSON.stringify((rec.mgr.actions || []).map((a) => [a.type, a.step, a.lastError]))); +const renewed = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id); +const renewedId = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id)?.id; +check('the consumed order is replaced by one for the fresh coin, in the same wake', (rec.mgr.scheduled || []).length === 1 && !!renewedId && rec.mgr.scheduled[0].inputIds.includes(renewedId), JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds))); +check('the change coin is spent', rec.mgr.vtxos.find((v) => v.id === change.id)?.state === 'spent'); +check('a fresh coin sits in the mirror', !!renewed, renewed && `${renewed.amountSat} sat`); +check('worth the change minus the scheduled fee', !!renewed && renewed.amountSat === change.amountSat - order2.feeSat, `${renewed?.amountSat} vs ${change?.amountSat} - ${order2?.feeSat}`); +check('with its expiry clock restarted', !!renewed && renewed.expiryHeight > change.expiryHeight, renewed && `${change.expiryHeight} -> ${renewed.expiryHeight}`); +check('the renewal is in the mirror\'s history', (rec.mgr.movements || []).some((m) => m.type === 'refresh' && m.status === 'complete')); + +console.log('\n[5] the app returns and merges the worker\'s state'); +// what mergeBgWorkerState does on connect: additive merge, the worker's spent flips win, reconcile +const merged = mergeArkStates(app.state, rec.mgr); +const workerState = new Map(rec.mgr.vtxos.map((v) => [v.id, v.state])); +for (const v of merged.vtxos) if (workerState.get(v.id) === 'spent') v.state = 'spent'; +merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); +app.state = merged; app._save(); +await app.reconcile().catch((e) => console.log(' reconcile:', e.message)); +await app.sync(); +const appCoins = app.vtxos().filter((v) => v.state === 'spendable'); +check('the app holds the fresh coin and nothing stale', appCoins.length === 1 && appCoins[0].id === renewed?.id, JSON.stringify(appCoins.map((v) => [v.id.slice(0, 8), v.amountSat, v.state]))); +check('balance is the change minus the renewal fee', app.balance().spendableSat === change.amountSat - order2.feeSat, JSON.stringify(app.balance())); +check('the app adopts the worker\'s order for the fresh coin', (app.state.scheduled || []).length === 1 && app.state.scheduled[0].inputIds.includes(renewed?.id) && app.state.scheduled[0].unlockHash === rec.mgr.scheduled[0].unlockHash, JSON.stringify((app.state.scheduled || []).map((s) => s.inputIds))); +check('the app is not busy', app.pendingActions().length === 0, JSON.stringify(app.pendingActions().map((a) => [a.type, a.step]))); +check('the renewal shows in the app\'s history once', app.movements().filter((m) => m.type === 'refresh' && m.status === 'complete').length === 1); + +console.log(ok ? '\nALL GREEN\n' : '\nFAILURES\n'); +process.exit(ok ? 0 : 1); |
