From 78789e0b2eaf91e2de6b4e488324fbec0fcefb9d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 31 Jul 2026 00:49:21 -0700 Subject: Add Ark <-> Lightning via the ASP (captaind-native submarine swaps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ASP is the HTLC counterparty (second.tech's protocol), so no external swap provider is needed: paying a bolt11 converts pubkey vtxos into ServerHtlcSend vtxos the server can only claim by revealing the invoice preimage; receiving grants ServerHtlcRecv vtxos claimed by revealing ours. Failure paths cosign the HTLC back (revocation) — proven end-to-end against regtest captaind, which independently derives the HTLC taproots. - ark/lightning.js: bolt11 decode, lightning fee schedules, all pay/receive gRPC-web wrappers (cosign, initiate, status, revocation, hold-invoice receive flow, anti-DoS attestation) - ark/send.js: arkoor builder generalized to policy outputs (pubkey / serverHtlcSend / serverHtlcRecv), two-leaf taproot trees, HTLC clause scripts, reusable per-part cosign helpers for package RPCs - ark/manager.js: ln-pay and ln-recv crash-safe action state machines (deterministic receive preimages from the seed, chain 5), mailbox lightning notifications, unpayable-invoice -> instant revocation - proto.js: lightning ArkInfo fields + mailbox lightning message decode - validate/refresh: policy-aware vtxo validation and reconstruction - features: bolt11 routes via ark when the balance covers it (Boltz stays the fallback), lightning-invoice receive on the Ark pane - tools/ark/ln-test.js: harness proving HTLC cosign + revocation against captaind; manager-test.js updated for the consolidated bc-container chain Co-Authored-By: Claude Fable 5 --- src/ark/lightning.js | 224 ++++++++++++++++++++++++++ src/ark/manager.js | 431 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/ark/proto.js | 40 +++++ src/ark/refresh.js | 4 +- src/ark/send.js | 157 ++++++++++++++----- src/ark/validate.js | 6 +- 6 files changed, 822 insertions(+), 40 deletions(-) create mode 100644 src/ark/lightning.js (limited to 'src/ark') diff --git a/src/ark/lightning.js b/src/ark/lightning.js new file mode 100644 index 0000000..c9d3ce1 --- /dev/null +++ b/src/ark/lightning.js @@ -0,0 +1,224 @@ +// Native-JS Ark <-> Lightning via the ASP (captaind's built-in submarine-swap +// flows): bolt11 decoding, the lightning fee schedules, and the gRPC-web +// wrappers for the pay (HTLC-out) and receive (HTLC-in) protocols. +// +// Mirrors bark's bark/src/actions/lightning/{pay,receive}.rs and +// server-rpc/protos/bark_server.proto. The ASP is the HTLC counterparty: +// paying converts pubkey vtxos into ServerHtlcSend vtxos the server can only +// claim by revealing the invoice preimage; receiving grants ServerHtlcRecv +// vtxos we claim by revealing our preimage. Worst case either side falls back +// to a unilateral on-chain exit — same trust class as a Boltz submarine swap. + +import { bech32, hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { schnorr } from '@noble/curves/secp256k1'; + +import { concatBytes, grpcCall, pbWriter, pbFields } from './proto.js'; +import { parsePackageCosignResponse } from './send.js'; + +const te = new TextEncoder(); +const td = new TextDecoder(); + +// --------------------------------------------------------------------------- +// bolt11 (decode only: payment hash, amount, expiry, network) +// --------------------------------------------------------------------------- + +function convertBits(data, from, to) { + let acc = 0, bits = 0; const out = []; const maxv = (1 << to) - 1; + for (const v of data) { acc = (acc << from) | v; bits += from; while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } } + return Uint8Array.from(out); +} + +export function decodeBolt11(invoice) { + const inv = String(invoice || '').trim().toLowerCase().replace(/^lightning:/, ''); + const dec = bech32.decode(inv, 4000); + const m = dec.prefix.match(/^ln(bcrt|bc|tbs|tb)(\d+)?([munp])?$/); + if (!m) throw new Error('not a bolt11 invoice'); + const network = { bc: 'mainnet', tb: 'testnet', tbs: 'signet', bcrt: 'regtest' }[m[1]]; + let amountMsat = null; + if (m[2]) { + const n = BigInt(m[2]); + amountMsat = m[3] === 'p' ? n / 10n : n * ({ m: 100000000n, u: 100000n, n: 100n }[m[3]] ?? 100000000000n); + } + const words = dec.words; + let ts = 0; + for (let i = 0; i < 7; i++) ts = ts * 32 + words[i]; + let payHash = null, expiry = 3600; + const end = words.length - 104; // last 104 words = signature + for (let i = 7; i + 3 <= end;) { + const type = words[i]; + const len = (words[i + 1] << 5) | words[i + 2]; + const data = words.slice(i + 3, i + 3 + len); + if (type === 1 && len === 52) payHash = convertBits(data, 5, 8).slice(0, 32); + if (type === 6) { expiry = 0; for (const w of data) expiry = expiry * 32 + w; } + i += 3 + len; + } + if (!payHash) throw new Error('invoice missing payment hash'); + return { + network, + paymentHash: hex.encode(payHash), + amountMsat, + amountSat: amountMsat != null ? Number(amountMsat / 1000n) : null, + expiresAt: (ts + expiry) * 1000, + }; +} + +// Decoded invoice or null — the "is this a bolt11?" test. +export function maybeBolt11(text) { + try { return decodeBolt11(text); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// fee schedules (ArkInfo.fees.lightning_send / lightning_receive) +// --------------------------------------------------------------------------- + +// Send fee: base + ppm on the paid amount, ppm entry picked per input vtxo by +// its blocks-to-expiry (ascending table, last entry <= blocks wins), floored +// at min. Rounded up — never undershoots what the server will charge. +export function lnSendFee(amountSat, fees, inputs, tip) { + let remaining = amountSat; + let ppmMicro = 0n; + for (const v of inputs) { + const charge = Math.min(v.amountSat, remaining); + remaining -= charge; + const blocks = v.expiryHeight - tip; + const entry = (fees.ppmExpiryTable || []).filter((e) => blocks >= e.thresholdBlocks).pop(); + if (entry) ppmMicro += BigInt(charge) * BigInt(entry.ppm); + } + const ppmFee = Number((ppmMicro + 999_999n) / 1_000_000n); + return Math.max(fees.minFeeSat || 0, (fees.baseFeeSat || 0) + ppmFee); +} + +export const lnReceiveFee = (amountSat, fees) => + (fees.baseFeeSat || 0) + Math.ceil((amountSat * (fees.ppm || 0)) / 1_000_000); + +// --------------------------------------------------------------------------- +// pay (HTLC-out) +// --------------------------------------------------------------------------- + +// Cosign the conversion of input vtxos into ServerHtlcSend vtxos. +// parts: serialized ArkoorCosignRequest messages (send.js cosignPartBytes). +export async function requestLightningPayHtlcCosign(ark, parts) { + const req = pbWriter(); + for (const p of parts) req.bytesField(2, p); // LightningPayHtlcCosignRequest.parts = 2 + const resp = await grpcCall(ark, 'bark_server.ArkService/RequestLightningPayHtlcCosign', req.finish()); + return parsePackageCosignResponse(resp); +} + +export async function initiateLightningPayment(ark, { invoice, htlcVtxoIdRaws, amountSat, mailboxPubkey }) { + const w = pbWriter(); + w.stringField(1, invoice); + for (const id of htlcVtxoIdRaws) w.bytesField(2, id); + w.varintField(3, amountSat); + if (mailboxPubkey) w.bytesField(4, mailboxPubkey); + await grpcCall(ark, 'bark_server.ArkService/InitiateLightningPayment', w.finish()); +} + +// -> { status: 'pending'|'success'|'failed', preimage? (hex) } +export async function checkLightningPayment(ark, paymentHash, wait = false) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + if (wait) w.varintField(2, 1); + const data = await grpcCall(ark, 'bark_server.ArkService/CheckLightningPayment', w.finish()); + for (const { field, value } of pbFields(data)) { + if (field === 2) { + let preimage = null; + for (const f of pbFields(value)) if (f.field === 1) preimage = hex.encode(f.value); + return { status: 'success', preimage }; + } + if (field === 3) return { status: 'failed' }; + } + return { status: 'pending' }; +} + +// Cosign the revocation of failed-payment HTLC vtxos back to a pubkey policy. +export async function requestLightningPayHtlcRevocation(ark, parts) { + const req = pbWriter(); + for (const p of parts) req.bytesField(1, p); // ArkoorPackageCosignRequest.parts = 1 + const resp = await grpcCall(ark, 'bark_server.ArkService/RequestLightningPayHtlcRevocation', req.finish()); + return parsePackageCosignResponse(resp); +} + +// --------------------------------------------------------------------------- +// receive (HTLC-in) +// --------------------------------------------------------------------------- + +export const LN_RECV_STATUS = ['created', 'accepted', 'htlcsReady', 'settled', 'canceled']; + +export async function startLightningReceive(ark, { paymentHash, amountSat, minCltvDelta, mailboxPubkey, description }) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + w.varintField(2, amountSat); + w.varintField(3, minCltvDelta); + if (mailboxPubkey) w.bytesField(4, mailboxPubkey); + if (description) w.stringField(5, description); + const data = await grpcCall(ark, 'bark_server.ArkService/StartLightningReceive', w.finish()); + for (const { field, value } of pbFields(data)) if (field === 1) return td.decode(value); + throw new Error('server returned no invoice'); +} + +function decodeReceiveStatus(value) { + const out = { invoice: null, amountSat: 0, status: 'created', htlcVtxos: [] }; + for (const f of pbFields(value)) { + if (f.field === 1) out.invoice = td.decode(f.value); + if (f.field === 2) out.amountSat = Number(f.value); + if (f.field === 3) out.status = LN_RECV_STATUS[Number(f.value)] || 'created'; + if (f.field === 4) out.htlcVtxos.push(f.value); + } + return out; +} + +export async function checkLightningReceive(ark, paymentHash) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + const data = await grpcCall(ark, 'bark_server.ArkService/CheckLightningReceive', w.finish()); + return decodeReceiveStatus(data); +} + +// "Lightning receive VTXO challenge" — 32-byte prefix (anti-DoS ownership proof) +const LN_RECV_ATTESTATION_PREFIX = te.encode('Lightning receive VTXO challenge'); + +export const lightningReceiveAttestation = (paymentHash, vtxoIdRaw, vtxoPrivkey) => + schnorr.sign(sha256(concatBytes(LN_RECV_ATTESTATION_PREFIX, paymentHash, vtxoIdRaw)), vtxoPrivkey); + +// antiDos: { vtxoIdRaw, attestation } | { token } | null +export async function prepareLightningReceiveClaim(ark, { paymentHash, userPubkey, htlcRecvExpiry, antiDos }) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + w.bytesField(2, userPubkey); + w.varintField(3, htlcRecvExpiry); + if (antiDos && antiDos.vtxoIdRaw) { + const iv = pbWriter(); + iv.bytesField(1, antiDos.vtxoIdRaw); + iv.bytesField(2, antiDos.attestation); + w.bytesField(4, iv.finish()); + } else if (antiDos && antiDos.token) { + w.stringField(5, antiDos.token); + } + const data = await grpcCall(ark, 'bark_server.ArkService/PrepareLightningReceiveClaim', w.finish()); + const out = { receive: null, htlcVtxos: [] }; + for (const { field, value } of pbFields(data)) { + if (field === 1) out.receive = decodeReceiveStatus(value); + if (field === 2) out.htlcVtxos.push(value); + } + return out; +} + +// Reveal the preimage in exchange for a cosigned claim of the HTLC vtxos. +// parts: serialized ArkoorCosignRequest messages. Idempotent server-side. +export async function claimLightningReceive(ark, { paymentHash, preimage, parts }) { + const pkg = pbWriter(); + for (const p of parts) pkg.bytesField(1, p); + const w = pbWriter(); + w.bytesField(1, paymentHash); + w.bytesField(2, preimage); + w.bytesField(4, pkg.finish()); + const resp = await grpcCall(ark, 'bark_server.ArkService/ClaimLightningReceive', w.finish()); + return parsePackageCosignResponse(resp); +} + +export async function cancelLightningReceive(ark, paymentHash) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + await grpcCall(ark, 'bark_server.ArkService/CancelLightningReceive', w.finish()); +} diff --git a/src/ark/manager.js b/src/ark/manager.js index 86d496a..e6c4174 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -26,7 +26,15 @@ import { import { buildArkoorSend, cosignWithServer, buildSignedVtxoBytes, registerVtxoTransactions, postArkoorMessage, txid, + genUserNonces, cosignPartBytes, combineCosign, } from './send.js'; +import { + decodeBolt11, lnSendFee, lnReceiveFee, + requestLightningPayHtlcCosign, initiateLightningPayment, checkLightningPayment, + requestLightningPayHtlcRevocation, startLightningReceive, checkLightningReceive, + prepareLightningReceiveClaim, claimLightningReceive, cancelLightningReceive, + lightningReceiveAttestation, +} from './lightning.js'; import { boardFee, p2trAddress, buildBoard, requestBoardCosign, combineBoardSignature, encodeBoardVtxo, registerBoardVtxo, @@ -63,6 +71,7 @@ export class ArkManager { this.onUpdate = onUpdate || (() => {}); this.state = null; this.info = null; + this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push } // ---- keys ---- @@ -160,7 +169,7 @@ export class ArkManager { const ack = this.state.receiveAckTs || 0; const cutoff = Date.now() - 2 * 3600 * 1000; return this.state.movements.filter((m) => - m.type === 'receive' && m.status === 'complete' && m.ts > ack && m.ts > cutoff); + ['receive', 'ln-receive'].includes(m.type) && m.status === 'complete' && m.ts > ack && m.ts > cutoff); } ackReceives() { this.state.receiveAckTs = Date.now(); @@ -187,6 +196,26 @@ export class ArkManager { async _processMailboxMessage(m) { let changed = false; if (m.checkpoint > this.state.mailboxCheckpoint) { this.state.mailboxCheckpoint = m.checkpoint; changed = true; } + if (m.kind === 'lnSendFinished') { + const a = this.state.actions.find((x) => + x.type === 'ln-pay' && x.paymentHash === m.paymentHash && !['done', 'failed'].includes(x.step)); + if (a && m.preimage && a.step === 'initiated' + && hex.encode(sha256(hex.decode(m.preimage))) === a.paymentHash) { + this._settleLnPay(a, m.preimage); + changed = true; + } else if (a && !m.preimage && a.step === 'initiated') { + a.step = 'revoking'; + changed = true; + this._driveLnPay(a).catch(() => {}); // revocation retried on sync if this fails + } + return changed; + } + if (m.kind === 'lnIncoming') { + const a = this.state.actions.find((x) => + x.type === 'ln-recv' && x.paymentHash === m.paymentHash && !['done', 'failed'].includes(x.step)); + if (a) this._driveLnRecv(a).catch(() => {}); // claim promptly; sync poll is the fallback + return changed; + } if (m.kind !== 'arkoor') return changed; const ourKeys = [hex.encode(this._key(0).pubkey)]; for (const v of m.vtxos) { @@ -274,9 +303,13 @@ export class ArkManager { if (action.type === 'board') await this._driveBoard(action); if (action.type === 'refresh') await this._driveRefresh(action); if (action.type === 'offboard') await this._driveOffboard(action); + if (action.type === 'ln-pay') await this._driveLnPay(action); + if (action.type === 'ln-recv') await this._driveLnRecv(action); + if (action.lastError) { delete action.lastError; this._save(); } } catch (e) { // transient errors leave the action where it is; a later sync retries if (!(e instanceof GrpcError)) throw e; + if (action.type.startsWith('ln-')) { action.lastError = e.message; this._save(); } } } } @@ -502,6 +535,402 @@ export class ArkManager { } } + // ---- lightning (the ASP is the swap counterparty; see ark/lightning.js) ---- + + // Deterministic receive preimage: no secret ever hits storage — only the + // derivation index does, so a restored seed can always re-derive it. + _lnPreimage(idx) { + return sha256(this.account.deriveChild(5).deriveChild(idx).privateKey); + } + + _expiryMargin() { return this.network === 'regtest' ? 12 : 144; } + + lnAction(id) { + return this.state.actions.find((a) => a.id === id && a.type.startsWith('ln-')); + } + + // Drive one lightning action forward (the UI's fast poll while its screen + // is open); transient gRPC errors are recorded, not thrown. + async driveLn(id) { + const a = this.lnAction(id); + if (!a || ['done', 'failed'].includes(a.step)) return a; + try { + if (a.type === 'ln-pay') await this._driveLnPay(a); + else await this._driveLnRecv(a); + if (a.lastError) { delete a.lastError; this._save(); } + } catch (e) { + if (!(e instanceof GrpcError)) throw e; + a.lastError = e.message; + this._save(); + } + return a; + } + + // Pay a bolt11 invoice with ark funds. Returns the action id; drive to a + // terminal step ('done' | 'failed') via driveLn()/sync. + async payLnInvoice(invoice, { amountSat: userAmountSat } = {}) { + const dec = decodeBolt11(invoice); + const expectNet = { bitcoin: 'mainnet', regtest: 'regtest', signet: 'signet', testnet: 'testnet' }[this.info.network]; + if (expectNet && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`); + const amountSat = dec.amountSat ?? userAmountSat; + if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount'); + if (this.state.actions.some((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) { + throw new Error('this invoice was already paid or is being paid'); + } + const tip = await this.chain.tipHeight(); + // smallest single vtxo that covers amount + its expiry-dependent fee + const candidates = this.state.vtxos + .filter((v) => v.state === 'spendable') + .sort((a, b) => a.amountSat - b.amountSat); + let input = null, feeSat = 0; + for (const v of candidates) { + const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip); + if (v.amountSat >= amountSat + fee) { input = v; feeSat = fee; break; } + } + if (!input) { + const total = this.balance().spendableSat; + throw new Error(total >= amountSat + ? 'no single vtxo covers this amount — consolidate with refresh() first' + : 'insufficient ark balance'); + } + const action = { + id: `lnpay-${Date.now()}`, type: 'ln-pay', step: 'created', + invoice, paymentHash: dec.paymentHash, amountSat, feeSat, + inputId: input.id, + htlcKeyIndex: this.state.nextKeyIndex++, // HTLC lock key, reused for change (bark does the same) + revKeyIndex: this.state.nextKeyIndex++, + htlcExpiry: tip + (this.info.htlcSendExpiryDelta || 258), + changeSat: input.amountSat - amountSat - feeSat, + }; + input.state = 'pending'; + this.state.actions.push(action); + this._save(); + await this._driveLnPay(action); + return action.id; + } + + async _driveLnPay(action) { + if (this._lnDriving.has(action.id)) return; + this._lnDriving.add(action.id); + try { await this._driveLnPayInner(action); } finally { this._lnDriving.delete(action.id); } + } + + async _driveLnPayInner(action) { + if (action.step === 'created') { + const inputRec = this._vtxo(action.inputId); + const input = this._decoded(inputRec); + const keys = this._keyForVtxo(inputRec); + const htlcKey = this._key(action.htlcKeyIndex); + const outputs = [{ + amountSat: action.amountSat + action.feeSat, + policy: { + type: 'serverHtlcSend', userPubkey: hex.encode(htlcKey.pubkey), + paymentHash: action.paymentHash, htlcExpiry: action.htlcExpiry, + }, + }]; + if (action.changeSat > 0) outputs.push({ amountSat: action.changeSat, userPubkey: htlcKey.pubkey }); + await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]); + const build = buildArkoorSend({ input, outputs, serverPubkey: this.serverPub, vtxoKeys: keys }); + const nonces = genUserNonces(build, keys); + let resp; + try { + // idempotent on payment hash server-side: a re-drive after a crash + // gets fresh partials for fresh nonces + [resp] = await requestLightningPayHtlcCosign(this.arkUrl, + [cosignPartBytes({ build, input, vtxoKeys: keys, nonces })]); + } catch (e) { + if (e instanceof GrpcError && /already spent|not spendable/i.test(e.message)) { + inputRec.state = 'spent'; + action.step = 'failed'; + action.error = e.message; + this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message }); + this._save(); + return; + } + throw e; + } + if (!resp) throw new Error('empty cosign response'); + const sigs = combineCosign({ build, nonces, serverResp: resp, vtxoKeys: keys, serverPubkey: this.serverPub }); + // the input is spent server-side from this moment: persist immediately + const htlcBytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); + action.htlcBytes = hex.encode(htlcBytes); + const htlcDecoded = decodeVtxo(htlcBytes); + action.htlcVtxoId = htlcDecoded.id; + this._addVtxo(htlcDecoded, htlcBytes, action.htlcKeyIndex, 'pending'); + if (action.changeSat > 0) { + const changeBytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 1 }); + action.changeBytes = hex.encode(changeBytes); + this._addVtxo(decodeVtxo(changeBytes), changeBytes, action.htlcKeyIndex, 'pending'); + } + inputRec.state = 'spent'; + action.step = 'cosigned'; + this._save(); + } + if (action.step === 'cosigned') { + const list = [hex.decode(action.htlcBytes)]; + if (action.changeBytes) list.push(hex.decode(action.changeBytes)); + await registerVtxoTransactions(this.arkUrl, list).catch(() => {}); // best-effort (bark warns too) + // the change is a plain cosigned vtxo, good regardless of what the + // payment does from here + if (action.changeBytes) { + const change = this._vtxo(decodeVtxo(hex.decode(action.changeBytes)).id); + if (change && change.state === 'pending') change.state = 'spendable'; + } + try { + await initiateLightningPayment(this.arkUrl, { + invoice: action.invoice, + htlcVtxoIdRaws: [decodeVtxo(hex.decode(action.htlcBytes)).point.raw], + amountSat: action.amountSat, + mailboxPubkey: this._mailboxKey().pubkey, + }); + action.step = 'initiated'; + this._save(); + } catch (e) { + // INVALID_ARGUMENT: the server itself deems the invoice unpayable — + // retrying can never succeed, revoke the HTLC instead + if (e instanceof GrpcError && e.grpcStatus === 3) { + action.error = e.message; + action.step = 'revoking'; + this._save(); + } else { + throw e; + } + } + } + if (action.step === 'initiated') { + const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false); + if (res.status === 'success' && res.preimage + && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) { + this._settleLnPay(action, res.preimage); + return; + } + if (res.status === 'failed') { + action.step = 'revoking'; + this._save(); + } else { + // still pending: past the HTLC expiry the payment can't complete — revoke + const tip = await this.chain.tipHeight(); + if (tip <= action.htlcExpiry) return; // poll again next sync + action.step = 'revoking'; + this._save(); + } + } + if (action.step === 'revoking') await this._revokeLnPay(action); + } + + _settleLnPay(action, preimageHex) { + const htlc = this._vtxo(action.htlcVtxoId); + if (htlc) htlc.state = 'spent'; + action.preimage = preimageHex; + action.step = 'done'; + this._movement({ + type: 'ln-send', amountSat: action.amountSat, status: 'complete', + detail: action.feeSat ? `fee ${action.feeSat} sat` : undefined, + invoice: action.invoice, preimage: preimageHex, + }); + this._save(); + } + + // Payment failed: ask the server to cosign the HTLC vtxo back to a fresh + // pubkey vtxo of ours. A refusing/unreachable server leaves the action in + // 'revoking' (retried every sync); near expiry the remaining option is a + // unilateral exit of the HTLC vtxo. + async _revokeLnPay(action) { + const htlcRec = this._vtxo(action.htlcVtxoId); + const input = this._decoded(htlcRec); + const keys = this._key(action.htlcKeyIndex); + const revKey = this._key(action.revKeyIndex); + const build = buildArkoorSend({ + input, + outputs: [{ amountSat: input.amountSat, userPubkey: revKey.pubkey }], + serverPubkey: this.serverPub, vtxoKeys: keys, + }); + const nonces = genUserNonces(build, keys); + const [resp] = await requestLightningPayHtlcRevocation(this.arkUrl, + [cosignPartBytes({ build, input, vtxoKeys: keys, nonces })]); + if (!resp) throw new Error('empty revocation response'); + const sigs = combineCosign({ build, nonces, serverResp: resp, vtxoKeys: keys, serverPubkey: this.serverPub }); + const bytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); + await registerVtxoTransactions(this.arkUrl, [bytes]).catch(() => {}); + htlcRec.state = 'spent'; + this._addVtxo(decodeVtxo(bytes), bytes, action.revKeyIndex); + action.step = 'failed'; + action.error = action.error || 'payment failed'; + this._movement({ + type: 'ln-send', amountSat: action.amountSat, status: 'failed', + detail: 'payment failed — funds returned', invoice: action.invoice, + }); + this._save(); + } + + // Mint a bolt11 invoice payable to this wallet's ark balance. + async createLnInvoice(amountSat, description) { + if (!(amountSat > 0)) throw new Error('invalid amount'); + // CLTV budget: exit delta + server htlc delta + exit margin + claim delta + // + 2-block prepare leniency (bark's composition, defaults inlined) + const minCltvDelta = (this.info.vtxoExitDelta || 0) + (this.info.htlcExpiryDelta || 6) + 12 + 18 + 2; + if (this.info.maxUserInvoiceCltvDelta && minCltvDelta > this.info.maxUserInvoiceCltvDelta) { + throw new Error('server max invoice CLTV delta is too low'); + } + const idx = this.state.nextLnRecvIndex || 0; + this.state.nextLnRecvIndex = idx + 1; + const keyIndex = this.state.nextKeyIndex++; + const paymentHash = sha256(this._lnPreimage(idx)); + const invoice = await startLightningReceive(this.arkUrl, { + paymentHash, amountSat, minCltvDelta, + mailboxPubkey: this._mailboxKey().pubkey, description, + }); + const decInv = decodeBolt11(invoice); + if (decInv.paymentHash !== hex.encode(paymentHash)) { + throw new Error('server invoice payment hash mismatch'); + } + const action = { + id: `lnrecv-${Date.now()}`, type: 'ln-recv', step: 'awaiting', + paymentHash: hex.encode(paymentHash), preimageIndex: idx, keyIndex, + amountSat, minCltvDelta, invoice, expiresAt: decInv.expiresAt, + }; + this.state.actions.push(action); + this._save(); + return action; + } + + // Cancel an unpaid invoice. Only valid before HTLCs are granted; the server + // cancel is best-effort (an abandoned hold invoice expires server-side). + async cancelLnInvoice(id) { + const a = this.lnAction(id); + if (!a || a.type !== 'ln-recv') throw new Error('unknown invoice'); + if (a.step !== 'awaiting') throw new Error('receive already in progress'); + await cancelLightningReceive(this.arkUrl, hex.decode(a.paymentHash)).catch(() => {}); + a.step = 'failed'; + a.error = 'canceled'; + this._save(); + } + + async _driveLnRecv(action) { + if (this._lnDriving.has(action.id)) return; + this._lnDriving.add(action.id); + try { await this._driveLnRecvInner(action); } finally { this._lnDriving.delete(action.id); } + } + + async _driveLnRecvInner(action) { + if (action.step === 'awaiting') { + const st = await checkLightningReceive(this.arkUrl, hex.decode(action.paymentHash)); + if (st.status === 'canceled') { + action.step = 'failed'; + action.error = 'canceled server-side'; + this._save(); + return; + } + if (st.status === 'created') { + // unpaid past the invoice expiry (+grace): reap + if (action.expiresAt && Date.now() > action.expiresAt + 60_000) { + action.step = 'failed'; + action.error = 'invoice expired'; + this._save(); + } + return; + } + if (st.status === 'settled') { + // settled without us claiming — shouldn't happen; surface it + action.step = 'failed'; + action.error = 'settled server-side without local claim'; + this._save(); + return; + } + // accepted | htlcsReady: ask for our HTLC vtxos and validate them + const tip = await this.chain.tipHeight(); + const keys = this._key(action.keyIndex); + const htlcRecvExpiry = tip + action.minCltvDelta; + let antiDos = null; + if (this.info.lnReceiveAntiDosRequired) { + const proof = this.state.vtxos.filter((v) => v.state === 'spendable') + .map((v) => ({ v, d: this._decoded(v) })) + .find(({ d }) => d.expiryHeight > tip + 2); + if (proof) { + antiDos = { + vtxoIdRaw: proof.d.point.raw, + attestation: lightningReceiveAttestation( + hex.decode(action.paymentHash), proof.d.point.raw, this._keyForVtxo(proof.v).privkey), + }; + } + } + const res = await prepareLightningReceiveClaim(this.arkUrl, { + paymentHash: hex.decode(action.paymentHash), + userPubkey: keys.pubkey, htlcRecvExpiry, antiDos, + }); + if (!res.htlcVtxos.length) return; // not ready after all; retry on sync + let total = 0; + for (const b of res.htlcVtxos) { + const v = decodeVtxo(b); + if (v.policy.type !== 'serverHtlcRecv') throw new Error('unexpected HTLC vtxo policy from server'); + if (v.policy.paymentHash !== action.paymentHash) throw new Error('HTLC vtxo payment hash mismatch'); + if (v.policy.userPubkey !== hex.encode(keys.pubkey)) throw new Error('HTLC vtxo pubkey mismatch'); + if (v.policy.htlcExpiry < htlcRecvExpiry) throw new Error('HTLC vtxo expiry lower than requested'); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] }); + total += v.amountSat; + } + if (total + lnReceiveFee(action.amountSat, this.info.lnReceiveFees) < action.amountSat) { + throw new Error('server returned insufficient HTLC value'); + } + action.htlcBytes = res.htlcVtxos.map((b) => hex.encode(b)); + action.step = 'htlcsReady'; + this._save(); + } + if (action.step === 'htlcsReady') { + const keys = this._key(action.keyIndex); + const decoded = action.htlcBytes.map((b) => decodeVtxo(hex.decode(b))) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + // preimage not revealed yet + HTLCs near expiry: abandon rather than + // commit (the inbound HTLC times out and the sender is refunded) + if (!action.preimageRevealed) { + const tip = await this.chain.tipHeight(); + if (tip > decoded[0].policy.htlcExpiry - this._expiryMargin()) { + action.step = 'failed'; + action.error = 'HTLCs near expiry — abandoned before preimage reveal'; + this._movement({ type: 'ln-receive', amountSat: action.amountSat, status: 'failed', detail: action.error }); + this._save(); + return; + } + } + const builds = decoded.map((input) => buildArkoorSend({ + input, + outputs: [{ amountSat: input.amountSat, userPubkey: keys.pubkey }], + serverPubkey: this.serverPub, vtxoKeys: keys, + })); + const noncesList = builds.map((b) => genUserNonces(b, keys)); + const parts = builds.map((b, i) => + cosignPartBytes({ build: b, input: decoded[i], vtxoKeys: keys, nonces: noncesList[i] })); + // past this point the preimage is out; the claim is idempotent + // server-side, so persist the fact and retry until it lands + action.preimageRevealed = true; + this._save(); + const resps = await claimLightningReceive(this.arkUrl, { + paymentHash: hex.decode(action.paymentHash), + preimage: this._lnPreimage(action.preimageIndex), + parts, + }); + if (resps.length !== builds.length) throw new Error('bad claim cosign response'); + const outBytes = builds.map((b, i) => { + const sigs = combineCosign({ + build: b, nonces: noncesList[i], serverResp: resps[i], + vtxoKeys: keys, serverPubkey: this.serverPub, + }); + return buildSignedVtxoBytes({ input: decoded[i], build: b, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); + }); + await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {}); + let total = 0; + for (const bts of outBytes) { + const v = decodeVtxo(bts); + this._addVtxo(v, bts, action.keyIndex); + total += v.amountSat; + } + action.step = 'done'; + this._movement({ type: 'ln-receive', amountSat: total, status: 'complete', invoice: action.invoice }); + this._save(); + } + } + // ---- board ---- // Returns the onchain funding address; hal's onchain wallet pays it (the // board output MUST be vout 0), then completeBoard(actionId, txid). diff --git a/src/ark/proto.js b/src/ark/proto.js index ae2342e..bb8918d 100644 --- a/src/ark/proto.js +++ b/src/ark/proto.js @@ -368,6 +368,8 @@ export async function getArkInfo(ark) { boardFees: {}, refreshFees: { baseFeeSat: 0, ppmExpiryTable: [] }, offboardFees: { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] }, + lnReceiveFees: { baseFeeSat: 0, ppm: 0 }, + lnSendFees: { minFeeSat: 0, baseFeeSat: 0, ppmExpiryTable: [] }, }; for (const { field, value } of pbFields(data)) { if (field === 1) info.network = td.decode(value); @@ -375,8 +377,12 @@ export async function getArkInfo(ark) { if (field === 3) info.roundIntervalSecs = Number(value); if (field === 5) info.vtxoExitDelta = Number(value); if (field === 6) info.vtxoExpiryDelta = Number(value); + if (field === 7) info.htlcSendExpiryDelta = Number(value); + if (field === 8) info.htlcExpiryDelta = Number(value); if (field === 10) info.requiredBoardConfirmations = Number(value); + if (field === 11) info.maxUserInvoiceCltvDelta = Number(value); if (field === 13) info.minBoardAmountSat = Number(value); + if (field === 15) info.lnReceiveAntiDosRequired = Number(value) !== 0; if (field === 16) info.mailboxPubkey = hex.encode(value); if (field === 20) info.maxOffboardInputs = Number(value); if (field === 18) { // FeeSchedule @@ -421,6 +427,26 @@ export async function getArkInfo(ark) { } info.refreshFees = rf; } + if (f.field === 4) { // lightning receive + for (const g of pbFields(f.value)) { + if (g.field === 1) info.lnReceiveFees.baseFeeSat = Number(g.value); + if (g.field === 2) info.lnReceiveFees.ppm = Number(g.value); + } + } + if (f.field === 5) { // lightning send + for (const g of pbFields(f.value)) { + if (g.field === 1) info.lnSendFees.minFeeSat = Number(g.value); + if (g.field === 2) info.lnSendFees.baseFeeSat = Number(g.value); + if (g.field === 3) { + 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); + } + info.lnSendFees.ppmExpiryTable.push(e); + } + } + } } } } @@ -464,6 +490,20 @@ export function decodeMailboxMessage(value) { if (a.field === 1) msg.vtxos.push(decodeVtxo(a.value)); } } + if (f.field === 5) { // IncomingLightningPaymentMessage + msg.kind = 'lnIncoming'; + for (const a of pbFields(f.value)) { + if (a.field === 1) msg.paymentHash = hex.encode(a.value); + if (a.field === 2) msg.amountMsat = Number(a.value); + } + } + if (f.field === 7) { // LightningSendFinishedMessage + msg.kind = 'lnSendFinished'; + for (const a of pbFields(f.value)) { + if (a.field === 1) msg.paymentHash = hex.encode(a.value); + if (a.field === 2) msg.preimage = hex.encode(a.value); + } + } } return msg; } diff --git a/src/ark/refresh.js b/src/ark/refresh.js index 54a3fc8..adf4c85 100644 --- a/src/ark/refresh.js +++ b/src/ark/refresh.js @@ -12,7 +12,7 @@ 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'; +import { musigInternalKey, taprootSighash, txid, P2A_SCRIPT, pubkeyPolicyTaproot, policyTaproot } from './send.js'; const te = new TextEncoder(); const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff); @@ -144,7 +144,7 @@ export function vtxoTransactions(vtxo, serverPub) { 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 }; + scriptPubKey: policyTaproot(vtxo.policy, 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 = [ 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: OP_CSV OP_DROP 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: OP_CSV OP_DROP OP_HASH160 OP_EQUALVERIFY 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: OP_CLTV OP_DROP OP_CSV OP_DROP 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 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), }); } diff --git a/src/ark/validate.js b/src/ark/validate.js index d80ba50..f7aecb9 100644 --- a/src/ark/validate.js +++ b/src/ark/validate.js @@ -22,11 +22,13 @@ 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 }) { +// allowPolicies: acceptable policy types (default plain pubkey only; lightning +// flows pass ['serverHtlcSend'] / ['serverHtlcRecv'] for their HTLC vtxos) +export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'] }) { const serverHex = hex.encode(serverPubkey); // -- structural -- - if (vtxo.policy.type !== 'pubkey') fail(`unsupported policy: ${vtxo.policy.type}`); + if (!allowPolicies.includes(vtxo.policy.type)) 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'); -- cgit v1.2.3