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/proto.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/proto.js')
| -rw-r--r-- | src/ark/proto.js | 408 |
1 files changed, 408 insertions, 0 deletions
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 }; +} |
