aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/zaps.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/features/zaps.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/features/zaps.js')
-rw-r--r--src/features/zaps.js55
1 files changed, 54 insertions, 1 deletions
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) {