diff --git a/GRANTLESS.md b/GRANTLESS.md new file mode 100644 index 0000000..08dcee1 --- /dev/null +++ b/GRANTLESS.md @@ -0,0 +1,142 @@ +# grantless — zappable funding for every project on nostr.net + +**One-liner:** nostr.net keeps being the curated awesome-list, but every project entry can now carry its own lightning address. Any visitor connects their wallet once (Bitcoin Connect / NWC), ticks the projects they want to support, and zaps them all in one go — with **full NIP-57 zap receipts**, so every payment shows up in feeds and on Zaplife. Nostr.NET itself is always pre-ticked as the curator/host. No middlemen, no grant committee, no "one size fits all" — just markdown, PRs, and sats. + +--- + +## 1. The problem this solves + +- People have wanted a no-gatekeeper alternative to Geyser/OpenSats for nostr projects *for a while*. `grantless.org` exists but gets no traction — it has no distribution. nostr.net already has the distribution: it's *the* list people link to. +- Projects on the list have no funding link at all today; discoverability ≠ support. +- The site itself (relay, curation work) has no obvious "fund this" path. + +The insight: the list **is** the funding platform. Turn entries into zappable entities without changing the low-tech, markdown-PR workflow that made it the best list in the first place. + +## 2. The markdown convention (single source of truth) + +Keep the existing format, add optional standardized chips at the end of an entry: + +```markdown +- [Amethyst](https://www.amethyst.social/) - Android-only app [⚡ zap](lightning:vitor@amethyst.social) [donate](https://geyser.fund/project/xyz) +- [Damus](https://damus.io/) - The OG nostr client for iOS [⚡ zap](lightning:jb55@damus.io) +``` + +Rules: + +- **`[⚡ zap](lightning:...)`** — a `lightning:` URI holding an LN address (or LNURL). This is what makes an entry *zappable*. Parsed with `/\[⚡\s*zap\]\(lightning:([^)\s]+)\)/i`. +- **`[donate](https://...)`** — optional external funding link (Geyser, OpenSats, Patreon, whatever). Rendered as a second chip. No one-size-fits-all: the project picks. +- Everything else about entries stays exactly as it is. GitHub renders the chips as normal links, so the README stays perfectly readable on GitHub too. +- Adding/changing a zap address happens via PR — same review flow as today. That's the "grantless" model: anyone can walk in, but the curator merges. + +## 3. Site integration (nostr.net itself) + +1. **Zap chip on every card** — in `createResourceCard()`, parse the raw entry; if a lightning address exists, render a `⚡ Zap` button on the card: + - Not connected: click opens the **zap dialog** — scannable LNURL QR (machine-verified with a real decoder), the LN address with copy button, an "Open wallet app" `lightning:` link, and a "connect a wallet" path. Zero connection required. + - Connected: full in-page LNURL-pay + NIP-57 zap (see §5). +2. **`donate` chip** — simple external link chip. +3. **Nav zap button** — a small ⚡ in the top nav ("Fund nostr.net") zapping the site's own lightning address: **`aljaz@minibits.cash`** (the relay/curation tip jar). +4. **"Zappable projects" section on the homepage** — the homepage aggregates every entry carrying a zap chip into a dedicated sidebar section (nostr.net pinned at the top as curator & host). It grows automatically as PRs add addresses — the main list *is* the funding directory. The homepage banner links to this section. +5. Banner/link on the homepage pointing to the Grant page (§4) for the batch-zap flow. + +## 4. The Grant page — the "grantless" experience + +A single static page at **`nostr.net/grant`** (`grant.html` with a Jekyll permalink; no backend, deployable alongside the site; the `grantless.nostr.net` subdomain is a pure DNS/alias question — see §8). + +UX flow: + +1. **Load** — page fetches `README.md`, extracts all entries with lightning addresses → grid of zappable projects (name, favicon, category, description). Category filter chips ("clients", "relays", "libraries", …) since we know the section each entry lives in. +2. **Select** — checkbox per project. Pinned at the top: **nostr.net (curator & host) · `aljaz@minibits.cash`**, checked by default (uncheckable). Quick-select presets: "Select all", "Top projects", per-category. +3. **Amount** — one "sats per project" field (default 1000), live total = `N × amount`. (v2: per-project override.) +4. **Connect** — `` from Bitcoin Connect (NWC string, Alby Hub, extension, LNC…). +5. **Zap** — "Zap 12 projects · 12,000 sats" → sequential loop with visible per-row progress (full NIP-57 flow per project): + - fetch LNURL-pay params (`https://host/.well-known/lnurlp/user`) + - build & sign a kind 9734 zap-request event (`p`-tag = recipient `nostrPubkey`; sender via NIP-07 or anonymous — see §5; comment if `commentAllowed`) + - request invoice with the zap request in the `nostr` param at `amount` msat (clamp to recipient's min/max, surface any clamping) + - pay via WebLN / NWC `pay_invoice` + - recipient's server publishes the kind 9735 zap receipt → row shows ✅ with a link to the receipt, ❌ with a "retry" affordance + - ~400ms pause between zaps (wallet friendliness) +6. **Summary** — "You zapped N projects, X sats sent, N zap receipts published." + **share on nostr** button (NIP-07): *"I just zapped N nostr projects via nostr.net/grant ⚡"* — the viral loop for the podcast shill. + +Edge cases: recipient LNURL down or no CORS → fall back to the `lightning:` link (click-to-open-wallet). Recipient LNURL doesn't support zaps (`allowsNostr` false / no `nostrPubkey`) → degrade that row to a plain payment, explicitly labeled "no zap receipt". Invoice below minSendable → clamp with a note. Disconnect mid-batch → stop, keep state, resume after reconnect. + +## 5. Wallet layer (all client-side) + +- **Connection:** Bitcoin Connect (`@getalby/bitcoin-connect@3.12.3` via esm.sh, `` in the nav; namespace import → `window.bitcoinConnect`). **Important:** BC does *not* set `window.webln` for NWC/LNC/LNbits — the connected WebLN provider is delivered via `onConnected(provider)` (fires immediately for previously-connected wallets). Both provider sources (extension `window.webln` + BC provider) are unified behind `getProvider()`; `launchModal()` is preferred over clicking inside shadow DOM. **Shipped on the homepage** — this part of Phase 2 landed early. No backend, no custody, no accounts — the site never sees keys. +- **Full NIP-57 in-page, verified end-to-end** (kind 9734 zap request with `p`/`relays`/`amount`/`lnurl` tags → invoice with `nostr` param → WebLN `sendPayment`). Implementation notes: the UMD CDN build has externalized globals — use the esm.sh module import instead; there is no `bc:connected` window event, so connection is detected by `window.webln` presence (+poll after opening the modal); anonymous zap receipts are signed client-side with `@noble/curves/secp256k1` schnorr (lazy-loaded) when no NIP-07 signer exists. +- **Sender identity, in order of preference:** ① **anonymous by default** (fast — no signing prompt per zap): ephemeral key + `anon` tag, still a valid zap receipt; after an anon zap, a one-time "zap as me instead" link in the toast opts into ② **attributed zaps** via NIP-07 (remembered in localStorage). Nobody is blocked from receipt-grade zaps by lacking a signer, and extension users don't get a signer popup on every zap. +- **Payment:** we drive LNURL-pay ourselves (plain `fetch`es) and pay invoices through the WebLN/NWC provider (`window.webln.sendPayment` / direct NWC `pay_invoice`), which gives us per-zap control in the batch loop. +- **Fallback, not a mode:** a recipient LNURL without zap support degrades that single row to a plain payment (labeled). Everything else stays receipt-grade. +- **Dead-wallet recovery:** if the connected wallet fails to pay (e.g. NWC wallet asleep — `no info event (kind 13194)`), the zap holds its already-fetched invoice and offers **Retry** (resolving the *most recently connected* provider at click time, so a BC reconnect beats a dead extension) or **Pay by QR** — the invoice QR (zap receipt baked in) can be paid by *any* wallet, phone-scan included. Pure client-side, GitHub-Pages friendly. +- **Invoice validation (LUD-06 + NIP-57):** every invoice is fully decoded (`zap-core.js`, unit-tested in `test/zap-core.test.js`) before payment — bech32 checksum, mainnet-only hrp, parsed amount **must equal the requested msat** (hard fail; blocks a malicious callback returning a higher-value invoice), plus expiry. For zaps, the description hash is checked against the **serialized zap-request event** (the exact string sent to the callback): bound → UI claims a verified receipt; unbound (e.g. primal embeds a constant hash, minibits omits it) → payment proceeds but the UI honestly says "receipt unverified" / "no zap receipt" and never claims baking. rizful (Zapstore) binds correctly and is reported as verified. Untrusted invoices are never interpolated into HTML — assigned via DOM properties. +- **Payment-failure triage:** wallet rejections are classified — transient connection failures (`13194`, timeouts, "not connected") get the recovery panel (Retry with the most-recently-connected provider / Pay by QR); cancellations and terminal errors (insufficient balance, invalid invoice) go through the ordinary error toast with the provider's message. One recovery at a time: a second failed zap can't destroy the pending invoice's controls (blocked with a hint + dismissible via ✕). + +## 6. Trust & moderation + +- Zap addresses go through the same PR review as everything else; the curator pays extra attention to *changes* to existing addresses (impersonation vector: fake "Amethyst" with attacker's address). +- Nice-to-have badge: "verified" when the address was added by a PR from the project's own domain/maintainer — can come later, review is enough for v1. +- The curator default-zap is clearly labeled ("nostr.net — curator & host") and can be unchecked. Transparent, not sneaky. +- NIP-57 receipts double as **public auditability**: anyone can verify on Zaplife/relays which projects actually receive zaps — hard to run a silent skim. + +## 7. Plan (deliberately vibe-codeable) + +| Phase | Scope | Effort | +|---|---|---| +| **0** | Markdown convention + contributing docs + PR template + seed ~15–25 flagship entries with known-good addresses (via PRs/issues to owners) | ~½ day (mostly outreach) | +| **1** | Parser + zap/donate chips on cards + nav "Fund nostr.net" button | ~½ day | +| **2** | `/grant` page: checkboxes, presets, amount, Bitcoin Connect, sequential NIP-57 zap loop (zap requests + receipts, anonymous fallback), progress/retry/summary, share button | ~1.5–2 days | +| **3** | per-project amounts, verified badge, grantless subdomain alias, "Zap Day" event mode | later | + +**"Live by Thursday" scope = Phases 0–2** (~2.5–3 days total — zap receipts are merged into launch scope, not deferred). Even Phase 1 alone is shillable on the podcast ("every project on nostr.net is now zappable, send a PR to add yours"). + +## 8. Open questions + +1. ~~**Curator lightning address**~~ **Resolved:** `aljaz@minibits.cash` — tip jar for the relay/curation work, pinned + pre-checked on `/grant`. +2. **Subdomain or path?** `nostr.net/grant` is zero-infra; `grantless.nostr.net` is a better story on a podcast but GitHub Pages serves one custom domain per site → likely a redirect rule or tiny separate repo. Recommend: launch on `/grant`, alias later if the name sticks. +3. **Bootstrap addresses** — **Resolved for launch: 31 projects seeded** (see appendix). Confirmed so far via owner-PR: **Hivetalk → `bitkarrot@primal.net`**. The rest were harvested from projects' own sources (repo donation sections, official npub profiles, homepage links) and live-verified — see appendix. Still missing flagship addresses for: snort, Primal, Amethyst, YakiHonne, Iris, Habla, highlighter, zap.stream (owners should PR them). +4. **Sender identity default** — remember "identified via NIP-07" vs "anonymous" per visit (localStorage), or ask each batch? +5. **Batch amount defaults** — flat 1000 sats per project, or let each entry advertise a suggested amount in markdown? + +--- + +## Appendix: seeded zap addresses & provenance + +All addresses below are **live-verified** (lnurlp endpoint responds, zap-receipt support noted) and sourced from the project itself. Sources: `repo` = donation section in the project's own GitHub README · `npub` = lud16 on the project's official nostr profile (npub resolved from the project's own domain via `.well-known/nostr.json` or homepage) · `site` = project's own website. + +| Project | Address | Source | NIP-57 receipts | +|---|---|---|---| +| nostr.net (curator) | aljaz@minibits.cash | owner | ✅ | +| Hivetalk | bitkarrot@primal.net | owner PR | ✅ | +| Damus | damus@sendsats.lol | npub + site (damus.io lightning: link → sendsats.lol/.well-known/lnurlp/damus) | ✅ | +| Coracle | hodlbod@getalby.com | npub (coracle.social) | ✅ | +| Zapstore | zapstore@rizful.com | npub (zapstore.dev) | ✅ | +| Nos | strongsnail1@primal.net | npub (nos.social) | ✅ | +| noStrudel | nostrudel@npub.cash | npub (nostrudel.ninja) | ✅ | +| Nostur | weathereddarkness25@getalby.com | npub (nostur.com) | ✅ | +| Jumble | codytseng@getalby.com | repo | ✅ | +| Vega | jure@getalby.com | repo | ✅ | +| Mostro | mostro_p2p@sats.mobi | npub (mostro.network) | ✅ | +| nostrich.love | nostrich@wallet.yakihonne.com | npub | ✅ | +| Decent Newsroom | decentnewsroom@rizful.com | npub | ✅ | +| Nuxstr | sebastian@lnd.sebastix.com | repo | ✅ | +| Fenrir-s | parkinghot99@walletofsatoshi.com | repo | ✅ | +| nostr-filter-relay | rifat@getalby.com | repo | ✅ | +| hunos.hu | hunosrelay@minibits.cash | npub | ✅ | +| nostress | hberaud@nostrcheck.me | repo | ✅ (redirects → primal) | +| noscrypt | chiptuner@coinos.io | repo | ✅ | +| nostr-hooks | sepehr@getalby.com | repo | ✅ | +| persian nostr book | kehiy@walletofsatoshi.com | repo | ✅ | +| NostrComments | slurpnc@coinos.io | repo | ✅ | +| cafe-society.news | cafe@getalby.com | repo | ✅ | +| Obsidian Nostr Writer | magoo@getalby.com | repo | ✅ | +| toll-booth | profusemeat89@walletofsatoshi.com | repo | ✅ | +| nostrcheck.me | public@nostrcheck.me | npub | ✅ (redirects → walletofsatoshi) | +| nostr.build | nostrbob@primal.net | npub | ✅ | +| NostrMedia.com | b0cc79@wallet.yakihonne.com | npub | ✅ | +| Zap Cooking | zapcooking@sats.zap.cooking | npub | ✅ | +| Zaplife | pablof7z@primal.net | npub | ✅ | +| Plebstr | passivebit94@walletofsatoshi.com | npub | ✅ | +| ZeusLN | tips@pay.zeusln.app | repo (BTCPay lnurl) | ❌ plain pay only | + +**Found but dead** (address published by project but endpoint down — not added): LifPay hello@lifpay.me · Flamingo t4t5@t4t5.com · Openvibe matej@openvibe.social · nostr-fetch jiftechnify@eclair.c-stellar.net · Nostros nostros@getalby.com. + +**Skipped** (example/placeholder addresses in repos, wrong owner, or project not on the list): nostr-to-rss, mostro-cli, gitnostr fork (address belongs to upstream author), Fountain app (not listed), aka-extension (not listed). diff --git a/index.html b/index.html index cc6d301..ae39dc1 100644 --- a/index.html +++ b/index.html @@ -121,6 +121,7 @@ + diff --git a/styles.css b/styles.css index 2cc1299..aa912a5 100644 --- a/styles.css +++ b/styles.css @@ -1290,3 +1290,72 @@ body.webln-ready .zap-chip { .zap-as-me { color: var(--link-color, #7c5cbf); font-weight: 500; } .zap-as-me:hover { text-decoration: underline; } + +/* payment-recovery panel (wallet failed, invoice still valid). + Deliberately not a .zap-toast — toast() removes those, and this panel + must survive reconnect notifications. */ +.zap-recovery { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; + max-width: min(92vw, 520px); + padding: 14px 20px; + border-radius: 12px; + font-size: 0.9rem; + line-height: 1.45; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); + animation: zap-toast-in 0.25s ease; + background: var(--card-background, #fff); + color: var(--text-color, #2d1b3d); + border: 1px solid #c62828; +} +.zap-recovery-msg { + font-size: 0.85rem; + line-height: 1.5; +} +.zap-toast-actions { + display: flex; + gap: 8px; + justify-content: center; + margin-top: 10px; + flex-wrap: wrap; +} +.zap-toast-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 10px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + background: var(--input-background, rgba(45,27,61,0.04)); + color: var(--text-color, #2d1b3d); + border: 1px solid var(--border-color, #ecdfe8); + font-family: inherit; + transition: all var(--transition, 0.25s); +} +.zap-toast-btn:hover { border-color: var(--primary-color, #e05297); } +.zap-toast-btn:disabled { opacity: 0.55; cursor: wait; } +.zap-toast-btn-primary { + background: #f7931a; + color: #fff; + border-color: #f7931a; +} +.zap-toast-btn-primary:hover { background: #e5840d; border-color: #e5840d; } + +.zap-recovery-close { + position: absolute; + top: 6px; + right: 8px; + background: none; + border: none; + color: var(--text-muted, #8b7e97); + cursor: pointer; + font-size: 0.85rem; + padding: 4px 8px; + border-radius: 6px; +} +.zap-recovery-close:hover { color: var(--text-color, #2d1b3d); background: var(--hover-color, #f6f0f8); } diff --git a/test/zap-core.test.js b/test/zap-core.test.js new file mode 100644 index 0000000..454d498 --- /dev/null +++ b/test/zap-core.test.js @@ -0,0 +1,271 @@ +// Regression tests for zap-core (node --test). +// Covers the production bugs from review: malicious/mismatched invoices +// (LUD-06 amount + description-hash validation), and provider-selection +// after reconnect. DOM-level behaviors (recovery panel surviving toast +// removal) remain covered by manual browser verification — this repo has +// no DOM test infrastructure, so the payment-critical logic lives here, +// dependency-free. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const crypto = require('node:crypto'); +const core = require('../zap-core.js'); + +const { CHARSET, bech32Polymod, hrpExpand } = core.__internals; + +// ---------- helpers ---------- + +function sha256Hex(s) { + return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); +} + +// craft a structurally-valid BOLT11 invoice (valid bech32 checksum) with a +// chosen hrp amount, timestamp, 'h' (23) and 'x' (6) tags, and a zero +// signature — good enough to exercise our validation-only decoder +function craftInvoice({ amountHrp = '10u', timestamp = Math.floor(Date.now() / 1000), hHex = null, expiry = null }) { + const words = []; + const pushInt = (n, wordCount) => { + const bin = n.toString(2).padStart(wordCount * 5, '0'); + for (let i = 0; i < wordCount; i++) words.push(parseInt(bin.slice(i * 5, i * 5 + 5), 2)); + }; + pushInt(timestamp, 7); // timestamp: 7 words + if (hHex) { + const value = Buffer.from(hHex, 'hex'); + // bytes → 5-bit words, right-padded + let acc = 0, bits = 0; + const vwords = []; + for (const b of value) { acc = (acc << 8) | b; bits += 8; while (bits >= 5) { bits -= 5; vwords.push((acc >> bits) & 31); } } + if (bits) vwords.push((acc << (5 - bits)) & 31); + words.push(23); // 'h' + pushInt(vwords.length, 2); // length in 5-bit words (2 words BE) + words.push(...vwords); + } + if (expiry != null) { + words.push(6); // 'x' + pushInt(1, 2); // one word of value + pushInt(expiry, 1); + } + for (let i = 0; i < 104; i++) words.push(0); // dummy signature + // bech32 checksum + const hrp = 'lnbc' + amountHrp; + const pm = bech32Polymod(hrpExpand(hrp).concat(words, [0, 0, 0, 0, 0, 0])) ^ 1; + const cs = [...Array(6)].map((_, i) => (pm >> 5 * (5 - i)) & 31); + return hrp + '1' + [...words, ...cs].map(d => CHARSET[d]).join(''); +} + +// ---------- malicious / malformed invoices ---------- + +test('attribute-injection payload is rejected', async () => { + const evil = 'lnbc10u1p4g9elc" onmouseover="alert(1)" data-x="'; + const r = await core.decodeBolt11(evil); + assert.strictEqual(r.ok, false); +}); + +test('garbage string with lnbc prefix is rejected', async () => { + const r = await core.decodeBolt11('lnbcnotaninvoicenotaninvoicenotaninvoice'); + assert.strictEqual(r.ok, false); +}); + +test('crafted valid invoice passes with matching amount and metadata hash', async () => { + const metadata = JSON.stringify([['text/plain', 'nostr.net zap'], ['text/identifier', 'user@host']]); + const inv = craftInvoice({ amountHrp: '10u', hHex: sha256Hex(metadata) }); + const r = await core.decodeBolt11(inv, { msat: 1000 * 1000, metadata }); + assert.strictEqual(r.ok, true, r.error); + assert.strictEqual(r.msat, 1000000); +}); + +test('LUD-06: invoice amount higher than requested is rejected', async () => { + const metadata = '[]'; + // server asked for 1000 sats but returns an invoice for 2000 sats (20u) + const inv = craftInvoice({ amountHrp: '20u', hHex: sha256Hex(metadata) }); + const r = await core.decodeBolt11(inv, { msat: 1000 * 1000, metadata }); + assert.strictEqual(r.ok, false); + assert.match(r.error, /amount mismatch/); +}); + +test('description hash mismatch is flagged, not fatal (primal-style non-compliance)', async () => { + const metadata = '[]'; + const inv = craftInvoice({ amountHrp: '10u', hHex: sha256Hex('{"different":"metadata"}') }); + const r = await core.decodeBolt11(inv, { msat: 1000 * 1000, metadata }); + assert.strictEqual(r.ok, true, r.error); + assert.strictEqual(r.metadataHashOk, false); +}); + +test('missing description hash is flagged, not fatal', async () => { + const inv = craftInvoice({ amountHrp: '10u' }); + const r = await core.decodeBolt11(inv, { msat: 1000 * 1000, metadata: '[]' }); + assert.strictEqual(r.ok, true, r.error); + assert.strictEqual(r.metadataHashOk, false); +}); + +test('compliant description hash is reported as verified', async () => { + const metadata = '[]'; + const inv = craftInvoice({ amountHrp: '10u', hHex: sha256Hex(metadata) }); + const r = await core.decodeBolt11(inv, { msat: 1000 * 1000, metadata }); + assert.strictEqual(r.ok, true, r.error); + assert.strictEqual(r.metadataHashOk, true); +}); + +test('zap invoices: description hash must commit to the serialized zap request (NIP-57)', async () => { + // NIP-57 protocol flow: for zaps the invoice description commits to the + // JSON-serialized kind 9734 event — NOT the LNURL metadata + const zapEvent = JSON.stringify({ + kind: 9734, pubkey: 'ab'.repeat(32), created_at: 1786970000, + tags: [['p', 'cd'.repeat(32)], ['amount', '1000000']], content: 'nostr.net zap' + }); + const bound = craftInvoice({ amountHrp: '10u', hHex: sha256Hex(zapEvent) }); + const ok = await core.decodeBolt11(bound, { msat: 1000 * 1000, metadata: zapEvent }); + assert.strictEqual(ok.ok, true, ok.error); + assert.strictEqual(ok.metadataHashOk, true); + + // an invoice bound to the LNURL metadata instead of the event is NOT a + // receipt-grade zap — must be flagged so the caller downgrades its claims + const metadataBound = craftInvoice({ amountHrp: '10u', hHex: sha256Hex('[["text/plain","x"]]') }); + const notBound = await core.decodeBolt11(metadataBound, { msat: 1000 * 1000, metadata: zapEvent }); + assert.strictEqual(notBound.ok, true, notBound.error); + assert.strictEqual(notBound.metadataHashOk, false); +}); + +test('expired invoice is rejected', async () => { + const inv = craftInvoice({ amountHrp: '10u', timestamp: Math.floor(Date.now() / 1000) - 7200, expiry: 60 }); + const r = await core.decodeBolt11(inv); + assert.strictEqual(r.ok, false); + assert.match(r.error, /expired/); +}); + +test('zero-amount invoice is rejected when an amount is expected', async () => { + const inv = craftInvoice({ amountHrp: '' }); // lnbc1… + const r = await core.decodeBolt11(inv, { msat: 1000 * 1000 }); + assert.strictEqual(r.ok, false); + assert.match(r.error, /amount/); +}); + +test('non-mainnet hrp is rejected', async () => { + const words = []; + const pushInt = (n, c) => { const b = n.toString(2).padStart(c * 5, '0'); for (let i = 0; i < c; i++) words.push(parseInt(b.slice(i * 5, i * 5 + 5), 2)); }; + pushInt(Math.floor(Date.now() / 1000), 7); + for (let i = 0; i < 104; i++) words.push(0); + const hrp = 'lntb10u'; + const pm = bech32Polymod(hrpExpand(hrp).concat(words, [0, 0, 0, 0, 0, 0])) ^ 1; + const cs = [...Array(6)].map((_, i) => (pm >> 5 * (5 - i)) & 31); + const inv = hrp + '1' + [...words, ...cs].map(d => CHARSET[d]).join(''); + const r = await core.decodeBolt11(inv); + assert.strictEqual(r.ok, false); + assert.match(r.error, /mainnet/); +}); + +// ---------- error classification ---------- + +test('transient payment errors are classified for recovery', () => { + const transient = [ + 'no info event (kind 13194) returned from relay', + 'Request timed out', + 'network error', + 'Connection closed', + 'wallet not connected', + new Error('Failed to fetch'), + ]; + for (const t of transient) assert.ok(core.isTransientPaymentError(t), String(t)); +}); + +test('terminal payment errors bypass recovery', () => { + const terminal = [ + 'User rejected the request', + 'Insufficient balance', + 'invoice expired', + 'Invoice is already paid', + '', + undefined, + ]; + for (const t of terminal) assert.ok(!core.isTransientPaymentError(t), String(t)); +}); + +// ---------- zap lifecycle guard ---------- +// (the DOM-side rendering of recovery remains browser-verified; the state +// machine it depends on is exercised here) + +test('guard serializes zaps and protects a pending recovery', () => { + let panel = false; + const g = core.createZapGuard({ hasRecoveryPanel: () => panel }); + + assert.ok(g.canStart()); + assert.strictEqual(g.start(), true); // zap 1 begins + assert.ok(!g.canStart()); // zap 2 blocked while in flight + assert.strictEqual(g.start(), false); + + g.recoveryShown(); // zap 1 failed transiently + g.finish(); // zap()'s finally releases busy; recovery keeps the lock + panel = true; + assert.ok(!g.canStart()); // blocked while recovery holds the invoice + assert.strictEqual(g.recoveryShown(), false); // a concurrent second failure must not displace it + + panel = false; // simulate the panel check racing the DOM removal + g.recoveryCleared(); // user dismissed (✕ / QR / terminal) + assert.ok(g.canStart()); + assert.deepStrictEqual(g.state, { zapBusy: false, recoveryActive: false }); +}); + +test('guard.finish releases the busy state on every zap outcome', () => { + const g = core.createZapGuard({ hasRecoveryPanel: () => false }); + g.start(); + g.finish(); // success / terminal error / thrown path + assert.ok(g.canStart()); +}); + +test('guard treats an existing panel as occupied even if recoveryActive was lost', () => { + let panel = true; + const g = core.createZapGuard({ hasRecoveryPanel: () => panel }); + assert.ok(!g.canStart()); // panel presence alone blocks + assert.strictEqual(g.recoveryShown(), false); // and cannot be displaced +}); + + +test('most recently connected provider wins', () => { + let webln = { id: 'A', sendPayment: async () => {} }; + let bc = null; + const sel = core.createProviderSelector({ getWebln: () => webln, getBc: () => bc }); + + // only the extension: A is used + assert.strictEqual(sel.get().id, 'A'); + + // extension failed; user reconnects via Bitcoin Connect → B must win + bc = { id: 'B', sendPayment: async () => {} }; + sel.register(bc); + assert.strictEqual(sel.get().id, 'B'); + + // extension updates itself (new object) → C wins + webln = { id: 'C', sendPayment: async () => {} }; + assert.strictEqual(sel.get().id, 'C'); + + // webln disappears → falls back to still-registered B + webln = undefined; + assert.strictEqual(sel.get().id, 'B'); + + // everything gone → null + bc = null; + assert.strictEqual(sel.get(), null); +}); + +test('non-provider objects are never selected', () => { + const sel = core.createProviderSelector({ getWebln: () => ({}), getBc: () => null }); + sel.register(null); + sel.register({ sendPayment: 'not-a-function' }); + assert.strictEqual(sel.get(), null); +}); + +// ---------- misc ---------- + +test('lnurlEncode round-trips to the lnurlp endpoint', () => { + const url = core.lnurlpUrl('damus@sendsats.lol'); + const ln = core.lnurlEncode(url).toUpperCase(); + const pos = ln.lastIndexOf('1'); + const hrp = 'lnurl'; + const words = [...ln.slice(pos + 1)].map(c => CHARSET.indexOf(c.toLowerCase())); + assert.strictEqual(bech32Polymod(hrpExpand(hrp).concat(words)), 1, 'checksum'); + assert.ok(url.startsWith('https://sendsats.lol/.well-known/lnurlp/damus')); +}); + +test('escapeHtml neutralizes markup', () => { + assert.ok(!/[<>"']/.test(core.escapeHtml(''))); +}); diff --git a/zap-core.js b/zap-core.js new file mode 100644 index 0000000..ad611bd --- /dev/null +++ b/zap-core.js @@ -0,0 +1,223 @@ +// zap-core.js — pure, dependency-free core for grantless zapping. +// Shared between the browser (zap.js) and node --test (test/zap-core.test.js). +// BOLT11 parsing per the reference decoder (bolt11 npm): data words are +// [timestamp:7 words][tagged fields…][signature:104 words]; each tagged field +// is [type:1 word][len:2 words BE, in 5-bit words][value:len words]. +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.zapCore = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + + // ---------- bech32 primitives ---------- + function bech32Polymod(values) { + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = (chk & 0x1ffffff) << 5 ^ v; + for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i]; + } + return chk; + } + function hrpExpand(hrp) { + return [...hrp].map(c => c.charCodeAt(0) >> 5).concat([0], [...hrp].map(c => c.charCodeAt(0) & 31)); + } + function convertBits(data, from, to) { + let acc = 0, bits = 0; + const ret = [], maxv = (1 << to) - 1; + for (const v of data) { + acc = (acc << from) | v; bits += from; + while (bits >= to) { bits -= to; ret.push((acc >> bits) & maxv); } + } + if (bits) ret.push((acc << (to - bits)) & maxv); + return ret; + } + + // ---------- lnurl ---------- + function lnurlpUrl(address) { + const [user, host] = address.split('@'); + return 'https://' + host + '/.well-known/lnurlp/' + encodeURIComponent(user); + } + function lnurlEncode(url) { + const data = convertBits([...new TextEncoder().encode(url)], 8, 5); + const values = hrpExpand('lnurl').concat(data); + const pm = bech32Polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ 1; + const cs = [...Array(6)].map((_, i) => (pm >> 5 * (5 - i)) & 31); + return 'lnurl' + '1' + [...data, ...cs].map(d => CHARSET[d]).join(''); + } + + // ---------- misc ---------- + const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + async function sha256Hex(str) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); + return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join(''); + } + + // ---------- BOLT11 decoding (validation only — no signature recovery) ---------- + // Amount must be exact; the wallet verifies the signature itself at payment + // time, but only *we* can verify the invoice matches what we asked for + // (LUD-06 requires the client to check amount + description hash). + const AMOUNT_FACTORS = { '': 10n ** 11n, m: 10n ** 8n, u: 10n ** 5n, n: 10n ** 2n }; + + function wordsToTrimmedHex(words) { + // 5-bit words → bytes, dropping right-padding bits (bolt11 wordsToBuffer(trim)); + // only complete bytes are emitted — the trailing partial byte is pad noise + let acc = 0, bits = 0; + const bytes = []; + for (const w of words) { + acc = (acc << 5) | w; bits += 5; + while (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 255); } + } + return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async function decodeBolt11(invoice, expect) { + const bad = (error) => ({ ok: false, error }); + try { + if (typeof invoice !== 'string') return bad('not a string'); + const inv = invoice.replace(/^lightning:/i, '').trim().toLowerCase(); + const sep = inv.lastIndexOf('1'); + if (sep < 4 || sep === inv.length - 1) return bad('missing separator'); + const hrp = inv.slice(0, sep); + const dataStr = inv.slice(sep + 1); + if (dataStr.length < 6 + 7 + 104) return bad('too short'); + const words = [...dataStr].map(c => CHARSET.indexOf(c)); + if (words.some(w => w < 0)) return bad('invalid characters'); + + // network: only mainnet lnbc is accepted for zaps + if (!/^lnbc/.test(hrp)) return bad('not a mainnet invoice'); + + // bech32 checksum over hrp + data incl. 6 checksum words + if (bech32Polymod(hrpExpand(hrp).concat(words)) !== 1) return bad('bad checksum'); + + // amount from hrp (e.g. lnbc10u… = 1000 sats) + const am = hrp.slice(4).match(/^(\d*)([munp]?)$/); + if (!am) return bad('bad amount in prefix'); + let msat = null; + if (am[1]) { + const n = BigInt(am[1]); + if (am[2] === 'p') { + if (n % 10n !== 0n) return bad('fractional msat amount'); + msat = n / 10n; + } else { + msat = n * AMOUNT_FACTORS[am[2]]; + } + } + + // data: [timestamp:7 words][tagged fields…][signature:104 words] + // (words includes the trailing 6 bech32 checksum words — excluded here) + const sigAt = words.length - 6 - 104; + const timestamp = parseInt(words.slice(0, 7).map(w => w.toString(2).padStart(5, '0')).join('') || '0', 2); + let expiry = null, descriptionHash = null; + let pos = 7; + while (pos + 3 <= sigAt) { + const type = words[pos]; + const len = words[pos + 1] * 32 + words[pos + 2]; + if (pos + 3 + len > sigAt) return bad('truncated tag'); + const value = words.slice(pos + 3, pos + 3 + len); + if (type === 23) descriptionHash = wordsToTrimmedHex(value); // 'h' purpose_commit_hash + else if (type === 6) expiry = parseInt(value.map(w => w.toString(2).padStart(5, '0')).join('') || '0', 2); // 'x' expire_time + pos += 3 + len; + } + + // expiry (default 3600) with a small clock-skew allowance + const expiryAt = timestamp + (expiry ?? 3600) + 60; + if (Date.now() / 1000 > expiryAt) return bad('invoice expired'); + + if (expect) { + if (expect.msat != null) { + if (msat === null || msat !== BigInt(Math.trunc(expect.msat))) return bad('invoice amount mismatch'); + } + // Description hash: LUD-06 asks clients to verify it, but major + // providers (e.g. primal) put a constant/non-committed hash in + // their invoices, and the reference client (@getalby/lightning- + // tools) does not enforce it either. Verify when compliant, + // flag — but don't fail — when not. Amount stays a hard fail. + let metadataHashOk = null; + if (expect.metadata != null) { + metadataHashOk = !!descriptionHash && descriptionHash === await sha256Hex(expect.metadata); + if (!metadataHashOk && typeof console !== 'undefined' && console.warn) { + console.warn('zap-core: invoice description hash does not match the committed description (provider non-compliance) — amount check still enforced'); + } + } + return { ok: true, msat: msat === null ? null : Number(msat), timestamp, expiry, descriptionHash, metadataHashOk }; + } + return { ok: true, msat: msat === null ? null : Number(msat), timestamp, expiry, descriptionHash }; + } catch (e) { + return bad('malformed invoice'); + } + } + + // ---------- error classification ---------- + // transient = the wallet might answer later (retry/QR make sense); + // cancellations, insufficient balance, invalid/expired invoices are terminal + function isTransientPaymentError(e) { + const pmsg = String((e && e.message) || e || ''); + return /no info event|13194|timeout|timed ?out|network|connection|econn|not connected|wallet not connected|failed to fetch/i.test(pmsg); + } + + // ---------- zap lifecycle guard ---------- + // Serializes zap attempts and protects a pending recovery invoice from + // being displaced (panel-existence check races two concurrent in-flight + // zaps). DOM-free so the lifecycle is unit-testable; the caller supplies + // a hasRecoveryPanel() probe. + function createZapGuard({ hasRecoveryPanel }) { + let zapBusy = false; + let recoveryActive = false; + const blocked = () => zapBusy || recoveryActive || hasRecoveryPanel(); + return { + canStart: () => !blocked(), + start: () => { + if (blocked()) return false; + zapBusy = true; + return true; + }, + finish: () => { zapBusy = false; }, // zap flow ended (any outcome) + recoveryShown: () => { + if (recoveryActive || hasRecoveryPanel()) return false; // never displace + recoveryActive = true; + return true; + }, + recoveryCleared: () => { recoveryActive = false; }, + get state() { return { zapBusy, recoveryActive }; } + }; + } + + // ---------- provider selection ---------- + // "Most recently connected provider wins". Covers: extension webln fails → + // user reconnects via Bitcoin Connect → fresh bcProvider must be preferred. + function createProviderSelector({ getWebln, getBc }) { + const stack = []; + let seenWebln, seenBc; + const register = (p) => { + if (p && typeof p.sendPayment === 'function') { + const i = stack.indexOf(p); + if (i !== -1) stack.splice(i, 1); + stack.unshift(p); + } + }; + const get = () => { + const w = getWebln(); + if (w !== seenWebln) { seenWebln = w; if (w) register(w); } + const b = getBc(); + if (b !== seenBc) { seenBc = b; if (b) register(b); } + // drop providers that are no longer reachable through either source + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i] !== getWebln() && stack[i] !== getBc()) stack.splice(i, 1); + } + return stack.find(p => typeof p?.sendPayment === 'function') || null; + }; + return { get, register }; + } + + return { + lnurlpUrl, lnurlEncode, escapeHtml, sha256Hex, decodeBolt11, createProviderSelector, + isTransientPaymentError, createZapGuard, + __internals: { CHARSET, bech32Polymod, hrpExpand, convertBits, wordsToTrimmedHex } + }; +}); diff --git a/zap.js b/zap.js index 4cb0e43..cd87d3d 100644 --- a/zap.js +++ b/zap.js @@ -5,52 +5,26 @@ // Falls back to plain lightning: links if JS/zap flow fails (links are never broken). (() => { + // shared, unit-tested core (zap-core.js must load before this script) + const { lnurlpUrl, lnurlEncode, escapeHtml, sha256Hex, decodeBolt11, createProviderSelector, + isTransientPaymentError, createZapGuard } = zapCore; + const ZAP_SATS = 1000; // default zap amount for chips const ZAP_MEMO = 'nostr.net zap'; // so recipients know where it came from const ZAP_RELAYS = ['wss://relay.nostr.net', 'wss://relay.damus.io', 'wss://nos.lol']; const NOBLE_URL = 'https://esm.sh/@noble/curves@1.4.0/secp256k1'; // lazy: only for anon zap receipts - const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; let pendingZap = null; // { lnAddress, name, chip } let pollTimer = null; + // serializes zaps + protects a pending recovery invoice (lifecycle in zap-core, unit-tested) + const guard = createZapGuard({ hasRecoveryPanel: () => !!document.querySelector('.zap-recovery') }); + + // clear a chip's transient UI state (used on every recovery exit) + const clearChip = (chip) => { if (chip) { delete chip.dataset.state; delete chip.dataset.zapping; } }; // ---------- small utils ---------- const hex = (bytes) => [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); const unhex = (h) => new Uint8Array(h.match(/../g).map(x => parseInt(x, 16))); - - async function sha256Hex(str) { - const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); - return hex(new Uint8Array(buf)); - } - - // bech32 encode (for the optional lnurl tag in zap requests) - function bech32Polymod(values) { - const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; - let chk = 1; - for (const v of values) { - const top = chk >> 25; - chk = (chk & 0x1ffffff) << 5 ^ v; - for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i]; - } - return chk; - } - function lnurlEncode(url) { - const hrp = 'lnurl'; - const data = new TextEncoder().encode(url); - const acc = { acc: 0, bits: 0, ret: [] }; - for (const b of data) { - acc.acc = (acc.acc << 8) | b; acc.bits += 8; - while (acc.bits >= 5) { acc.bits -= 5; acc.ret.push((acc.acc >> acc.bits) & 31); } - } - if (acc.bits) acc.ret.push((acc.acc << (5 - acc.bits)) & 31); - const values = [...hrp].map(c => c.charCodeAt(0) >> 5) - .concat([0], [...hrp].map(c => c.charCodeAt(0) & 31), acc.ret); - const pm = bech32Polymod([...values, 0, 0, 0, 0, 0, 0]) ^ 1; - const cs = [...Array(6)].map((_, i) => (pm >> 5 * (5 - i)) & 31); - return hrp + '1' + [...acc.ret, ...cs].map(d => CHARSET[d]).join(''); - } - - // ---------- toast ---------- function toast(msg, type = 'info', ms = 4200) { document.querySelectorAll('.zap-toast').forEach(t => t.remove()); const el = document.createElement('div'); @@ -62,11 +36,6 @@ } // ---------- LNURL ---------- - function lnurlpUrl(address) { - const [user, host] = address.split('@'); - return `https://${host}/.well-known/lnurlp/${encodeURIComponent(user)}`; - } - async function fetchLnurlParams(address) { const r = await fetch(lnurlpUrl(address)); if (!r.ok) throw new Error(`lnurlp ${r.status}`); @@ -89,7 +58,17 @@ const j = await r.json(); if (j.status === 'ERROR') throw new Error(j.reason || 'invoice error'); if (!j.pr) throw new Error('no invoice'); - return j; + // LUD-06: amount must match what we asked for (hard fail). + // NIP-57: for zaps the description hash must commit to the serialized + // zap-request event (the exact string sent above) — when it does, the + // receipt is verifiably baked in; when a provider doesn't comply we + // still send/pay but must not claim a guaranteed receipt. + const committedDesc = zapEvent ? JSON.stringify(zapEvent) : params.metadata; + const check = await decodeBolt11(j.pr, { msat, metadata: committedDesc }); + if (!check.ok) throw new Error(check.error); + // receiptBound only for actual zaps whose invoice commits to the zap + // request — a plain (metadata-bound) invoice must never claim one + return { pr: j.pr, receiptBound: !!zapEvent && check.metadataHashOk === true }; } // ---------- NIP-57 zap request ---------- @@ -144,14 +123,11 @@ // ---------- payment ---------- // Provider sources: ① window.webln (extension) ② Bitcoin Connect's // connected provider (NWC / LNC / LNbits) — handed to us via onConnected(), - // it is NOT set on window.webln. + // it is NOT set on window.webln. The selector prefers whichever provider + // connected most recently, so a BC reconnect after a dead extension wins. let bcProvider = null; - - const getProvider = () => { - if (window.webln && typeof window.webln.sendPayment === 'function') return window.webln; - if (bcProvider && typeof bcProvider.sendPayment === 'function') return bcProvider; - return null; - }; + const selector = createProviderSelector({ getWebln: () => window.webln, getBc: () => bcProvider }); + const getProvider = selector.get; const weblnReady = () => !!getProvider(); // enable() once per provider+session, started as early as possible (prompt @@ -183,9 +159,158 @@ return lnurlCache.get(address); } + // ---------- success / payment-recovery helpers ---------- + function finishZap({ chip, msat, event, anon, name, receiptBound }) { + if (chip) { chip.dataset.state = 'zapped'; delete chip.dataset.zapping; } + const sats = msat / 1000; + // claims match what the invoice actually proves: + // bound → the invoice commits to this zap request (receipt-bound); + // publishing still depends on the recipient's server + // unbound → zap request was sent, but the provider didn't commit it + // none → plain payment + let msg; + if (event && receiptBound) { + msg = `⚡ Zapped ${escapeHtml(name)} ${sats} sats${anon ? ' (anon zap receipt bound)' : ' · zap receipt bound'}`; + } else if (event) { + msg = `⚡ Zapped ${escapeHtml(name)} ${sats} sats (receipt unverified)`; + } else { + msg = `⚡ Sent ${escapeHtml(name)} ${sats} sats (no zap receipt)`; + } + toast(msg, 'success', 6000); + // one-time opt-in for attributed zaps — only meaningful when receipts bind + if (anon && receiptBound && window.nostr?.signEvent && localStorage.getItem(SIGN_AS_ME_KEY) !== '1') { + const t = document.querySelector('.zap-toast'); + if (t) { + const a = document.createElement('a'); + a.href = '#'; + a.className = 'zap-as-me'; + a.textContent = 'zap as me instead'; + a.addEventListener('click', (ev) => { + ev.preventDefault(); + localStorage.setItem(SIGN_AS_ME_KEY, '1'); + toast('Zaps will now be signed with your npub ⚡', 'success', 3000); + }); + t.appendChild(document.createTextNode(' · ')); + t.appendChild(a); + } + } + } + + // (invoice validation lives in zap-core.decodeBolt11 — full BOLT11 parse + // with amount/description-hash checks; nothing untrusted reaches HTML) + + // wallet failed to pay, but we already hold a valid invoice: offer recovery. + // NOTE: intentionally NOT using the .zap-toast class — toast() removes those, + // and the recovery controls must survive reconnect notifications. + function showPayRecovery({ name, invoice, msat, chip, event, anon, receiptBound }) { + // defensive: never displace an existing recovery panel (zapBusy should + // already prevent this, but the invoice controls are too costly to lose) + if (document.querySelector('.zap-recovery')) { + toast('A payment is still waiting — retry it, pay by QR, or dismiss it first.', 'info', 4500); + return; + } + document.querySelectorAll('.zap-toast').forEach(t => t.remove()); + const el = document.createElement('div'); + el.className = 'zap-recovery'; + el.setAttribute('role', 'alert'); + // every exit path must clear the guard and the chip's error state + const closeRecovery = () => { el.remove(); guard.recoveryCleared(); clearChip(chip); }; + el.innerHTML = ` + +
${escapeHtml(name)} · ${Math.round(msat / 1000)} sats — your wallet didn’t answer over NWC. + Open the wallet app (or reconnect via the ⚡ button), then retry — or pay the invoice with any wallet.
+
+ + +
`; + document.body.appendChild(el); + + el.querySelector('.zap-recovery-close').addEventListener('click', closeRecovery); + + el.querySelector('[data-act="retry"]').addEventListener('click', async (e) => { + const btn = e.currentTarget; + // resolve the provider at retry time: a reconnect (e.g. via the ⚡ + // button) creates a fresh bcProvider that must be used, not the + // stale one captured when the zap first failed + const provider = getProvider(); + const msg = el.querySelector('.zap-recovery-msg'); + if (!provider) { + if (msg) msg.textContent = 'No wallet connected — reconnect via the ⚡ button, or use “Pay by QR”.'; + return; + } + btn.disabled = true; + if (chip) { chip.dataset.zapping = '1'; chip.dataset.state = 'zapping'; } + try { + await payInvoice(invoice, provider); + el.remove(); + guard.recoveryCleared(); + finishZap({ chip, msat, event, anon, name, receiptBound }); + } catch (retryErr) { + console.error('retry failed:', retryErr); + btn.disabled = false; + if (chip) { chip.dataset.state = 'error'; delete chip.dataset.zapping; } + if (isTransientPaymentError(retryErr)) { + if (msg) msg.textContent = 'Still no answer from the wallet — try “Pay by QR”, or reconnect via the ⚡ button.'; + } else { + // terminal (canceled, insufficient balance, expired…): + // drop the panel, surface the provider's own message + closeRecovery(); + toast(`Couldn\u2019t zap ${escapeHtml(name)}: ${escapeHtml(String(retryErr?.message || retryErr).slice(0, 140))}`, 'error', 7000); + } + } + }); + + el.querySelector('[data-act="qr"]').addEventListener('click', () => { + closeRecovery(); + showInvoiceModal({ name, invoice, msat, receiptBound }); + }); + } + + // pay an existing invoice with any wallet (QR / lightning: link) + async function showInvoiceModal({ name, invoice, msat, receiptBound }) { + const check = await decodeBolt11(invoice); + if (!check.ok) { + console.warn('refusing to show invalid invoice', check.error); + toast(`Couldn’t build a payment link for ${escapeHtml(name)}: the server returned an invalid invoice (${check.error}).`, 'error', 7000); + return; + } + closeZapModal(); + const backdrop = document.createElement('div'); + backdrop.className = 'zap-modal-backdrop'; + backdrop.innerHTML = ` + `; + document.body.appendChild(backdrop); + // assign the untrusted invoice via the DOM property (no HTML interpolation) + backdrop.querySelector('.zap-modal-btn-primary').href = 'lightning:' + invoice; + backdrop.querySelector('.zap-modal-close').addEventListener('click', closeZapModal); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeZapModal(); }); + qrSvgForContent('lightning:' + invoice) + .then(svg => { const box = backdrop.querySelector('.zap-qr'); if (box) box.innerHTML = svg; }) + .catch(e => { console.warn('invoice QR failed', e); backdrop.querySelector('.zap-qr-box')?.remove(); }); + } + // ---------- main zap flow ---------- async function zap(lnAddress, name, chip) { if (chip?.dataset.zapping === '1') return; + // one payment at a time: an in-flight zap or a pending recovery invoice + // must never be displaced by a second zap + if (!guard.canStart()) { + toast('A payment is still waiting — retry it, pay by QR, or dismiss it first.', 'info', 4500); + return; + } + guard.start(); if (chip) { chip.dataset.zapping = '1'; chip.dataset.state = 'zapping'; } try { @@ -206,51 +331,45 @@ const { event, anon } = await buildZapRequest(params, msat, lnAddress); const inv = await fetchInvoice(params, msat, event); - await payInvoice(inv.pr, provider); - if (chip) { chip.dataset.state = 'zapped'; delete chip.dataset.zapping; } - const sats = msat / 1000; - toast( - event - ? `⚡ Zapped ${escapeHtml(name)} ${sats} sats${anon ? ' (anon zap receipt)' : ' · zap receipt published'}` - : `⚡ Sent ${escapeHtml(name)} ${sats} sats (no zap receipt)`, - 'success', 6000 - ); - // one-time opt-in for attributed zaps (NIP-07 available but not enabled) - if (anon && window.nostr?.signEvent && localStorage.getItem(SIGN_AS_ME_KEY) !== '1') { - const t = document.querySelector('.zap-toast'); - if (t) { - const a = document.createElement('a'); - a.href = '#'; - a.className = 'zap-as-me'; - a.textContent = 'zap as me instead'; - a.addEventListener('click', (ev) => { - ev.preventDefault(); - localStorage.setItem(SIGN_AS_ME_KEY, '1'); - toast('Zaps will now be signed with your npub ⚡', 'success', 3000); - }); - t.appendChild(document.createTextNode(' · ')); - t.appendChild(a); + try { + await payInvoice(inv.pr, provider); + } catch (payErr) { + console.error('payment failed:', payErr); + // Recovery (retry/QR) only makes sense for transient connection + // failures — a wallet that might answer later. Cancellations, + // insufficient balance, invalid/already-paid invoices are + // terminal: report them through the ordinary error path. + const transient = isTransientPaymentError(payErr); + if (!transient) throw payErr; + if (chip) { chip.dataset.state = 'error'; delete chip.dataset.zapping; } + if (guard.recoveryShown()) { + showPayRecovery({ name, invoice: inv.pr, msat, chip, event, anon, receiptBound: inv.receiptBound }); } + return; } + finishZap({ chip, msat, event, anon, name, receiptBound: inv.receiptBound }); } catch (err) { console.error('zap failed:', err); if (chip) { chip.dataset.state = 'error'; delete chip.dataset.zapping; setTimeout(() => { if (chip.dataset.state === 'error') delete chip.dataset.state; }, 2500); } const msg = err?.message || 'failed'; let hint; - if (/no info event|13194/i.test(msg)) { + if (/cancel|denied|aborted|dismissed|rejected/i.test(msg)) { + hint = 'canceled in the wallet'; + } else if (/no info event|13194/i.test(msg)) { hint = 'your wallet didn\u2019t answer over NWC — make sure the wallet app is running and connected, then try again'; } else if (/Failed to fetch/i.test(msg)) { // CORS-masking: the recipient's server returned an error we can't read hint = 'the recipient\u2019s server rejected the request (often temporary) — try again in a moment'; } else { - hint = msg; + hint = msg.slice(0, 140); } toast(`Couldn\u2019t zap ${escapeHtml(name)}: ${escapeHtml(hint)}`, 'error', 7000); + } finally { + guard.finish(); } } - const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // ---------- connect-modal path ---------- function openConnectModal() { @@ -259,7 +378,6 @@ window.bitcoinConnect.launchModal(); return true; } - // fallback: click the real button inside the shadow root const btn = document.querySelector('bc-button'); if (!btn) return false; const inner = btn.shadowRoot?.querySelector('bci-button'); @@ -292,13 +410,17 @@ return qrLibPromise; } - async function qrSvgFor(address) { - if (qrCache.has(address)) return qrCache.get(address); + async function qrSvgForContent(content) { const q = await loadQr(); const qr = q(0, 'M'); - qr.addData('lightning:' + lnurlEncode(lnurlpUrl(address)).toUpperCase(), 'Byte'); + qr.addData(content, 'Byte'); qr.make(); - const svg = qr.createSvgTag({ cellSize: 4, margin: 0 }); + return qr.createSvgTag({ cellSize: 4, margin: 0 }); + } + + async function qrSvgFor(address) { + if (qrCache.has(address)) return qrCache.get(address); + const svg = await qrSvgForContent('lightning:' + lnurlEncode(lnurlpUrl(address)).toUpperCase()); qrCache.set(address, svg); return svg; } @@ -411,6 +533,7 @@ api.onConnected(provider => { if (provider && typeof provider.sendPayment === 'function') { bcProvider = provider; + selector.register(provider); // most-recently-connected wins for retries enabledFor = null; // re-enable for the new provider document.body.classList.add('webln-ready'); toast('Wallet connected ⚡', 'success', 2500);