mirror of
https://github.com/aljazceru/awesome-nostr.git
synced 2026-09-14 02:55:07 +00:00
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:
+142
@@ -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 ~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).
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
};
|
||||
});
|
||||
@@ -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 didn’t 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(`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 = `
|
||||
<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 doesn’t 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|
||||
|
||||
Reference in New Issue
Block a user