aboutsummaryrefslogtreecommitdiffstats
path: root/src/bolt12.js
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/bolt12.js
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/bolt12.js')
-rw-r--r--src/bolt12.js225
1 files changed, 225 insertions, 0 deletions
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');
+}