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
136
137
138
139
140
141
142
143
|
// End-to-end test of the persistent ArkManager on regtest:
//
// receive (full validation) -> send -> CRASH mid-send + resume from storage
// -> board -> refresh/consolidate -> spend consolidated -> reload wallet
//
// Storage is JSON-round-tripped on every save (like localStorage would),
// and "restarts" build a brand-new manager over the same stored bytes.
//
// Usage: bun tools/ark/manager-test.js
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 { hex } from '@scure/base';
import { ArkManager } from '../../src/ark/manager.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';
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 aliceBalance = () => JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat;
const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`);
let ok = true;
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
if (!cond) ok = false;
};
// localStorage-faithful storage: JSON round-trip on both directions
const store = { raw: null };
const storage = {
load: () => store.raw ? JSON.parse(store.raw) : null,
save: (s) => { store.raw = JSON.stringify(s); },
};
// fresh wallet every run so the test is re-runnable
const mnemonic = generateMnemonic(wordlist);
const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'");
const newManager = () => new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
let mgr = await newManager();
// --- 1. receive with full validation ---
console.log('\n[1] receive');
const addr = mgr.address();
sh(`${ALICE} -q send ${addr} "20000 sat"`);
for (let i = 0; i < 10 && !mgr.balance().spendableSat; i++) { await mgr.sync(); await sleep(1500); }
check('received 20000 sat', mgr.balance().spendableSat === 20000, JSON.stringify(mgr.balance()));
check('movement logged', mgr.movements().some((m) => m.type === 'receive' && m.status === 'complete'));
// tampered vtxo must fail validation
const rec = mgr.vtxos()[0];
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 {
await validateVtxo(decodeVtxo(tampered), { serverPubkey: mgr.serverPub, chain: mgr.chain });
} catch { rejected = true; }
check('tampered vtxo rejected by validator', rejected);
// --- 2. plain send ---
console.log('\n[2] send');
const aliceAddr = () => sh(`${ALICE} -q address`);
let before = aliceBalance();
await mgr.send(aliceAddr(), 5000);
check('alice +5000', aliceBalance() === before + 5000);
check('change tracked', mgr.balance().spendableSat === 15000, JSON.stringify(mgr.balance()));
// --- 3. crash mid-send: kill the network after cosign, resume from storage ---
console.log('\n[3] crash mid-send + resume');
before = aliceBalance();
const realFetch = globalThis.fetch;
let registerCalls = 0;
globalThis.fetch = (url, opts) => {
if (String(url).includes('RegisterVtxoTransactions') && ++registerCalls === 2) {
// the register of the signed outputs — the state is already 'cosigned'
return Promise.reject(new Error('simulated crash: network down'));
}
return realFetch(url, opts);
};
let crashed = false;
try { await mgr.send(aliceAddr(), 4000); } catch { crashed = true; }
globalThis.fetch = realFetch;
check('send crashed mid-flight', crashed);
check('action persisted at cosigned', JSON.parse(store.raw).actions.some((a) => a.step === 'cosigned'));
// "restart": brand-new manager over the same stored bytes
mgr = await newManager();
await mgr.sync(); // resumes pending actions
check('resumed send completed', mgr.pendingActions().length === 0);
check('alice +4000 after resume', aliceBalance() === before + 4000);
check('balance after resumed send', mgr.balance().spendableSat === 11000, JSON.stringify(mgr.balance()));
// --- 4. board ---
console.log('\n[4] board');
const { actionId, fundingAddress, feeSat } = await mgr.startBoard(30000);
const raw = sh(`${BCLI} createrawtransaction '[]' '[{"${fundingAddress}":0.00030000}]'`);
const funded = JSON.parse(sh(`${BCLI} fundrawtransaction ${raw} '{"changePosition":1}'`));
const signed = JSON.parse(sh(`${BCLI} signrawtransactionwithwallet ${funded.hex}`));
const fundingTxid = sh(`${BCLI} sendrawtransaction ${signed.hex}`);
await mgr.completeBoard(actionId, fundingTxid);
check('board waits for confirmations', mgr.pendingActions().length === 1);
mine(2);
for (let i = 0; i < 20 && mgr.pendingActions().length; i++) { await sleep(2000); await mgr.sync(); }
check('board vtxo spendable', mgr.balance().spendableSat === 11000 + 30000 - feeSat, JSON.stringify(mgr.balance()));
// --- 5. refresh: consolidate everything into one vtxo ---
console.log('\n[5] refresh / consolidate');
const vtxosBefore = mgr.vtxos().filter((v) => v.state === 'spendable').length;
await mgr.refresh();
for (let i = 0; i < 40 && mgr.pendingActions().length; i++) {
await sleep(2000);
mine(1); // keep confirming the round funding tx
await mgr.sync();
}
const spendable = mgr.vtxos().filter((v) => v.state === 'spendable');
check('refresh completed', mgr.pendingActions().length === 0);
check(`consolidated ${vtxosBefore} vtxos into 1`, spendable.length === 1, `${spendable.length} spendable`);
console.log(` balance after refresh: ${mgr.balance().spendableSat} sat`);
// --- 6. spend the consolidated vtxo, then reload wallet from storage ---
console.log('\n[6] spend consolidated + reload');
before = aliceBalance();
await mgr.send(aliceAddr(), 6000);
check('alice +6000 from consolidated vtxo', aliceBalance() === before + 6000);
const balBefore = mgr.balance();
mgr = await newManager(); // reload
check('state survives reload', JSON.stringify(mgr.balance()) === JSON.stringify(balBefore), JSON.stringify(mgr.balance()));
check('movement history intact', mgr.movements().length >= 5, `${mgr.movements().length} movements`);
console.log(ok ? '\n✅ SUCCESS: ArkManager receive/send/crash-resume/board/refresh/reload all pass'
: '\n❌ some checks failed');
process.exit(ok ? 0 : 1);
|