aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-29 00:50:09 -0700
committerAdam Soltys <[email protected]>2026-08-29 00:50:09 -0700
commit2023fe06d5cc4867f09d5916680b5fe92394dacb (patch)
treeaf8e230bd283776273537da60fe9c0d6782cba28 /src/ark
parent8ea0416c08535750839088c060d3252415510cc5 (diff)
downloadcoinosv3-2023fe06d5cc4867f09d5916680b5fe92394dacb.tar.gz
coinosv3-2023fe06d5cc4867f09d5916680b5fe92394dacb.zip
The wallet learns to read BOLT 12 for itself: offers and invoices decode client-side, and every invoice the ASP fetches over onion messages is verified here — payment hash read from the raw TLVs, offer terms mirrored byte-for-byte, the issuer's schnorr signature checked against LDK's exact merkle construction (proven against a live CLN-signed fixture) — so no server can hand back an invoice whose preimage it already holds. Paste an lno1 or resolve a name whose record carries one and the existing HTLC pay flow carries it (captaind's Invoice enum takes lni strings natively); a name's owner may now publish their OWN node's offer in their DNS record, retiring the registrar's stand-in — senders fetch invoices straight from their node and no coinos service ever touches the money. The /lnquote fallback prices blinded paths by entry node + advertised payinfo
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019WboeoetMF3MTmgT7Ew8yi
Diffstat (limited to 'src/ark')
-rw-r--r--src/ark/lightning.js34
-rw-r--r--src/ark/manager.js32
2 files changed, 61 insertions, 5 deletions
diff --git a/src/ark/lightning.js b/src/ark/lightning.js
index dab2d6d..da5ad8e 100644
--- a/src/ark/lightning.js
+++ b/src/ark/lightning.js
@@ -15,6 +15,7 @@ import { schnorr } from '@noble/curves/secp256k1';
import { concatBytes, grpcCall, pbWriter, pbFields } from './proto.js';
import { parsePackageCosignResponse } from './send.js';
+import { decodeBolt12Invoice } from '../bolt12.js';
const te = new TextEncoder();
const td = new TextDecoder();
@@ -68,6 +69,26 @@ export function maybeBolt11(text) {
try { return decodeBolt11(text); } catch { return null; }
}
+// Either invoice kind, one shape: bolt11 via decodeBolt11, bolt12 (lni…) via
+// the BOLT 12 decoder. network is null for a bolt12 on an unrecognized chain
+// (custom signets) — callers skip the network gate rather than guess.
+export function decodeLnInvoice(invoice) {
+ const s = String(invoice || '').trim().toLowerCase().replace(/^lightning:/, '');
+ if (/^lni1/.test(s)) {
+ const d = decodeBolt12Invoice(s);
+ return {
+ network: d.network, paymentHash: d.paymentHash,
+ amountMsat: d.amountMsat, amountSat: d.amountSat,
+ expiresAt: d.expiresAt, bolt12: true,
+ };
+ }
+ return decodeBolt11(invoice);
+}
+
+export function maybeLnInvoice(text) {
+ try { return decodeLnInvoice(text); } catch { return null; }
+}
+
// ---------------------------------------------------------------------------
// fee schedules (ArkInfo.fees.lightning_send / lightning_receive)
// ---------------------------------------------------------------------------
@@ -127,6 +148,19 @@ export async function initiateLightningPayment(ark, { invoice, htlcVtxoIdRaws, a
await grpcCall(ark, 'bark_server.ArkService/InitiateLightningPayment', w.finish());
}
+// Ask the server's CLN to fetch a BOLT 12 invoice for an offer (it speaks
+// the onion messages the browser can't). Returns the invoice's raw TLV
+// bytes — the caller MUST decode and verify them (src/bolt12.js) before
+// locking anything to the payment hash inside.
+export async function fetchBolt12Invoice(ark, offerBytes, amountSat) {
+ const w = pbWriter();
+ w.bytesField(1, offerBytes);
+ if (amountSat) w.varintField(2, amountSat);
+ const data = await grpcCall(ark, 'bark_server.ArkService/FetchBolt12Invoice', w.finish());
+ for (const { field, value } of pbFields(data)) if (field === 1) return value;
+ throw new Error('server returned no invoice');
+}
+
// -> { status: 'pending'|'success'|'failed', preimage? (hex) }
export async function checkLightningPayment(ark, paymentHash, wait = false) {
const w = pbWriter();
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 50214fc..2a6c956 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -29,7 +29,7 @@ import {
genUserNonces, cosignPartBytes, combineCosign,
} from './send.js';
import {
- decodeBolt11, lnSendFee, lnReceiveFee, fetchLnRouteFee,
+ decodeBolt11, decodeLnInvoice, fetchBolt12Invoice, lnSendFee, lnReceiveFee, fetchLnRouteFee,
requestLightningPayHtlcCosign, initiateLightningPayment, checkLightningPayment,
requestLightningPayHtlcRevocation, startLightningReceive, checkLightningReceive,
prepareLightningReceiveClaim, claimLightningReceive, cancelLightningReceive,
@@ -51,6 +51,7 @@ import {
} from './offboard.js';
import { signedExitTxs } from './exit.js';
import { validateVtxo, VtxoValidationError } from './validate.js';
+import { decodeOffer, decodeBolt12Invoice, verifyOfferInvoice, encodeBech32Raw } from '../bolt12.js';
const EMPTY_STATE = () => ({
v: 1,
@@ -787,12 +788,33 @@ export class ArkManager {
catch { return Math.max(3, Math.ceil(amountSat / 1000)); }
}
- // Pay a bolt11 invoice with ark funds. Returns the action id; drive to a
- // terminal step ('done' | 'failed') via driveLn()/sync.
+ // Fetch a BOLT 12 invoice for an offer through the server's CLN, then
+ // verify it HERE: hash and amount are read out of the raw TLVs and the
+ // issuer's signature is checked, so the server can't hand back an invoice
+ // whose preimage it already holds. Returns the lni string payLnInvoice
+ // (and captaind) accept.
+ async fetchBolt12(offerStr, amountSat) {
+ const offer = decodeOffer(offerStr);
+ if (offer.currency) throw new Error(`offer is priced in ${offer.currency}, not sats`);
+ const bytes = await fetchBolt12Invoice(this.arkUrl, offer.bytes, amountSat);
+ const inv = decodeBolt12Invoice(bytes);
+ verifyOfferInvoice(offer, inv);
+ if (amountSat && inv.amountSat !== amountSat) throw new Error('invoice amount does not match');
+ if (inv.expiresAt && inv.expiresAt < Date.now() + 60_000) throw new Error('invoice already expired');
+ return {
+ invoice: encodeBech32Raw('lni', bytes),
+ amountSat: inv.amountSat, paymentHash: inv.paymentHash, description: inv.description,
+ };
+ }
+
+ // Pay a bolt11 or bolt12 invoice with ark funds. Returns the action id;
+ // drive to a terminal step ('done' | 'failed') via driveLn()/sync.
async payLnInvoice(invoice, { amountSat: userAmountSat, routingFeeSat } = {}) {
- const dec = decodeBolt11(invoice);
+ const dec = decodeLnInvoice(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}`);
+ // a null network is a bolt12 on a chain we don't recognize (custom
+ // signets) — the server rejects a true mismatch, so don't guess here
+ if (expectNet && dec.network && 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) =>