Files
Artstr-Studio/docs/ZAP_TRACKING_FEATURE.md
T
thePR0M3TH3ANandClaude Opus 4.7 c9cf28fbb9 Add NIP-57 zap tracking with totals, sort, and auto-close
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]>
2026-05-18 14:46:31 -04:00

242 lines
12 KiB
Markdown

# 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 sats` and 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
1. Make tipping discoverable. If a design has gotten 10k sats, that's a signal future viewers should see.
2. Preserve the existing plain-LNURL-pay flow as a fallback so tips never silently fail.
3. 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 (`anon` tag 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)
```json
{
"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:
```json
{
"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:
```json
{
"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
- `openTipModal` accepts an optional `targetEvent: { 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 }` or `null` if any precondition fails.
- `fetchLnurlInvoice` gains an optional `nostr` param; when present, appends `&nostr=<urlencoded>` to the callback.
- `getTipInvoiceFlow` calls `tryZapPath` first; 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 (`allowsNostr` absent 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 `_zapCache` Map keyed by event id (and a separate one keyed by author for profile totals).
- Validate each receipt:
- Parse the embedded `description` JSON → must be a kind-9734.
- Receipt's `pubkey` must equal the recipient's metadata `nostrPubkey` if we know it (we may not, so this check is opportunistic, not required for v1).
- Sum `amount` from the zap request tag (or parse from `bolt11` as a fallback).
### Display
- **Feed card**: small chip `⚡ 5.2k` in 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)
1. Add `buildZapRequestEvent`, `tryZapPath`, `signNostrEvent` reuse.
2. Extend `openTipModal` to accept `{ targetEvent }`.
3. Thread `targetEvent` through feed-card and preview Tip buttons.
4. Update `getTipInvoiceFlow` to attempt zap path; fall back on any failure.
5. Tiny `⚡ Tracked zap` badge 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
1. `_zapCache` keyed by event id and by author.
2. Fetch receipts during community feed loads and on profile page open.
3. Parse + validate + sum.
4. Render zap totals on feed cards / preview / profile.
5. 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
1. **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 `description` tag already proves it existed, and double-publishing pollutes relays.
2. **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.
3. **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.
4. **Verify receipt service pubkey strictly?**
Recommend v1 = loose verification (any kind-9735 with a parseable embedded request counts). v2 = strict (cache `metadata.nostrPubkey` per recipient and require receipt.pubkey to match).
5. **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 `e` and `a` tags 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 zap` badge 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.