aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/refresh-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/refresh-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/refresh-test.js')
-rw-r--r--tools/ark/refresh-test.js138
1 files changed, 138 insertions, 0 deletions
diff --git a/tools/ark/refresh-test.js b/tools/ark/refresh-test.js
new file mode 100644
index 0000000..f23ee05
--- /dev/null
+++ b/tools/ark/refresh-test.js
@@ -0,0 +1,138 @@
+// Regtest end-to-end test for the native-JS DELEGATED ROUND REFRESH (hArk),
+// proven by spending the refreshed vtxo:
+//
+// 1. alice (bark CLI) funds us with a fresh vtxo
+// 2. Submit delegated round participation; wait for the round to execute
+// 3. Mine confirmations for the round funding tx
+// 4. MuSig2-cosign the hash-locked leaf, forfeit the old vtxo, get the preimage
+// 5. Patch + validate the refreshed vtxo, then arkoor-spend it back to alice
+//
+// Usage: bun tools/ark/refresh-test.js
+
+import { execSync } from 'node:child_process';
+import { mnemonicToSeedSync } from '@scure/bip39';
+import { HDKey } from '@scure/bip32';
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import { secp256k1 } from '@noble/curves/secp256k1';
+
+import { getArkInfo, encodeAddress, blindMailboxId, readMailbox, decodeVtxo, decodeAddress } from '../../src/ark/proto.js';
+import {
+ buildArkoorSend, cosignWithServer, buildSignedVtxoBytes,
+ registerVtxoTransactions, postArkoorMessage,
+} from '../../src/ark/send.js';
+import {
+ submitRoundParticipation, roundParticipationStatus, parseTx,
+ cosignHarkLeaf, requestForfeitNonces, forfeitBundle, forfeitVtxos,
+ encodeVtxoFromDecoded,
+} from '../../src/ark/refresh.js';
+
+const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
+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 FUND_SAT = 12345;
+const SPEND_SAT = 3000;
+
+const MNEMONIC = 'rug rebuild abstract kiwi rifle vast food robust rifle spirit dilemma stumble';
+const master = HDKey.fromMasterSeed(mnemonicToSeedSync(MNEMONIC));
+const kp = (path) => {
+ const k = master.derive(path);
+ return { privkey: k.privateKey, pubkey: secp256k1.getPublicKey(k.privateKey, true) };
+};
+const keys = { vtxo: kp("m/350'/0'/5'"), mailbox: kp("m/350'/1'/0'"), change: kp("m/350'/0'/6'") };
+
+const info = await getArkInfo(ARK);
+const serverPub = hex.decode(info.serverPubkey);
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// --- 1. get funded on a fresh key ---
+const blinded = blindMailboxId(keys.mailbox.pubkey, hex.decode(info.mailboxPubkey), keys.vtxo.privkey);
+const addr = encodeAddress({
+ testnet: true, serverPubkey: serverPub, userPubkey: keys.vtxo.pubkey, blindedMailboxId: blinded,
+});
+execSync(`${ALICE} -q send ${addr} "${FUND_SAT} sat"`, { stdio: 'ignore' });
+let input = null;
+for (let i = 0; i < 10 && !input; i++) {
+ const { messages } = await readMailbox(ARK, keys.mailbox);
+ const mine = messages.filter((m) => m.kind === 'arkoor').flatMap((m) => m.vtxos)
+ .filter((v) => v.policy.userPubkey === hex.encode(keys.vtxo.pubkey));
+ input = mine[mine.length - 1] ?? null;
+ if (!input) await sleep(1500);
+}
+if (!input) { console.error('funding vtxo never arrived'); process.exit(1); }
+console.log(`input vtxo: ${input.id} (${input.amountSat} sat, expiry ${input.expiryHeight})`);
+
+// --- 2. refresh fee + participation ---
+const tipNow = Number(execSync(`${BCLI} getblockcount`).toString().trim());
+const expiryBlocks = input.expiryHeight - tipNow;
+const entry = info.refreshFees.ppmExpiryTable.filter((e) => e.thresholdBlocks <= expiryBlocks).pop();
+const fee = info.refreshFees.baseFeeSat + Math.floor((input.amountSat * (entry?.ppm ?? 0)) / 1_000_000);
+const outputs = [{ amountSat: input.amountSat - fee, userPubkey: keys.vtxo.pubkey }];
+console.log(`refresh fee: ${fee} sat (ppm ${entry?.ppm}, ${expiryBlocks} blocks to expiry) -> requesting ${outputs[0].amountSat} sat`);
+
+await registerVtxoTransactions(ARK, [input._raw.bytes]);
+const unlockHash = await submitRoundParticipation(ARK, { inputs: [{ vtxo: input, keys: keys.vtxo }], outputs });
+console.log(`participation submitted, unlock hash ${hex.encode(unlockHash)}`);
+
+// --- 3. wait for the round, confirm the funding tx ---
+let status;
+for (let i = 0; i < 40; i++) {
+ status = await roundParticipationStatus(ARK, unlockHash);
+ if (status.status !== 0 && status.fundingTx) break;
+ await sleep(2000);
+}
+if (!status?.fundingTx) { console.error('round never executed'); process.exit(1); }
+const fundingTx = parseTx(status.fundingTx);
+console.log(`round executed: funding tx ${fundingTx.txid}, ${status.outputVtxos.length} output vtxo(s)`);
+execSync(`${BCLI} generatetoaddress 2 $(${BCLI} getnewaddress) >/dev/null`, { shell: '/bin/bash' });
+await sleep(2000);
+
+const newVtxo = decodeVtxo(status.outputVtxos[0]);
+console.log(`new vtxo: ${newVtxo.id} (${newVtxo.amountSat} sat, expiry ${newVtxo.expiryHeight}, transitions: ${newVtxo.genesis.map((g) => g.transition.type).join(' -> ')})`);
+if (newVtxo.amountSat !== outputs[0].amountSat) throw new Error('unexpected new vtxo amount');
+if (newVtxo.policy.userPubkey !== hex.encode(keys.vtxo.pubkey)) throw new Error('new vtxo not ours');
+
+// --- 4. leaf cosign + forfeit dance ---
+const leafSig = await cosignHarkLeaf(ARK, newVtxo, fundingTx, keys.vtxo, serverPub);
+console.log('✓ hash-locked leaf MuSig2 cosigned (script-spend, no tweak)');
+
+const [serverNonce] = await requestForfeitNonces(ARK, unlockHash, [input.point.raw]);
+const bundle = forfeitBundle({ input, unlockHash, vtxoKeys: keys.vtxo, serverPub, serverNonce });
+const preimage = await forfeitVtxos(ARK, [bundle]);
+if (hex.encode(sha256(preimage)) !== hex.encode(unlockHash)) throw new Error('preimage does not match unlock hash');
+console.log(`✓ old vtxo forfeited, unlock preimage received: ${hex.encode(preimage)}`);
+
+// --- 5. patch the vtxo with sig + preimage, then spend it to alice ---
+const last = newVtxo.genesis[newVtxo.genesis.length - 1];
+last.transition.signature = hex.encode(leafSig);
+last.transition.unlock = { preimage: hex.encode(preimage) };
+const refreshed = decodeVtxo(encodeVtxoFromDecoded(newVtxo));
+console.log(`refreshed vtxo finalized: ${refreshed.id} (${refreshed.amountSat} sat)`);
+
+const aliceAddr = execSync(`${ALICE} -q address`).toString().trim();
+const alice = decodeAddress(aliceAddr);
+const aliceBlinded = hex.decode(alice.delivery.find((d) => d.type === 1).data);
+const balBefore = JSON.parse(execSync(`${ALICE} -q balance`).toString()).spendable_sat;
+
+const spendOutputs = [
+ { amountSat: SPEND_SAT, userPubkey: hex.decode(alice.userPubkey) },
+ { amountSat: refreshed.amountSat - SPEND_SAT, userPubkey: keys.change.pubkey },
+];
+await registerVtxoTransactions(ARK, [refreshed._raw.bytes]);
+const build = buildArkoorSend({ input: refreshed, outputs: spendOutputs, serverPubkey: serverPub, vtxoKeys: keys.vtxo });
+const sigs = await cosignWithServer(ARK, build, { input: refreshed, outputs: spendOutputs, vtxoKeys: keys.vtxo, serverPubkey: serverPub });
+const destBytes = buildSignedVtxoBytes({ input: refreshed, outputs: spendOutputs, build, finalSigs: sigs, serverPubkey: serverPub, idx: 0 });
+const changeBytes = buildSignedVtxoBytes({ input: refreshed, outputs: spendOutputs, build, finalSigs: sigs, serverPubkey: serverPub, idx: 1 });
+await registerVtxoTransactions(ARK, [destBytes, changeBytes]);
+await postArkoorMessage(ARK, aliceBlinded, [destBytes]);
+
+const balAfter = JSON.parse(execSync(`${ALICE} -q balance`).toString()).spendable_sat;
+console.log(`alice balance: ${balBefore} -> ${balAfter} sat`);
+
+if (balAfter === balBefore + SPEND_SAT) {
+ console.log('\n✅ SUCCESS: native-JS delegated refresh (round + forfeit) -> refreshed vtxo spent to bark CLI');
+} else {
+ console.log('\n❌ alice balance did not increase as expected');
+ process.exit(1);
+}