aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/refresh.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/refresh.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/refresh.js')
-rw-r--r--src/ark/refresh.js399
1 files changed, 399 insertions, 0 deletions
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');
+}