aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/ark/board-test.js125
-rw-r--r--tools/ark/manager-test.js144
-rw-r--r--tools/ark/receive-test.js106
-rw-r--r--tools/ark/refresh-test.js138
-rw-r--r--tools/ark/send-test.js94
5 files changed, 607 insertions, 0 deletions
diff --git a/tools/ark/board-test.js b/tools/ark/board-test.js
new file mode 100644
index 0000000..40cf0aa
--- /dev/null
+++ b/tools/ark/board-test.js
@@ -0,0 +1,125 @@
+// Regtest end-to-end test for the native-JS BOARD path, proven by spending:
+//
+// 1. Build the board funding taproot address from our key + the server's
+// 2. Fund it onchain (miner wallet, board output forced to vout 0)
+// 3. MuSig2-cosign the exit tx via RequestBoardCosign, register the vtxo
+// 4. Mine a confirmation, then ARKOOR-SPEND the fresh board vtxo to alice
+//
+// Usage: bun tools/ark/board-test.js
+
+import { execSync } from 'node:child_process';
+import { mnemonicToSeedSync } from '@scure/bip39';
+import { HDKey } from '@scure/bip32';
+import { hex } from '@scure/base';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import * as musig2 from '@scure/btc-signer/musig2';
+
+import { getArkInfo, decodeAddress, decodeVtxo } from '../../src/ark/proto.js';
+import {
+ buildArkoorSend, cosignWithServer, buildSignedVtxoBytes,
+ registerVtxoTransactions, postArkoorMessage, txid,
+} from '../../src/ark/send.js';
+import {
+ boardFee, p2trAddress, buildBoard, requestBoardCosign,
+ combineBoardSignature, encodeBoardVtxo, registerBoardVtxo,
+} from '../../src/ark/board.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 BOARD_SAT = 30000;
+const SPEND_SAT = 5000;
+
+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) };
+};
+// fresh key for the board vtxo so this test is independent of prior spikes
+const keys = { vtxo: kp("m/350'/0'/2'"), change: kp("m/350'/0'/3'") };
+
+const info = await getArkInfo(ARK);
+const serverPub = hex.decode(info.serverPubkey);
+const tip = Number(await fetch(`${ESPLORA}/blocks/tip/height`).then((r) => r.text()));
+const expiryHeight = tip + info.vtxoExpiryDelta;
+const feeSat = boardFee(BOARD_SAT, info.boardFees);
+console.log(`boarding ${BOARD_SAT} sat (fee ${feeSat}), expiry height ${expiryHeight} (tip ${tip})`);
+
+// --- 1. funding address ---
+const probe = buildBoard({
+ userPub: keys.vtxo.pubkey, serverPub, amountSat: BOARD_SAT, feeSat,
+ expiryHeight, exitDelta: info.vtxoExitDelta, fundingOutpointRaw: new Uint8Array(36),
+});
+const fundingAddr = p2trAddress(probe.fundingTaproot.outputXOnly);
+console.log('funding address:', fundingAddr);
+
+// --- 2. fund it, board output at vout 0 ---
+const btc = (BOARD_SAT / 1e8).toFixed(8);
+const raw = execSync(`${BCLI} createrawtransaction '[]' '[{"${fundingAddr}":${btc}}]'`).toString().trim();
+const funded = JSON.parse(execSync(`${BCLI} fundrawtransaction ${raw} '{"changePosition":1}'`).toString());
+const signed = JSON.parse(execSync(`${BCLI} signrawtransactionwithwallet ${funded.hex}`).toString());
+const fundingTxid = execSync(`${BCLI} sendrawtransaction ${signed.hex}`).toString().trim();
+console.log(`funding tx broadcast: ${fundingTxid}`);
+
+const fundingOutpointRaw = new Uint8Array(36);
+fundingOutpointRaw.set(hex.decode(fundingTxid).reverse(), 0); // vout 0
+
+// --- 3. cosign the exit tx ---
+const board = buildBoard({
+ userPub: keys.vtxo.pubkey, serverPub, amountSat: BOARD_SAT, feeSat,
+ expiryHeight, exitDelta: info.vtxoExitDelta, fundingOutpointRaw,
+});
+const nonces = musig2.nonceGen(keys.vtxo.pubkey, keys.vtxo.privkey, undefined, board.sighash);
+const cosign = await requestBoardCosign(ARK, {
+ amountSat: BOARD_SAT, fundingOutpointRaw, expiryHeight,
+ userPub: keys.vtxo.pubkey, pubNonce: nonces.public,
+});
+const finalSig = combineBoardSignature({ board, serverCosign: cosign, userNonces: nonces, vtxoKeys: keys.vtxo, serverPub });
+console.log('✓ board exit tx MuSig2 signature combined and verified');
+
+const exitTxidInternal = txid(board.exitTx);
+const vtxoBytes = encodeBoardVtxo({
+ userPub: keys.vtxo.pubkey, serverPub, amountSat: BOARD_SAT, feeSat,
+ expiryHeight, exitDelta: info.vtxoExitDelta, fundingOutpointRaw, exitTxidInternal, finalSig,
+});
+const vtxo = decodeVtxo(vtxoBytes); // round-trip sanity
+console.log(`board vtxo: ${vtxo.id} (${vtxo.amountSat} sat)`);
+
+// --- 4. confirm, register, prove spendability by paying alice ---
+execSync(`${BCLI} generatetoaddress 2 $(${BCLI} getnewaddress) >/dev/null`, { shell: '/bin/bash' });
+await new Promise((r) => setTimeout(r, 2000)); // let the server index the blocks
+console.log('mined 2 blocks (board confirmed)');
+
+await registerBoardVtxo(ARK, vtxoBytes);
+console.log('board vtxo registered with server');
+
+const aliceAddr = execSync(`${ALICE} -q address`).toString().trim();
+const alice = decodeAddress(aliceAddr);
+const aliceBlindedId = hex.decode(alice.delivery.find((d) => d.type === 1).data);
+const balBefore = JSON.parse(execSync(`${ALICE} -q balance`).toString()).spendable_sat;
+
+const outputs = [
+ { amountSat: SPEND_SAT, userPubkey: hex.decode(alice.userPubkey) },
+ { amountSat: vtxo.amountSat - SPEND_SAT, userPubkey: keys.change.pubkey },
+];
+await registerVtxoTransactions(ARK, [vtxoBytes]);
+const build = buildArkoorSend({ input: vtxo, outputs, serverPubkey: serverPub, vtxoKeys: keys.vtxo });
+const sigs = await cosignWithServer(ARK, build, { input: vtxo, outputs, vtxoKeys: keys.vtxo, serverPubkey: serverPub });
+const destBytes = buildSignedVtxoBytes({ input: vtxo, outputs, build, finalSigs: sigs, serverPubkey: serverPub, idx: 0 });
+const changeBytes = buildSignedVtxoBytes({ input: vtxo, outputs, build, finalSigs: sigs, serverPubkey: serverPub, idx: 1 });
+await registerVtxoTransactions(ARK, [destBytes, changeBytes]);
+await postArkoorMessage(ARK, aliceBlindedId, [destBytes]);
+console.log(`spent board vtxo: ${SPEND_SAT} sat to alice, ${vtxo.amountSat - SPEND_SAT} sat change`);
+
+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 board -> confirmed onchain -> arkoor-spent to bark CLI');
+} else {
+ console.log('\n❌ alice balance did not increase as expected');
+ process.exit(1);
+}
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);
diff --git a/tools/ark/receive-test.js b/tools/ark/receive-test.js
new file mode 100644
index 0000000..1e6adbe
--- /dev/null
+++ b/tools/ark/receive-test.js
@@ -0,0 +1,106 @@
+// Regtest end-to-end test for the native-JS Ark receive path.
+//
+// 1. Derive vtxo + mailbox keys from a mnemonic (hal-style @scure stack)
+// 2. Encode an ark address with a blinded mailbox delivery
+// 3. Pay it from the bark CLI wallet (alice)
+// 4. Read the mailbox over gRPC-web, decode the VTXO, validate it
+//
+// Usage: bun tools/ark/receive-test.js (from the halwallet repo root)
+
+import { execSync } from 'node:child_process';
+import { mnemonicToSeedSync } from '@scure/bip39';
+import { HDKey } from '@scure/bip32';
+import { hex } from '@scure/base';
+import { secp256k1 } from '@noble/curves/secp256k1';
+
+import {
+ getArkInfo, handshake, encodeAddress, decodeAddress,
+ blindMailboxId, readMailbox,
+} from '../../src/ark/proto.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 -q`;
+const AMOUNT_SAT = 12345;
+
+const MNEMONIC = 'rug rebuild abstract kiwi rifle vast food robust rifle spirit dilemma stumble';
+
+// --- keys: our own derivation scheme (the ASP doesn't care) ---
+const seed = mnemonicToSeedSync(MNEMONIC);
+const master = HDKey.fromMasterSeed(seed);
+const vtxoKey = master.derive("m/350'/0'/0'");
+const mailboxKey = master.derive("m/350'/1'/0'");
+const keys = {
+ vtxo: { privkey: vtxoKey.privateKey, pubkey: secp256k1.getPublicKey(vtxoKey.privateKey, true) },
+ mailbox: { privkey: mailboxKey.privateKey, pubkey: secp256k1.getPublicKey(mailboxKey.privateKey, true) },
+};
+
+// --- 1. ark info + handshake ---
+const info = await getArkInfo(ARK);
+console.log('ark info:', info);
+const hs = await handshake(ARK);
+console.log('handshake:', hs);
+
+// --- 2. build our address ---
+// NB the blinding DH partner is the *mailbox service* pubkey from ArkInfo,
+// not the server identity pubkey.
+const blindedId = blindMailboxId(keys.mailbox.pubkey, hex.decode(info.mailboxPubkey), keys.vtxo.privkey);
+const address = encodeAddress({
+ testnet: info.network !== 'bitcoin',
+ serverPubkey: hex.decode(info.serverPubkey),
+ userPubkey: keys.vtxo.pubkey,
+ blindedMailboxId: blindedId,
+});
+console.log('\nour ark address:', address);
+console.log('round-trip decode:', decodeAddress(address));
+
+// --- 3. pay it from the bark CLI ---
+console.log(`\npaying ${AMOUNT_SAT} sat from alice (bark CLI)...`);
+execSync(`${ALICE} send ${address} "${AMOUNT_SAT} sat"`, { stdio: 'inherit' });
+
+// --- 4. read mailbox, decode, validate ---
+let received = null;
+for (let attempt = 0; attempt < 15 && !received; attempt++) {
+ const { messages } = await readMailbox(ARK, keys.mailbox);
+ for (const m of messages) {
+ if (m.kind === 'arkoor' && m.vtxos.length) received = m;
+ }
+ if (!received) await new Promise((res) => setTimeout(res, 2000));
+}
+if (!received) {
+ console.error('❌ no arkoor message in mailbox after 30s');
+ process.exit(1);
+}
+
+console.log(`\nmailbox message (checkpoint ${received.checkpoint}), ${received.vtxos.length} vtxo(s):`);
+const ourPub = hex.encode(keys.vtxo.pubkey);
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+for (const v of received.vtxos) {
+ console.log(`\nvtxo ${v.id}`);
+ console.log(` amount: ${v.amountSat} sat, expiry height: ${v.expiryHeight}, exit delta: ${v.exitDelta}`);
+ console.log(` anchor: ${v.anchorPoint.txid}:${v.anchorPoint.vout}, genesis depth: ${v.genesis.length}`);
+ console.log(` transitions: ${v.genesis.map((g) => g.transition.type).join(' -> ')}`);
+
+ check('policy is pubkey', v.policy.type === 'pubkey', v.policy.type);
+ check('paid to our key', v.policy.userPubkey === ourPub, v.policy.userPubkey);
+ check('amount matches', v.amountSat === AMOUNT_SAT, String(v.amountSat));
+ check('server pubkey matches ark info', v.serverPubkey === info.serverPubkey);
+ check('all genesis transitions signed', v.genesis.every((g) => g.transition.signature));
+
+ const st = await fetch(`${ESPLORA}/tx/${v.anchorPoint.txid}/status`).then((r) => r.json());
+ check('chain anchor tx confirmed onchain', st.confirmed === true, `esplora height ${st.block_height}`);
+
+ const tip = await fetch(`${ESPLORA}/blocks/tip/height`).then((r) => r.text());
+ check('not expired', v.expiryHeight > Number(tip), `expires ${v.expiryHeight}, tip ${tip}`);
+}
+
+console.log(ok
+ ? `\n✅ SUCCESS: native-JS client received + decoded + validated an off-chain Ark payment (balance: ${received.vtxos.reduce((n, v) => n + v.amountSat, 0)} sat)`
+ : '\n❌ some checks failed');
+process.exit(ok ? 0 : 1);
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);
+}
diff --git a/tools/ark/send-test.js b/tools/ark/send-test.js
new file mode 100644
index 0000000..181f877
--- /dev/null
+++ b/tools/ark/send-test.js
@@ -0,0 +1,94 @@
+// Regtest end-to-end test for the native-JS arkoor SEND path (MuSig2 cosign).
+//
+// Spends the VTXO received in receive-test.js: pays alice (bark CLI) 4000 sat
+// with change back to a fresh key of ours, entirely from JS.
+//
+// Usage: bun tools/ark/send-test.js (run receive-test.js first to get funded)
+
+import { execSync } from 'node:child_process';
+import { mnemonicToSeedSync } from '@scure/bip39';
+import { HDKey } from '@scure/bip32';
+import { hex } from '@scure/base';
+import { secp256k1 } from '@noble/curves/secp256k1';
+
+import { getArkInfo, decodeAddress, readMailbox, decodeVtxo } from '../../src/ark/proto.js';
+import {
+ buildArkoorSend, cosignWithServer, buildSignedVtxoBytes,
+ registerVtxoTransactions, postArkoorMessage,
+} from '../../src/ark/send.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 SEND_SAT = 4000;
+
+const MNEMONIC = 'rug rebuild abstract kiwi rifle vast food robust rifle spirit dilemma stumble';
+const seed = mnemonicToSeedSync(MNEMONIC);
+const master = HDKey.fromMasterSeed(seed);
+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'/0'"), mailbox: kp("m/350'/1'/0'"), change: kp("m/350'/0'/1'") };
+
+const info = await getArkInfo(ARK);
+const serverPubkey = hex.decode(info.serverPubkey);
+
+// --- find our latest vtxo from the mailbox ---
+const { messages } = await readMailbox(ARK, keys.mailbox);
+const vtxos = messages.filter((m) => m.kind === 'arkoor').flatMap((m) => m.vtxos)
+ .filter((v) => v.policy.userPubkey === hex.encode(keys.vtxo.pubkey));
+if (!vtxos.length) { console.error('no vtxos in mailbox — run receive-test.js first'); process.exit(1); }
+const input = vtxos[vtxos.length - 1];
+console.log(`input vtxo: ${input.id} (${input.amountSat} sat, genesis depth ${input.genesis.length})`);
+
+// --- alice's address -> destination pubkey + her blinded mailbox id ---
+const aliceAddr = execSync(`${ALICE} -q address`).toString().trim();
+const alice = decodeAddress(aliceAddr);
+console.log(`alice pubkey: ${alice.userPubkey}`);
+const aliceBlindedId = hex.decode(alice.delivery.find((d) => d.type === 1).data);
+
+const outputs = [
+ { amountSat: SEND_SAT, userPubkey: hex.decode(alice.userPubkey) },
+ { amountSat: input.amountSat - SEND_SAT, userPubkey: keys.change.pubkey },
+];
+
+const aliceBalanceBefore = JSON.parse(execSync(`${ALICE} -q balance`).toString()).spendable_sat;
+
+// --- 1. pre-register our input chain ---
+await registerVtxoTransactions(ARK, [input._raw.bytes]);
+console.log('input chain registered with server');
+
+// --- 2. build txs + sighashes ---
+const build = buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys: keys.vtxo });
+console.log(`checkpoint txid: ${hex.encode(build.checkpointTxid.slice().reverse())}`);
+console.log(`sighashes: ${build.sighashes.length} (1 checkpoint + ${outputs.length} arkoor)`);
+
+// --- 3. MuSig2 cosign ceremony with the ASP ---
+const finalSigs = await cosignWithServer(ARK, build, { input, outputs, vtxoKeys: keys.vtxo, serverPubkey });
+console.log(`✓ ${finalSigs.length} MuSig2 signatures combined and verified against taproot output keys`);
+
+// --- 4. assemble signed vtxos, round-trip check, register, deliver ---
+const destBytes = buildSignedVtxoBytes({ input, outputs, build, finalSigs, serverPubkey, idx: 0 });
+const changeBytes = buildSignedVtxoBytes({ input, outputs, build, finalSigs, serverPubkey, idx: 1 });
+const dest = decodeVtxo(destBytes); // local round-trip sanity check
+console.log(`destination vtxo: ${dest.id} (${dest.amountSat} sat, depth ${dest.genesis.length})`);
+const change = decodeVtxo(changeBytes);
+console.log(`change vtxo: ${change.id} (${change.amountSat} sat) -> our change key`);
+
+await registerVtxoTransactions(ARK, [destBytes, changeBytes]);
+console.log('signed vtxos registered with server');
+
+await postArkoorMessage(ARK, aliceBlindedId, [destBytes]);
+console.log('posted to alice\'s mailbox');
+
+// --- 5. verify alice received it (balance triggers a wallet sync) ---
+const aliceBalanceAfter = JSON.parse(execSync(`${ALICE} -q balance`).toString()).spendable_sat;
+console.log(`\nalice balance: ${aliceBalanceBefore} -> ${aliceBalanceAfter} sat`);
+
+if (aliceBalanceAfter === aliceBalanceBefore + SEND_SAT) {
+ console.log(`\n✅ SUCCESS: native-JS wallet sent ${SEND_SAT} sat over Ark via MuSig2 cosign — bark CLI accepted the vtxo`);
+} else {
+ console.log('\n❌ alice balance did not increase as expected');
+ process.exit(1);
+}