aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/board.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/board.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/board.js')
-rw-r--r--src/ark/board.js114
1 files changed, 114 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());
+}