diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/ark/board-test.js | 125 | ||||
| -rw-r--r-- | tools/ark/manager-test.js | 144 | ||||
| -rw-r--r-- | tools/ark/receive-test.js | 106 | ||||
| -rw-r--r-- | tools/ark/refresh-test.js | 138 | ||||
| -rw-r--r-- | tools/ark/send-test.js | 94 |
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); +} |
