aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ark/board.js114
-rw-r--r--src/ark/manager.js452
-rw-r--r--src/ark/proto.js408
-rw-r--r--src/ark/refresh.js399
-rw-r--r--src/ark/send.js364
-rw-r--r--src/ark/validate.js93
6 files changed, 1830 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;
+}