1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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);
|