aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/lightning.js
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/lightning.js
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/lightning.js')
-rw-r--r--src/ark/lightning.js224
1 files changed, 224 insertions, 0 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());
+}