aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/validate.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/validate.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/validate.js')
-rw-r--r--src/ark/validate.js93
1 files changed, 93 insertions, 0 deletions
diff --git a/src/ark/validate.js b/src/ark/validate.js
new file mode 100644
index 0000000..d80ba50
--- /dev/null
+++ b/src/ark/validate.js
@@ -0,0 +1,93 @@
+// Full receive-side VTXO validation: reconstruct the entire off-chain
+// transaction chain from genesis data, pin it to a confirmed onchain anchor,
+// and verify every transition signature. Mirrors bark's
+// lib/src/vtxo/validation.rs.
+//
+// A VTXO that passes here is cryptographically spendable by us alone (given
+// the exit-path delays), assuming the anchor doesn't get reorged and no
+// prior arkoor double-spend was cosigned by the server before ours.
+
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import { schnorr } from '@noble/curves/secp256k1';
+
+import { taprootSighash, txid } from './send.js';
+import {
+ vtxoTransactions, vtxoAnchorAmount, transitionInputTxout,
+ harkLeafTaproot, taprootScriptSighash, tapLeafHash, parseTx,
+} from './refresh.js';
+
+export class VtxoValidationError extends Error {}
+const fail = (msg) => { throw new VtxoValidationError(msg); };
+
+// chain: an object with getTxHex(txid) -> hex|null and tipHeight() -> number,
+// plus getTxStatus(txid) -> {confirmed, block_height} (esplora semantics)
+export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys }) {
+ const serverHex = hex.encode(serverPubkey);
+
+ // -- structural --
+ if (vtxo.policy.type !== 'pubkey') fail(`unsupported policy: ${vtxo.policy.type}`);
+ if (vtxo.serverPubkey !== serverHex) fail('vtxo server pubkey does not match ours');
+ if (expectPubkeys && !expectPubkeys.includes(vtxo.policy.userPubkey)) {
+ fail('vtxo not paid to one of our keys');
+ }
+ if (!vtxo.genesis.length) fail('vtxo has no genesis transitions');
+
+ // -- expiry --
+ const tip = await chain.tipHeight();
+ if (vtxo.expiryHeight <= tip) fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`);
+
+ // -- anchor: onchain, confirmed, and its output matches what the first
+ // transition claims to spend --
+ const status = await chain.getTxStatus(vtxo.anchorPoint.txid);
+ if (!status?.confirmed) fail(`anchor tx ${vtxo.anchorPoint.txid} not confirmed`);
+ const anchorTx = parseTx(hex.decode(await chain.getTxHex(vtxo.anchorPoint.txid)));
+ const anchorOut = anchorTx.outputs[vtxo.anchorPoint.vout];
+ if (!anchorOut) fail('anchor vout does not exist');
+ const expected = transitionInputTxout(
+ vtxo.genesis[0].transition, vtxoAnchorAmount(vtxo), serverPubkey, vtxo.expiryHeight,
+ );
+ if (anchorOut.valueSat !== expected.valueSat
+ || hex.encode(anchorOut.scriptPubKey) !== hex.encode(expected.scriptPubKey)) {
+ fail('anchor output does not match the genesis chain');
+ }
+
+ // -- chain reconstruction must land exactly on the vtxo's outpoint --
+ const items = vtxoTransactions(vtxo, serverPubkey);
+ const last = items[items.length - 1];
+ const lastTxid = hex.encode(txid(last.tx).slice().reverse());
+ if (lastTxid !== vtxo.point.txid || last.outputIdx !== vtxo.point.vout) {
+ fail(`reconstructed chain tip ${lastTxid}:${last.outputIdx} != vtxo point ${vtxo.id}`);
+ }
+
+ // -- every transition signature --
+ let prevout = anchorOut;
+ for (let i = 0; i < items.length; i++) {
+ const t = vtxo.genesis[i].transition;
+ const tx = items[i].tx;
+ if (!t.signature) fail(`transition ${i} (${t.type}) is unsigned`);
+ const sig = hex.decode(t.signature);
+
+ let msg, key;
+ if (t.type === 'hashLockedCosigned') {
+ // script-spend through the unlock clause; signed by the raw agg key
+ const hash = t.unlock.hash ? hex.decode(t.unlock.hash) : sha256(hex.decode(t.unlock.preimage));
+ const tp = harkLeafTaproot(hex.decode(t.userPubkey), serverPubkey, vtxo.expiryHeight, hash);
+ msg = taprootScriptSighash(tx, [prevout], tapLeafHash(tp.unlockScript));
+ key = tp.internalXOnly;
+ if (!t.unlock.preimage) fail(`transition ${i} has no unlock preimage`);
+ if (hex.encode(sha256(hex.decode(t.unlock.preimage))) !== hex.encode(hash)) {
+ fail(`transition ${i} preimage does not match its hash`);
+ }
+ } else {
+ // keyspend of the previous output — the prevout script itself carries
+ // the taproot output key the signature must verify against
+ msg = taprootSighash(tx, [prevout]);
+ key = prevout.scriptPubKey.slice(2); // 5120 || xonly
+ }
+ if (!schnorr.verify(sig, msg, key)) fail(`transition ${i} (${t.type}) signature invalid`);
+ prevout = tx.outputs[items[i].outputIdx];
+ }
+
+ return true;
+}