aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/manager-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-03 14:46:36 -0700
committerAdam Soltys <[email protected]>2026-07-03 14:46:36 -0700
commit5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9 (patch)
treec2de49506f15aad217e5df02e5ec03b19ce1491c /tools/ark/manager-test.js
parent8029e4bd43cc425d31f50286d7ae4e94b4206ace (diff)
downloadcoinosv3-5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9.tar.gz
coinosv3-5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9.zip
Ark: native-JS protocol client + persistent ArkManager
Speaks Second's bark/captaind Ark protocol directly from the browser — gRPC-web over fetch with hand-rolled protobuf, no wasm, no new deps (crypto via the existing @scure/@noble stack incl. btc-signer's BIP-327 musig2 module). src/ark: - proto.js transport, ark address codec, mailbox blinding/auth, VTXO ProtocolEncoding v2 decode - send.js arkoor send: checkpoint+arkoor txs, BIP-341 sighashes, MuSig2 cosign ceremony, signed-VTXO assembly - board.js onchain -> ark: funding taproot, exit-tx cosign - refresh.js delegated round (hArk): participation, tx-chain reconstruction, hash-locked leaf cosign, forfeit swap - validate.js receive-side verification: chain must land on the vtxo point, anchor checked onchain, every transition sig verified - manager.js persistent wallet layer: storage-checkpointed step-machine actions (send/board/refresh) that survive crashes, mailbox sync with validation, multi-input consolidation refresh tools/ark: end-to-end regtest tests (receive, send, board, refresh, manager incl. crash-mid-send + resume), all passing against captaind with the bark CLI as counterparty. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'tools/ark/manager-test.js')
-rw-r--r--tools/ark/manager-test.js144
1 files changed, 144 insertions, 0 deletions
diff --git a/tools/ark/manager-test.js b/tools/ark/manager-test.js
new file mode 100644
index 0000000..3fab3cf
--- /dev/null
+++ b/tools/ark/manager-test.js
@@ -0,0 +1,144 @@
+// 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 } 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 = `bitcoin-cli -regtest -datadir=${process.env.HOME}/ark-regtest/bitcoind -rpcport=18543 -rpcwallet=miner`;
+
+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 = hex.decode(rec.bytes);
+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);
+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);