grantless: harden zap payments, honest receipt claims, recovery lifecycle

- zap-core.js: dependency-free BOLT11 decoder (checksum, mainnet hrp, exact
  amount vs requested msat per LUD-06, expiry, description-hash binding to
  the serialized NIP-57 zap request), provider selector (most-recently-
  connected wins), transient-error classifier, zap lifecycle guard — all
  unit-tested (node --test, 20 tests)
- zap.js: invoice claims match proof (receipt-bound / unverified / none);
  payment triage (transient -> recovery panel, terminal -> provider message);
  single pending recovery protected from concurrent failures; all recovery
  exits clear guard + chip state
- loads zap-core.js before zap.js in index.html (both must deploy together)
This commit is contained in:
Aljaz Ceru
2026-08-17 21:13:52 +02:00
parent 0e462c1eab
commit 3968411206
6 changed files with 909 additions and 80 deletions
+142
View File
@@ -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**`<bc-button>` 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, `<bc-button>` 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 ~1525 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.52 days |
| **3** | per-project amounts, verified badge, grantless subdomain alias, "Zap Day" event mode | later |
**"Live by Thursday" scope = Phases 02** (~2.53 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).
+1
View File
@@ -121,6 +121,7 @@
</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="script.js"></script>
<script src="zap-core.js"></script>
<script src="zap.js"></script>
<!-- Google tag (gtag.js) -->
+69
View File
@@ -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); }
+271
View File
@@ -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('<img src=x onerror="alert(1)">')));
});
+223
View File
@@ -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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 }
};
});
+203 -80
View File
@@ -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 <strong>${escapeHtml(name)}</strong> ${sats} sats${anon ? ' (anon zap receipt bound)' : ' · zap receipt bound'}`;
} else if (event) {
msg = `⚡ Zapped <strong>${escapeHtml(name)}</strong> ${sats} sats (receipt unverified)`;
} else {
msg = `⚡ Sent <strong>${escapeHtml(name)}</strong> ${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 = `
<button class="zap-recovery-close" aria-label="Dismiss pending payment">✕</button>
<div class="zap-recovery-msg"><strong>${escapeHtml(name)}</strong> · ${Math.round(msat / 1000)} sats — your wallet didnt answer over NWC.
Open the wallet app (or reconnect via the ⚡ button), then retry — or pay the invoice with any wallet.</div>
<div class="zap-toast-actions">
<button class="zap-toast-btn" type="button" data-act="retry"><i class="fas fa-redo" aria-hidden="true"></i> Retry</button>
<button class="zap-toast-btn zap-toast-btn-primary" type="button" data-act="qr"><i class="fas fa-qrcode" aria-hidden="true"></i> Pay by QR</button>
</div>`;
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(`Couldnt 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 = `
<div class="zap-modal" role="dialog" aria-modal="true" aria-label="Complete zap for ${escapeHtml(name)}">
<button class="zap-modal-close" aria-label="Close">✕</button>
<h3 class="zap-modal-title"><i class="fas fa-bolt" aria-hidden="true"></i> ${Math.round(msat / 1000)} sats → ${escapeHtml(name)}</h3>
<div class="zap-qr-box"><div class="zap-qr" aria-hidden="true"></div></div>
<p class="zap-modal-hint">${receiptBound
? 'Scan with any wallet — the zap request is baked into this invoice; paying it publishes the zap receipt. Valid for a few minutes.'
: 'Scan with any wallet to pay. This provider doesnt bind zap receipts to invoices, so the zap may not appear in feeds.'}</p>
<div class="zap-modal-line">
<a class="zap-modal-btn zap-modal-btn-primary">
<i class="fas fa-wallet" aria-hidden="true"></i> Open wallet
</a>
</div>
</div>`;
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 <strong>${escapeHtml(name)}</strong> ${sats} sats${anon ? ' (anon zap receipt)' : ' · zap receipt published'}`
: `⚡ Sent <strong>${escapeHtml(name)}</strong> ${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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);