aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--names/server.js57
-rw-r--r--src/ark/lightning.js34
-rw-r--r--src/ark/manager.js32
-rw-r--r--src/bolt12.js225
-rw-r--r--src/features/ark.js14
-rw-r--r--src/features/names.js101
-rw-r--r--src/features/zaps.js55
-rw-r--r--src/i18n.js4
-rw-r--r--tools/bolt12-test.js63
9 files changed, 528 insertions, 57 deletions
diff --git a/names/server.js b/names/server.js
index bf90b1a..61a3ca1 100644
--- a/names/server.js
+++ b/names/server.js
@@ -30,6 +30,7 @@ import { ArkManager } from '../src/ark/manager.js';
import { decodeAddress } from '../src/ark/proto.js';
import { decodeNoffer } from '../src/noffer.js';
import { decodeBolt11 } from '../src/ark/lightning.js';
+import { decodeOffer } from '../src/bolt12.js';
const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
|| join(import.meta.dir, 'config.json'), 'utf8'));
@@ -694,7 +695,23 @@ Bun.serve({
const feeMsat = await routeFeeMsat(our, dest, amountMsat, finalCltv);
return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false });
} catch (e) {
- // Private recipient: not in gossip, reachable only via the
+ // A bolt12 invoice with blinded paths: its node id is a blinded
+ // key no router knows. Quote = route to a path's public entry
+ // node + the blinded segment's advertised payinfo fees.
+ const paths = dec.invoice_paths || [];
+ let best = null;
+ for (const p of paths) {
+ const pay = p.payinfo || {};
+ const entry = p.first_node_id;
+ if (!entry) continue;
+ const f = (pay.fee_base_msat || 0) + Math.ceil(amountMsat * (pay.fee_proportional_millionths || 0) / 1_000_000);
+ try {
+ const toEntry = entry === our ? 0 : await routeFeeMsat(our, entry, amountMsat + f, finalCltv);
+ if (best == null || toEntry + f < best) best = toEntry + f;
+ } catch {}
+ }
+ if (best != null) return json({ feeMsat: best, feeSat: quoteSat(best), direct: false, blinded: true });
+ // Private bolt11 recipient: not in gossip, reachable only via the
// invoice's route hints. Quote = route to the hint's public entry
// node + the hint hops' advertised fees.
const hint = (dec.routes || [])[0];
@@ -785,7 +802,7 @@ Bun.serve({
if (m && req.method === 'GET') {
const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
const rec = state.names[`${m[1]}@${domain}`];
- if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null });
+ if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null, lno: rec.lno || null });
const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || await takenByCoinosUser(domain, m[1]);
return json({ name: m[1], domain, taken: reserved, reserved });
}
@@ -809,7 +826,7 @@ Bun.serve({
if (!mine.length) return json({});
const [key, r] = mine[0];
const [name, domain] = key.split('@');
- return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null });
+ return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null, lno: r.lno || null });
}
// --- hats ------------------------------------------------------------
@@ -1108,6 +1125,22 @@ Bun.serve({
} catch (e) { return json({ error: 'bad noffer: ' + e.message }, 400); }
}
}
+ // Own BOLT 12 offer: same absent/''/set semantics. When set, the DNS
+ // record carries THEIR lno — senders' fetchinvoice goes straight to
+ // their node and no money ever touches this service.
+ let lno = existing?.lno;
+ if (claim.lno !== undefined) {
+ if (!claim.lno) lno = undefined;
+ else {
+ try {
+ const d = decodeOffer(String(claim.lno).trim());
+ if (domain !== 'staging.coinos.io' && d.network && d.network !== 'mainnet') {
+ throw new Error(`offer is for ${d.network}`);
+ }
+ lno = String(claim.lno).trim().toLowerCase();
+ } catch (e) { return json({ error: 'bad offer: ' + e.message }, 400); }
+ }
+ }
// A corrupt ark address recorded here is a payment black hole: forwards
// fail forever while the sats sit on the node (goyslop's 190k sat sat
// behind an invalid checksum). Refuse it at the door.
@@ -1117,11 +1150,18 @@ Bun.serve({
catch { return json({ error: 'the ark address in the uri does not decode' }, 400); }
}
- // Every name also gets a static Lightning offer on our node; payments
- // to it are forwarded to the ark destination in the claim.
+ // The record's lno=: the owner's own offer when they set one, else a
+ // static offer on our node with the settle forwarder behind it.
let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null;
- if (!offer) { try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); } }
- const published = offer ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${offer.bolt12}` : claim.uri;
+ if (lno) {
+ // their node answers fetchinvoice directly now — retire our stand-in
+ if (offer && ln) await ln.call('disableoffer', { offer_id: offer.offerId }).catch(() => {});
+ offer = null;
+ } else if (!offer) {
+ try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); }
+ }
+ const pubLno = lno || offer?.bolt12;
+ const published = pubLno ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${pubLno}` : claim.uri;
const recordId = await cfWrite(name, domain, published, existing?.recordId);
state.names[key] = {
@@ -1132,8 +1172,9 @@ Bun.serve({
uri: published, recordId, updated: Date.now(), domain,
// the wallet's CLINK offer key, so LNURL can ask it for invoices
offerPk: (claim.offerPk && /^[0-9a-f]{64}$/.test(claim.offerPk)) ? claim.offerPk : existing?.offerPk,
- // the owner's own CLINK service, when they pointed delivery at it
+ // the owner's own CLINK service / BOLT 12 offer, when they set one
...(noffer ? { noffer } : {}),
+ ...(lno ? { lno } : {}),
offerId: offer?.offerId, bolt12: offer?.bolt12,
};
persist();
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) =>
diff --git a/src/bolt12.js b/src/bolt12.js
new file mode 100644
index 0000000..8954162
--- /dev/null
+++ b/src/bolt12.js
@@ -0,0 +1,225 @@
+// BOLT 12 offers and invoices — decode and verify, client-side.
+//
+// An offer (`lno1…`) names who to pay; the ASP's CLN fetches the actual
+// invoice (`lni…`) over onion messages (fetchBolt12Invoice in
+// src/ark/lightning.js). The invoice's payment hash MUST be extracted and
+// its signature verified HERE: an HTLC locked to a hash the server supplied
+// on trust could be claimed with a preimage the server already knows,
+// without the recipient ever being paid.
+//
+// The signature scheme (BOLT 12 "Signature Calculation", mirrored from
+// LDK's offers/merkle.rs): every non-signature TLV record contributes two
+// leaves — tagged("LnLeaf", record) and tagged("LnNonce"||first-record,
+// type-bytes) — pairs combine lexicographically under tagged("LnBranch"),
+// the tree reduces in place (odd nodes carry up), and the schnorr message
+// is tagged("lightninginvoicesignature", merkle_root).
+
+import { sha256 } from '@noble/hashes/sha256';
+import { schnorr } from '@noble/curves/secp256k1';
+import { hex } from '@scure/base';
+
+const te = new TextEncoder();
+const td = new TextDecoder();
+
+// ---------------------------------------------------------------------------
+// bech32 without a checksum (BOLT 12 uses the charset, not the checksum;
+// `+` followed by whitespace is allowed as a continuation in transport)
+// ---------------------------------------------------------------------------
+
+const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
+
+function convertBits(data, from, to, pad) {
+ 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); }
+ }
+ if (pad) { if (bits) out.push((acc << (to - bits)) & maxv); }
+ else if (bits >= from || ((acc << (to - bits)) & maxv)) throw new Error('invalid padding');
+ return Uint8Array.from(out);
+}
+
+export function decodeBech32Raw(str) {
+ const s = String(str || '').trim().replace(/\+\s*/g, '');
+ if (s !== s.toLowerCase() && s !== s.toUpperCase()) throw new Error('mixed case');
+ const lower = s.toLowerCase();
+ const pos = lower.lastIndexOf('1');
+ if (pos < 1) throw new Error('missing prefix');
+ const words = [];
+ for (const c of lower.slice(pos + 1)) {
+ const v = CHARSET.indexOf(c);
+ if (v < 0) throw new Error('bad character');
+ words.push(v);
+ }
+ return { hrp: lower.slice(0, pos), bytes: convertBits(words, 5, 8, false) };
+}
+
+export function encodeBech32Raw(hrp, bytes) {
+ const words = convertBits(bytes, 8, 5, true);
+ let out = hrp + '1';
+ for (const w of words) out += CHARSET[w];
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// TLV stream (bigsize types/lengths, truncated big-endian integers)
+// ---------------------------------------------------------------------------
+
+function readBigSize(b, off) {
+ const first = b[off];
+ if (first < 0xfd) return [first, off + 1];
+ if (first === 0xfd) return [(b[off + 1] << 8) | b[off + 2], off + 3];
+ if (first === 0xfe) return [((b[off + 1] << 24) >>> 0) + (b[off + 2] << 16) + (b[off + 3] << 8) + b[off + 4], off + 5];
+ let v = 0n;
+ for (let i = 1; i <= 8; i++) v = (v << 8n) | BigInt(b[off + i]);
+ return [Number(v), off + 9];
+}
+
+const tu64 = (v) => { let n = 0n; for (const b of v) n = (n << 8n) | BigInt(b); return n; };
+
+// → [{ type, value, raw (whole record), typeRaw (just the type bytes) }]
+export function parseTlvStream(bytes) {
+ const records = [];
+ let off = 0;
+ while (off < bytes.length) {
+ const start = off;
+ let type, len;
+ [type, off] = readBigSize(bytes, off);
+ const typeEnd = off;
+ [len, off] = readBigSize(bytes, off);
+ if (off + len > bytes.length) throw new Error('truncated TLV stream');
+ records.push({
+ type,
+ value: bytes.slice(off, off + len),
+ raw: bytes.slice(start, off + len),
+ typeRaw: bytes.slice(start, typeEnd),
+ });
+ off += len;
+ }
+ return records;
+}
+
+// ---------------------------------------------------------------------------
+// offers and invoices
+// ---------------------------------------------------------------------------
+
+// genesis chain hashes as they appear in offer_chains / invreq_chain
+const CHAINS = {
+ '6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000': 'mainnet',
+ '43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000': 'testnet',
+ f61eee3b63a380a477a063af32b2bbc97c9ff9f01f2c4225e973988108000000: 'signet',
+ '06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f': 'regtest',
+};
+// absent chain TLV = bitcoin mainnet; an unrecognized hash (custom signets) = null
+const chainNet = (v) => (v ? (CHAINS[hex.encode(v.slice(0, 32))] || null) : 'mainnet');
+
+export function decodeOffer(str) {
+ const { hrp, bytes } = decodeBech32Raw(str);
+ if (hrp !== 'lno') throw new Error('not a payment offer');
+ const records = parseTlvStream(bytes);
+ const out = { bytes, records, network: 'mainnet' };
+ for (const r of records) {
+ if (r.type === 2) out.network = chainNet(r.value);
+ else if (r.type === 6) out.currency = td.decode(r.value);
+ else if (r.type === 8) out.amountMsat = tu64(r.value);
+ else if (r.type === 10) out.description = td.decode(r.value);
+ else if (r.type === 16) out.hasPaths = true;
+ else if (r.type === 18) out.issuer = td.decode(r.value);
+ else if (r.type === 22) out.issuerPubkey = hex.encode(r.value);
+ }
+ return out;
+}
+
+export function decodeBolt12Invoice(input) {
+ let bytes = input;
+ if (typeof input === 'string') {
+ const dec = decodeBech32Raw(input);
+ if (dec.hrp !== 'lni') throw new Error('not a bolt12 invoice');
+ bytes = dec.bytes;
+ }
+ const records = parseTlvStream(bytes);
+ const out = { bytes, records, network: 'mainnet' };
+ for (const r of records) {
+ if (r.type === 2 || r.type === 80) out.network = chainNet(r.value);
+ else if (r.type === 10) out.description = td.decode(r.value);
+ else if (r.type === 164) out.createdAt = Number(tu64(r.value));
+ else if (r.type === 166) out.relativeExpiry = Number(tu64(r.value));
+ else if (r.type === 168) out.paymentHash = hex.encode(r.value);
+ else if (r.type === 170) out.amountMsat = tu64(r.value);
+ else if (r.type === 176) out.nodeId = hex.encode(r.value);
+ else if (r.type === 240) out.signature = r.value;
+ }
+ if (!out.paymentHash || out.paymentHash.length !== 64) throw new Error('invoice missing payment hash');
+ if (out.amountMsat == null) throw new Error('invoice missing amount');
+ out.amountSat = Number((out.amountMsat + 999n) / 1000n); // bark rounds msats up
+ out.expiresAt = ((out.createdAt || 0) + (out.relativeExpiry ?? 7200)) * 1000;
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// signature verification
+// ---------------------------------------------------------------------------
+
+const taggedEngine = (tagHash) => {
+ const parts = [tagHash, tagHash];
+ return (msg) => sha256(concat([...parts, msg]));
+};
+function concat(arrs) {
+ const total = arrs.reduce((n, a) => n + a.length, 0);
+ const out = new Uint8Array(total);
+ let off = 0;
+ for (const a of arrs) { out.set(a, off); off += a.length; }
+ return out;
+}
+
+function merkleRoot(records) {
+ const usable = records.filter((r) => r.type < 240 || r.type > 1000);
+ if (!records.length) throw new Error('empty TLV stream');
+ const leafHash = taggedEngine(sha256(te.encode('LnLeaf')));
+ // the nonce tag commits to the FIRST record of the whole stream
+ const nonceHash = taggedEngine(sha256(concat([te.encode('LnNonce'), records[0].raw])));
+ const branch = taggedEngine(sha256(te.encode('LnBranch')));
+ const leaves = [];
+ for (const r of usable) {
+ leaves.push(leafHash(r.raw));
+ leaves.push(nonceHash(r.typeRaw));
+ }
+ const cmp = (a, b) => { for (let i = 0; i < 32; i++) { if (a[i] !== b[i]) return a[i] - b[i]; } return 0; };
+ // in-place reduction, unpaired nodes carry up (mirrors LDK root_hash)
+ const n = leaves.length;
+ for (let level = 0; ; level++) {
+ const step = 2 << level;
+ const offset = step / 2;
+ if (offset >= n) break;
+ for (let i = 0, j = offset; j < n; i += step, j += step) {
+ const [lo, hi] = cmp(leaves[i], leaves[j]) < 0 ? [leaves[i], leaves[j]] : [leaves[j], leaves[i]];
+ leaves[i] = branch(concat([lo, hi]));
+ }
+ }
+ return leaves[0];
+}
+
+// Throws unless the invoice is signed by the offer's issuer and mirrors the
+// offer's terms. An offer that names no issuer key (blinded-path-only) can't
+// be pinned to a stable key here — the signature and mirrored TLVs are still
+// checked, and CLN validated issuance when it fetched the invoice.
+export function verifyOfferInvoice(offer, invoice) {
+ if (!invoice.signature || invoice.signature.length !== 64) throw new Error('invoice is unsigned');
+ if (!invoice.nodeId || invoice.nodeId.length !== 66) throw new Error('invoice names no signer');
+ // every offer term must be mirrored byte-for-byte in the invoice
+ const invRaw = new Set(invoice.records.map((r) => hex.encode(r.raw)));
+ for (const r of offer.records) {
+ if (r.type >= 1 && r.type <= 79 && !invRaw.has(hex.encode(r.raw))) {
+ throw new Error('invoice does not match the offer');
+ }
+ }
+ if (offer.issuerPubkey && offer.issuerPubkey !== invoice.nodeId) {
+ throw new Error('invoice signed by the wrong node');
+ }
+ const digest = taggedEngine(sha256(te.encode('lightninginvoicesignature')))(merkleRoot(invoice.records));
+ const xonly = hex.decode(invoice.nodeId).slice(1);
+ if (!schnorr.verify(invoice.signature, digest, xonly)) throw new Error('invalid invoice signature');
+}
diff --git a/src/features/ark.js b/src/features/ark.js
index cd4fa47..291ae92 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -9,7 +9,7 @@ import { sha256 } from '@noble/hashes/sha256';
import { ArkManager } from '../ark/manager.js';
import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js';
import { boardFee } from '../ark/board.js';
-import { maybeBolt11, lnSendFee } from '../ark/lightning.js';
+import { maybeBolt11, maybeLnInvoice, 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';
@@ -662,7 +662,7 @@ export function arkFeature(ctx) {
// 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);
+ const dec = maybeLnInvoice(invoice);
if (!dec || !arkAvailable() || wallet.watchOnly) return false;
const s = arkStateNow();
const covers = (state, sats) =>
@@ -2431,7 +2431,7 @@ export function arkFeature(ctx) {
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 (maybeLnInvoice(inv) && startArkLnPay(inv)) return true;
}
if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) {
if (!arkAvailable()) throw new Error(t('arkNotConnected'));
@@ -2457,6 +2457,12 @@ export function arkFeature(ctx) {
// The zaps feature's generic Lightning seams (once served by the retired
// swaps feature): the ASP pays invoices natively over ark.
canLnPay() { return arkAvailable() && !wallet.watchOnly && !!(ark && ark.info); },
+ // Fetch + verify a bolt12 invoice for an offer (the ASP's CLN speaks the
+ // onion messages) — resolves { invoice (lni), amountSat, paymentHash }.
+ async arkFetchBolt12Invoice(offerStr, amountSat) {
+ const mgr = await connectArk();
+ return mgr.fetchBolt12(offerStr, amountSat);
+ },
lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; },
settingsCards() { return [autoWithdrawCard()]; },
// The onboarding wizard's top-up step borrows the board form wholesale.
@@ -2546,7 +2552,7 @@ export function arkFeature(ctx) {
return true;
}
}
- if (maybeBolt11(inv)) return startArkLnPay(inv);
+ if (maybeLnInvoice(inv)) return startArkLnPay(inv);
const pk = npubToHex(text);
if (!pk || !arkAvailable() || !wallet.nostrFetch) return false;
startNpubPay(pk, String(text).trim());
diff --git a/src/features/names.js b/src/features/names.js
index 2c69a84..ff43089 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -89,12 +89,14 @@ export function namesFeature(ctx) {
return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null;
}
- async function claim(name, { signer, manager, quietProfile, noffer } = {}) {
+ async function claim(name, { signer, manager, quietProfile, noffer, lno } = {}) {
const uri = await currentUri();
if (!uri) throw new Error(t('namesNeedArk'));
- // Delivery noffer: undefined keeps whatever the record carries (here or
- // registrar-side), '' clears it, a string points delivery at that code.
+ // Own-node fields (delivery noffer / published BOLT 12 offer): undefined
+ // keeps whatever the record carries (here or registrar-side), '' clears,
+ // a string sets.
const nof = noffer !== undefined ? noffer : (load().noffer || undefined);
+ const ownLno = lno !== undefined ? lno : (load().lno || undefined);
const j = await post('/register', 'POST', {
name, uri, domain: DOMAIN(),
// lets the address take Lightning payments from LNURL-only wallets:
@@ -104,9 +106,11 @@ export function namesFeature(ctx) {
// wallet key to keep the record updated afterwards
...(manager ? { manager } : {}),
...(nof !== undefined ? { noffer: nof } : {}),
+ ...(ownLno !== undefined ? { lno: ownLno } : {}),
}, signer);
const prev = load().name;
- save({ ...load(), name, domain: DOMAIN(), uri, offerPk: hook('nwcOfferPubkey') || null, noffer: nof || null, updated: Date.now() });
+ save({ ...load(), name, domain: DOMAIN(), uri, offerPk: hook('nwcOfferPubkey') || null,
+ noffer: nof || null, lno: ownLno || null, updated: Date.now() });
// Releasing the previous name is for renames the user meant. A CUSTOM
// name must never be released because a placeholder claim raced in: that
// exact race once deleted a user's claimed name — adoptIdentity's npub
@@ -143,7 +147,8 @@ export function namesFeature(ctx) {
const r = await withTimeout(
fetch(`${REGISTRAR}/pubkey/${pk}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()), 12000, 'registrar');
if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN(), uri: r.uri,
- ...(r.noffer !== undefined ? { noffer: r.noffer } : {}) });
+ ...(r.noffer !== undefined ? { noffer: r.noffer } : {}),
+ ...(r.lno !== undefined ? { lno: r.lno } : {}) });
} catch (e) { console.warn('names: lookup failed —', e.message); }
}
@@ -184,7 +189,8 @@ export function namesFeature(ctx) {
8000, 'registrar');
if (r && r.name && !/^npub1/.test(r.name)) {
save({ ...st, name: r.name, domain: r.domain || DOMAIN(), uri: r.uri,
- ...(r.noffer !== undefined ? { noffer: r.noffer } : {}) });
+ ...(r.noffer !== undefined ? { noffer: r.noffer } : {}),
+ ...(r.lno !== undefined ? { lno: r.lno } : {}) });
st = load();
}
} catch {}
@@ -318,6 +324,12 @@ export function namesFeature(ctx) {
render();
return;
}
+ // no payable ark instruction: a bolt12 offer (a foreign node, or a
+ // name delivered to its owner's own node) beats falling to on-chain
+ if (dec?.params?.lno && hook('startBolt12Pay', dec.params.lno, `${parsed.name}@${parsed.domain}`)) {
+ render();
+ return;
+ }
if (dec?.onchain) {
ui.send.recipients[0].address = dec.onchain;
render();
@@ -589,50 +601,61 @@ export function namesFeature(ctx) {
copyBtn(st.address, t('copy')));
}
- // Point the address's Lightning delivery at the user's own node: any
- // CLINK-capable service beside their node (Lightning.Pub etc.) hands out
- // its own noffer; registered here, the registrar asks THAT service for
- // invoices, so payments land on their node and never touch this wallet.
- async function setDeliveryNoffer(raw) {
+ // Point the address at the user's own node, two complementary ways:
+ // - noffer: a CLINK service beside their node (Lightning.Pub etc.) mints
+ // the invoices the registrar hands out and pays — LN delivery lands on
+ // their node instead of in this wallet.
+ // - lno: their node's own BOLT 12 offer goes into the DNS record, so
+ // BIP-353 senders fetch invoices straight from their node — no coinos
+ // hop at all.
+ async function setOwnNode(field, raw) {
const st = load();
- if (!st.name) { ui.namesNofferError = t('namesNeedArk'); render(); return; }
- ui.namesNofferBusy = true; ui.namesNofferError = null; render();
+ if (!st.name) { ui.namesOwnErr = t('namesNeedArk'); render(); return; }
+ ui.namesOwnBusy = field; ui.namesOwnErr = null; render();
try {
- if (raw) {
+ if (raw && field === 'noffer') {
const d = decodeNoffer(raw);
if (!d.relay) throw new Error(t('namesOwnNodeNoRelay'));
}
- await claim(st.name, { noffer: raw || '' });
- ui.namesNofferInput = '';
+ if (raw && field === 'lno' && !/^lno1[a-z0-9]+$/i.test(raw)) throw new Error(t('namesOwnLnoBad'));
+ await claim(st.name, { [field]: raw || '' });
+ ui.namesOwnInput = { ...ui.namesOwnInput, [field]: '' };
toast(raw ? t('namesOwnNodeSet') : t('namesOwnNodeCleared'));
- } catch (e) { ui.namesNofferError = e.message; }
- ui.namesNofferBusy = false; render();
+ } catch (e) { ui.namesOwnErr = e.message; }
+ ui.namesOwnBusy = null; render();
+ }
+
+ function ownNodeField(field, current, placeholder, how) {
+ const busy = ui.namesOwnBusy === field;
+ if (current) {
+ return h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t(field === 'noffer' ? 'namesOwnNodeActive' : 'namesOwnLnoActive')),
+ h('div', { class: 'addr-box break', style: 'font-size:10px' }, current),
+ h('button', { class: 'btn-ghost btn-sm', disabled: !!ui.namesOwnBusy,
+ onClick: () => setOwnNode(field, '') },
+ busy ? h('span', { class: 'spinner sm' }) : t('namesOwnNodeClear')));
+ }
+ return h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, how),
+ h('div', { class: 'row gap6' },
+ h('input', {
+ type: 'text', class: 'grow mono-input', placeholder,
+ value: (ui.namesOwnInput || {})[field] || '',
+ onInput: (e) => { ui.namesOwnInput = { ...ui.namesOwnInput, [field]: e.target.value }; },
+ }),
+ h('button', { class: 'btn-sm', disabled: !!ui.namesOwnBusy,
+ onClick: () => setOwnNode(field, ((ui.namesOwnInput || {})[field] || '').trim()) },
+ busy ? h('span', { class: 'spinner sm' }) : t('save'))));
}
function ownNodeSection() {
const st = load();
if (!st.name) return null;
- return h('div', { class: 'col', style: 'gap:8px;width:100%;margin-top:6px' },
+ return h('div', { class: 'col', style: 'gap:10px;width:100%;margin-top:6px' },
h('strong', { class: 'small' }, t('namesOwnNode')),
- st.noffer
- ? h('div', { class: 'col', style: 'gap:8px' },
- h('div', { class: 'small muted' }, t('namesOwnNodeActive')),
- h('div', { class: 'addr-box break', style: 'font-size:10px' }, st.noffer),
- h('button', { class: 'btn-ghost btn-sm', disabled: !!ui.namesNofferBusy,
- onClick: () => setDeliveryNoffer('') },
- ui.namesNofferBusy ? h('span', { class: 'spinner sm' }) : t('namesOwnNodeClear')))
- : h('div', { class: 'col', style: 'gap:8px' },
- h('div', { class: 'small muted' }, t('namesOwnNodeHow')),
- h('div', { class: 'row gap6' },
- h('input', {
- type: 'text', class: 'grow mono-input', placeholder: 'noffer1…',
- value: ui.namesNofferInput || '',
- onInput: (e) => { ui.namesNofferInput = e.target.value; },
- }),
- h('button', { class: 'btn-sm', disabled: !!ui.namesNofferBusy,
- onClick: () => setDeliveryNoffer((ui.namesNofferInput || '').trim()) },
- ui.namesNofferBusy ? h('span', { class: 'spinner sm' }) : t('save')))),
- ui.namesNofferError ? h('div', { class: 'notice err small' }, ui.namesNofferError) : null);
+ ownNodeField('noffer', st.noffer, 'noffer1…', t('namesOwnNodeHow')),
+ ownNodeField('lno', st.lno, 'lno1…', t('namesOwnLnoHow')),
+ ui.namesOwnErr ? h('div', { class: 'notice err small' }, ui.namesOwnErr) : null);
}
function clinkPane() {
@@ -731,7 +754,7 @@ export function namesFeature(ctx) {
namesSeed(rec) {
if (!rec || !rec.name) return null;
const st = load();
- if (!st.name) save({ name: rec.name, domain: rec.domain || DOMAIN(), uri: rec.uri, noffer: rec.noffer ?? null });
+ if (!st.name) save({ name: rec.name, domain: rec.domain || DOMAIN(), uri: rec.uri, noffer: rec.noffer ?? null, lno: rec.lno ?? null });
return true;
},
// 'pending' while the restore/claim pass is still running — the wallet
diff --git a/src/features/zaps.js b/src/features/zaps.js
index 7dfc272..0938d3d 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -16,6 +16,7 @@ import { npubOf } from '../nostr.js';
import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js';
import { decodeNoffer } from '../noffer.js';
import { requestOfferInvoice } from '../clink.js';
+import { decodeOffer } from '../bolt12.js';
import { maybeBolt11 } from '../ark/lightning.js';
export function zapsFeature(ctx) {
@@ -34,6 +35,22 @@ export function zapsFeature(ctx) {
try { return { ...decodeNoffer(s), raw: s }; } catch { return null; }
}
+ // A pasted BOLT 12 offer (`lno1…`) — decoded, or null.
+ function maybeOffer(text) {
+ const s = String(text || '').trim().replace(/^(lightning|bitcoin):/i, '').toLowerCase();
+ if (!/^lno1/.test(s)) return null;
+ try { return { ...decodeOffer(s), raw: s }; } catch { return null; }
+ }
+
+ const shortCode = (s) => s.slice(0, 12) + '…' + s.slice(-6);
+
+ // Begin the amount screen for a decoded offer. Fetching the actual invoice
+ // (via the ASP's CLN) and verifying it happens on confirm.
+ function beginBolt12(off, display) {
+ begin({ kind: 'bolt12', offer: off, address: display }, off.description || display);
+ return true;
+ }
+
// Kick off resolution of a parsed target. `display` is what we show as the
// recipient until a profile name arrives.
function begin(target, display) {
@@ -46,6 +63,18 @@ export function zapsFeature(ctx) {
async function resolve(z) {
let target = z.target;
+ // A BOLT 12 offer carries its own terms — no lookup until confirm, when
+ // the ASP's CLN fetches (and this wallet verifies) the actual invoice.
+ if (target.kind === 'bolt12') {
+ const off = target.offer;
+ if (off.currency) throw new Error(t('bolt12Currency', { currency: off.currency }));
+ const fixedSat = off.amountMsat ? Number((off.amountMsat + 999n) / 1000n) : null;
+ if (fixedSat) z.amount = getUnit() === 'sats' ? String(fixedSat) : (fixedSat / 1e8).toFixed(8);
+ z.params = { minSendable: 1000, maxSendable: 21e15, commentAllowed: 0, allowsNostr: false, fixedSat };
+ z.status = 'ready';
+ render();
+ return;
+ }
// A CLINK offer needs no HTTP at all: the code itself says who to ask.
if (target.kind === 'noffer') {
const n = target.noffer;
@@ -130,6 +159,19 @@ export function zapsFeature(ctx) {
return render();
}
z.error = ''; z.status = 'invoicing'; render();
+ if (z.target.kind === 'bolt12') {
+ try {
+ // fetch through the ASP's CLN; the manager verifies signature, offer
+ // mirroring and amount before handing back the lni
+ const r = await hook('arkFetchBolt12Invoice', z.target.offer.raw, sats);
+ ui.zap = null;
+ hook('startLnPay', r.invoice, { name: z.name, address: z.address, pk: null, comment: '', isZap: false });
+ render();
+ } catch (e) {
+ z.status = 'ready'; z.error = e.message; render();
+ }
+ return;
+ }
if (z.target.kind === 'noffer') {
try {
const invoice = await requestOfferInvoice(z.target.noffer, { amountSat: sats, description: z.comment });
@@ -249,10 +291,12 @@ export function zapsFeature(ctx) {
if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false;
const nof = maybeNoffer(text);
if (nof) {
- const short = nof.raw.slice(0, 12) + '…' + nof.raw.slice(-6);
+ const short = shortCode(nof.raw);
begin({ kind: 'noffer', noffer: nof, address: short }, short);
return true;
}
+ const off = maybeOffer(text);
+ if (off) return beginBolt12(off, shortCode(off.raw));
const target = parseZapTarget(text);
if (!target) return false;
// npub with no nostr seam to look up a profile → can't resolve here.
@@ -278,6 +322,15 @@ export function zapsFeature(ctx) {
onClick: () => begin(target, display),
}, '⚡ ' + t('lnZapOffer', { addr: display }));
},
+ // A BIP-353 name resolved to a record whose lno= is the only payable
+ // instruction (a foreign BOLT 12 node, or a coinos name whose owner
+ // pointed it at their own node) — pay the offer.
+ startBolt12Pay(lno, display) {
+ if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false;
+ const off = maybeOffer(lno);
+ if (!off) return false;
+ return beginBolt12(off, display || shortCode(off.raw));
+ },
// The names feature resolves user@domain over DNS first (BIP-353) and
// lands here when the name has no DNS record — the classic LNURL path.
lnAddressFallback(text) {
diff --git a/src/i18n.js b/src/i18n.js
index ca7ded5..c5d9d20 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -739,6 +739,10 @@ const STR = {
namesOwnNodeSet: 'Delivery updated — payments go to your node',
namesOwnNodeCleared: 'Payments land in this wallet again',
namesOwnNodeNoRelay: 'That offer code names no relay',
+ namesOwnLnoHow: 'Or paste your node’s BOLT12 offer (lno1…) — it goes into your address’s DNS record, so senders fetch invoices straight from your node.',
+ namesOwnLnoActive: 'Your address’s DNS record carries your own node’s offer:',
+ namesOwnLnoBad: 'That doesn’t look like a BOLT12 offer',
+ bolt12Currency: 'This offer is priced in {currency}, which this wallet can’t pay',
clinkBadInvoice: 'The recipient’s service returned an invoice that doesn’t match',
clinkFixedAmount: 'This offer has a fixed price',
nwcTitle: 'Nostr Wallet Connect',
diff --git a/tools/bolt12-test.js b/tools/bolt12-test.js
new file mode 100644
index 0000000..28909d3
--- /dev/null
+++ b/tools/bolt12-test.js
@@ -0,0 +1,63 @@
+// BOLT 12 decode + signature verification against a REAL offer/invoice pair
+// (fetched from a live CLN via fetchinvoice) plus tamper checks.
+// Run: bun tools/bolt12-test.js [offer-file invoice-file]
+import { readFileSync } from 'node:fs';
+import {
+ decodeOffer, decodeBolt12Invoice, verifyOfferInvoice, encodeBech32Raw, decodeBech32Raw,
+} from '../src/bolt12.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 140) : ''}`);
+ if (!ok) fails++;
+};
+const throws = (fn) => { try { fn(); return null; } catch (e) { return e.message; } };
+
+const [offerFile, invoiceFile] = process.argv.slice(2);
+const offerStr = readFileSync(offerFile || '/tmp/claude-1000/-home-adam-coinosv3/556b3f05-c728-4e11-a426-50382ad8bd8d/scratchpad/fixture-offer.txt', 'utf8').trim();
+const invStr = readFileSync(invoiceFile || '/tmp/claude-1000/-home-adam-coinosv3/556b3f05-c728-4e11-a426-50382ad8bd8d/scratchpad/fixture-invoice.txt', 'utf8').trim();
+
+console.log('[codec]');
+const rt = decodeBech32Raw(invStr);
+check('lni hrp', rt.hrp === 'lni');
+check('bech32 roundtrips', encodeBech32Raw('lni', rt.bytes) === invStr.toLowerCase());
+
+console.log('\n[offer]');
+const offer = decodeOffer(offerStr);
+check('decodes', !!offer.records.length, `${offer.records.length} records`);
+check('mainnet', offer.network === 'mainnet');
+check('describes itself', typeof offer.description === 'string', offer.description);
+
+console.log('\n[invoice]');
+const inv = decodeBolt12Invoice(invStr);
+check('payment hash present', /^[0-9a-f]{64}$/.test(inv.paymentHash || ''), inv.paymentHash);
+check('amount = 12345 msat', inv.amountMsat === 12345n, String(inv.amountMsat));
+check('amount rounds up to 13 sat', inv.amountSat === 13);
+check('signer named', /^[0-9a-f]{66}$/.test(inv.nodeId || ''), inv.nodeId);
+check('expiry sane', inv.expiresAt > Date.now() - 86400_000, new Date(inv.expiresAt).toISOString());
+
+console.log('\n[verification]');
+check('real invoice verifies', throws(() => verifyOfferInvoice(offer, inv)) === null,
+ throws(() => verifyOfferInvoice(offer, inv)) || 'ok');
+
+// tamper with the amount TLV: signature must fail
+const tampered = decodeBolt12Invoice(invStr);
+const amtRec = tampered.records.find((r) => r.type === 170);
+amtRec.raw[amtRec.raw.length - 1] ^= 1;
+check('tampered amount rejected', /signature/.test(throws(() => verifyOfferInvoice(offer, tampered)) || ''));
+
+// swap in a different offer description: mirror check must fail
+const wrongOffer = decodeOffer(offerStr);
+const descRec = wrongOffer.records.find((r) => r.type === 10);
+if (descRec) {
+ descRec.raw[descRec.raw.length - 1] ^= 1;
+ check('mismatched offer rejected', /match/.test(throws(() => verifyOfferInvoice(wrongOffer, inv)) || ''));
+}
+
+// forged signer: issuer pin must fail
+const forged = decodeBolt12Invoice(invStr);
+forged.nodeId = '02' + '11'.repeat(32);
+check('wrong signer rejected', /wrong node|signature/.test(throws(() => verifyOfferInvoice(offer, forged)) || ''));
+
+console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ bolt12 verifies');
+process.exit(fails ? 1 : 0);