aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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
-rw-r--r--src/features/ark.js238
-rw-r--r--src/features/swaps.js9
-rw-r--r--src/i18n.js12
-rw-r--r--tools/ark/ln-test.js152
-rw-r--r--tools/ark/manager-test.js5
11 files changed, 1227 insertions, 51 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');
diff --git a/src/features/ark.js b/src/features/ark.js
index 08e44de..9b057b1 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -8,6 +8,7 @@ import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import { ArkManager } from '../ark/manager.js';
import { boardFee } from '../ark/board.js';
+import { maybeBolt11, lnSendFee } from '../ark/lightning.js';
import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js';
import { signedExitTxs, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js';
import { utxoId } from '../wallet.js';
@@ -94,6 +95,7 @@ export function mergeArkStates(a, b) {
out.gifts = [...gifts.values()];
out.mailboxCheckpoint = Math.max(a.mailboxCheckpoint || 0, b.mailboxCheckpoint || 0);
out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1);
+ out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0);
out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0);
return out;
}
@@ -148,6 +150,8 @@ export function arkFeature(ctx) {
arkTimer = null;
if (ark) ark.stopMailboxStream();
stopNwcFunding();
+ for (const timer of arkLnTimers.values()) clearInterval(timer);
+ arkLnTimers.clear();
ark = null;
arkConnectPromise = null;
}
@@ -310,16 +314,220 @@ export function arkFeature(ctx) {
ui.busy = false; render();
}
+ // ---- lightning via the ASP (captaind pays/holds invoices natively) -------
+ // One poll timer per in-flight lightning action; cleared on settle.
+ const arkLnTimers = new Map();
+ function stopArkLnPoll(id) {
+ const timer = arkLnTimers.get(id);
+ if (timer) { clearInterval(timer); arkLnTimers.delete(id); }
+ }
+ function pollArkLn(id, onSettle) {
+ stopArkLnPoll(id);
+ arkLnTimers.set(id, setInterval(async () => {
+ if (!ark) return;
+ const a = await ark.driveLn(id).catch(() => null);
+ if (a && ['done', 'failed'].includes(a.step)) {
+ stopArkLnPoll(id);
+ onSettle(a);
+ }
+ render();
+ }, 2500));
+ }
+
+ // A bolt11 lands in Send (via the swaps feature's delegation hook, or
+ // directly when swaps is absent): take over when the ark balance can
+ // plausibly cover it, else decline so the Boltz path handles it.
+ function startArkLnPay(invoice, meta) {
+ const dec = maybeBolt11(invoice);
+ if (!dec || !arkAvailable() || wallet.watchOnly) return false;
+ const s = arkStateNow();
+ const canCover = (sats) =>
+ !!s && (s.vtxos || []).some((v) => v.state === 'spendable' && v.amountSat >= sats);
+ if (!canCover(dec.amountSat || 1)) return false;
+ ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote' };
+ ui.sendError = '';
+ render();
+ connectArk().then(async (mgr) => {
+ const p = ui.arkLnPay;
+ if (!p || p.invoice !== invoice) return;
+ if (p.amountSat) {
+ // real quote: smallest vtxo that covers amount + its expiry-based fee
+ const tip = await mgr.chain.tipHeight();
+ const candidates = mgr.vtxos().filter((v) => v.state === 'spendable')
+ .sort((a, b) => a.amountSat - b.amountSat);
+ let fee = null;
+ for (const v of candidates) {
+ const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip);
+ if (v.amountSat >= p.amountSat + f) { fee = f; break; }
+ }
+ if (fee == null) {
+ // can't cover once fees are in — hand off to the Boltz path
+ ui.arkLnPay = null;
+ if (!ctx.hook('startLnPayBoltz', invoice, meta)) { ui.sendError = t('arkNotConnected'); }
+ render();
+ return;
+ }
+ p.feeSat = fee;
+ }
+ p.status = 'ready';
+ render();
+ }).catch((e) => {
+ if (ui.arkLnPay && ui.arkLnPay.invoice === invoice) { ui.sendError = e.message; render(); }
+ });
+ return true;
+ }
+
+ async function doArkLnPay() {
+ const p = ui.arkLnPay;
+ const sats = p.amountSat || ctx.parseAmount(p.amount, ctx.getUnit());
+ if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; }
+ ui.busy = true; ui.sendError = ''; render();
+ try {
+ const mgr = await connectArk();
+ const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats });
+ const settle = (a) => {
+ if (a.step === 'done') {
+ ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta };
+ ui.arkLnPay = null;
+ } else if (ui.arkLnPay) {
+ ui.arkLnPay.status = 'ready';
+ ui.sendError = a.error === 'payment failed' ? t('arkLnRefunded') : (a.error || t('arkLnPayFailed'));
+ }
+ render();
+ };
+ const a = mgr.lnAction(id);
+ if (['done', 'failed'].includes(a.step)) settle(a);
+ else { p.actionId = id; p.status = 'paying'; pollArkLn(id, settle); }
+ } catch (e) {
+ ui.sendError = e.message;
+ }
+ ui.busy = false; render();
+ }
+
+ function arkLnPayView() {
+ const u = ' ' + unitLabel();
+ if (ui.arkLnPaid) {
+ const zap = ui.arkLnPaid.meta;
+ return h('div', {
+ class: 'card col',
+ style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px',
+ onClick: () => { ui.arkLnPaid = null; ui.send = blankSend(); render(); },
+ },
+ h('div', { class: 'check-badge' }, '⚡'),
+ h('h2', { style: 'margin:0' }, zap ? t('arkZapSentTitle') : t('arkLnPaidTitle')),
+ zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null,
+ h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkLnPaid.amountSat) + u),
+ h('div', { class: 'small muted' }, t('tapToProceed')));
+ }
+ const p = ui.arkLnPay;
+ if (!p) return null;
+ const zap = p.meta;
+ const row = (label, sats, bold) => h('div', { class: 'row between' + (bold ? '' : ''), style: bold ? 'font-weight:600' : '' },
+ h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u));
+ const total = (p.amountSat || 0) + (p.feeSat || 0);
+ return h('div', { class: 'col', style: 'gap:12px' },
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('h3', { style: 'margin:0' }, '⚡ ' + (zap ? t('lnZapReviewTitle') : t('lnPayTitle'))),
+ h('div', { class: 'small muted' }, t('arkLnPayVia')),
+ zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null,
+ zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null,
+ p.status === 'quote'
+ ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnQuoting')))
+ : null,
+ p.amountSat == null && p.status !== 'quote'
+ ? h('div', { class: 'input-group' },
+ h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: p.amount,
+ onInput: (e) => { p.amount = e.target.value; } }),
+ h('div', { style: 'display:flex;align-items:center' }, unitTag()))
+ : null,
+ p.amountSat != null ? row(t('lnPayAmount'), p.amountSat) : null,
+ p.feeSat != null && p.feeSat > 0 ? row(t('arkLnFee'), p.feeSat) : null,
+ p.amountSat != null && p.feeSat != null
+ ? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)]
+ : null),
+ ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null,
+ p.status === 'paying'
+ ? h('div', { class: 'card row gap6', style: 'align-items:center' },
+ h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkLnPaying')))
+ : h('button', { class: 'btn-primary btn-block', disabled: !!ui.busy || p.status === 'quote', onClick: doArkLnPay },
+ ui.busy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')),
+ p.status !== 'paying'
+ ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnPay = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back'))
+ : null);
+ }
+
+ async function doArkLnInvoice() {
+ const sats = parseInt((ui.arkLnRecvAmt || '').trim(), 10);
+ if (!sats) return;
+ ui.arkBusy = 'lninvoice'; ui.arkError = ''; render();
+ try {
+ const mgr = await connectArk();
+ const a = await mgr.createLnInvoice(sats);
+ ui.arkLnRecvId = a.id;
+ pollArkLn(a.id, () => render()); // 'done' -> celebration via unseenReceives
+ } catch (e) {
+ ui.arkError = e.message;
+ }
+ ui.arkBusy = null; render();
+ }
+
+ // Lightning-receive block on the Ark receive pane: amount form -> invoice QR.
+ function arkLnReceiveSection() {
+ if (!ark || !ark.info || !ark.info.htlcExpiryDelta) return null; // server without lightning
+ const s = arkStateNow();
+ let rec = ui.arkLnRecvId ? ((s && s.actions) || []).find((a) => a.id === ui.arkLnRecvId) : null;
+ if (rec && ['done', 'failed'].includes(rec.step)) {
+ // settled (celebration takes over) or dead — reset to the form
+ if (rec.step === 'failed' && rec.error && rec.error !== 'canceled') ui.arkError = rec.error;
+ ui.arkLnRecvId = null;
+ ui.arkLnRecvAmt = '';
+ rec = null;
+ }
+ const divider = { class: 'col', style: 'width:100%;gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:14px' };
+ if (rec) {
+ let svg = null;
+ try { svg = qrSvg(rec.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
+ return h('div', divider,
+ h('div', { class: 'small muted', style: 'text-align:center' }, t('lnReceiveAwaiting')),
+ svg ? h('div', { style: 'align-self:center', html: svg }) : null,
+ h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, rec.invoice),
+ h('div', { class: 'row gap6', style: 'justify-content:center' }, copyBtn(rec.invoice, t('copyInvoice'))),
+ h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' },
+ h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnReceiveWatching'))),
+ rec.step === 'awaiting'
+ ? h('button', { class: 'btn-ghost btn-block', onClick: async () => {
+ stopArkLnPoll(rec.id);
+ try { await ark.cancelLnInvoice(rec.id); } catch {}
+ ui.arkLnRecvId = null; ui.arkLnRecvAmt = ''; render();
+ } }, t('arkLnCancelInvoice'))
+ : null);
+ }
+ return h('div', divider,
+ h('div', { class: 'small muted', style: 'text-align:center' }, t('arkLnInvoiceIntro')),
+ h('div', { class: 'input-group' },
+ h('input', {
+ type: 'number', inputmode: 'numeric', min: '0',
+ placeholder: t('amountSatsLabel'),
+ value: ui.arkLnRecvAmt || '',
+ onInput: (e) => { ui.arkLnRecvAmt = e.target.value; render(); },
+ }),
+ h('button', { class: 'btn-sm', disabled: !!ui.arkBusy || !parseInt(ui.arkLnRecvAmt || '', 10), onClick: doArkLnInvoice },
+ ui.arkBusy === 'lninvoice' ? h('span', { class: 'spinner sm' }) : '⚡ ' + t('lnCreateInvoice'))));
+ }
+
function arkHistoryItem(m) {
- const incoming = !['send', 'offboard', 'exit'].includes(m.type);
+ const incoming = !['send', 'offboard', 'exit', 'ln-send'].includes(m.type);
const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded')
+ : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory')
: m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : t('sent');
return h(
'div',
{ class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } },
// Ark mark in the (direction-colored) circle carries the rail; the label
// + signed amount carry direction, so no redundant "Ark" text chip.
- h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: ARK_MARK(15) }),
+ m.type.startsWith('ln-')
+ ? h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, '⚡')
+ : h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: ARK_MARK(15) }),
h('div', { class: 'grow' },
h('div', { class: 'row gap6' },
label,
@@ -331,8 +539,9 @@ export function arkFeature(ctx) {
}
function arkMoveDetailView(m) {
- const incoming = !['send', 'offboard', 'exit'].includes(m.type);
+ const incoming = !['send', 'offboard', 'exit', 'ln-send'].includes(m.type);
const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded')
+ : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory')
: m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : t('sent');
const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' },
h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v));
@@ -424,7 +633,7 @@ export function arkFeature(ctx) {
(() => {
const counts = {};
for (const a of pendingActions) { if (a.type !== 'exit') counts[a.type] = (counts[a.type] || 0) + 1; }
- const LABEL = { board: 'arkOpsBoard', send: 'arkOpsSend', refresh: 'arkOpsRefresh', offboard: 'arkOpsOffboard' };
+ const LABEL = { board: 'arkOpsBoard', send: 'arkOpsSend', refresh: 'arkOpsRefresh', offboard: 'arkOpsOffboard', 'ln-pay': 'arkOpsLnPay', 'ln-recv': 'arkOpsLnRecv' };
const parts = Object.entries(counts).map(([type, n]) => t(LABEL[type] || 'arkPendingActions', { n }));
return parts.length ? h('div', { class: 'small muted' }, parts.join(' · ')) : null;
})(),
@@ -919,7 +1128,9 @@ export function arkFeature(ctx) {
canBoard
? h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardAvailable', { n: fmtAmount(wallet.spendable) + ' ' + unitLabel() }))
: h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardNoFunds', { n: minBoard.toLocaleString() })),
- ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null)
+ ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null),
+ // Lightning receive straight into the ark balance (ASP-native swap)
+ arkLnReceiveSection()
);
}
throw new Error('unreachable');
@@ -1083,6 +1294,10 @@ export function arkFeature(ctx) {
return h('div', { class: 'small faint' }, t('arkSendHint'));
},
interceptReview(s) {
+ if (s.recipients.length === 1) {
+ const inv = (s.recipients[0].address || '').trim().replace(/^lightning:/i, '');
+ if (maybeBolt11(inv) && startArkLnPay(inv)) return true;
+ }
if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) {
if (!arkAvailable()) throw new Error(t('arkNotConnected'));
const sats = ctx.parseAmount(s.recipients[0].amount, ctx.getUnit());
@@ -1094,13 +1309,22 @@ export function arkFeature(ctx) {
return false;
},
sendView() {
+ if (ui.arkLnPaid || ui.arkLnPay) return arkLnPayView();
if (ui.arkZapped || ui.arkZap) return arkZapView();
if (ui.arkSent || ui.arkSend) return arkSendReview();
return null;
},
+ // A bolt11 pays from the ark balance when it can cover it. Reached via the
+ // swaps feature's delegation (its matcher runs first) or directly in
+ // builds without the swaps feature.
+ startArkLnPay(invoice, meta) { return startArkLnPay(invoice, meta); },
// An npub pasted into Send becomes an ark zap (needs the nostr seam from
- // the sync feature and a connected-able ark).
+ // the sync feature and a connected-able ark). A bolt11 is handled here
+ // only in builds without the swaps feature (whose matcher runs first and
+ // delegates back via the startArkLnPay hook).
matchSendText(text) {
+ const inv = (text || '').trim().replace(/^lightning:/i, '');
+ if (maybeBolt11(inv)) return startArkLnPay(inv);
const pk = npubToHex(text);
if (!pk || !arkAvailable() || !wallet.nostrFetch) return false;
ui.arkZap = { npub: String(text).trim(), pk, amount: '', comment: '', status: 'lookup' };
@@ -1115,7 +1339,7 @@ export function arkFeature(ctx) {
const s = arkStateNow();
if (!s) return [];
return (s.movements || [])
- .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit'].includes(m.type) && m.status === 'complete')
+ .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive'].includes(m.type) && m.status === 'complete')
.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }));
},
historyDetail() {
diff --git a/src/features/swaps.js b/src/features/swaps.js
index 091ca32..bad823c 100644
--- a/src/features/swaps.js
+++ b/src/features/swaps.js
@@ -132,7 +132,11 @@ export function swapsFeature(ctx) {
line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status));
}
- async function startLnSend(invoice, meta = null) {
+ async function startLnSend(invoice, meta = null, skipArk = false) {
+ // The ark feature pays bolt11 straight from the ark balance when it can
+ // cover it (the ASP is the swap counterparty) — cheaper and instant, so
+ // it gets first refusal before the on-chain Boltz submarine path.
+ if (!skipArk && ctx.hook('startArkLnPay', invoice, meta)) return;
ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnPayMeta = meta; ui.lnSendBusy = true; render();
try { ui.lnSend = await swaps.quoteSubmarine(invoice); }
catch (e) { ui.sendError = e.message; }
@@ -228,5 +232,8 @@ export function swapsFeature(ctx) {
// the review as a zap (recipient name, comment). Returns true so the hook
// dispatcher stops here.
startLnPay(invoice, meta) { startLnSend(invoice, meta || null); return true; },
+ // The ark feature falls back here when the ark balance can't cover an
+ // invoice after fees — skipArk avoids bouncing back and forth.
+ startLnPayBoltz(invoice, meta) { startLnSend(invoice, meta || null, true); return true; },
};
}
diff --git a/src/i18n.js b/src/i18n.js
index 04e7e06..82a397b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -427,6 +427,18 @@ const STR = {
arkOpsSend: '{n} send(s) finishing up',
arkOpsRefresh: '{n} refresh(es) waiting for the next round',
arkOpsOffboard: '{n} withdrawal(s) confirming',
+ arkOpsLnPay: '{n} Lightning payment(s) in flight',
+ arkOpsLnRecv: '{n} Lightning invoice(s) outstanding',
+ arkLnPayVia: 'Instant, from your Ark balance',
+ arkLnFee: 'Ark fee',
+ arkLnPaying: 'Paying invoice…',
+ arkLnPayFailed: 'Payment failed',
+ arkLnRefunded: 'Payment failed — funds returned to your Ark balance.',
+ arkLnPaidTitle: 'Paid!',
+ arkLnInvoiceIntro: 'Or receive over Lightning, straight into your Ark balance.',
+ arkLnPaidHistory: 'Paid Lightning invoice',
+ arkLnReceivedHistory: 'Received over Lightning',
+ arkLnCancelInvoice: 'Cancel invoice',
arkExited: 'Exited on-chain',
arkExitHistory: 'Ark exit',
arkRefreshStarted: 'Refresh submitted — completes with the next round.',
diff --git a/tools/ark/ln-test.js b/tools/ark/ln-test.js
new file mode 100644
index 0000000..c76d914
--- /dev/null
+++ b/tools/ark/ln-test.js
@@ -0,0 +1,152 @@
+// Lightning-over-ark harness against the regtest captaind.
+//
+// Without a CLN node attached to captaind, a payment can never settle — which
+// is exactly what this proves out: the HTLC cosign (the server independently
+// derives the ServerHtlcSend taproot + sighashes, so a wrong script tree fails
+// the MuSig ceremony), the initiate/status flow, and the revocation path that
+// returns the funds. Receive-side StartLightningReceive is exercised for clean
+// error behavior (hold invoices need CLN).
+//
+// Usage: bun tools/ark/ln-test.js (ark regtest stack up, alice funded)
+
+import { execSync } from 'node:child_process';
+import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { HDKey } from '@scure/bip32';
+import { bech32, hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import { secp256k1 } from '@noble/curves/secp256k1';
+
+import { ArkManager } from '../../src/ark/manager.js';
+import { decodeBolt11 } from '../../src/ark/lightning.js';
+
+const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
+const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002';
+const BARK = `${process.env.HOME}/bark/target/debug/bark`;
+const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+// --- minimal bolt11 encoder (regtest, signed with a throwaway key) ---------
+function toWords5(bytes) { return bech32.toWords(bytes); }
+function intToWords(n, len) {
+ const w = [];
+ for (let i = len - 1; i >= 0; i--) w.push((n >> (5 * i)) & 31);
+ return w;
+}
+function tagged(type, words) {
+ const t = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'.indexOf(type);
+ return [t, (words.length >> 5) & 31, words.length & 31, ...words];
+}
+function makeBolt11({ amountSat, paymentHash, privkey }) {
+ const hrp = `lnbcrt${amountSat * 10}n`; // sat -> 10*n (n = 100 msat... 1 sat = 10n)
+ const ts = Math.floor(Date.now() / 1000);
+ const data = [
+ ...intToWords(ts, 7),
+ ...tagged('p', toWords5(paymentHash)), // payment hash (52 words)
+ ...tagged('s', toWords5(crypto.getRandomValues(new Uint8Array(32)))), // payment secret
+ ...tagged('9', intToWords(0b100000100, 2)), // features: var_onion(8) + payment_secret(14)? keep minimal
+ ];
+ // signature: sha256(hrp || data packed 5->8 with zero pad)
+ const padded = data.slice();
+ const dataBytes = (() => {
+ let acc = 0, bits = 0; const out = [];
+ for (const w of padded) { acc = (acc << 5) | w; bits += 5; while (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff); } }
+ if (bits) out.push((acc << (8 - bits)) & 0xff);
+ return Uint8Array.from(out);
+ })();
+ const msg = sha256(new Uint8Array([...new TextEncoder().encode(hrp), ...dataBytes]));
+ const sig = secp256k1.sign(msg, privkey);
+ const sigWords = toWords5(new Uint8Array([...sig.toCompactRawBytes(), sig.recovery]));
+ return bech32.encode(hrp, [...data, ...sigWords], 4000);
+}
+
+// features field: set var_onion_optin (bit 9) + payment_secret (bit 14) required-ish;
+// 2 words = 10 bits only, so re-do with 3 words for bit 14
+function makeInvoice(amountSat) {
+ const privkey = sha256(new TextEncoder().encode('fake-ln-node'));
+ const preimage = crypto.getRandomValues(new Uint8Array(32));
+ const paymentHash = sha256(preimage);
+ const inv = makeBolt11({ amountSat, paymentHash, privkey });
+ return { inv, paymentHash: hex.encode(paymentHash) };
+}
+
+// --- wallet setup: fresh manager funded by alice ---------------------------
+const store = { raw: null };
+const storage = {
+ load: () => (store.raw ? JSON.parse(store.raw) : null),
+ save: (s) => { store.raw = JSON.stringify(s); },
+};
+const mnemonic = generateMnemonic(wordlist);
+const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'");
+const mgr = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
+
+console.log('\n[0] fund manager from alice');
+sh(`${ALICE} -q send ${mgr.address()} "25000 sat"`);
+for (let i = 0; i < 10 && !mgr.balance().spendableSat; i++) { await mgr.sync(); await sleep(1500); }
+check('funded 25000 sat', mgr.balance().spendableSat === 25000, JSON.stringify(mgr.balance()));
+
+console.log('\n[1] lightning fee schedule parsed from ArkInfo');
+check('lnSendFees parsed', mgr.info.lnSendFees.baseFeeSat === 75 && mgr.info.lnSendFees.minFeeSat === 10
+ && mgr.info.lnSendFees.ppmExpiryTable.length === 4, JSON.stringify(mgr.info.lnSendFees));
+check('lnReceiveFees parsed', mgr.info.lnReceiveFees.baseFeeSat === 100 && mgr.info.lnReceiveFees.ppm === 2000,
+ JSON.stringify(mgr.info.lnReceiveFees));
+check('htlc deltas parsed', mgr.info.htlcSendExpiryDelta === 258 && mgr.info.htlcExpiryDelta === 40,
+ JSON.stringify({ send: mgr.info.htlcSendExpiryDelta, recv: mgr.info.htlcExpiryDelta }));
+
+console.log('\n[2] pay: HTLC cosign against the real server (payment must fail: no CLN)');
+const { inv } = makeInvoice(5000);
+const dec = decodeBolt11(inv);
+check('own bolt11 decodes', dec.amountSat === 5000 && dec.network === 'regtest',
+ `${dec.network} ${dec.amountSat} ${dec.paymentHash}`);
+
+let payId = null, payErr = null;
+try {
+ payId = await mgr.payLnInvoice(inv);
+} catch (e) {
+ payErr = e;
+}
+
+if (payErr) {
+ // cosign or initiate rejected — surface exactly where it died
+ console.log(' payLnInvoice error:', payErr.message);
+ const a = mgr.state.actions.find((x) => x.type === 'ln-pay');
+ console.log(' action state:', a ? JSON.stringify({ step: a.step, error: a.error, lastError: a.lastError }) : 'none');
+ check('HTLC cosign accepted by server', a && ['cosigned', 'initiated', 'revoking', 'failed'].includes(a.step),
+ a ? a.step : 'no action');
+} else {
+ const a = mgr.lnAction(payId);
+ console.log(' after first drive: step =', a.step, a.lastError ? `(lastError: ${a.lastError})` : '');
+ check('HTLC cosign accepted by server', ['cosigned', 'initiated', 'revoking', 'failed', 'done'].includes(a.step), a.step);
+ // drive until terminal (initiate will fail without CLN -> revocation)
+ for (let i = 0; i < 20 && !['done', 'failed'].includes(mgr.lnAction(payId).step); i++) {
+ await sleep(1500);
+ await mgr.driveLn(payId);
+ }
+ const fin = mgr.lnAction(payId);
+ console.log(' terminal:', JSON.stringify({ step: fin.step, error: fin.error, lastError: fin.lastError }));
+ check('payment reached a terminal step', ['done', 'failed'].includes(fin.step), fin.step);
+ check('funds recovered after failure', mgr.balance().spendableSat === 25000,
+ JSON.stringify(mgr.balance()));
+ check('failure movement recorded', mgr.movements().some((m) => m.type === 'ln-send' && m.status === 'failed'));
+}
+
+console.log('\n[3] receive: StartLightningReceive error behavior without CLN');
+try {
+ const a = await mgr.createLnInvoice(4000);
+ console.log(' got invoice (server has CLN?):', a.invoice.slice(0, 40) + '…');
+ check('invoice payment hash matches derived preimage', decodeBolt11(a.invoice).paymentHash === a.paymentHash);
+} catch (e) {
+ console.log(' createLnInvoice error (expected without CLN):', e.message);
+ check('receive fails cleanly without CLN', /grpc|lightning|invoice|node|unavailable/i.test(e.message), e.message);
+}
+
+console.log(ok ? '\n✅ SUCCESS' : '\n❌ some checks failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/ark/manager-test.js b/tools/ark/manager-test.js
index 3fab3cf..902d757 100644
--- a/tools/ark/manager-test.js
+++ b/tools/ark/manager-test.js
@@ -22,7 +22,7 @@ const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002';
const BARK = `${process.env.HOME}/bark/target/debug/bark`;
const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`;
-const BCLI = `bitcoin-cli -regtest -datadir=${process.env.HOME}/ark-regtest/bitcoind -rpcport=18543 -rpcwallet=miner`;
+const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
@@ -110,8 +110,7 @@ const fundingTxid = sh(`${BCLI} sendrawtransaction ${signed.hex}`);
await mgr.completeBoard(actionId, fundingTxid);
check('board waits for confirmations', mgr.pendingActions().length === 1);
mine(2);
-await sleep(2000);
-await mgr.sync();
+for (let i = 0; i < 20 && mgr.pendingActions().length; i++) { await sleep(2000); await mgr.sync(); }
check('board vtxo spendable', mgr.balance().spendableSat === 11000 + 30000 - feeSat, JSON.stringify(mgr.balance()));
// --- 5. refresh: consolidate everything into one vtxo ---