aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 00:49:21 -0700
committerAdam Soltys <[email protected]>2026-07-31 00:49:21 -0700
commit78789e0b2eaf91e2de6b4e488324fbec0fcefb9d (patch)
tree0fb96db0a8fcc62771bb86e9e0f14791c9bc1715 /src/ark
parent6dc7f9f89a071e43de77bf6dd82cf8d4012ce8c3 (diff)
downloadcoinosv3-78789e0b2eaf91e2de6b4e488324fbec0fcefb9d.tar.gz
coinosv3-78789e0b2eaf91e2de6b4e488324fbec0fcefb9d.zip
Add Ark <-> Lightning via the ASP (captaind-native submarine swaps)
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 <[email protected]>
Diffstat (limited to 'src/ark')
-rw-r--r--src/ark/lightning.js224
-rw-r--r--src/ark/manager.js431
-rw-r--r--src/ark/proto.js40
-rw-r--r--src/ark/refresh.js4
-rw-r--r--src/ark/send.js157
-rw-r--r--src/ark/validate.js6
6 files changed, 822 insertions, 40 deletions
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,8 +26,16 @@ 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,
} from './board.js';
@@ -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: <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),
});
}
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');