diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/lightning.js | 34 | ||||
| -rw-r--r-- | src/ark/manager.js | 32 | ||||
| -rw-r--r-- | src/bolt12.js | 225 | ||||
| -rw-r--r-- | src/features/ark.js | 14 | ||||
| -rw-r--r-- | src/features/names.js | 101 | ||||
| -rw-r--r-- | src/features/zaps.js | 55 | ||||
| -rw-r--r-- | src/i18n.js | 4 |
7 files changed, 416 insertions, 49 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) => 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', |
