diff options
| author | Adam Soltys <[email protected]> | 2026-08-29 00:50:09 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-29 00:50:09 -0700 |
| commit | 2023fe06d5cc4867f09d5916680b5fe92394dacb (patch) | |
| tree | af8e230bd283776273537da60fe9c0d6782cba28 /src/bolt12.js | |
| parent | 8ea0416c08535750839088c060d3252415510cc5 (diff) | |
| download | coinosv3-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.js | 225 |
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'); +} |
