diff options
| -rw-r--r-- | src/ark/board.js | 114 | ||||
| -rw-r--r-- | src/ark/manager.js | 452 | ||||
| -rw-r--r-- | src/ark/proto.js | 408 | ||||
| -rw-r--r-- | src/ark/refresh.js | 399 | ||||
| -rw-r--r-- | src/ark/send.js | 364 | ||||
| -rw-r--r-- | src/ark/validate.js | 93 | ||||
| -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 |
11 files changed, 2437 insertions, 0 deletions
diff --git a/src/ark/board.js b/src/ark/board.js new file mode 100644 index 0000000..3352823 --- /dev/null +++ b/src/ark/board.js @@ -0,0 +1,114 @@ +// Native-JS board: move onchain funds into the Ark. +// +// Mirrors bark's lib/src/board.rs BoardBuilder: the funding output pays a +// taproot of musig(user, server) with a server-after-expiry sweep leaf (the +// same shape as the checkpoint policy), the exit tx is cosigned via MuSig2 +// through RequestBoardCosign, and the resulting Vtxo<Full> has a single +// Cosigned genesis transition anchored at the funding outpoint. + +import { hex, bech32m } from '@scure/base'; +import { schnorr } from '@noble/curves/secp256k1'; +import * as musig2 from '@scure/btc-signer/musig2'; + +import { grpcCall, pbWriter, pbFields, concatBytes } from './proto.js'; +import { checkpointPolicyTaproot, pubkeyPolicyTaproot, taprootSighash, P2A_SCRIPT } from './send.js'; + +const u16le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff); +const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff); +const u64le = (n) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setBigUint64(0, BigInt(n), true); + return b; +}; + +export const boardFee = (amountSat, fees) => + Math.max(fees.minFeeSat, fees.baseFeeSat + Math.floor((amountSat * fees.ppm) / 1_000_000)); + +export const p2trAddress = (programXOnly, hrp = 'bcrt') => + bech32m.encode(hrp, [1, ...bech32m.toWords(programXOnly)], 1023); + +// Build the board: funding output spec + exit tx + sighash. +// `fundingOutpointRaw` (36B) is set after the funding tx is known. +export function buildBoard({ userPub, serverPub, amountSat, feeSat, expiryHeight, exitDelta, fundingOutpointRaw }) { + const fundingTaproot = checkpointPolicyTaproot(userPub, serverPub, expiryHeight); + const exitTaproot = pubkeyPolicyTaproot(userPub, serverPub, exitDelta); + + const exitTx = { + version: 3, locktime: 0, + inputs: [{ prevout: fundingOutpointRaw, sequence: 0 }], + outputs: [ + { valueSat: amountSat - feeSat, scriptPubKey: exitTaproot.scriptPubKey }, + { valueSat: feeSat, scriptPubKey: P2A_SCRIPT }, // board fee rides the anchor + ], + }; + const fundingPrevout = { valueSat: amountSat, scriptPubKey: fundingTaproot.scriptPubKey }; + const sighash = taprootSighash(exitTx, [fundingPrevout]); + return { fundingTaproot, exitTaproot, exitTx, sighash }; +} + +export async function requestBoardCosign(ark, { amountSat, fundingOutpointRaw, expiryHeight, userPub, pubNonce }) { + const w = pbWriter(); + w.varintField(1, amountSat); + w.bytesField(2, fundingOutpointRaw); + w.varintField(3, expiryHeight); + w.bytesField(4, userPub); + w.bytesField(5, pubNonce); + const resp = await grpcCall(ark, 'bark_server.ArkService/RequestBoardCosign', w.finish()); + const out = {}; + for (const { field, value } of pbFields(resp)) { + if (field === 1) out.pubNonce = value; + if (field === 2) out.partialSig = value; + } + return out; +} + +// MuSig2: combine our partial with the server's over the exit tx sighash. +export function combineBoardSignature({ board, serverCosign, userNonces, vtxoKeys, serverPub }) { + const aggNonce = musig2.nonceAggregate([userNonces.public, serverCosign.pubNonce]); + const session = new musig2.Session( + aggNonce, board.fundingTaproot.sortedKeys, board.sighash, [board.fundingTaproot.tapTweak], [true], + ); + const serverIdx = board.fundingTaproot.sortedKeys.findIndex( + (k) => hex.encode(k) === hex.encode(serverPub)); + const noncesInKeyOrder = serverIdx === 0 + ? [serverCosign.pubNonce, userNonces.public] : [userNonces.public, serverCosign.pubNonce]; + if (!session.partialSigVerify(serverCosign.partialSig, noncesInKeyOrder, serverIdx)) { + throw new Error('server board partial signature is invalid'); + } + const userPartial = session.sign(userNonces.secret, vtxoKeys.privkey); + const finalSig = session.partialSigAgg([userPartial, serverCosign.partialSig]); + if (!schnorr.verify(finalSig, board.sighash, board.fundingTaproot.outputXOnly)) { + throw new Error('combined board signature does not verify'); + } + return finalSig; +} + +// Vtxo<Full> bytes for the board: one Cosigned genesis transition. +export function encodeBoardVtxo({ userPub, serverPub, amountSat, feeSat, expiryHeight, exitDelta, + fundingOutpointRaw, exitTxidInternal, finalSig }) { + const cosignedTransition = concatBytes( + Uint8Array.of(0x01), // GenesisTransition::Cosigned + Uint8Array.of(0x02), userPub, serverPub, // LengthPrefixedVector [user, server] + finalSig, + ); + return concatBytes( + u16le(2), // encoding version + u64le(amountSat - feeSat), + u32le(expiryHeight), + serverPub, + u16le(exitDelta), + fundingOutpointRaw, // anchor point = funding utxo + Uint8Array.of(0x01), // one genesis item + cosignedTransition, + Uint8Array.of(0x01, 0x00), // nb_outputs=1, output_idx=0 + u64le(feeSat), // fee_amount + Uint8Array.of(0x00), userPub, // policy: Pubkey(user) + exitTxidInternal, u32le(0), // point = exit_txid:0 + ); +} + +export async function registerBoardVtxo(ark, vtxoBytes) { + const w = pbWriter(); + w.bytesField(1, vtxoBytes); + await grpcCall(ark, 'bark_server.ArkService/RegisterBoardVtxo', w.finish()); +} diff --git a/src/ark/manager.js b/src/ark/manager.js new file mode 100644 index 0000000..b2d01ce --- /dev/null +++ b/src/ark/manager.js @@ -0,0 +1,452 @@ +// ArkManager — persistent, crash-safe Ark wallet state on top of the +// src/ark protocol modules. Follows hal's SwapManager shape: a class over a +// wallet-scoped storage adapter, driving multi-step actions that are +// checkpointed to storage before and after every server-effectful RPC. +// +// The invariant that matters (learned the hard way on regtest): the ASP marks +// an input spent the moment it cosigns, so every action persists enough state +// BEFORE the effectful call to resume — and signed vtxo bytes are persisted +// the instant they exist. +// +// Keys: vtxo keys on account chain 3 (index 0 = the receive address key, +// 1.. = change/refresh outputs), mailbox key on chain 4. Chains 0-2 are used +// by hal for receive/change/swaps. + +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import * as musig2 from '@scure/btc-signer/musig2'; + +import { + getArkInfo, handshake, encodeAddress, decodeAddress, blindMailboxId, + readMailbox, decodeVtxo, arkIdFromServerPubkey, GrpcError, +} from './proto.js'; +import { + buildArkoorSend, cosignWithServer, buildSignedVtxoBytes, + registerVtxoTransactions, postArkoorMessage, txid, +} from './send.js'; +import { + boardFee, p2trAddress, buildBoard, requestBoardCosign, + combineBoardSignature, encodeBoardVtxo, registerBoardVtxo, +} from './board.js'; +import { + submitRoundParticipation, roundParticipationStatus, parseTx, + cosignHarkLeaf, requestForfeitNonces, forfeitBundle, forfeitVtxos, + encodeVtxoFromDecoded, +} from './refresh.js'; +import { validateVtxo, VtxoValidationError } from './validate.js'; + +const EMPTY_STATE = () => ({ + v: 1, + mailboxCheckpoint: 0, + nextKeyIndex: 1, // 0 is the receive-address key + vtxos: [], // { id, bytes, keyIndex, amountSat, expiryHeight, state } + actions: [], // { id, type, step, ... } + movements: [], // { id, type, amountSat, ts, status, detail } +}); + +export class ArkManager { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate }) { + this.account = account; // HDKey node; ark keys derived beneath it + this.storage = storage; // { load(): obj|null, save(obj): void } + this.arkUrl = arkUrl; + this.esploraUrl = esploraUrl; + this.network = network; + this.onUpdate = onUpdate || (() => {}); + this.state = null; + this.info = null; + } + + // ---- keys ---- + _key(index) { + const node = this.account.deriveChild(3).deriveChild(index); + return { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) }; + } + _mailboxKey() { + const node = this.account.deriveChild(4).deriveChild(0); + return { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) }; + } + _keyForVtxo(v) { return this._key(v.keyIndex); } + + // ---- lifecycle ---- + async init() { + this.state = this.storage.load() || EMPTY_STATE(); + this.info = await getArkInfo(this.arkUrl); + const hs = await handshake(this.arkUrl).catch(() => null); + this.psa = hs?.psa; + this.serverPub = hex.decode(this.info.serverPubkey); + return this; + } + + _save() { + this.storage.save(this.state); + this.onUpdate(this); + } + _movement(m) { + this.state.movements.push({ id: `${Date.now()}-${this.state.movements.length}`, ts: Date.now(), ...m }); + } + _vtxo(id) { return this.state.vtxos.find((v) => v.id === id); } + _addVtxo(decoded, bytes, keyIndex, state = 'spendable') { + if (this._vtxo(decoded.id)) return false; + this.state.vtxos.push({ + id: decoded.id, bytes: hex.encode(bytes), keyIndex, + amountSat: decoded.amountSat, expiryHeight: decoded.expiryHeight, state, + }); + return true; + } + _decoded(v) { return decodeVtxo(hex.decode(v.bytes)); } + + // ---- chain adapter (esplora REST, same API hal already speaks) ---- + get chain() { + const base = this.esploraUrl; + return { + tipHeight: async () => Number(await fetch(`${base}/blocks/tip/height`).then((r) => r.text())), + getTxStatus: async (txid) => fetch(`${base}/tx/${txid}/status`).then((r) => r.ok ? r.json() : null), + getTxHex: async (txid) => fetch(`${base}/tx/${txid}/hex`).then((r) => r.ok ? r.text() : null), + }; + } + + // ---- public surface ---- + address() { + const k0 = this._key(0); + const blinded = blindMailboxId(this._mailboxKey().pubkey, hex.decode(this.info.mailboxPubkey), k0.privkey); + return encodeAddress({ + testnet: this.info.network !== 'bitcoin', + serverPubkey: this.serverPub, + userPubkey: k0.pubkey, + blindedMailboxId: blinded, + }); + } + + balance() { + const sum = (st) => this.state.vtxos.filter((v) => v.state === st).reduce((n, v) => n + v.amountSat, 0); + return { spendableSat: sum('spendable'), pendingSat: sum('pending') }; + } + vtxos() { return this.state.vtxos.slice(); } + movements() { return this.state.movements.slice(); } + pendingActions() { return this.state.actions.filter((a) => !['done', 'failed'].includes(a.step)); } + + // Read new mailbox messages, fully validate incoming vtxos, then push any + // in-flight actions forward. + async sync() { + const mailbox = this._mailboxKey(); + const { messages } = await readMailbox(this.arkUrl, mailbox, this.state.mailboxCheckpoint); + const ourKeys = [hex.encode(this._key(0).pubkey)]; + let changed = false; + for (const m of messages) { + if (m.checkpoint > this.state.mailboxCheckpoint) this.state.mailboxCheckpoint = m.checkpoint; + if (m.kind !== 'arkoor') { changed = true; continue; } + for (const v of m.vtxos) { + if (this._vtxo(v.id)) continue; + try { + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys }); + } catch (e) { + if (e instanceof VtxoValidationError) { + this._movement({ type: 'receive', amountSat: v.amountSat, status: 'rejected', detail: e.message }); + changed = true; + continue; + } + throw e; // network errors etc: retry next sync + } + this._addVtxo(v, v._raw.bytes, 0); + this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete' }); + changed = true; + } + changed = true; + } + if (changed) this._save(); + await this.resumePending(); + } + + async resumePending() { + for (const action of this.pendingActions()) { + try { + if (action.type === 'send') await this._driveSend(action); + if (action.type === 'board') await this._driveBoard(action); + if (action.type === 'refresh') await this._driveRefresh(action); + } catch (e) { + // transient errors leave the action where it is; a later sync retries + if (!(e instanceof GrpcError)) throw e; + } + } + } + + // ---- send ---- + _selectInput(amountSat) { + const candidates = this.state.vtxos + .filter((v) => v.state === 'spendable' && v.amountSat >= amountSat) + .sort((a, b) => a.amountSat - b.amountSat); + if (!candidates.length) { + const total = this.balance().spendableSat; + throw new Error(total >= amountSat + ? 'no single vtxo covers this amount — consolidate with refresh() first' + : 'insufficient ark balance'); + } + return candidates[0]; + } + + async send(addrString, amountSat) { + const dest = decodeAddress(addrString); + if (dest.arkId !== hex.encode(arkIdFromServerPubkey(this.serverPub))) { + throw new Error('address belongs to a different ark server'); + } + const mailboxDelivery = dest.delivery.find((d) => d.type === 1); + if (!mailboxDelivery) throw new Error('address has no mailbox delivery mechanism'); + + const input = this._selectInput(amountSat); + const changeSat = input.amountSat - amountSat; + const action = { + id: `send-${Date.now()}`, type: 'send', step: 'created', + inputId: input.id, amountSat, + destPubkey: dest.userPubkey, destBlindedId: mailboxDelivery.data, + changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null, changeSat, + }; + input.state = 'pending'; + this.state.actions.push(action); + this._save(); + await this._driveSend(action); + return action.id; + } + + _sendOutputs(action) { + const outputs = [{ amountSat: action.amountSat, userPubkey: hex.decode(action.destPubkey) }]; + if (action.changeSat > 0) { + outputs.push({ amountSat: action.changeSat, userPubkey: this._key(action.changeIndex).pubkey }); + } + return outputs; + } + + async _driveSend(action) { + if (action.step === 'created') { + const inputRec = this._vtxo(action.inputId); + const input = this._decoded(inputRec); + const keys = this._keyForVtxo(inputRec); + const outputs = this._sendOutputs(action); + await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]); + const build = buildArkoorSend({ input, outputs, serverPubkey: this.serverPub, vtxoKeys: keys }); + let sigs; + try { + sigs = await cosignWithServer(this.arkUrl, build, { input, outputs, vtxoKeys: keys, serverPubkey: this.serverPub }); + } catch (e) { + if (e instanceof GrpcError && /already spent/i.test(e.message)) { + // the input is gone (possibly a prior crashed attempt) — don't retry + inputRec.state = 'spent'; + action.step = 'failed'; + action.error = e.message; + this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message }); + this._save(); + return; + } + throw e; + } + // the input is spent server-side from this moment: persist immediately + action.destBytes = hex.encode(buildSignedVtxoBytes({ input, outputs, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 })); + if (action.changeSat > 0) { + const changeBytes = buildSignedVtxoBytes({ input, outputs, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 1 }); + action.changeBytes = hex.encode(changeBytes); + this._addVtxo(decodeVtxo(changeBytes), changeBytes, action.changeIndex, 'pending'); + } + inputRec.state = 'spent'; + action.step = 'cosigned'; + this._save(); + } + if (action.step === 'cosigned') { + const list = [hex.decode(action.destBytes)]; + if (action.changeBytes) list.push(hex.decode(action.changeBytes)); + await registerVtxoTransactions(this.arkUrl, list); + action.step = 'registered'; + this._save(); + } + if (action.step === 'registered') { + await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId), [hex.decode(action.destBytes)]); + if (action.changeBytes) { + const change = this._vtxo(decodeVtxo(hex.decode(action.changeBytes)).id); + if (change) change.state = 'spendable'; + } + action.step = 'done'; + this._movement({ type: 'send', amountSat: action.amountSat, status: 'complete' }); + this._save(); + } + } + + // ---- board ---- + // Returns the onchain funding address; hal's onchain wallet pays it (the + // board output MUST be vout 0), then completeBoard(actionId, txid). + async startBoard(amountSat) { + if (amountSat < this.info.minBoardAmountSat) { + throw new Error(`board minimum is ${this.info.minBoardAmountSat} sat`); + } + const feeSat = boardFee(amountSat, this.info.boardFees); + const tip = await this.chain.tipHeight(); + const action = { + id: `board-${Date.now()}`, type: 'board', step: 'created', + amountSat, feeSat, expiryHeight: tip + this.info.vtxoExpiryDelta, + keyIndex: this.state.nextKeyIndex++, + }; + this.state.actions.push(action); + this._save(); + const keys = this._key(action.keyIndex); + const probe = buildBoard({ + userPub: keys.pubkey, serverPub: this.serverPub, amountSat, feeSat, + expiryHeight: action.expiryHeight, exitDelta: this.info.vtxoExitDelta, + fundingOutpointRaw: new Uint8Array(36), + }); + const hrp = { bitcoin: 'bc', regtest: 'bcrt' }[this.info.network] || 'tb'; + return { actionId: action.id, fundingAddress: p2trAddress(probe.fundingTaproot.outputXOnly, hrp), feeSat }; + } + + async completeBoard(actionId, fundingTxid) { + const action = this.state.actions.find((a) => a.id === actionId); + if (!action || action.type !== 'board') throw new Error('unknown board action'); + if (action.step === 'created') { + action.fundingTxid = fundingTxid; + action.step = 'funded'; + this._save(); + } + await this._driveBoard(action); + return action; + } + + async _driveBoard(action) { + if (action.step === 'created') return; // waiting for funding txid + const keys = this._key(action.keyIndex); + if (action.step === 'funded') { + const status = await this.chain.getTxStatus(action.fundingTxid); + if (!status?.confirmed) return; // wait for confirmations; retried on sync + const tip = await this.chain.tipHeight(); + if (tip - status.block_height + 1 < this.info.requiredBoardConfirmations) return; + + const fundingOutpointRaw = new Uint8Array(36); + fundingOutpointRaw.set(hex.decode(action.fundingTxid).reverse(), 0); // vout 0 + const board = buildBoard({ + userPub: keys.pubkey, serverPub: this.serverPub, + amountSat: action.amountSat, feeSat: action.feeSat, + expiryHeight: action.expiryHeight, exitDelta: this.info.vtxoExitDelta, + fundingOutpointRaw, + }); + const nonces = musig2.nonceGen(keys.pubkey, keys.privkey, undefined, board.sighash); + const cosign = await requestBoardCosign(this.arkUrl, { + amountSat: action.amountSat, fundingOutpointRaw, + expiryHeight: action.expiryHeight, userPub: keys.pubkey, pubNonce: nonces.public, + }); + const finalSig = combineBoardSignature({ board, serverCosign: cosign, userNonces: nonces, vtxoKeys: keys, serverPub: this.serverPub }); + action.vtxoBytes = hex.encode(encodeBoardVtxo({ + userPub: keys.pubkey, serverPub: this.serverPub, + amountSat: action.amountSat, feeSat: action.feeSat, + expiryHeight: action.expiryHeight, exitDelta: this.info.vtxoExitDelta, + fundingOutpointRaw, exitTxidInternal: txid(board.exitTx), finalSig, + })); + action.step = 'cosigned'; + this._save(); + } + if (action.step === 'cosigned') { + const bytes = hex.decode(action.vtxoBytes); + await registerBoardVtxo(this.arkUrl, bytes); + const decoded = decodeVtxo(bytes); + this._addVtxo(decoded, bytes, action.keyIndex); + action.step = 'done'; + this._movement({ type: 'board', amountSat: decoded.amountSat, status: 'complete' }); + this._save(); + } + } + + // ---- refresh (also the consolidation primitive) ---- + refreshFee(inputs, tip) { + const table = this.info.refreshFees.ppmExpiryTable; + let fee = this.info.refreshFees.baseFeeSat; + for (const v of inputs) { + const blocks = v.expiryHeight - tip; + const entry = table.filter((e) => e.thresholdBlocks <= blocks).pop(); + fee += Math.floor((v.amountSat * (entry?.ppm ?? 0)) / 1_000_000); + } + return fee; + } + + async refresh(vtxoIds) { + const inputs = (vtxoIds + ? vtxoIds.map((id) => this._vtxo(id)) + : this.state.vtxos.filter((v) => v.state === 'spendable')); + if (!inputs.length || inputs.some((v) => !v || v.state !== 'spendable')) { + throw new Error('no spendable vtxos to refresh'); + } + const tip = await this.chain.tipHeight(); + const totalSat = inputs.reduce((n, v) => n + v.amountSat, 0); + const feeSat = this.refreshFee(inputs, tip); + const action = { + id: `refresh-${Date.now()}`, type: 'refresh', step: 'created', + inputIds: inputs.map((v) => v.id), + outKeyIndex: this.state.nextKeyIndex++, + outAmountSat: totalSat - feeSat, feeSat, + }; + for (const v of inputs) v.state = 'pending'; + this.state.actions.push(action); + this._save(); + await this._driveRefresh(action); + return action.id; + } + + _refreshOutputs(action) { + return [{ amountSat: action.outAmountSat, userPubkey: this._key(action.outKeyIndex).pubkey }]; + } + + async _driveRefresh(action) { + const outputs = this._refreshOutputs(action); + const inputRecs = action.inputIds.map((id) => this._vtxo(id)); + + if (action.step === 'created') { + const inputBytes = inputRecs.map((v) => hex.decode(v.bytes)); + await registerVtxoTransactions(this.arkUrl, inputBytes); + const unlockHash = await submitRoundParticipation(this.arkUrl, { + inputs: inputRecs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })), + outputs, + }); + action.unlockHash = hex.encode(unlockHash); + action.step = 'submitted'; + this._save(); + } + if (action.step === 'submitted') { + const status = await roundParticipationStatus(this.arkUrl, hex.decode(action.unlockHash)); + if (status.status === 0 || !status.fundingTx) return; // round pending; retry on next sync + const fundingTx = parseTx(status.fundingTx); + const confirmed = await this.chain.getTxStatus(fundingTx.txid); + if (!confirmed?.confirmed) return; // wait for funding confirmations + action.fundingTxHex = hex.encode(status.fundingTx); + action.outputVtxos = status.outputVtxos.map((b) => hex.encode(b)); + action.step = 'issued'; + this._save(); + } + if (action.step === 'issued') { + const outKeys = this._key(action.outKeyIndex); + const fundingTx = parseTx(hex.decode(action.fundingTxHex)); + const unlockHash = hex.decode(action.unlockHash); + const newVtxos = action.outputVtxos.map((b) => decodeVtxo(hex.decode(b))); + // leaf cosign + forfeit are both idempotent server-side + const leafSigs = []; + for (const v of newVtxos) { + leafSigs.push(await cosignHarkLeaf(this.arkUrl, v, fundingTx, outKeys, this.serverPub)); + } + const serverNonces = await requestForfeitNonces(this.arkUrl, unlockHash, + inputRecs.map((v) => this._decoded(v).point.raw)); + const bundles = inputRecs.map((v, i) => forfeitBundle({ + input: this._decoded(v), unlockHash, + vtxoKeys: this._keyForVtxo(v), serverPub: this.serverPub, serverNonce: serverNonces[i], + })); + const preimage = await forfeitVtxos(this.arkUrl, bundles); + if (hex.encode(sha256(preimage)) !== action.unlockHash) { + throw new Error('unlock preimage does not match hash'); + } + for (let i = 0; i < newVtxos.length; i++) { + const v = newVtxos[i]; + const last = v.genesis[v.genesis.length - 1]; + last.transition.signature = hex.encode(leafSigs[i]); + last.transition.unlock = { preimage: hex.encode(preimage) }; + const bytes = encodeVtxoFromDecoded(v); + this._addVtxo(decodeVtxo(bytes), bytes, action.outKeyIndex); + } + for (const v of inputRecs) v.state = 'spent'; + action.step = 'done'; + this._movement({ type: 'refresh', amountSat: action.outAmountSat, status: 'complete', detail: `${inputRecs.length} in -> ${newVtxos.length} out` }); + this._save(); + } + } +} diff --git a/src/ark/proto.js b/src/ark/proto.js new file mode 100644 index 0000000..24fb5a6 --- /dev/null +++ b/src/ark/proto.js @@ -0,0 +1,408 @@ +// Minimal native-JS Ark (bark/captaind) client primitives — receive-only spike. +// +// Speaks gRPC-web directly to the ASP with hand-rolled protobuf, encodes ark +// addresses, blinds mailbox identifiers, signs mailbox read authorizations, +// and decodes the bark ProtocolEncoding VTXO wire format (version 2). +// +// Wire formats mirrored from github.com/ark-bitcoin/bark: +// lib/src/address.rs, lib/src/mailbox.rs, lib/src/encode.rs, lib/src/vtxo/mod.rs + +import { bech32m, hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; + +const te = new TextEncoder(); +const td = new TextDecoder(); + +export const concatBytes = (...arrs) => { + const out = new Uint8Array(arrs.reduce((n, a) => n + a.length, 0)); + let o = 0; + for (const a of arrs) { out.set(a, o); o += a.length; } + return out; +}; + +// --------------------------------------------------------------------------- +// protobuf (proto3) mini codec — just what the ark RPCs need +// --------------------------------------------------------------------------- + +export function pbWriter() { + const chunks = []; + const varint = (n) => { + n = BigInt(n); + const bs = []; + do { let b = Number(n & 0x7fn); n >>= 7n; if (n) b |= 0x80; bs.push(b); } while (n); + chunks.push(Uint8Array.from(bs)); + }; + return { + varintField(field, n) { varint((field << 3) | 0); varint(n); }, + bytesField(field, bytes) { varint((field << 3) | 2); varint(bytes.length); chunks.push(bytes); }, + stringField(field, s) { this.bytesField(field, te.encode(s)); }, + finish: () => concatBytes(...chunks), + }; +} + +export function* pbFields(buf) { + let i = 0; + const varint = () => { + let v = 0n, s = 0n; + for (;;) { + const b = buf[i++]; + v |= BigInt(b & 0x7f) << s; + if (!(b & 0x80)) return v; + s += 7n; + } + }; + while (i < buf.length) { + const tag = Number(varint()); + const field = tag >> 3, wire = tag & 7; + if (wire === 0) yield { field, value: varint() }; + else if (wire === 2) { + const len = Number(varint()); + yield { field, value: buf.slice(i, i + len) }; + i += len; + } else if (wire === 5) { yield { field, value: buf.slice(i, i + 4) }; i += 4; } + else if (wire === 1) { yield { field, value: buf.slice(i, i + 8) }; i += 8; } + else throw new Error('unsupported protobuf wire type ' + wire); + } +} + +// --------------------------------------------------------------------------- +// gRPC-web transport (unary) — plain fetch, works in any browser +// --------------------------------------------------------------------------- + +// Ark protocol version we speak (server advertises min/max in Handshake). +export const PVER = 3; + +export class GrpcError extends Error { + constructor(status, message, method) { + super(`grpc ${method}: status ${status}${message ? ': ' + message : ''}`); + this.grpcStatus = status; + } +} + +export async function grpcCall(base, method, reqBytes) { + const frame = new Uint8Array(5 + reqBytes.length); + new DataView(frame.buffer).setUint32(1, reqBytes.length); + frame.set(reqBytes, 5); + + const resp = await fetch(`${base}/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/grpc-web+proto', 'x-grpc-web': '1', 'pver': String(PVER) }, + body: frame, + }); + if (!resp.ok) throw new Error(`${method}: http ${resp.status}`); + + const body = new Uint8Array(await resp.arrayBuffer()); + let i = 0, data = null; + const trailers = {}; + // headers may carry grpc-status when there is no trailer frame + for (const [k, v] of resp.headers) trailers[k] = v; + while (i < body.length) { + const flag = body[i]; + const len = new DataView(body.buffer, body.byteOffset + i + 1, 4).getUint32(0); + const payload = body.slice(i + 5, i + 5 + len); + if (flag & 0x80) { + for (const line of td.decode(payload).trim().split('\r\n')) { + const c = line.indexOf(':'); + if (c > 0) trailers[line.slice(0, c).trim()] = line.slice(c + 1).trim(); + } + } else data = payload; + i += 5 + len; + } + const status = Number(trailers['grpc-status'] ?? 0); + if (status !== 0) { + throw new GrpcError(status, decodeURIComponent(trailers['grpc-message'] || ''), method); + } + return data ?? new Uint8Array(0); +} + +// --------------------------------------------------------------------------- +// byte reader + bitcoin primitives (CompactSize, OutPoint, TxOut) +// --------------------------------------------------------------------------- + +export function reader(buf) { + let i = 0; + const r = { + get pos() { return i; }, + get remaining() { return buf.length - i; }, + bytes(n) { + if (i + n > buf.length) throw new Error('unexpected end of data'); + const b = buf.slice(i, i + n); i += n; return b; + }, + u8: () => r.bytes(1)[0], + u16: () => { const b = r.bytes(2); return b[0] | (b[1] << 8); }, + u32: () => { const b = r.bytes(4); return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0; }, + u64: () => { const b = r.bytes(8); let v = 0n; for (let k = 7; k >= 0; k--) v = (v << 8n) | BigInt(b[k]); return v; }, + i64: () => { const v = r.u64(); return v > 0x7fffffffffffffffn ? v - 0x10000000000000000n : v; }, + compactSize() { + const b = r.u8(); + if (b < 0xfd) return b; + if (b === 0xfd) return r.u16(); + if (b === 0xfe) return r.u32(); + return Number(r.u64()); + }, + outPoint() { + const raw = r.bytes(36); + const txid = raw.slice(0, 32); // internal byte order + const vout = raw[32] | (raw[33] << 8) | (raw[34] << 16) | (raw[35] << 24); + return { txid: hex.encode(txid.slice().reverse()), vout, raw }; + }, + txOut() { + const valueSat = r.u64(); + const script = r.bytes(r.compactSize()); + return { valueSat: Number(valueSat), scriptPubKey: hex.encode(script) }; + }, + }; + return r; +} + +export const i64le = (n) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setBigInt64(0, BigInt(n), true); + return b; +}; + +// --------------------------------------------------------------------------- +// ark address (lib/src/address.rs) — bech32m, HRP ark/tark, version 'p' +// --------------------------------------------------------------------------- + +const ADDR_VERSION_POLICY = 1; // bech32 char 'p' +const DELIVERY_MAILBOX = 0x01; +const POLICY_PUBKEY = 0x00; +const BECH32_LIMIT = 1023; + +const compactSizeBytes = (n) => { + if (n < 0xfd) return Uint8Array.of(n); + throw new Error('compact size > 252 not needed here'); +}; + +export const arkIdFromServerPubkey = (serverPubkey /* 33B */) => sha256(serverPubkey).slice(0, 4); + +export function encodeAddress({ testnet, serverPubkey, userPubkey, blindedMailboxId }) { + const policy = concatBytes(Uint8Array.of(POLICY_PUBKEY), userPubkey); + const delivery = concatBytes(Uint8Array.of(DELIVERY_MAILBOX), blindedMailboxId); + const payload = concatBytes( + arkIdFromServerPubkey(serverPubkey), + compactSizeBytes(policy.length), policy, + compactSizeBytes(delivery.length), delivery, + ); + const words = [ADDR_VERSION_POLICY, ...bech32m.toWords(payload)]; + return bech32m.encode(testnet ? 'tark' : 'ark', words, BECH32_LIMIT); +} + +export function decodeAddress(addr) { + const { prefix, words } = bech32m.decode(addr, BECH32_LIMIT); + if (prefix !== 'ark' && prefix !== 'tark') throw new Error('bad hrp: ' + prefix); + if (words[0] !== ADDR_VERSION_POLICY) throw new Error('unsupported address version ' + words[0]); + const payload = bech32m.fromWords(words.slice(1)); + const r = reader(payload); + const arkId = hex.encode(r.bytes(4)); + const policyBytes = r.bytes(r.compactSize()); + if (policyBytes[0] !== POLICY_PUBKEY) throw new Error('unsupported policy type ' + policyBytes[0]); + const userPubkey = hex.encode(policyBytes.slice(1, 34)); + const delivery = []; + while (r.remaining > 0) { + const d = r.bytes(r.compactSize()); + delivery.push({ type: d[0], data: hex.encode(d.slice(1)) }); + } + return { testnet: prefix === 'tark', arkId, userPubkey, delivery }; +} + +// --------------------------------------------------------------------------- +// mailbox (lib/src/mailbox.rs) +// --------------------------------------------------------------------------- + +// blinded_id = mailbox_pubkey + ECDH(server_pubkey, vtxo_privkey) as points +export function blindMailboxId(mailboxPubkey, serverPubkey, vtxoPrivkey) { + const P = secp256k1.ProjectivePoint; + const dh = P.fromHex(serverPubkey).multiply(secp256k1.utils.normPrivateKeyToScalar(vtxoPrivkey)); + const blinded = P.fromHex(mailboxPubkey).add(dh); + return blinded.toRawBytes(true); +} + +const MAILBOX_AUTH_PREFIX = te.encode('Ark VTXO mailbox authorization: '); // 32 bytes + +// serialized MailboxAuthorization: pubkey(33) || expiry i64 LE || schnorr sig(64) +export function mailboxAuthorization(mailboxPrivkey, mailboxPubkey, expiryUnixSec) { + const msg = sha256(concatBytes(MAILBOX_AUTH_PREFIX, i64le(expiryUnixSec))); + const sig = schnorr.sign(msg, mailboxPrivkey); + return concatBytes(mailboxPubkey, i64le(expiryUnixSec), sig); +} + +// --------------------------------------------------------------------------- +// VTXO decoding (lib/src/vtxo/mod.rs, ProtocolEncoding version 2) +// --------------------------------------------------------------------------- + +const ZERO_SIG = new Uint8Array(64); +const optSignature = (r) => { + const sig = r.bytes(64); + return sig.every((b) => b === 0) ? null : hex.encode(sig); +}; + +function decodeGenesisTransition(r) { + const type = r.u8(); + if (type === 1) { // Cosigned + const n = r.compactSize(); + const pubkeys = []; + for (let k = 0; k < n; k++) pubkeys.push(hex.encode(r.bytes(33))); + return { type: 'cosigned', pubkeys, signature: optSignature(r) }; + } + if (type === 2) { // Arkoor + const n = r.compactSize(); + const clientCosigners = []; + for (let k = 0; k < n; k++) clientCosigners.push(hex.encode(r.bytes(33))); + const tapTweak = hex.encode(r.bytes(32)); + return { type: 'arkoor', clientCosigners, tapTweak, signature: optSignature(r) }; + } + if (type === 3) { // HashLockedCosigned + const userPubkey = hex.encode(r.bytes(33)); + const signature = optSignature(r); + const tag = r.u8(); + const unlock = { [tag === 0 ? 'preimage' : 'hash']: hex.encode(r.bytes(32)) }; + return { type: 'hashLockedCosigned', userPubkey, signature, unlock }; + } + throw new Error('unknown genesis transition type ' + type); +} + +function decodePolicy(r) { + const type = r.u8(); + if (type === 0x00) return { type: 'pubkey', userPubkey: hex.encode(r.bytes(33)) }; + if (type === 0x01) return { + type: 'serverHtlcSend', userPubkey: hex.encode(r.bytes(33)), + paymentHash: hex.encode(r.bytes(32)), htlcExpiry: r.u32(), + }; + if (type === 0x02) return { + type: 'serverHtlcRecv', userPubkey: hex.encode(r.bytes(33)), + paymentHash: hex.encode(r.bytes(32)), htlcExpiry: r.u32(), htlcExpiryDelta: r.u16(), + }; + throw new Error('unknown vtxo policy type ' + type); +} + +export function decodeVtxo(bytes) { + const r = reader(bytes); + const version = r.u16(); + if (version !== 2) throw new Error('unsupported vtxo encoding version ' + version); + const amountSat = Number(r.u64()); + const expiryHeight = r.u32(); + const serverPubkey = hex.encode(r.bytes(33)); + const exitDelta = r.u16(); + const anchorPoint = r.outPoint(); + + const genesisStart = r.pos; // after this sits compactSize(nItems) + items + const nItems = r.compactSize(); + const itemsStart = r.pos; + const genesis = []; + for (let k = 0; k < nItems; k++) { + const transition = decodeGenesisTransition(r); + const nbOutputs = r.u8(); + const outputIdx = r.u8(); + const otherOutputs = []; + for (let j = 0; j < nbOutputs - 1; j++) otherOutputs.push(r.txOut()); + const feeSat = Number(r.u64()); + genesis.push({ transition, nbOutputs, outputIdx, otherOutputs, feeSat }); + } + + const itemsEnd = r.pos; + const policy = decodePolicy(r); + const point = r.outPoint(); + if (r.remaining !== 0) throw new Error(`trailing vtxo bytes: ${r.remaining}`); + + return { + id: `${point.txid}:${point.vout}`, + amountSat, expiryHeight, serverPubkey, exitDelta, + anchorPoint, genesis, policy, point, + // raw material for re-encoding when this vtxo is used as a spend input + _raw: { bytes, nItems, itemsStart, itemsEnd, genesisStart }, + }; +} + +// --------------------------------------------------------------------------- +// RPCs +// --------------------------------------------------------------------------- + +export async function getArkInfo(ark) { + const data = await grpcCall(ark, 'bark_server.ArkService/GetArkInfo', new Uint8Array(0)); + const info = {}; + for (const { field, value } of pbFields(data)) { + if (field === 1) info.network = td.decode(value); + if (field === 2) info.serverPubkey = hex.encode(value); + if (field === 3) info.roundIntervalSecs = Number(value); + if (field === 5) info.vtxoExitDelta = Number(value); + if (field === 6) info.vtxoExpiryDelta = Number(value); + if (field === 10) info.requiredBoardConfirmations = Number(value); + if (field === 13) info.minBoardAmountSat = Number(value); + if (field === 16) info.mailboxPubkey = hex.encode(value); + if (field === 18) { // FeeSchedule + for (const f of pbFields(value)) { + if (f.field === 1) { // board + const b = {}; + for (const g of pbFields(f.value)) { + if (g.field === 1) b.minFeeSat = Number(g.value); + if (g.field === 2) b.baseFeeSat = Number(g.value); + if (g.field === 3) b.ppm = Number(g.value); + } + info.boardFees = b; + } + if (f.field === 3) { // refresh + const rf = { baseFeeSat: 0, ppmExpiryTable: [] }; + for (const g of pbFields(f.value)) { + if (g.field === 1) rf.baseFeeSat = Number(g.value); + if (g.field === 2) { + const e = { thresholdBlocks: 0, ppm: 0 }; + for (const h of pbFields(g.value)) { + if (h.field === 1) e.thresholdBlocks = Number(h.value); + if (h.field === 2) e.ppm = Number(h.value); + } + rf.ppmExpiryTable.push(e); + } + } + info.refreshFees = rf; + } + } + } + } + return info; +} + +export async function handshake(ark, version = 'hal-ark-spike/0.0.1') { + const w = pbWriter(); + w.stringField(1, version); + const data = await grpcCall(ark, 'bark_server.ArkService/Handshake', w.finish()); + const out = {}; + for (const { field, value } of pbFields(data)) { + if (field === 1) out.minProtocolVersion = Number(value); + if (field === 2) out.maxProtocolVersion = Number(value); + if (field === 3) out.psa = td.decode(value); + } + return out; +} + +// Read the mailbox; returns { messages: [{checkpoint, vtxos: [decoded]}], haveMore } +export async function readMailbox(ark, mailbox, checkpoint = 0) { + const expiry = Math.floor(Date.now() / 1000) + 60; + const auth = mailboxAuthorization(mailbox.privkey, mailbox.pubkey, expiry); + const w = pbWriter(); + w.bytesField(1, mailbox.pubkey); + w.bytesField(2, auth); + if (checkpoint) w.varintField(3, checkpoint); + const data = await grpcCall(ark, 'mailbox_server.MailboxService/ReadMailbox', w.finish()); + + const messages = []; + let haveMore = false; + for (const { field, value } of pbFields(data)) { + if (field === 2) haveMore = Number(value) !== 0; + if (field !== 1) continue; + const msg = { checkpoint: 0, vtxos: [], kind: 'other' }; + for (const f of pbFields(value)) { + if (f.field === 2) msg.checkpoint = Number(f.value); + if (f.field === 1) { // ArkoorMessage + msg.kind = 'arkoor'; + for (const a of pbFields(f.value)) { + if (a.field === 1) msg.vtxos.push(decodeVtxo(a.value)); + } + } + } + messages.push(msg); + } + return { messages, haveMore }; +} diff --git a/src/ark/refresh.js b/src/ark/refresh.js new file mode 100644 index 0000000..e3be402 --- /dev/null +++ b/src/ark/refresh.js @@ -0,0 +1,399 @@ +// Native-JS delegated round refresh (hArk): submit participation, poll the +// round result, MuSig2-cosign the hash-locked leaf, forfeit the old VTXO in +// exchange for the unlock preimage, and finalize the refreshed VTXO. +// +// Mirrors bark's bark/src/round/mod.rs (join_next_round_delegated / +// progress_delegated / hark_vtxo_swap) and lib/src/{forfeit.rs,tree/signed.rs}. + +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; +import * as musig2 from '@scure/btc-signer/musig2'; + +import { grpcCall, pbWriter, pbFields, concatBytes, reader } from './proto.js'; +import { musigInternalKey, taprootSighash, txid, P2A_SCRIPT, pubkeyPolicyTaproot } from './send.js'; + +const te = new TextEncoder(); +const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff); +const u64le = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }; +const u64be = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), false); return b; }; +const u16le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff); +const varint = (n) => { + if (n < 0xfd) return Uint8Array.of(n); + if (n <= 0xffff) return concatBytes(Uint8Array.of(0xfd), u16le(n)); + return concatBytes(Uint8Array.of(0xfe), u32le(n)); +}; +const taggedHash = (tag, ...data) => { + const th = sha256(te.encode(tag)); + return sha256(concatBytes(th, th, ...data)); +}; + +// --------------------------------------------------------------------------- +// scripts + taproots for round vtxo chain reconstruction +// --------------------------------------------------------------------------- + +function pushInt(n) { + if (n === 0) return Uint8Array.of(0x00); + if (n >= 1 && n <= 16) return Uint8Array.of(0x50 + n); + const bytes = []; + let v = n; + while (v > 0) { bytes.push(v & 0xff); v >>= 8; } + if (bytes[bytes.length - 1] & 0x80) bytes.push(0x00); + return Uint8Array.of(bytes.length, ...bytes); +} + +const timelockSignScript = (height, xonlyKey) => + concatBytes(pushInt(height), Uint8Array.of(0xb1, 0x75, 0x20), xonlyKey, Uint8Array.of(0xac)); +const delayedSignScript = (delta, xonlyKey) => + concatBytes(pushInt(delta), Uint8Array.of(0xb2, 0x75, 0x20), xonlyKey, Uint8Array.of(0xac)); +// OP_HASH160 <ripemd160(hash)> OP_EQUALVERIFY <xonly> OP_CHECKSIG +const hashSignScript = (unlockHash, xonlyKey) => + concatBytes(Uint8Array.of(0xa9, 0x14), ripemd160(unlockHash), Uint8Array.of(0x88, 0x20), xonlyKey, Uint8Array.of(0xac)); + +export const tapLeafHash = (script) => + taggedHash('TapLeaf', Uint8Array.of(0xc0), varint(script.length), script); + +const byteCompare = (a, b) => { + for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] - b[i]; } + return 0; +}; +const tapBranch = (a, b) => { + const [lo, hi] = byteCompare(a, b) <= 0 ? [a, b] : [b, a]; + return taggedHash('TapBranch', lo, hi); +}; + +function taprootFromMerkle(internalXOnly, merkleRoot) { + const tapTweak = taggedHash('TapTweak', internalXOnly, ...(merkleRoot ? [merkleRoot] : [])); + const P = secp256k1.ProjectivePoint; + const internalPoint = P.fromHex(concatBytes(Uint8Array.of(0x02), internalXOnly)); + const outputPoint = internalPoint.add(P.BASE.multiply(BigInt('0x' + hex.encode(tapTweak)))); + const outputXOnly = outputPoint.toRawBytes(true).slice(1); + return { tapTweak, outputXOnly, scriptPubKey: concatBytes(hex.decode('5120'), outputXOnly) }; +} + +// Cosigned transition input: internal = musig(pubkeys), one timelock leaf +function cosignedInputTaproot(pubkeys, serverPub, expiryHeight) { + const { internalXOnly } = musigInternalKey(pubkeys); + const leaf = tapLeafHash(timelockSignScript(expiryHeight, serverPub.slice(1))); + return taprootFromMerkle(internalXOnly, leaf); +} + +// HashLockedCosigned transition input: hArk leaf output — +// internal = musig(user, server); leaves: expiry (server) + unlock (agg key) +export function harkLeafTaproot(userPub, serverPub, expiryHeight, unlockHash) { + const { internalXOnly } = musigInternalKey([userPub, serverPub]); + const expiryLeaf = tapLeafHash(timelockSignScript(expiryHeight, serverPub.slice(1))); + const unlockScript = hashSignScript(unlockHash, internalXOnly); + const unlockLeaf = tapLeafHash(unlockScript); + return { ...taprootFromMerkle(internalXOnly, tapBranch(expiryLeaf, unlockLeaf)), unlockScript, internalXOnly }; +} + +// Forfeit claim output: internal = musig(user, server); +// leaves: delayed exit (user) + unlock (server key) +function forfeitClaimTaproot(userPub, serverPub, exitDelta, unlockHash) { + const { internalXOnly } = musigInternalKey([userPub, serverPub]); + const exitLeaf = tapLeafHash(delayedSignScript(exitDelta, userPub.slice(1))); + const unlockLeaf = tapLeafHash(hashSignScript(unlockHash, serverPub.slice(1))); + return taprootFromMerkle(internalXOnly, tapBranch(exitLeaf, unlockLeaf)); +} + +// --------------------------------------------------------------------------- +// vtxo tx-chain reconstruction (VtxoTxIter) +// --------------------------------------------------------------------------- + +const otherSum = (item) => + item.feeSat + item.otherOutputs.reduce((n, o) => n + o.valueSat, 0); + +export function transitionInputTxout(transition, amountSat, serverPub, expiryHeight) { + if (transition.type === 'cosigned') { + const pubkeys = transition.pubkeys.map((p) => hex.decode(p)); + return { valueSat: amountSat, scriptPubKey: cosignedInputTaproot(pubkeys, serverPub, expiryHeight).scriptPubKey }; + } + if (transition.type === 'hashLockedCosigned') { + const hash = hex.decode(transition.unlock.hash ?? sha256Hex(transition.unlock.preimage)); + const tp = harkLeafTaproot(hex.decode(transition.userPubkey), serverPub, expiryHeight, hash); + return { valueSat: amountSat, scriptPubKey: tp.scriptPubKey }; + } + if (transition.type === 'arkoor') { + // output key = musig(cosigners + server) with the stored x-only taptweak + const keys = musig2.sortKeys([...transition.clientCosigners.map((p) => hex.decode(p)), serverPub]); + const ctx = musig2.keyAggregate(keys, [hex.decode(transition.tapTweak)], [true]); + const outputXOnly = musig2.keyAggExport(ctx); + return { valueSat: amountSat, scriptPubKey: concatBytes(hex.decode('5120'), outputXOnly) }; + } + throw new Error('input_txout unsupported for transition ' + transition.type); +} +const sha256Hex = (h) => hex.encode(sha256(hex.decode(h))); + +export const vtxoAnchorAmount = (vtxo) => + vtxo.amountSat + vtxo.genesis.reduce((n, it) => n + otherSum(it), 0); + +// Walk the genesis chain, returning [{tx, outputIdx}] like Vtxo::transactions() +export function vtxoTransactions(vtxo, serverPub) { + const anchorAmount = vtxoAnchorAmount(vtxo); + let prev = vtxo.anchorPoint.raw; + let currentAmount = anchorAmount; + const items = []; + for (let i = 0; i < vtxo.genesis.length; i++) { + const item = vtxo.genesis[i]; + const nextAmount = currentAmount - otherSum(item); + const nextOutput = i + 1 < vtxo.genesis.length + ? transitionInputTxout(vtxo.genesis[i + 1].transition, nextAmount, serverPub, vtxo.expiryHeight) + : { valueSat: vtxo.amountSat, + scriptPubKey: pubkeyPolicyTaproot(hex.decode(vtxo.policy.userPubkey), serverPub, vtxo.exitDelta).scriptPubKey }; + // decoded txouts carry hex-string scripts — normalize to bytes + const others = item.otherOutputs.map((o) => ({ valueSat: o.valueSat, scriptPubKey: hex.decode(o.scriptPubKey) })); + const outs = [ + ...others.slice(0, item.outputIdx), + nextOutput, + ...others.slice(item.outputIdx), + { valueSat: item.feeSat, scriptPubKey: P2A_SCRIPT }, + ]; + const tx = { version: 3, locktime: 0, inputs: [{ prevout: prev, sequence: 0 }], outputs: outs }; + const id = txid(tx); + prev = concatBytes(id, u32le(item.outputIdx)); + currentAmount = nextAmount; + items.push({ tx, outputIdx: item.outputIdx }); + } + return items; +} + +// BIP-341 script-spend sighash (SIGHASH_DEFAULT, single input, no annex) +export function taprootScriptSighash(tx, prevouts, leafHash) { + const base = taprootSighashFields(tx, prevouts); + return taggedHash('TapSighash', + ...base, + Uint8Array.of(0x02), // spend type: ext_flag=1 (script), no annex + u32le(0), // input index + leafHash, Uint8Array.of(0x00), u32le(0xffffffff), // ext: leaf, key ver, codesep + ); +} +function taprootSighashFields(tx, prevouts) { + const shaPrevouts = sha256(concatBytes(...tx.inputs.map((i) => i.prevout))); + const shaAmounts = sha256(concatBytes(...prevouts.map((p) => u64le(p.valueSat)))); + const shaScripts = sha256(concatBytes(...prevouts.map((p) => concatBytes(varint(p.scriptPubKey.length), p.scriptPubKey)))); + const shaSequences = sha256(concatBytes(...tx.inputs.map((i) => u32le(i.sequence)))); + const shaOutputs = sha256(concatBytes(...tx.outputs.map((o) => concatBytes(u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey)))); + return [Uint8Array.of(0x00), Uint8Array.of(0x00), u32le(tx.version), u32le(tx.locktime), + shaPrevouts, shaAmounts, shaScripts, shaSequences, shaOutputs]; +} + +// --------------------------------------------------------------------------- +// consensus tx parsing (for the round funding tx) +// --------------------------------------------------------------------------- + +export function parseTx(bytes) { + const r = reader(bytes); + const version = r.u32(); + let marker = false; + let inCount = r.compactSize(); + if (inCount === 0) { // segwit marker + const flag = r.u8(); + if (flag !== 1) throw new Error('bad segwit flag'); + marker = true; + inCount = r.compactSize(); + } + const inputs = []; + for (let i = 0; i < inCount; i++) { + const prevout = r.bytes(36); + const scriptSig = r.bytes(r.compactSize()); + const sequence = r.u32(); + inputs.push({ prevout, scriptSig, sequence }); + } + const outCount = r.compactSize(); + const outputs = []; + for (let i = 0; i < outCount; i++) { + const valueSat = Number(r.u64()); + const scriptPubKey = r.bytes(r.compactSize()); + outputs.push({ valueSat, scriptPubKey }); + } + if (marker) for (let i = 0; i < inCount; i++) { + const n = r.compactSize(); + for (let j = 0; j < n; j++) r.bytes(r.compactSize()); + } + const locktime = r.u32(); + // txid over the stripped serialization + const stripped = concatBytes( + u32le(version), varint(inputs.length), + ...inputs.flatMap((i) => [i.prevout, varint(i.scriptSig.length), i.scriptSig, u32le(i.sequence)]), + varint(outputs.length), + ...outputs.flatMap((o) => [u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey]), + u32le(locktime), + ); + const id = sha256(sha256(stripped)); + return { version, inputs, outputs, locktime, txidInternal: id, txid: hex.encode(id.slice().reverse()) }; +} + +// --------------------------------------------------------------------------- +// vtxo re-encoding from decoded form (to patch leaf sig + preimage) +// --------------------------------------------------------------------------- + +const encodeTransition = (t) => { + const sig = t.signature ? hex.decode(t.signature) : new Uint8Array(64); + if (t.type === 'cosigned') { + return concatBytes(Uint8Array.of(0x01), varint(t.pubkeys.length), + ...t.pubkeys.map((p) => hex.decode(p)), sig); + } + if (t.type === 'arkoor') { + return concatBytes(Uint8Array.of(0x02), varint(t.clientCosigners.length), + ...t.clientCosigners.map((p) => hex.decode(p)), hex.decode(t.tapTweak), sig); + } + if (t.type === 'hashLockedCosigned') { + const [tag, val] = t.unlock.preimage != null + ? [0, hex.decode(t.unlock.preimage)] : [1, hex.decode(t.unlock.hash)]; + return concatBytes(Uint8Array.of(0x03), hex.decode(t.userPubkey), sig, Uint8Array.of(tag), val); + } + throw new Error('unknown transition type ' + t.type); +}; + +export function encodeVtxoFromDecoded(v) { + return concatBytes( + u16le(2), u64le(v.amountSat), u32le(v.expiryHeight), + hex.decode(v.serverPubkey), u16le(v.exitDelta), v.anchorPoint.raw, + varint(v.genesis.length), + ...v.genesis.map((item) => concatBytes( + encodeTransition(item.transition), + Uint8Array.of(item.nbOutputs, item.outputIdx), + ...item.otherOutputs.map((o) => concatBytes(u64le(o.valueSat), varint(hex.decode(o.scriptPubKey).length), hex.decode(o.scriptPubKey))), + u64le(item.feeSat), + )), + Uint8Array.of(0x00), hex.decode(v.policy.userPubkey), + v.point.raw, + ); +} + +// --------------------------------------------------------------------------- +// RPCs + ceremonies +// --------------------------------------------------------------------------- + +const PARTICIPATION_ATTESTATION_PREFIX = te.encode('hArk round join ownership proof '); + +// NB this attestation uses BIG-endian integers (unlike the arkoor one) +export function participationAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { + const msg = sha256(concatBytes( + PARTICIPATION_ATTESTATION_PREFIX, + inputVtxoIdRaw, + u64be(outputs.length), + ...outputs.flatMap((o) => [u64be(o.amountSat), concatBytes(Uint8Array.of(0x00), o.userPubkey)]), + )); + return schnorr.sign(msg, vtxoPrivkey); +} + +// inputs: [{ vtxo, keys }] — each input's attestation is signed by its own key +export async function submitRoundParticipation(ark, { inputs, outputs }) { + const w = pbWriter(); + for (const { vtxo, keys } of inputs) { + const iv = pbWriter(); + iv.bytesField(1, vtxo.point.raw); + iv.bytesField(2, participationAttestation(vtxo.point.raw, outputs, keys.privkey)); + w.bytesField(2, iv.finish()); + } + for (const o of outputs) { + const vr = pbWriter(); + vr.varintField(1, o.amountSat); + vr.bytesField(2, concatBytes(Uint8Array.of(0x00), o.userPubkey)); + w.bytesField(3, vr.finish()); + } + const resp = await grpcCall(ark, 'bark_server.ArkService/SubmitRoundParticipation', w.finish()); + for (const { field, value } of pbFields(resp)) if (field === 1) return value; // unlock_hash + throw new Error('no unlock hash in response'); +} + +export async function roundParticipationStatus(ark, unlockHash) { + const w = pbWriter(); + w.bytesField(1, unlockHash); + const resp = await grpcCall(ark, 'bark_server.ArkService/RoundParticipationStatus', w.finish()); + const out = { status: 0, outputVtxos: [] }; + for (const { field, value } of pbFields(resp)) { + if (field === 1) out.status = Number(value); + if (field === 2) out.fundingTx = value; + if (field === 3) out.outputVtxos.push(value); + if (field === 4) out.unlockPreimage = value; + } + return out; +} + +// MuSig2 script-spend cosign of the hash-locked leaf (NO taptweak) +export async function cosignHarkLeaf(ark, vtxo, fundingTx, vtxoKeys, serverPub) { + const last = vtxo.genesis[vtxo.genesis.length - 1]; + if (last.transition.type !== 'hashLockedCosigned') throw new Error('not a hark leaf vtxo'); + const unlockHash = hex.decode(last.transition.unlock.hash); + + // leaf tx + the output it spends + const chain = vtxoTransactions(vtxo, serverPub); + const leaf = chain[chain.length - 1]; + const preleafTxout = chain.length >= 2 + ? chain[chain.length - 2].tx.outputs[chain[chain.length - 2].outputIdx] + : fundingTx.outputs[vtxo.anchorPoint.vout]; + + const tp = harkLeafTaproot(vtxoKeys.pubkey, serverPub, vtxo.expiryHeight, unlockHash); + const sighash = taprootScriptSighash(leaf.tx, [preleafTxout], tapLeafHash(tp.unlockScript)); + + const nonces = musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sighash); + const w = pbWriter(); + w.bytesField(1, vtxo.point.raw); + w.bytesField(2, nonces.public); + const resp = await grpcCall(ark, 'bark_server.ArkService/RequestLeafVtxoCosign', w.finish()); + let serverNonce, serverPartial; + for (const { field, value } of pbFields(resp)) { + if (field === 1) serverNonce = value; + if (field === 2) serverPartial = value; + } + + const { sortedKeys } = musigInternalKey([vtxoKeys.pubkey, serverPub]); + const aggNonce = musig2.nonceAggregate([nonces.public, serverNonce]); + const session = new musig2.Session(aggNonce, sortedKeys, sighash); // no tweak: script spend + const serverIdx = sortedKeys.findIndex((k) => hex.encode(k) === hex.encode(serverPub)); + const noncesInKeyOrder = serverIdx === 0 ? [serverNonce, nonces.public] : [nonces.public, serverNonce]; + if (!session.partialSigVerify(serverPartial, noncesInKeyOrder, serverIdx)) { + throw new Error('server leaf partial signature invalid'); + } + const userPartial = session.sign(nonces.secret, vtxoKeys.privkey); + const finalSig = session.partialSigAgg([userPartial, serverPartial]); + if (!schnorr.verify(finalSig, sighash, tp.internalXOnly)) { + throw new Error('combined leaf signature does not verify against agg key'); + } + return finalSig; +} + +export async function requestForfeitNonces(ark, unlockHash, vtxoIdRaws) { + const w = pbWriter(); + w.bytesField(1, unlockHash); + for (const id of vtxoIdRaws) w.bytesField(2, id); + const resp = await grpcCall(ark, 'bark_server.ArkService/RequestForfeitNonces', w.finish()); + const nonces = []; + for (const { field, value } of pbFields(resp)) if (field === 1) nonces.push(value); + return nonces; +} + +// Sign the forfeit tx for `input` and build the wire bundle +export function forfeitBundle({ input, unlockHash, vtxoKeys, serverPub, serverNonce }) { + const inputTaproot = pubkeyPolicyTaproot(vtxoKeys.pubkey, serverPub, input.exitDelta); + const claim = forfeitClaimTaproot(vtxoKeys.pubkey, serverPub, input.exitDelta, unlockHash); + const forfeitTx = { + version: 3, locktime: 0, + inputs: [{ prevout: input.point.raw, sequence: 0xffffffff }], + outputs: [ + { valueSat: input.amountSat, scriptPubKey: claim.scriptPubKey }, + { valueSat: 0, scriptPubKey: P2A_SCRIPT }, + ], + }; + const prevout = { valueSat: input.amountSat, scriptPubKey: inputTaproot.scriptPubKey }; + const sighash = taprootSighash(forfeitTx, [prevout]); + + const nonces = musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sighash); + const aggNonce = musig2.nonceAggregate([nonces.public, serverNonce]); + const session = new musig2.Session(aggNonce, inputTaproot.sortedKeys, sighash, [inputTaproot.tapTweak], [true]); + const partial = session.sign(nonces.secret, vtxoKeys.privkey); + + return concatBytes(Uint8Array.of(0x01), input.point.raw, unlockHash, nonces.public, partial); +} + +export async function forfeitVtxos(ark, bundles) { + const w = pbWriter(); + for (const b of bundles) w.bytesField(1, b); + const resp = await grpcCall(ark, 'bark_server.ArkService/ForfeitVtxos', w.finish()); + for (const { field, value } of pbFields(resp)) if (field === 1) return value; // preimage + throw new Error('no unlock preimage in response'); +} diff --git a/src/ark/send.js b/src/ark/send.js new file mode 100644 index 0000000..39cd1fb --- /dev/null +++ b/src/ark/send.js @@ -0,0 +1,364 @@ +// Native-JS arkoor send: checkpoint + arkoor transaction construction, +// BIP-341 sighashes, MuSig2 cosign ceremony with the ASP, and signed-VTXO +// assembly. Mirrors bark's lib/src/arkoor/mod.rs ArkoorBuilder (checkpoint +// mode, no dust isolation). + +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; +import * as musig2 from '@scure/btc-signer/musig2'; + +import { concatBytes, reader, grpcCall, pbWriter, pbFields } from './proto.js'; + +const te = new TextEncoder(); + +// --------------------------------------------------------------------------- +// small bitcoin helpers +// --------------------------------------------------------------------------- + +const u16le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff); +const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff); +const u64le = (n) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setBigUint64(0, BigInt(n), true); + return b; +}; +const varint = (n) => { + if (n < 0xfd) return Uint8Array.of(n); + if (n <= 0xffff) return concatBytes(Uint8Array.of(0xfd), u16le(n)); + return concatBytes(Uint8Array.of(0xfe), u32le(n)); +}; + +const sha256d = (b) => sha256(sha256(b)); + +const taggedHash = (tag, ...data) => { + const th = sha256(te.encode(tag)); + return sha256(concatBytes(th, th, ...data)); +}; + +// minimal script-number push (as bitcoin's push_int) +function pushInt(n) { + if (n === 0) return Uint8Array.of(0x00); // OP_0 + if (n >= 1 && n <= 16) return Uint8Array.of(0x50 + n); // OP_1..OP_16 + const bytes = []; + let v = n; + while (v > 0) { bytes.push(v & 0xff); v >>= 8; } + if (bytes[bytes.length - 1] & 0x80) bytes.push(0x00); + return Uint8Array.of(bytes.length, ...bytes); +} + +const OP = { CSV: 0xb2, CLTV: 0xb1, DROP: 0x75, CHECKSIG: 0xac }; +export const P2A_SCRIPT = hex.decode('51024e73'); + +// delayed_sign: <csv> OP_CSV OP_DROP <xonly> OP_CHECKSIG +const delayedSignScript = (delta, xonly) => + concatBytes(pushInt(delta), Uint8Array.of(OP.CSV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); + +// timelock_sign: <height> OP_CLTV OP_DROP <xonly> OP_CHECKSIG +const timelockSignScript = (height, xonly) => + concatBytes(pushInt(height), Uint8Array.of(OP.CLTV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); + +// --------------------------------------------------------------------------- +// taproot + musig key aggregation +// --------------------------------------------------------------------------- + +const xonly = (pub33) => pub33.slice(1); + +// bark: musig::combine_keys — KeySort then KeyAgg, no tweak +export function musigInternalKey(keys) { + const sorted = musig2.sortKeys(keys.map((k) => Uint8Array.from(k))); + const ctx = musig2.keyAggregate(sorted); + return { sortedKeys: sorted, internalXOnly: musig2.keyAggExport(ctx) }; // 32B x-only +} + +const tapLeafHash = (script) => + taggedHash('TapLeaf', Uint8Array.of(0xc0), varint(script.length), script); + +// One-leaf taproot around a musig2 internal key. +// Returns everything both tx-building and signing need. +export function taprootOneLeaf(userPub, serverPub, leafScript) { + const { sortedKeys, internalXOnly } = musigInternalKey([userPub, serverPub]); + const merkleRoot = tapLeafHash(leafScript); + const tapTweak = taggedHash('TapTweak', internalXOnly, merkleRoot); + const P = secp256k1.ProjectivePoint; + // lift internal x-only, add tweak*G + const internalPoint = P.fromHex(concatBytes(Uint8Array.of(0x02), internalXOnly)); + const outputPoint = internalPoint.add(P.BASE.multiply(BigInt('0x' + hex.encode(tapTweak)))); + const outputXOnly = outputPoint.toRawBytes(true).slice(1); + const scriptPubKey = concatBytes(hex.decode('5120'), outputXOnly); + return { sortedKeys, internalXOnly, merkleRoot, tapTweak, outputXOnly, scriptPubKey }; +} + +// PubkeyVtxoPolicy: keyspend musig(user,server); leaf = delayed_sign(exit_delta, user) +export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => + taprootOneLeaf(userPub, serverPub, delayedSignScript(exitDelta, xonly(userPub))); + +// CheckpointVtxoPolicy: keyspend musig(user,server); leaf = timelock_sign(expiry, server) +export const checkpointPolicyTaproot = (userPub, serverPub, expiryHeight) => + taprootOneLeaf(userPub, serverPub, timelockSignScript(expiryHeight, xonly(serverPub))); + +// --------------------------------------------------------------------------- +// transactions (version 3, zero locktime, sequence 0) +// --------------------------------------------------------------------------- + +const serializeTx = (tx) => concatBytes( + u32le(tx.version), + varint(tx.inputs.length), + ...tx.inputs.flatMap((i) => [i.prevout, Uint8Array.of(0x00), u32le(i.sequence)]), + varint(tx.outputs.length), + ...tx.outputs.flatMap((o) => [u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey]), + u32le(tx.locktime), +); + +export const txid = (tx) => sha256d(serializeTx(tx)); // internal byte order + +const outpointBytes = (txidInternal, vout) => concatBytes(txidInternal, u32le(vout)); + +// BIP-341 key-spend sighash, SIGHASH_DEFAULT, single input +export function taprootSighash(tx, prevouts) { + const shaPrevouts = sha256(concatBytes(...tx.inputs.map((i) => i.prevout))); + const shaAmounts = sha256(concatBytes(...prevouts.map((p) => u64le(p.valueSat)))); + const shaScripts = sha256(concatBytes(...prevouts.map((p) => + concatBytes(varint(p.scriptPubKey.length), p.scriptPubKey)))); + const shaSequences = sha256(concatBytes(...tx.inputs.map((i) => u32le(i.sequence)))); + const shaOutputs = sha256(concatBytes(...tx.outputs.map((o) => + concatBytes(u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey)))); + return taggedHash('TapSighash', + Uint8Array.of(0x00), // sighash epoch + Uint8Array.of(0x00), // hash type: default + u32le(tx.version), u32le(tx.locktime), + shaPrevouts, shaAmounts, shaScripts, shaSequences, shaOutputs, + Uint8Array.of(0x00), // spend type: key path, no annex + u32le(0), // input index + ); +} + +// --------------------------------------------------------------------------- +// VTXO encoding (mirror of decodeVtxo) +// --------------------------------------------------------------------------- + +const encodePubkeyPolicy = (pub33) => concatBytes(Uint8Array.of(0x00), pub33); + +// GenesisTransition::Arkoor +const encodeArkoorTransition = ({ cosigners, tapTweak, signature }) => concatBytes( + Uint8Array.of(0x02), + varint(cosigners.length), ...cosigners, + tapTweak, + signature ?? new Uint8Array(64), +); + +const encodeGenesisItem = ({ transition, nbOutputs, outputIdx, otherOutputs, feeSat }) => concatBytes( + transition, + Uint8Array.of(nbOutputs, outputIdx), + ...otherOutputs.map((o) => concatBytes(u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey)), + u64le(feeSat), +); + +// Assemble a Vtxo<Full> from the spent input's raw genesis bytes + new items. +function encodeVtxo({ amountSat, expiryHeight, serverPubkey, exitDelta, anchorPointRaw, + inputGenesisRaw, inputGenesisCount, newItems, policyBytes, pointRaw }) { + return concatBytes( + u16le(2), // encoding version + u64le(amountSat), + u32le(expiryHeight), + serverPubkey, + u16le(exitDelta), + anchorPointRaw, + varint(inputGenesisCount + newItems.length), + inputGenesisRaw, + ...newItems, + policyBytes, + pointRaw, + ); +} + +// --------------------------------------------------------------------------- +// the send itself +// --------------------------------------------------------------------------- + +// Build everything for a checkpointed single-input arkoor send. +// input: decoded vtxo (from decodeVtxo, with _raw), owned by keys.vtxo +// outputs: [{ amountSat, userPubkey (33B) }...] — destination first, change last +export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { + const userPub = vtxoKeys.pubkey; + if (hex.encode(userPub) !== input.policy.userPubkey) throw new Error('input not owned by our key'); + + const inputTaproot = pubkeyPolicyTaproot(userPub, serverPubkey, input.exitDelta); + const checkpointTaproot = checkpointPolicyTaproot(userPub, serverPubkey, input.expiryHeight); + + // checkpoint tx: spends the input vtxo, one output per destination + // (all with the checkpoint policy spk) + P2A fee anchor + const checkpointTx = { + version: 3, locktime: 0, + inputs: [{ prevout: input.point.raw, sequence: 0 }], + outputs: [ + ...outputs.map((o) => ({ valueSat: o.amountSat, scriptPubKey: checkpointTaproot.scriptPubKey })), + { valueSat: 0, scriptPubKey: P2A_SCRIPT }, + ], + }; + const checkpointTxid = txid(checkpointTx); + + // one arkoor tx per output, spending checkpoint:vout + const arkoorTxs = outputs.map((o, vout) => { + const destTaproot = pubkeyPolicyTaproot(o.userPubkey, serverPubkey, input.exitDelta); + return { + version: 3, locktime: 0, + inputs: [{ prevout: outpointBytes(checkpointTxid, vout), sequence: 0 }], + outputs: [ + { valueSat: o.amountSat, scriptPubKey: destTaproot.scriptPubKey }, + { valueSat: 0, scriptPubKey: P2A_SCRIPT }, + ], + }; + }); + + // sighashes: [checkpoint spend of input, arkoor_i spend of checkpoint:i] + const inputPrevout = { valueSat: input.amountSat, scriptPubKey: inputTaproot.scriptPubKey }; + const sighashes = [ + taprootSighash(checkpointTx, [inputPrevout]), + ...arkoorTxs.map((tx, i) => taprootSighash(tx, [checkpointTx.outputs[i]])), + ]; + // taptweaks per signature (bark: taptweak_at) + const tweaks = [inputTaproot.tapTweak, ...arkoorTxs.map(() => checkpointTaproot.tapTweak)]; + + return { checkpointTx, checkpointTxid, arkoorTxs, sighashes, tweaks, inputTaproot, checkpointTaproot }; +} + +// "arkoor cosign attestation " — 32-byte prefix +const ATTESTATION_PREFIX = te.encode('arkoor cosign attestation '); + +export function cosignAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { + const msg = sha256(concatBytes( + ATTESTATION_PREFIX, + inputVtxoIdRaw, + u32le(outputs.length), + ...outputs.flatMap((o) => [u64le(o.amountSat), encodePubkeyPolicy(o.userPubkey)]), + )); + return schnorr.sign(msg, vtxoPrivkey); +} + +// full MuSig2 ceremony over the ASP's RequestArkoorCosign +export async function cosignWithServer(ark, build, { input, outputs, vtxoKeys, serverPubkey }) { + const nSigs = build.sighashes.length; + + // 1. nonces, bound to each sighash + const nonces = build.sighashes.map((sh) => + musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sh)); + + // 2. request + const part = pbWriter(); + part.bytesField(1, input.point.raw); // vtxo id = outpoint bytes + for (const o of outputs) { + const dest = pbWriter(); + dest.varintField(1, o.amountSat); + dest.bytesField(2, encodePubkeyPolicy(o.userPubkey)); + part.bytesField(2, dest.finish()); + } + for (const n of nonces) part.bytesField(3, n.public); + part.varintField(5, 1); // use_checkpoint = true + part.bytesField(6, cosignAttestation(input.point.raw, outputs, vtxoKeys.privkey)); + + const req = pbWriter(); + req.bytesField(1, part.finish()); + const respBytes = await grpcCall(ark, 'bark_server.ArkService/RequestArkoorCosign', req.finish()); + + // 3. parse response + const serverNonces = [], serverPartials = []; + for (const { field, value } of pbFields(respBytes)) { + if (field !== 1) continue; + for (const f of pbFields(value)) { + if (f.field === 1) serverNonces.push(f.value); + if (f.field === 2) serverPartials.push(f.value); + } + } + if (serverNonces.length !== nSigs || serverPartials.length !== nSigs) { + throw new Error(`bad cosign response: ${serverNonces.length} nonces, ${serverPartials.length} partials, wanted ${nSigs}`); + } + + // 4. combine: session per sighash with the taptweak as x-only tweak + const finalSigs = []; + for (let i = 0; i < nSigs; i++) { + const aggNonce = musig2.nonceAggregate([nonces[i].public, serverNonces[i]]); + const session = new musig2.Session( + aggNonce, build.inputTaproot.sortedKeys, build.sighashes[i], [build.tweaks[i]], [true], + ); + // verify the server's partial before combining (bark does the same) + const serverIdx = build.inputTaproot.sortedKeys.findIndex( + (k) => hex.encode(k) === hex.encode(serverPubkey)); + const noncesInKeyOrder = serverIdx === 0 + ? [serverNonces[i], nonces[i].public] : [nonces[i].public, serverNonces[i]]; + if (!session.partialSigVerify(serverPartials[i], noncesInKeyOrder, serverIdx)) { + throw new Error(`server partial signature ${i} is invalid`); + } + const userPartial = session.sign(nonces[i].secret, vtxoKeys.privkey); + const finalSig = session.partialSigAgg([userPartial, serverPartials[i]]); + // self-check against the taproot output key this signature must satisfy + const outputKey = i === 0 ? build.inputTaproot.outputXOnly : build.checkpointTaproot.outputXOnly; + if (!schnorr.verify(finalSig, build.sighashes[i], outputKey)) { + throw new Error(`combined signature ${i} does not verify against taproot output key`); + } + finalSigs.push(finalSig); + } + return finalSigs; +} + +// assemble the final signed Vtxo<Full> bytes for output `idx` +export function buildSignedVtxoBytes({ input, outputs, build, finalSigs, serverPubkey, idx }) { + const o = outputs[idx]; + // NB nb_outputs on the wire counts own output + other_outputs — the P2A + // fee anchor is excluded (bark: other_outputs.len() + 1). + const checkpointOthers = build.checkpointTx.outputs.filter((out, i) => + i !== idx && hex.encode(out.scriptPubKey) !== hex.encode(P2A_SCRIPT)); + const checkpointItem = encodeGenesisItem({ + transition: encodeArkoorTransition({ + cosigners: [Uint8Array.from(hex.decode(input.policy.userPubkey))], + tapTweak: build.inputTaproot.tapTweak, + signature: finalSigs[0], + }), + nbOutputs: checkpointOthers.length + 1, + outputIdx: idx, + otherOutputs: checkpointOthers, + feeSat: 0, + }); + const arkoorItem = encodeGenesisItem({ + transition: encodeArkoorTransition({ + cosigners: [Uint8Array.from(hex.decode(input.policy.userPubkey))], + tapTweak: build.checkpointTaproot.tapTweak, + signature: finalSigs[1 + idx], + }), + nbOutputs: 1, + outputIdx: 0, + otherOutputs: [], + feeSat: 0, + }); + + const raw = input._raw; + return encodeVtxo({ + amountSat: o.amountSat, + expiryHeight: input.expiryHeight, + serverPubkey, + exitDelta: input.exitDelta, + anchorPointRaw: input.anchorPoint.raw, + inputGenesisRaw: raw.bytes.slice(raw.itemsStart, raw.itemsEnd), + inputGenesisCount: raw.nItems, + newItems: [checkpointItem, arkoorItem], + policyBytes: encodePubkeyPolicy(o.userPubkey), + pointRaw: outpointBytes(txid(build.arkoorTxs[idx]), 0), + }); +} + +// --------------------------------------------------------------------------- +// remaining RPCs +// --------------------------------------------------------------------------- + +export async function registerVtxoTransactions(ark, vtxoBytesList) { + const w = pbWriter(); + for (const v of vtxoBytesList) w.bytesField(1, v); + await grpcCall(ark, 'bark_server.ArkService/RegisterVtxoTransactions', w.finish()); +} + +export async function postArkoorMessage(ark, blindedId, vtxoBytesList) { + const w = pbWriter(); + w.bytesField(1, blindedId); + for (const v of vtxoBytesList) w.bytesField(2, v); + await grpcCall(ark, 'mailbox_server.MailboxService/PostArkoorMessage', w.finish()); +} diff --git a/src/ark/validate.js b/src/ark/validate.js new file mode 100644 index 0000000..d80ba50 --- /dev/null +++ b/src/ark/validate.js @@ -0,0 +1,93 @@ +// Full receive-side VTXO validation: reconstruct the entire off-chain +// transaction chain from genesis data, pin it to a confirmed onchain anchor, +// and verify every transition signature. Mirrors bark's +// lib/src/vtxo/validation.rs. +// +// A VTXO that passes here is cryptographically spendable by us alone (given +// the exit-path delays), assuming the anchor doesn't get reorged and no +// prior arkoor double-spend was cosigned by the server before ours. + +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { schnorr } from '@noble/curves/secp256k1'; + +import { taprootSighash, txid } from './send.js'; +import { + vtxoTransactions, vtxoAnchorAmount, transitionInputTxout, + harkLeafTaproot, taprootScriptSighash, tapLeafHash, parseTx, +} from './refresh.js'; + +export class VtxoValidationError extends Error {} +const fail = (msg) => { throw new VtxoValidationError(msg); }; + +// chain: an object with getTxHex(txid) -> hex|null and tipHeight() -> number, +// plus getTxStatus(txid) -> {confirmed, block_height} (esplora semantics) +export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys }) { + const serverHex = hex.encode(serverPubkey); + + // -- structural -- + if (vtxo.policy.type !== 'pubkey') fail(`unsupported policy: ${vtxo.policy.type}`); + if (vtxo.serverPubkey !== serverHex) fail('vtxo server pubkey does not match ours'); + if (expectPubkeys && !expectPubkeys.includes(vtxo.policy.userPubkey)) { + fail('vtxo not paid to one of our keys'); + } + if (!vtxo.genesis.length) fail('vtxo has no genesis transitions'); + + // -- expiry -- + const tip = await chain.tipHeight(); + if (vtxo.expiryHeight <= tip) fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`); + + // -- anchor: onchain, confirmed, and its output matches what the first + // transition claims to spend -- + const status = await chain.getTxStatus(vtxo.anchorPoint.txid); + if (!status?.confirmed) fail(`anchor tx ${vtxo.anchorPoint.txid} not confirmed`); + const anchorTx = parseTx(hex.decode(await chain.getTxHex(vtxo.anchorPoint.txid))); + const anchorOut = anchorTx.outputs[vtxo.anchorPoint.vout]; + if (!anchorOut) fail('anchor vout does not exist'); + const expected = transitionInputTxout( + vtxo.genesis[0].transition, vtxoAnchorAmount(vtxo), serverPubkey, vtxo.expiryHeight, + ); + if (anchorOut.valueSat !== expected.valueSat + || hex.encode(anchorOut.scriptPubKey) !== hex.encode(expected.scriptPubKey)) { + fail('anchor output does not match the genesis chain'); + } + + // -- chain reconstruction must land exactly on the vtxo's outpoint -- + const items = vtxoTransactions(vtxo, serverPubkey); + const last = items[items.length - 1]; + const lastTxid = hex.encode(txid(last.tx).slice().reverse()); + if (lastTxid !== vtxo.point.txid || last.outputIdx !== vtxo.point.vout) { + fail(`reconstructed chain tip ${lastTxid}:${last.outputIdx} != vtxo point ${vtxo.id}`); + } + + // -- every transition signature -- + let prevout = anchorOut; + for (let i = 0; i < items.length; i++) { + const t = vtxo.genesis[i].transition; + const tx = items[i].tx; + if (!t.signature) fail(`transition ${i} (${t.type}) is unsigned`); + const sig = hex.decode(t.signature); + + let msg, key; + if (t.type === 'hashLockedCosigned') { + // script-spend through the unlock clause; signed by the raw agg key + const hash = t.unlock.hash ? hex.decode(t.unlock.hash) : sha256(hex.decode(t.unlock.preimage)); + const tp = harkLeafTaproot(hex.decode(t.userPubkey), serverPubkey, vtxo.expiryHeight, hash); + msg = taprootScriptSighash(tx, [prevout], tapLeafHash(tp.unlockScript)); + key = tp.internalXOnly; + if (!t.unlock.preimage) fail(`transition ${i} has no unlock preimage`); + if (hex.encode(sha256(hex.decode(t.unlock.preimage))) !== hex.encode(hash)) { + fail(`transition ${i} preimage does not match its hash`); + } + } else { + // keyspend of the previous output — the prevout script itself carries + // the taproot output key the signature must verify against + msg = taprootSighash(tx, [prevout]); + key = prevout.scriptPubKey.slice(2); // 5120 || xonly + } + if (!schnorr.verify(sig, msg, key)) fail(`transition ${i} (${t.type}) signature invalid`); + prevout = tx.outputs[items[i].outputIdx]; + } + + return true; +} 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); +} |
