diff options
Diffstat (limited to 'src/ark/send.js')
| -rw-r--r-- | src/ark/send.js | 157 |
1 files changed, 122 insertions, 35 deletions
diff --git a/src/ark/send.js b/src/ark/send.js index a959b22..e4f294a 100644 --- a/src/ark/send.js +++ b/src/ark/send.js @@ -5,11 +5,15 @@ 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 { concatBytes, reader, grpcCall, pbWriter, pbFields } from './proto.js'; +// hex string or byte array -> bytes (decoded vtxos carry hex-string fields) +const asBytes = (v) => (typeof v === 'string' ? hex.decode(v) : Uint8Array.from(v)); + const te = new TextEncoder(); // --------------------------------------------------------------------------- @@ -47,7 +51,7 @@ function pushInt(n) { return Uint8Array.of(bytes.length, ...bytes); } -const OP = { CSV: 0xb2, CLTV: 0xb1, DROP: 0x75, CHECKSIG: 0xac }; +const OP = { CSV: 0xb2, CLTV: 0xb1, DROP: 0x75, CHECKSIG: 0xac, HASH160: 0xa9, EQUALVERIFY: 0x88 }; export const P2A_SCRIPT = hex.decode('51024e73'); // delayed_sign: <csv> OP_CSV OP_DROP <xonly> OP_CHECKSIG @@ -58,6 +62,17 @@ const delayedSignScript = (delta, xonly) => const timelockSignScript = (height, xonly) => concatBytes(pushInt(height), Uint8Array.of(OP.CLTV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); +// hash_delay_sign: <csv> OP_CSV OP_DROP OP_HASH160 <ripemd160(hash)> OP_EQUALVERIFY <xonly> OP_CHECKSIG +// (the witness reveals the 32B preimage; HASH160(preimage) = ripemd160(sha256(preimage))) +const hashDelaySignScript = (paymentHash, delta, xonly) => concatBytes( + pushInt(delta), Uint8Array.of(OP.CSV, OP.DROP, OP.HASH160, 0x14), ripemd160(paymentHash), + Uint8Array.of(OP.EQUALVERIFY, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); + +// delay_timelock_sign: <height> OP_CLTV OP_DROP <csv> OP_CSV OP_DROP <xonly> OP_CHECKSIG +const delayTimelockSignScript = (height, delta, xonly) => concatBytes( + pushInt(height), Uint8Array.of(OP.CLTV, OP.DROP), pushInt(delta), + Uint8Array.of(OP.CSV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); + // --------------------------------------------------------------------------- // taproot + musig key aggregation // --------------------------------------------------------------------------- @@ -74,11 +89,20 @@ export function musigInternalKey(keys) { const tapLeafHash = (script) => taggedHash('TapLeaf', Uint8Array.of(0xc0), varint(script.length), script); -// One-leaf taproot around a musig2 internal key. +// BIP-341 branch hash: children sorted lexicographically +const tapBranch = (a, b) => { + let swap = false; + for (let i = 0; i < 32; i++) { if (a[i] !== b[i]) { swap = a[i] > b[i]; break; } } + return swap ? taggedHash('TapBranch', b, a) : taggedHash('TapBranch', a, b); +}; + +// Taproot around a musig2(user,server) internal key with 1 or 2 script leaves +// (two leaves sit at depth 1, mirroring bark's TaprootBuilder usage). // Returns everything both tx-building and signing need. -export function taprootOneLeaf(userPub, serverPub, leafScript) { +export function taprootFromLeaves(userPub, serverPub, leafScripts) { const { sortedKeys, internalXOnly } = musigInternalKey([userPub, serverPub]); - const merkleRoot = tapLeafHash(leafScript); + const leafHashes = leafScripts.map(tapLeafHash); + const merkleRoot = leafHashes.length === 1 ? leafHashes[0] : tapBranch(leafHashes[0], leafHashes[1]); const tapTweak = taggedHash('TapTweak', internalXOnly, merkleRoot); const P = secp256k1.ProjectivePoint; // lift internal x-only, add tweak*G @@ -91,6 +115,9 @@ export function taprootOneLeaf(userPub, serverPub, leafScript) { return { sortedKeys, internalXOnly, merkleRoot, tapTweak, outputXOnly, outputParity, scriptPubKey }; } +export const taprootOneLeaf = (userPub, serverPub, leafScript) => + taprootFromLeaves(userPub, serverPub, [leafScript]); + // PubkeyVtxoPolicy: keyspend musig(user,server); leaf = delayed_sign(exit_delta, user). // leafScript is returned so a unilateral exit can claim through it after the CSV. export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => { @@ -102,6 +129,30 @@ export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => { export const checkpointPolicyTaproot = (userPub, serverPub, expiryHeight) => taprootOneLeaf(userPub, serverPub, timelockSignScript(expiryHeight, xonly(serverPub))); +// Taproot for any user-facing VTXO policy (mirror of bark's VtxoPolicy::taproot). +// policy fields may be hex strings (decoded form) or byte arrays. +export function policyTaproot(policy, serverPub, exitDelta) { + const user = asBytes(policy.userPubkey); + if (policy.type === 'pubkey') return pubkeyPolicyTaproot(user, serverPub, exitDelta); + if (policy.type === 'serverHtlcSend') { + const hash = asBytes(policy.paymentHash); + // leaf 1: server spends with preimage after exit_delta + // leaf 2: user refund after htlc expiry + 2*exit_delta delay + const serverClaim = hashDelaySignScript(hash, exitDelta, xonly(serverPub)); + const userRefund = delayTimelockSignScript(policy.htlcExpiry, 2 * exitDelta, xonly(user)); + return { ...taprootFromLeaves(user, serverPub, [serverClaim, userRefund]), serverClaim, userRefund }; + } + if (policy.type === 'serverHtlcRecv') { + const hash = asBytes(policy.paymentHash); + // leaf 1: server reclaim after htlc expiry + exit_delta delay + // leaf 2: user spends with preimage after htlc_expiry_delta + exit_delta + const serverRefund = delayTimelockSignScript(policy.htlcExpiry, exitDelta, xonly(serverPub)); + const userClaim = hashDelaySignScript(hash, policy.htlcExpiryDelta + exitDelta, xonly(user)); + return { ...taprootFromLeaves(user, serverPub, [serverRefund, userClaim]), serverRefund, userClaim }; + } + throw new Error('unsupported vtxo policy type ' + policy.type); +} + // --------------------------------------------------------------------------- // transactions (version 3, zero locktime, sequence 0) // --------------------------------------------------------------------------- @@ -144,6 +195,20 @@ export function taprootSighash(tx, prevouts) { const encodePubkeyPolicy = (pub33) => concatBytes(Uint8Array.of(0x00), pub33); +// Serialize any user-facing VtxoPolicy (mirror of proto.js decodePolicy). +export function encodePolicy(p) { + const user = asBytes(p.userPubkey); + if (p.type === 'pubkey') return encodePubkeyPolicy(user); + if (p.type === 'serverHtlcSend') { + return concatBytes(Uint8Array.of(0x01), user, asBytes(p.paymentHash), u32le(p.htlcExpiry)); + } + if (p.type === 'serverHtlcRecv') { + return concatBytes(Uint8Array.of(0x02), user, asBytes(p.paymentHash), + u32le(p.htlcExpiry), u16le(p.htlcExpiryDelta)); + } + throw new Error('unsupported vtxo policy type ' + p.type); +} + // GenesisTransition::Arkoor const encodeArkoorTransition = ({ cosigners, tapTweak, signature }) => concatBytes( Uint8Array.of(0x02), @@ -183,12 +248,19 @@ function encodeVtxo({ amountSat, expiryHeight, serverPubkey, exitDelta, anchorPo // Build everything for a checkpointed single-input arkoor send. // input: decoded vtxo (from decodeVtxo, with _raw), owned by keys.vtxo -// outputs: [{ amountSat, userPubkey (33B) }...] — destination first, change last +// outputs: [{ amountSat, userPubkey (33B) }...] or [{ amountSat, policy }...] +// — destination first, change last export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { const userPub = vtxoKeys.pubkey; if (hex.encode(userPub) !== input.policy.userPubkey) throw new Error('input not owned by our key'); - const inputTaproot = pubkeyPolicyTaproot(userPub, serverPubkey, input.exitDelta); + // normalize plain-pubkey outputs to policy form + const outs = outputs.map((o) => ({ + amountSat: o.amountSat, + policy: o.policy || { type: 'pubkey', userPubkey: hex.encode(o.userPubkey) }, + })); + + const inputTaproot = policyTaproot(input.policy, serverPubkey, input.exitDelta); const checkpointTaproot = checkpointPolicyTaproot(userPub, serverPubkey, input.expiryHeight); // checkpoint tx: spends the input vtxo, one output per destination @@ -197,15 +269,15 @@ export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { version: 3, locktime: 0, inputs: [{ prevout: input.point.raw, sequence: 0 }], outputs: [ - ...outputs.map((o) => ({ valueSat: o.amountSat, scriptPubKey: checkpointTaproot.scriptPubKey })), + ...outs.map((o) => ({ valueSat: o.amountSat, scriptPubKey: checkpointTaproot.scriptPubKey })), { valueSat: 0, scriptPubKey: P2A_SCRIPT }, ], }; const checkpointTxid = txid(checkpointTx); // one arkoor tx per output, spending checkpoint:vout - const arkoorTxs = outputs.map((o, vout) => { - const destTaproot = pubkeyPolicyTaproot(o.userPubkey, serverPubkey, input.exitDelta); + const arkoorTxs = outs.map((o, vout) => { + const destTaproot = policyTaproot(o.policy, serverPubkey, input.exitDelta); return { version: 3, locktime: 0, inputs: [{ prevout: outpointBytes(checkpointTxid, vout), sequence: 0 }], @@ -225,7 +297,7 @@ export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { // taptweaks per signature (bark: taptweak_at) const tweaks = [inputTaproot.tapTweak, ...arkoorTxs.map(() => checkpointTaproot.tapTweak)]; - return { checkpointTx, checkpointTxid, arkoorTxs, sighashes, tweaks, inputTaproot, checkpointTaproot }; + return { outputs: outs, checkpointTx, checkpointTxid, arkoorTxs, sighashes, tweaks, inputTaproot, checkpointTaproot }; } // "arkoor cosign attestation " — 32-byte prefix @@ -236,50 +308,54 @@ export function cosignAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { ATTESTATION_PREFIX, inputVtxoIdRaw, u32le(outputs.length), - ...outputs.flatMap((o) => [u64le(o.amountSat), encodePubkeyPolicy(o.userPubkey)]), + ...outputs.flatMap((o) => [u64le(o.amountSat), encodePolicy(o.policy)]), )); return schnorr.sign(msg, vtxoPrivkey); } -// full MuSig2 ceremony over the ASP's RequestArkoorCosign -export async function cosignWithServer(ark, build, { input, outputs, vtxoKeys, serverPubkey }) { - const nSigs = build.sighashes.length; +// One musig2 nonce per sighash, bound to it. +export const genUserNonces = (build, vtxoKeys) => + build.sighashes.map((sh) => musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sh)); - // 1. nonces, bound to each sighash - const nonces = build.sighashes.map((sh) => - musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sh)); - - // 2. request +// Serialize one ArkoorCosignRequest part for a built arkoor spend. +export function cosignPartBytes({ build, input, vtxoKeys, nonces }) { const part = pbWriter(); part.bytesField(1, input.point.raw); // vtxo id = outpoint bytes - for (const o of outputs) { + for (const o of build.outputs) { const dest = pbWriter(); dest.varintField(1, o.amountSat); - dest.bytesField(2, encodePubkeyPolicy(o.userPubkey)); + dest.bytesField(2, encodePolicy(o.policy)); part.bytesField(2, dest.finish()); } for (const n of nonces) part.bytesField(3, n.public); part.varintField(5, 1); // use_checkpoint = true - part.bytesField(6, cosignAttestation(input.point.raw, outputs, vtxoKeys.privkey)); - - const req = pbWriter(); - req.bytesField(1, part.finish()); - const respBytes = await grpcCall(ark, 'bark_server.ArkService/RequestArkoorCosign', req.finish()); + part.bytesField(6, cosignAttestation(input.point.raw, build.outputs, vtxoKeys.privkey)); + return part.finish(); +} - // 3. parse response - const serverNonces = [], serverPartials = []; +// Parse an ArkoorPackageCosignResponse into per-part nonce/partial lists. +export function parsePackageCosignResponse(respBytes) { + const parts = []; for (const { field, value } of pbFields(respBytes)) { if (field !== 1) continue; + const p = { serverNonces: [], serverPartials: [] }; for (const f of pbFields(value)) { - if (f.field === 1) serverNonces.push(f.value); - if (f.field === 2) serverPartials.push(f.value); + if (f.field === 1) p.serverNonces.push(f.value); + if (f.field === 2) p.serverPartials.push(f.value); } + parts.push(p); } + return parts; +} + +// Combine our partials with the server's into final schnorr signatures, +// verifying the server partial and the combined signature along the way. +export function combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubkey }) { + const nSigs = build.sighashes.length; + const { serverNonces, serverPartials } = serverResp; if (serverNonces.length !== nSigs || serverPartials.length !== nSigs) { throw new Error(`bad cosign response: ${serverNonces.length} nonces, ${serverPartials.length} partials, wanted ${nSigs}`); } - - // 4. combine: session per sighash with the taptweak as x-only tweak const finalSigs = []; for (let i = 0; i < nSigs; i++) { const aggNonce = musig2.nonceAggregate([nonces[i].public, serverNonces[i]]); @@ -306,9 +382,20 @@ export async function cosignWithServer(ark, build, { input, outputs, vtxoKeys, s return finalSigs; } +// full MuSig2 ceremony over the ASP's RequestArkoorCosign +export async function cosignWithServer(ark, build, { input, vtxoKeys, serverPubkey }) { + const nonces = genUserNonces(build, vtxoKeys); + const req = pbWriter(); + req.bytesField(1, cosignPartBytes({ build, input, vtxoKeys, nonces })); + const respBytes = await grpcCall(ark, 'bark_server.ArkService/RequestArkoorCosign', req.finish()); + const [serverResp] = parsePackageCosignResponse(respBytes); + if (!serverResp) throw new Error('empty cosign response'); + return combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubkey }); +} + // assemble the final signed Vtxo<Full> bytes for output `idx` -export function buildSignedVtxoBytes({ input, outputs, build, finalSigs, serverPubkey, idx }) { - const o = outputs[idx]; +export function buildSignedVtxoBytes({ input, build, finalSigs, serverPubkey, idx }) { + const o = build.outputs[idx]; // NB nb_outputs on the wire counts own output + other_outputs — the P2A // fee anchor is excluded (bark: other_outputs.len() + 1). const checkpointOthers = build.checkpointTx.outputs.filter((out, i) => @@ -346,7 +433,7 @@ export function buildSignedVtxoBytes({ input, outputs, build, finalSigs, serverP inputGenesisRaw: raw.bytes.slice(raw.itemsStart, raw.itemsEnd), inputGenesisCount: raw.nItems, newItems: [checkpointItem, arkoorItem], - policyBytes: encodePubkeyPolicy(o.userPubkey), + policyBytes: encodePolicy(o.policy), pointRaw: outpointBytes(txid(build.arkoorTxs[idx]), 0), }); } |
