aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/manager.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-03 14:46:36 -0700
committerAdam Soltys <[email protected]>2026-07-03 14:46:36 -0700
commit5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9 (patch)
treec2de49506f15aad217e5df02e5ec03b19ce1491c /src/ark/manager.js
parent8029e4bd43cc425d31f50286d7ae4e94b4206ace (diff)
downloadcoinosv3-5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9.tar.gz
coinosv3-5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9.zip
Ark: native-JS protocol client + persistent ArkManager
Speaks Second's bark/captaind Ark protocol directly from the browser — gRPC-web over fetch with hand-rolled protobuf, no wasm, no new deps (crypto via the existing @scure/@noble stack incl. btc-signer's BIP-327 musig2 module). src/ark: - proto.js transport, ark address codec, mailbox blinding/auth, VTXO ProtocolEncoding v2 decode - send.js arkoor send: checkpoint+arkoor txs, BIP-341 sighashes, MuSig2 cosign ceremony, signed-VTXO assembly - board.js onchain -> ark: funding taproot, exit-tx cosign - refresh.js delegated round (hArk): participation, tx-chain reconstruction, hash-locked leaf cosign, forfeit swap - validate.js receive-side verification: chain must land on the vtxo point, anchor checked onchain, every transition sig verified - manager.js persistent wallet layer: storage-checkpointed step-machine actions (send/board/refresh) that survive crashes, mailbox sync with validation, multi-input consolidation refresh tools/ark: end-to-end regtest tests (receive, send, board, refresh, manager incl. crash-mid-send + resume), all passing against captaind with the bark CLI as counterparty. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'src/ark/manager.js')
-rw-r--r--src/ark/manager.js452
1 files changed, 452 insertions, 0 deletions
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();
+ }
+ }
+}