diff options
| author | Adam Soltys <[email protected]> | 2026-07-03 14:46:36 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-03 14:46:36 -0700 |
| commit | 5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9 (patch) | |
| tree | c2de49506f15aad217e5df02e5ec03b19ce1491c /src/ark/board.js | |
| parent | 8029e4bd43cc425d31f50286d7ae4e94b4206ace (diff) | |
| download | coinosv3-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.js | 114 |
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()); +} |
