Phase 1 — Sign zap requests: - When the LNURL endpoint advertises allowsNostr + nostrPubkey and the tipper has NIP-07, sign a kind-9734 zap request and pass it as the nostr= param to the pay callback. Tags include e (event id), a (naddr coords) and p (recipient) for feed-card / preview tips, or just p for profile tips. - Silent fallback to plain LNURL-pay for non-zap endpoints or signed-out users. Tips never silently fail. - Tiny "⚡ Tracked zap" badge in the invoice view when the zap path engaged, so QR / copy users know their tip will count. Phase 2 — Aggregate and display: - Parse kind-9735 receipts via the embedded description tag (NIP-57 amount) or a bolt11 amount fallback (with multiplier table). - _zapCache aggregates by event id and by author pubkey; receipts are deduped by event id. - Feed enrichment phase batch-queries receipts by #e for visible designs; profile-page opens query by #p for the author and by #e for each design in the grid. - Feed cards display a "⚡ <total>" badge in the actions row. - Design preview meta line gets the same badge. - Profile page header shows "⚡ X sats received" under the npub. Sort and visibility: - New "⚡ Zapped" tab in both the Community modal and the welcome modal. Sort is pure zap-total with date as the tiebreaker. - Existing Popular sort now leads with zap totals, falling back to reactions, trust, and date. - isPreviewBroken now flags designs pre-emptively when the preview URL is missing (for modes that require artwork) or uses a non-http scheme, so broken designs sink to the bottom on first paint instead of waiting for an actual image-load failure. - Broken-image set persists to localStorage so detected breakage survives reloads. Payment detection: - After a tracked invoice is issued, a background poller queries kind-9735 events with #p=recipient + since=now. When a receipt arrives whose embedded zap request matches the tipper's pubkey, the event id, and the amount (within 10% tolerance), the modal shows "✓ Payment received! Closing…" and auto-closes 1.2s later. Polling stops after 5 minutes (invoice expiry), on close, or on "New amount". - WebLN auto-pay button appears in the invoice view when window.webln is available; one click pays via the extension and closes the modal on success. Rejections are handled quietly so the QR / copy paths remain available. Co-Authored-By: Claude Opus 4.7 <[email protected]>
12 KiB
Tracked Zaps (NIP-57)
Goals
Turn today's untracked LNURL-pay tips into on-relay-recorded zaps so we can sum them per design, per author, and per author's profile, and rank by them.
- A zap that fires today should generate a kind-9735 receipt on Nostr that anyone can later read.
- A design card should be able to display
⚡ N satsand a count of zappers. - The feed should be sortable by zap totals.
- Profile pages should show the total zaps that author has received (and, later, sent).
Product goals
- Make tipping discoverable. If a design has gotten 10k sats, that's a signal future viewers should see.
- Preserve the existing plain-LNURL-pay flow as a fallback so tips never silently fail.
- Stay Nostr-native: use NIP-57 directly, no analytics service, no central scoreboard.
Non-goals (v1)
- Backfilling tips from the pre-tracking era. Those LN payments are gone from a Nostr perspective.
- Punishing or hiding designs with few zaps. We surface signal, not anti-signal.
- Showing who zapped (privacy + UI clutter); v1 only sums amounts. A "Zappers" list can come later.
- Anonymous zaps (
anontag in NIP-57). Adds branch complexity. Defer. - WebLN auto-pay (would also need a NIP-57 path; deferrable).
- Zap leaderboards across all designs / authors. Once aggregation works, leaderboards are a small UI; not v1.
Current state
- The existing tip flow (
openTipModal→fetchLnurlPayMetadata→fetchLnurlInvoice) does plain LNURL-pay: fetches an amount-locked bolt11 and renders QR + copy. Nothing about that payment lands on Nostr. - LN providers that support zaps publish a kind-9735 iff the LNURL-pay request carries a
nostr=<encoded-zap-request>param. The plain flow omits it, so receipts are never created for our tips.
NIP-57 in our terms
What a zap request looks like (kind 9734)
{
"kind": 9734,
"pubkey": "<tipper hex>",
"created_at": 1700000000,
"tags": [
["relays", "wss://relay.damus.io", "wss://nos.lol"],
["amount", "21000"], // millisats
["p", "<author hex>"], // recipient
["e", "<design event id>"], // OPTIONAL — present for design zaps; omitted for pure profile zaps
["a", "30078:<author>:<d-tag>"] // OPTIONAL — naddr coords for replaceable kinds
],
"content": "Optional comment"
}
Sent to the LNURL-pay endpoint as:
GET <callback>?amount=<msats>&nostr=<URL-encoded JSON of signed event>
What a zap receipt looks like (kind 9735)
Published by the LN service (or by Alby's zap.land etc.) after the invoice settles:
{
"kind": 9735,
"pubkey": "<LN-service zap pubkey>", // matches metadata.nostrPubkey
"created_at": 1700000000,
"tags": [
["bolt11", "lnbc..."],
["description", "<JSON string of original 9734>"],
["p", "<recipient>"],
["e", "<event id>"],
["a", "30078:..."]
],
"content": ""
}
Amount is parsed from the bolt11 tag (HRP lnbc<amount><multiplier> encoding) or from the amount tag in the embedded zap request.
Metadata flag
The LNURL-pay metadata endpoint advertises support via:
{
"allowsNostr": true,
"nostrPubkey": "<hex pubkey of the zap-publisher>"
}
Both must be present and non-empty to use the zap path. nostrPubkey is also what we use to verify that incoming kind-9735 receipts are legitimate (the receipt's pubkey must match).
Phase 1 — Sign zap requests when supported
Decision tree at "Get invoice" click time
LNURL metadata advertises allowsNostr + nostrPubkey?
├── No → plain LNURL-pay (today's flow)
└── Yes
Tipper has NIP-07 signer available?
├── No → plain LNURL-pay (today's flow)
└── Yes
Sign kind-9734 with the right tags →
?nostr=<encoded> appended to callback →
bolt11 returned + QR rendered as before
The user-visible UX is identical for the picker / invoice view. The only difference is a tiny ⚡ Tracked zap badge next to the amount once we know the zap path is engaged (so a user paying via QR/copy knows their tip will count).
Tag rules per entry point
| Entry point | Tags on the 9734 |
|---|---|
| Profile page Tip chip | p=author, relays, amount. No e, no a. Pure profile zap. |
| Feed card Tip button | p=author, e=design event id, a=<kind>:<author>:<d-tag> if d-tag exists, relays, amount. |
| Design preview Tip button | Same as feed card. |
This lets us aggregate by author OR by design just from filters.
Plumbing changes
openTipModalaccepts an optionaltargetEvent: { id, kind, dTag, authorPubkey }. Profile-page openings omit it; feed-card / preview openings pass it.- New helper
buildZapRequestEvent({ recipient, amount, eventId, kind, dTag, relays, comment })returns the unsigned 9734. - New helper
tryZapPath(metadata, target, amount)returns{ encodedZapRequest, zapPubkey }ornullif any precondition fails. fetchLnurlInvoicegains an optionalnostrparam; when present, appends&nostr=<urlencoded>to the callback.getTipInvoiceFlowcallstryZapPathfirst; on success, signs and includes; on failure, falls through to plain.
What never changes
- The bolt11 we hand the user is identical in structure either way.
- Fallback paths for malformed addresses, CORS errors, min/max range remain exactly as today.
- No new modal states. Just a tiny optional badge.
Out of scope (still) in Phase 1
- Reading 9735s. That's Phase 2.
- Showing the tipper's pubkey to the recipient. NIP-57 receipts already do this naturally if the recipient queries — we don't add UI here.
- Encrypted zaps / private zaps.
Acceptance
- A signed-in user tipping a zap-supporting author (e.g., Wallet of Satoshi) sees the same invoice/QR flow as before — but minutes later a kind-9735 receipt with our zap request inside appears on the relays.
- A signed-out user tipping the same author still gets a working bolt11 — the LN provider returns one even without
nostr=, just no receipt. - A signed-in user tipping a non-zap LN endpoint (
allowsNostrabsent or false) silently falls back — no error, no extra UI noise.
Phase 2 — Aggregate & display
Fetch strategy
- When the community feed renders, batch-query receipts for visible designs:
{ kinds: [9735], '#e': [eventId1, eventId2, …], limit: 500 } - For author totals (profile page), query:
{ kinds: [9735], '#p': [authorPubkey], limit: 500 } - Stream results into a
_zapCacheMap keyed by event id (and a separate one keyed by author for profile totals). - Validate each receipt:
- Parse the embedded
descriptionJSON → must be a kind-9734. - Receipt's
pubkeymust equal the recipient's metadatanostrPubkeyif we know it (we may not, so this check is opportunistic, not required for v1). - Sum
amountfrom the zap request tag (or parse frombolt11as a fallback).
- Parse the embedded
Display
- Feed card: small chip
⚡ 5.2kin the actions row when total > 0. - Profile page: under the avatar, optional line
⚡ 84k sats received. - Design preview: same chip in the preview meta line.
Counts use abbreviated formatting (1.2k, 21k, 1.4M).
Sort
- Feed sort dropdown gets a new option: Most zapped. Sums received within a sliding window (default: forever; could later add 7-day / 30-day).
- Ranking is purely client-side from the visible window of receipts. No global leaderboard.
Caching
- Same TTL as feed reactions (~2 min). Cheap to refetch.
- Soft-mod cache and zap cache live independently. Both keyed by event id.
Acceptance
- A design that has received zaps shows the total on its card and in its preview.
- Sorting the feed by "Most zapped" reorders cards correctly within the loaded set.
- Profile pages show the author's lifetime zap-received total.
Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Tipper not signed in → zap untracked | Show a one-line hint in the tip modal: "Sign in with Nostr to make this tip count toward this design's totals." Optional. |
| LN provider doesn't support zaps | Silent fallback. Don't surface failure as user-facing error — the tip still works. |
| Wallet publishes the 9735 to relays we don't query | Fetch from a broad relay set (use relays tag from the receipt's embedded zap request, plus the viewer's relays). |
| Forged zap receipts (someone publishes a fake 9735) | v1 trusts what we see. v2 adds the pubkey === metadata.nostrPubkey check by caching service pubkeys per recipient. |
| Sum inflated by duplicate receipts | Dedupe receipts by id (event id), not by zap request. |
| bolt11 amount mismatch with zap request amount | Trust the bolt11 amount (it's what actually got paid); zap-request amount is the intent. |
| Querying receipts for 50 designs at once → big subscription | Use existing queryRelays streaming with onEvent; batch in groups of 20 if relays balk. |
| Race between Phase 1 sign + endpoint response | Standard try/catch; on signing failure, fall back to plain flow. |
Phased delivery (concrete)
Phase 1 — Sign zap requests (this PR)
- Add
buildZapRequestEvent,tryZapPath,signNostrEventreuse. - Extend
openTipModalto accept{ targetEvent }. - Thread
targetEventthrough feed-card and preview Tip buttons. - Update
getTipInvoiceFlowto attempt zap path; fall back on any failure. - Tiny
⚡ Tracked zapbadge on the invoice view when zap path engaged.
Ship gate: a tip from a signed-in user to a zap-enabled provider produces a kind-9735 receipt on the recipient's relays within a minute.
Phase 2 — Aggregate and display
_zapCachekeyed by event id and by author.- Fetch receipts during community feed loads and on profile page open.
- Parse + validate + sum.
- Render zap totals on feed cards / preview / profile.
- Add "Most zapped" sort option.
Ship gate: zap totals appear on cards within a session; sort order changes when the new sort is selected.
Open questions
- Should we publish the kind-9734 ourselves before sending to LNURL-pay?
NIP-57 says clients MAY publish it themselves; not required. Recommendation: no — the receipt's
descriptiontag already proves it existed, and double-publishing pollutes relays. - Use a fixed default-zap amount (e.g., 21 sats)? Not v1. Picker already handles this. Default zap amounts can land later as a profile preference.
- Should we surface "you zapped this" on cards (visible only to the zapper)?
Nice but requires querying receipts whose embedded request has
pubkey === me. Cheap; could be a small v2 add. - Verify receipt service pubkey strictly?
Recommend v1 = loose verification (any kind-9735 with a parseable embedded request counts). v2 = strict (cache
metadata.nostrPubkeyper recipient and require receipt.pubkey to match). - Should the picker grow a "comment" box for zap-supporting endpoints?
Many endpoints accept comments via
commentAllowed. v1 = no, to keep UI quiet. v2 = optional.
Acceptance summary
Phase 1
- Profile-page tip flow signs and sends a zap request when supported; plain LNURL-pay otherwise.
- Feed-card tip flow does the same with
eandatags pointing at the design. - Design-preview tip flow same.
- All three entry points fall back silently for non-zap endpoints and signed-out users.
- A
⚡ Tracked zapbadge appears in the invoice view when the zap path engaged.
Phase 2
- Feed cards show
⚡ <total>when receipts exist. - Profile pages show author totals.
- Design preview shows the same chip.
- "Most zapped" sort option works.