mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The store now supports p: (profile/channel) pointers — every zap tagged to a creator pubkey (direct profile zaps AND zaps to their videos, both carry a `p` tag), from 9735 receipts + verified tallies: - pointerKey accepts "p"; new profilePointer(pubkey) helper. - runBatch batches p: pointers into a #p filter; both folds count #p-tagged events (deduped by payment_hash like a:/e:). - Channel page: a "⚡ N sats zapped" total under the lightning address, wired to the store via js/channelZapTotal.js (own module for channelProfile.js's size budget), hidden until nonzero. Completes the D2 video + profile scope; the bitvid-native zap tally feature is now feature-complete (publish + count + chart + video + profile). Tests: profilePointer/pointerKey + #p counting in tests/zap-totals.test.mjs. Lint (size cap ok) + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
523 lines
28 KiB
Markdown
523 lines
28 KiB
Markdown
# bitvid-native zap tally — dev plan
|
||
|
||
> Status: **IN PROGRESS** (video-zap tally shipped; profile + chart remain) · Created 2026-07-04
|
||
> Scope: (1) a bitvid-published, preimage-verified zap event so "Most Zapped" and
|
||
> the per-card / per-modal zap totals are global, durable, and independent of
|
||
> whether the recipient's Lightning provider publishes NIP-57 receipts; and (2) a
|
||
> second (orange) zaps-over-time line on the per-video Popularity chart, with a
|
||
> legend (red=Views / orange=Zaps) and visible date labels (§5.9).
|
||
|
||
This document is the single source of truth for the change so work stays neat
|
||
across sessions. Read it top to bottom before touching code; the **Task
|
||
checklist** at the end is the ordered work queue.
|
||
|
||
---
|
||
|
||
## 1. Problem & context
|
||
|
||
Zap totals today come from **NIP-57 zap receipts (kind 9735)**, which are
|
||
published by the **recipient's LNURL / Lightning-address server** — not by
|
||
bitvid, not by NWC, not by the sender's wallet. Custodial providers (Strike,
|
||
the current `PLATFORM_LUD16_OVERRIDE`) routinely **do not publish** them. So a
|
||
paid zap can leave **no on-relay record at all**, and:
|
||
|
||
- per-card / modal zap badges show `0 sats` for videos that were really zapped,
|
||
- "Most Zapped" ranks by recency because the metric is empty,
|
||
- there is no global/cross-user tally to display.
|
||
|
||
### What already exists (as of commit `cbc62306`)
|
||
|
||
The zap-totals store (`js/zapTotals.js`) already layers three concerns; this
|
||
plan **adds a fourth source** and leaves the rest intact:
|
||
|
||
1. **Real 9735 receipts** — fetched + summed from relays, deduped by event id
|
||
(`runBatch`). Gold standard when they exist. **Keep.**
|
||
2. **Session optimistic bump** — `ingestLocalZap`, shown instantly on send,
|
||
cleared when a real receipt for the pointer later arrives. **Keep.**
|
||
3. **Durable local ledger** — `localStorage: bitvid:sentZaps:v1`, seeds the
|
||
optimistic layer at construction so the sender's own zaps survive reload.
|
||
Sender-only, per-device, not global. **Keep** (becomes the offline/instant
|
||
overlay).
|
||
4. **NEW: bitvid-native zap tally event** — this plan. Global, durable,
|
||
cross-user, cryptographically verifiable, wallet-independent.
|
||
|
||
### The insight that makes this sound (not a hack)
|
||
|
||
A NIP-57 receipt is trusted because the recipient's server signs it — but the
|
||
reason the *server* can attest is that it holds the **preimage** (proof the
|
||
invoice settled). **The payer holds the same preimage** after an NWC
|
||
`pay_invoice`. And the invoice's **description hash commits to the zap request**
|
||
(`description_hash(bolt11) === sha256(zapRequestJSON)`), and the zap request
|
||
contains the **video/profile pointer + amount**. So a payer-signed event
|
||
carrying `{ zapRequest, bolt11, preimage }` is verifiable by *any* client with
|
||
the same cryptographic strength as a 9735 receipt:
|
||
|
||
1. `sha256(preimage) === payment_hash(bolt11)` → the invoice was actually paid.
|
||
2. `description_hash(bolt11) === sha256(zapRequestJSON)` → that payment was for
|
||
*this* zap request (binds it to the pointer + amount — replay-proof: a stolen
|
||
preimage cannot be reused for a different video).
|
||
|
||
bitvid **already implements both halves** of the bolt11 decode in
|
||
`js/payments/zapReceiptValidator.js` (`extractDescriptionHashFromBolt11`, bech32
|
||
tagged-field walker) — we extend it to also pull `payment_hash` and add one
|
||
sha256 check. No new crypto dependency.
|
||
|
||
---
|
||
|
||
## 2. Goals & non-goals
|
||
|
||
**Goals**
|
||
- A zap the user sends through bitvid produces a **published, verifiable** event
|
||
so **other bitvid users** see it and it ranks on Most Zapped.
|
||
- Survives reload / new device (it's on relays).
|
||
- Works for **video zaps and profile zaps**.
|
||
- Anti-spam by construction (must actually pay to inflate; misattribution
|
||
blocked by the description-hash binding).
|
||
- Never double-counts against a real 9735 receipt for the same payment.
|
||
- **Popularity chart** (§5.9): a second **orange zaps-over-time line** beside the
|
||
red views line, a **legend**, and **visible date labels** so the chart is
|
||
self-explanatory.
|
||
|
||
**Non-goals**
|
||
- Interop with other Nostr clients' zap UIs. bitvid **cannot** mint a valid
|
||
NIP-57 9735 (that must be signed by the recipient's server), so this event is
|
||
**bitvid-specific**; other clients ignore it. We still *count* real 9735s
|
||
wherever they exist, so bitvid stays a good NIP-57 citizen on the read side.
|
||
- Replacing the local ledger or the 9735 path — this is an **additional** source.
|
||
- Server-side aggregation. bitvid is a static client; counting stays client-side
|
||
(same as view counts).
|
||
|
||
---
|
||
|
||
## 3. The new event
|
||
|
||
A **regular, non-replaceable** event (one per successful zap share; they sum),
|
||
signed by the **payer's active signer** (the logged-in user). Modeled on how the
|
||
kind-9735 receipt is shaped so our existing validator logic transfers.
|
||
|
||
```
|
||
kind: ZAP_TALLY_KIND // see Open decision D1 (proposed 9736)
|
||
pubkey: <payer pubkey> // the sender's active signer
|
||
created_at: <now>
|
||
content: "" // (optional: mirror the zap comment)
|
||
tags:
|
||
["p", <recipientPubkey>] // zapped party
|
||
["e", <videoEventId>] (video zaps) // from the zap request
|
||
["a", "30078:<pubkey>:<dTag>"] (video zaps) // stable coordinate
|
||
["amount", "<msats>"] // echoes the zap request
|
||
["bolt11", "<invoice>"] // carries payment_hash + description_hash
|
||
["preimage", "<preimage>"] // proof of settlement
|
||
["description", <zapRequestJSON>] // the signed 9734 (NIP-57 shape)
|
||
["client", "bitvid"] // provenance marker
|
||
["t", "zap"]
|
||
```
|
||
|
||
- For **profile-only** zaps there is no `e`/`a`; the `p` tag is the pointer.
|
||
- `description`, `bolt11`, `preimage`, `amount` are **exactly** what a 9735 would
|
||
carry, so verification reuses the 9735 validator almost verbatim.
|
||
- The event is **self-verifying**: no need to trust the `amount` tag — the
|
||
authoritative amount is derived from the bolt11 (as the 9735 path already does
|
||
via `nip57.getSatoshisAmountFromBolt11`).
|
||
|
||
### Verification algorithm (a valid tally event)
|
||
1. `kind === ZAP_TALLY_KIND` and has a `bolt11` + `preimage` tag.
|
||
2. `sha256(hexToBytes(preimage)) === payment_hash` extracted from the bolt11.
|
||
3. `description_hash` from the bolt11 `=== sha256(utf8(description tag JSON))`.
|
||
*(NIP-57's own check — already the spine of `zapReceiptValidator`.)*
|
||
4. Sats = `getSatoshisAmountFromBolt11(bolt11)` (authoritative, not the tag).
|
||
5. Pointer keys = the `a`/`e` (and `p`) tags **from the embedded zap request**,
|
||
not the outer event's tags (defense against a mangled wrapper).
|
||
|
||
Steps 2–3 are the anti-spam core. A faker must pay a real invoice, and cannot
|
||
retarget a stolen preimage to a different video (the description hash binds it).
|
||
|
||
---
|
||
|
||
## 4. Counting, dedup & reconciliation
|
||
|
||
The store gains a **fourth input** folded into the same `totals` map. The
|
||
critical new concern is **cross-source dedup by `payment_hash`** so a single
|
||
payment that produces *both* a 9735 receipt *and* a bitvid tally is counted once.
|
||
|
||
Per-pointer entry becomes:
|
||
```
|
||
{
|
||
sats, // real receipts (9735) + verified tallies, deduped
|
||
optimisticSats, // durable ledger + session bumps (unchanged)
|
||
receiptIds: Set, // dedup by EVENT id (unchanged, for 9735)
|
||
paymentHashes: Set, // NEW: dedup across sources by payment_hash
|
||
fetchedAt,
|
||
}
|
||
```
|
||
|
||
Folding rules in `runBatch` (which now queries **both** kinds — see §5):
|
||
- **9735 receipt**: if its `payment_hash` (from its bolt11) is already in
|
||
`paymentHashes` for the pointer → skip; else add sats, record event id +
|
||
payment_hash. 9735 is **preferred**: if a tally with the same payment_hash was
|
||
already counted, the 9735 replaces it (same sats — no delta — but marks the
|
||
payment as recipient-attested).
|
||
- **bitvid tally**: run the §3 verification. If it fails → ignore. If its
|
||
`payment_hash` is already counted (by a 9735 or an earlier tally) → skip. Else
|
||
add the bolt11-derived sats, record payment_hash.
|
||
- **optimistic/ledger prune** (existing behavior, generalized): when a pointer
|
||
gains *any* real counted zap (9735 **or** verified tally) whose payment_hash
|
||
matches nothing we optimistically bumped, we still clear the pointer's
|
||
`optimisticSats` + ledger entry, because the relay is now authoritative for it.
|
||
(Same "relay wins" rule already shipped; just triggered by tallies too.)
|
||
|
||
Result: exactly-once counting per real payment, 9735-preferred, tally as the
|
||
fallback, local ledger as the instant/offline overlay.
|
||
|
||
---
|
||
|
||
## 5. Code changes (file by file)
|
||
|
||
### 5.1 `js/nostrEventSchemas.js` — new event type + builder
|
||
- Add `NOTE_TYPES.ZAP_TALLY = "zapTally"` and a schema entry (kind
|
||
`ZAP_TALLY_KIND`, append-tags, content=text) next to `ZAP_REQUEST`.
|
||
- Add `buildZapTallyEvent({ pubkey, created_at, recipientPubkey, eventId,
|
||
coordinate, amountMsats, bolt11, preimage, zapRequestJSON, comment })` mirroring
|
||
`buildZapRequestEvent` (§2270). Returns the unsigned event; caller signs.
|
||
- Export the constant `ZAP_TALLY_KIND` (Open decision D1).
|
||
|
||
### 5.2 `js/payments/zapReceiptValidator.js` — extract payment_hash + verify
|
||
- Generalize the existing bolt11 tagged-field walker
|
||
(`extractDescriptionHashFromBolt11`, §164) into a helper that can also return
|
||
the **payment_hash** field (bolt11 tag `p`, 256-bit) — e.g.
|
||
`extractBolt11Fields(bolt11) -> { descriptionHash, paymentHash }`. Keep the old
|
||
function as a thin wrapper (no behavior change to the 9735 path).
|
||
- Add `verifyPaymentPreimage(preimage, paymentHash)` = `sha256(hexToBytes(
|
||
preimage)) === paymentHash` (use `@noble/hashes/sha256` already pulled via
|
||
nostr-tools, or WebCrypto `subtle.digest`).
|
||
- Add `verifyBitvidZapTally(event, { tools })` implementing §3 steps 1–5,
|
||
returning `{ ok, sats, pointerTags, paymentHash }`. Reuses the description-hash
|
||
check the 9735 validator already performs.
|
||
|
||
### 5.3 `js/payments/zapSplit.js` — surface bolt11 + preimage on the receipt
|
||
- The per-share `receipt` object (§481) already carries `invoice`, `payment`,
|
||
`zapRequest`, `amount`. Add the resolved **preimage** explicitly:
|
||
`receipt.preimage = payment?.result?.preimage || null` (the NWC `pay_invoice`
|
||
result is `payment.result` per `nwcClient.js` §1155; NIP-47 puts the preimage
|
||
there). No flow change — just expose it so the controller can publish a tally.
|
||
- (bolt11 is already on `receipt.invoice.invoice`.)
|
||
|
||
### 5.4 `js/ui/zapController.js` — publish tallies on success
|
||
- The success terminal point already fires `this.callbacks.onZapSuccess({ video,
|
||
sats })` (added commit `af09505f`). **Extend the payload** to include the
|
||
per-share proof needed to build tallies:
|
||
`onZapSuccess({ video, sats, shares: receipts.map(r => ({ recipientType,
|
||
amountSats: r.amount, bolt11: r.invoice?.invoice, preimage: r.preimage,
|
||
zapRequest: r.zapRequest })) })`.
|
||
- Only include shares whose `status === "success"` **and** have a `preimage`
|
||
(payment settled). Unvalidated-on-relay is fine — that's exactly the case this
|
||
feature exists for.
|
||
- The **retry** path (`executeRetry`, §226) should fire the same hook for the
|
||
shares it settles (today it doesn't call onZapSuccess at all — wire it).
|
||
|
||
### 5.5 `js/ui/ModalManager.js` — build, sign, publish, ingest
|
||
- The `onZapSuccess` callback (added `af09505f`, currently just calls
|
||
`ingestLocalVideoZap`) becomes:
|
||
1. Keep the instant `ingestLocalVideoZap(pointer, sats)` (optimistic overlay).
|
||
2. For each successful share with a preimage, call a new
|
||
`app.publishZapTally({ video, share })` (thin app method) that:
|
||
- derives the pointer via `app.deriveVideoPointerInfo(video)` (same key the
|
||
badge uses),
|
||
- builds the tally via `buildZapTallyEvent(...)`,
|
||
- signs with the active signer (the user is logged in to have zapped),
|
||
- publishes via the existing relay-publish path
|
||
(`signAndPublishEvent` / `publishEventToRelays`, using
|
||
`assertAnyRelayAcceptedOrUnconfirmed` — an all-timeout publish is a soft
|
||
success like #49),
|
||
- on success, `store.ingestVerifiedZap(pointer, sats, paymentHash)` so the
|
||
local view is authoritative immediately (and the durable ledger entry can
|
||
be upgraded/cleared — see §4).
|
||
- Publishing is **best-effort and non-blocking**: a failed tally publish must not
|
||
affect the payment UX (the sats already moved). Log + rely on the local ledger.
|
||
|
||
### 5.6 `js/zapTotals.js` — count tallies as a fourth source
|
||
- `runBatch`: add a second filter set querying `kinds: [ZAP_TALLY_KIND]` by the
|
||
same `#a`/`#e` pointer batches (and `#p` for profile pointers — see Open
|
||
decision D2). One `manager.list` call can carry both kinds' filters.
|
||
- Fold results per §4: verify each tally (`verifyBitvidZapTally`), dedup by
|
||
`payment_hash`, prefer 9735.
|
||
- Add `paymentHashes` to entries; thread it through the existing dedup.
|
||
- Add `ingestVerifiedZap(pointer, sats, paymentHash)` — like `ingestLocalZap`
|
||
but records the payment_hash and writes to `sats` (authoritative) rather than
|
||
`optimisticSats`, so the sender's own tally isn't re-counted when it comes back
|
||
from the relay (dedup by payment_hash handles the echo).
|
||
- Expose `verifyBitvidZapTally` injection point for tests (mirror `getTools`).
|
||
|
||
### 5.7 `js/constants.js` — feature flag
|
||
- Add `FEATURE_ZAP_TALLY` (default **off** for the first ship; flip on after a
|
||
soak). Gate both **publishing** (5.5) and **counting** (5.6) behind it so it's
|
||
a clean rollback and so a half-rolled-out network doesn't miscount.
|
||
|
||
### 5.8 Profile zaps (optional, same primitives)
|
||
- Channel/profile zap surfaces that call the zap flow pass a `p`-only pointer;
|
||
the store keys by `p:<pubkey>`. The card/modal badges are video-scoped today,
|
||
so a profile zap total would surface on channel pages — a follow-up UI task,
|
||
not required for the video path. Track as D2.
|
||
|
||
### 5.9 Popularity chart: second (orange) zaps line + legend + date axis
|
||
|
||
The per-video **Popularity** modal (`js/viewCountChart.js`, opened from the ⋯
|
||
menu — feature #26) currently plots ONE cumulative **views** series and, notably,
|
||
puts the date range only in the SVG `aria-label` — there is **no visible legend
|
||
and no visible date labels**. This sub-feature adds a second series and the
|
||
missing chart furniture. It can ship in two stages: the chart plumbing works
|
||
with today's on-relay zap data (real 9735 receipts) even before the tally event
|
||
(§3) exists; tallies simply make the orange line populated for Strike-style zaps.
|
||
|
||
**Current shape to build on**
|
||
- `buildViewCountTimeSeries(events)` → `{ series:[{bucketStart, count,
|
||
cumulative}], total }`, bucketed per day (`VIEW_CHART_WINDOW_SECONDS`).
|
||
- `buildViewCountChartSvg(doc, series, {width,height})` — one `<polyline>` +
|
||
area, colored by `svg.classList.add("text-accent")` + `stroke="currentColor"`
|
||
(this instance's `--color-accent` is red). `pad.bottom:18` already reserves
|
||
space for an axis strip that today draws nothing visible.
|
||
- The modal (`openPopularityModal`) fetches view events
|
||
(`listVideoViewEventsWithDefaultClient` + `subscribe…`), dedupes by id, and
|
||
re-renders on a 250ms debounce.
|
||
|
||
**Changes**
|
||
|
||
1. **Zap time-series data.** Add a `buildZapSatsTimeSeries(events, { tools })`
|
||
next to `buildViewCountTimeSeries`: bucket **cumulative SATS** per day from
|
||
the union of **9735 receipts** and **verified bitvid tallies** (§3), deduping
|
||
by **payment_hash** (reuse the store's rule so the chart and the badge agree).
|
||
Amount per event = `getSatoshisAmountFromBolt11` (authoritative), created_at =
|
||
the receipt/tally timestamp. Output shape mirrors the view series:
|
||
`{ series:[{bucketStart, sats, cumulative}], total }`.
|
||
|
||
2. **Zap event source.** Add `listVideoZapEventsWithDefaultClient(pointer)` +
|
||
`subscribeVideoZapEvents…` (mirror the view-event facade) that list/subscribe
|
||
`kinds:[9735, ZAP_TALLY_KIND]` for the pointer's `#a`/`#e`. The modal fetches
|
||
BOTH streams (views + zaps) and re-renders on the same debounce. Reuse the
|
||
store's verify + payment_hash dedup helpers (§5.6) so there is one code path
|
||
for "what counts."
|
||
|
||
3. **Dual-series renderer.** Generalize `buildViewCountChartSvg` into
|
||
`buildPopularityChartSvg(doc, { views, zaps }, opts)` that draws BOTH
|
||
cumulative lines on a shared **time** X axis, each with its **own Y scale**
|
||
(views in counts, zaps in sats are different units — independent per-series
|
||
scaling shows the trend/shape honestly without a misleading shared axis; a
|
||
dual labeled Y-axis is an option — see D5). Colors are token-based:
|
||
- views line/area → `text-accent` / `currentColor` (red) — unchanged.
|
||
- zaps line/area → the **`--color-zap`** orange token added for the badges.
|
||
The token lint forbids raw colors and `var()` in attributes, so add a
|
||
small utility class (e.g. `.text-zap { color: var(--color-zap); }` in
|
||
`css/tailwind.source.css`) and wrap the zap series in a `<g class="text-zap">`
|
||
using `stroke="currentColor"` — same pattern the views line already uses.
|
||
Draw the zap area at a low opacity like the views area so overlaps stay legible.
|
||
|
||
4. **Legend.** Below/above the chart, a small two-item legend: a red swatch
|
||
"Views" and an orange swatch "Zaps (sats)". Plain DOM (`el(doc,…)` helper),
|
||
token colors via the `.text-accent` / `.text-zap` classes. Include it in the
|
||
modal so both series are labeled; keep it out of the tiny inline card sparkline
|
||
if one is ever added.
|
||
|
||
5. **Visible date axis.** Draw X-axis tick labels (not just `aria-label`): at
|
||
minimum the first and last bucket dates, ideally 3–4 evenly spaced ticks, using
|
||
the existing `formatDay(seconds)` formatter, as `<text>` nodes in the reserved
|
||
`pad.bottom` strip (token text color, `text-2xs`). Add light vertical gridlines
|
||
at the ticks if it reads cleanly. This answers "users don't know what they're
|
||
looking at."
|
||
|
||
6. **Headline + copy.** The modal headline stays the authoritative view count;
|
||
add a second line for the zap total (from `store.getSnapshot`, so it matches
|
||
the badge) — e.g. "1.2K views · 4.3K sats zapped". Update the footer note to
|
||
"Public view + zap data · updates as more load."
|
||
|
||
**Empty/degenerate states** (mirror the view chart): no zaps → no orange line +
|
||
a greyed "Zaps (sats)" legend item (or omit); a single zap bucket → flat baseline
|
||
segment like the single-view case.
|
||
|
||
**Independence:** this renders whatever zap events exist on relays. Before the
|
||
tally event ships, the orange line reflects only real 9735s (often empty for
|
||
Strike); after, it reflects tallies too. The local durable ledger (badge-only,
|
||
no per-event timestamps) does **not** feed the time chart — another reason the
|
||
published tally matters for a meaningful zaps-over-time line.
|
||
|
||
---
|
||
|
||
## 6. Data flow (end to end)
|
||
|
||
```
|
||
User clicks Zap (video modal)
|
||
→ zapController.sendZap → runZapAttempt → app.splitAndZap (zapSplit)
|
||
→ per share: build 9734 zap request → LNURL callback → invoice
|
||
→ NWC pay_invoice → payment.result.preimage
|
||
→ receipt { amount, invoice.invoice(bolt11), preimage, zapRequest, status }
|
||
→ success terminal:
|
||
onZapSuccess({ video, sats, shares:[{amountSats,bolt11,preimage,zapRequest}] })
|
||
→ ingestLocalVideoZap(pointer, sats) // instant + durable ledger
|
||
→ for each settled share: app.publishZapTally // build→sign→publish
|
||
→ store.ingestVerifiedZap(pointer, sats, paymentHash)
|
||
|
||
Any client later, on a grid/modal:
|
||
store.request(pointer) → runBatch queries kinds [9735, ZAP_TALLY_KIND] by #a/#e
|
||
→ 9735: sum (dedup by event id + payment_hash)
|
||
→ tally: verifyBitvidZapTally → sum (dedup by payment_hash, 9735-preferred)
|
||
→ badge + Most-Zapped metric reflect the global, verified total
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Security & edge cases
|
||
|
||
- **Fake inflation** — blocked: must pay a real invoice (preimage check), and the
|
||
sats are bolt11-derived, not tag-claimed. Inflation costs the attacker real
|
||
sats.
|
||
- **Preimage replay to another video** — blocked: `description_hash(bolt11)`
|
||
commits to the zap request, which contains the pointer; a retargeted event
|
||
fails step 3.
|
||
- **Duplicate publish / relay echo** — dedup by `payment_hash` across all
|
||
sources; the sender's own tally coming back from a relay is a no-op.
|
||
- **Self-zapping** — possible but only adds sats the attacker actually spent;
|
||
economically pointless. WoT/moderation already available if we later want to
|
||
weight or hide (mirror the view-count treatment).
|
||
- **Amount mismatch** — ignore the `amount` tag; always use the bolt11 amount.
|
||
- **Missing preimage** (wallet returns none) — skip the tally, fall back to the
|
||
local ledger; the sender still sees their zap, it just isn't globally attested.
|
||
- **REQ load** — one extra kind in the existing batched receipt fetch (no new
|
||
round trip); one publish per zap share (small, non-blocking).
|
||
- **Privacy** — a tally publicly links the payer pubkey → video + amount. This is
|
||
the same disclosure as a NIP-57 zap (zaps are public by design), but note it in
|
||
the UI copy. The feature flag lets us default-off until we're comfortable.
|
||
|
||
---
|
||
|
||
## 8. Testing plan
|
||
|
||
Unit (node:test, deterministic, no network):
|
||
- `zap-tally-verify.test.mjs` — `verifyBitvidZapTally`: valid passes; bad preimage
|
||
fails (step 2); mismatched description hash fails (step 3, replay); amount taken
|
||
from bolt11 not the tag. Use fixed bolt11/preimage vectors.
|
||
- `zap-tally-schema.test.mjs` — `buildZapTallyEvent` emits the exact tag set for
|
||
video vs profile pointers.
|
||
- Extend `zap-totals.test.mjs` — cross-source dedup: a 9735 and a tally with the
|
||
same payment_hash count once; a tally with a new payment_hash adds; an invalid
|
||
tally is ignored; 9735-preferred.
|
||
- `zap-controller` / `ModalManager` wiring — onZapSuccess forwards per-share
|
||
proof; publishZapTally is best-effort (a publish throw doesn't break the success
|
||
path); flag-off short-circuits publish + count.
|
||
|
||
Live (Playwright harness, mock relay — pattern from this session's diagnostics):
|
||
- Seed a verified tally event → it counts + ranks; seed an invalid one → ignored.
|
||
- End-to-end with a stubbed wallet returning a known preimage → publish → re-fetch
|
||
→ badge reflects it after a simulated reload.
|
||
|
||
Gates: `npm run lint`, `npm run build`, targeted suites. New tests carry
|
||
`test_integrity_note` blocks (Dark Factory rules).
|
||
|
||
---
|
||
|
||
## 9. Rollout
|
||
|
||
1. Land behind `FEATURE_ZAP_TALLY = false` on `unstable`; verify counting logic
|
||
with seeded events (no publishing yet in the wild).
|
||
2. Flip publishing on for `unstable`, dogfood real zaps, confirm cross-user
|
||
visibility on a second browser.
|
||
3. Soak on `beta`; watch for miscounts / dedup gaps.
|
||
4. Default-on to `main` once the payment_hash dedup is proven against real 9735s.
|
||
|
||
Rollback: flip the flag off (stops publishing + counting); already-published
|
||
tallies simply stop being counted. No data migration.
|
||
|
||
---
|
||
|
||
## 10. Decisions (RESOLVED 2026-07-04)
|
||
|
||
- **D1 — event kind → ADDRESSABLE `ZAP_TALLY_KIND = 30081`, `d = payment_hash`.**
|
||
Chosen: a higher, app-specific kind in bitvid's own family (30078 video,
|
||
30079 view, **30081** zap tally). Addressable (30000–39999) with the
|
||
per-payment `payment_hash` as the `d` tag makes each real payment map to
|
||
exactly ONE canonical event — relay-level idempotency; a re-publish replaces
|
||
rather than duplicates. Still queried by its `#a`/`#e`/`#p` tags like a 9735.
|
||
Reserve the kind in `docs/nostr-event-schemas.md`.
|
||
- **D2 — scope → VIDEO + PROFILE zaps in v1.** Video path drives the badges/Most
|
||
Zapped; profile path reuses the same primitives with a `p`-only pointer and a
|
||
channel-page total (new small UI surface). Both ship in v1.
|
||
- **D3 — publish relays → user's WRITE relays** (same path as video publishing).
|
||
The store queries `client.relays` (a superset incl. write relays); verify at
|
||
wiring time.
|
||
- **D4 — publishing → ON BY DEFAULT.** `FEATURE_ZAP_TALLY` still exists for
|
||
rollback but defaults **true**; every successful zap publishes a tally. Keep a
|
||
short one-line disclosure in the zap UI ("zaps are publicly attributed on
|
||
bitvid") since it's honest and cheap, but no opt-in gate.
|
||
- **D5 — chart Y-axis → INDEPENDENT PER-SERIES scaling.** Each cumulative line
|
||
fills the height against its own max; the legend names the two units. No dual
|
||
labeled Y-axis.
|
||
|
||
---
|
||
|
||
## 11. Task checklist (ordered — pick up here across sessions)
|
||
|
||
- [x] **D1** `ZAP_TALLY_KIND = 30081` (addressable, d=payment_hash) + constant +
|
||
`docs/nostr-event-schemas.md` row. (commit a6b2996f)
|
||
- [x] `constants.js`: `FEATURE_ZAP_TALLY` (default ON per D4) + runtime plumbing.
|
||
- [x] `nostrEventSchemas.js`: `NOTE_TYPES.ZAP_TALLY` schema + `buildZapTallyEvent`.
|
||
- [x] `zapReceiptValidator.js`: `extractBolt11Fields`, `verifyPaymentPreimage`,
|
||
`verifyBitvidZapTally`. Tests incl. a real amount-bearing bolt11 e2e.
|
||
- [x] `zapSplit.js`: expose `receipt.preimage`. (commit 41e533e6)
|
||
- [x] `zapController.js`: `onZapSuccess` forwards per-share proof.
|
||
*(Retry path still TODO — see below.)*
|
||
- [x] `zapTallyPublisher.js` (buildTallyFromShare + publishZapTallies) +
|
||
`ModalManager.js` wiring: build→sign→publish (best-effort, flag-gated).
|
||
No `ingestVerifiedZap` needed — the store's existing "relay wins" prune
|
||
reconciles the optimistic ledger when the published tally is fetched back.
|
||
- [x] `zapTotals.js`: query `[9735, ZAP_TALLY_KIND]`, verify + dedup by
|
||
payment_hash (`paymentHashes` set), flag-gated counting. (commit 025ab348)
|
||
- [x] Tests: verify, schema, publisher, cross-source dedup, flag-off, e2e verify.
|
||
- [x] `lint` + `build` + suites green; pushed to `unstable`.
|
||
- [ ] **Remaining v1 work:**
|
||
- [x] **Profile-zap counting DONE:** pointerKey/store support `p:`
|
||
pointers (profilePointer(pubkey)); runBatch queries `#p` and both
|
||
folds count `#p`-tagged 9735s + tallies. Channel page shows a
|
||
"⚡ N sats zapped" total (js/channelZapTotal.js, hidden until
|
||
nonzero). Tests in tests/zap-totals.test.mjs. This CLOSES the zap
|
||
tally feature (D2 video + profile).
|
||
- [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess`
|
||
so retried shares publish tallies too. (commit afed041f)
|
||
- [x] **Popularity chart §5.9 DONE:** orange zaps-over-time line beside
|
||
the red views line, legend (Views/Zaps), visible date-axis ticks,
|
||
and a "· N sats zapped" headline. buildZapSatsTimeSeries (dedup by
|
||
payment_hash) + buildPopularityChartSvg + zapEventsFacade. Tests:
|
||
tests/zap-time-series-chart.test.mjs.
|
||
- [ ] Live end-to-end on unstable (real zap → tally on relay → second
|
||
browser counts it); rollout per §9.
|
||
|
||
**Popularity chart (§5.9 — can start once the store's verify + dedup helpers
|
||
exist; the renderer/legend/axis parts have no dependency on publishing tallies):**
|
||
- [ ] `.text-zap` utility in `css/tailwind.source.css`.
|
||
- [ ] `viewCountChart.js`: `buildZapSatsTimeSeries` (bucket cumulative sats,
|
||
verify + payment_hash dedup).
|
||
- [ ] Zap-event facade: `listVideoZapEventsWithDefaultClient` +
|
||
`subscribeVideoZapEvents…` (kinds `[9735, ZAP_TALLY_KIND]`).
|
||
- [ ] Generalize `buildViewCountChartSvg` → `buildPopularityChartSvg({views,zaps})`:
|
||
two token-colored lines, per-series Y (D5), visible X date ticks via
|
||
`formatDay`, legend (Views=red / Zaps=orange), second headline line.
|
||
- [ ] Wire both fetches + debounced re-render in `openPopularityModal`; empty/
|
||
single-bucket states for the zap line.
|
||
- [ ] Tests: `zap-time-series.test.mjs` (bucketing + dedup); chart-svg test
|
||
(two `<polyline>`s, legend nodes, date-tick `<text>` present).
|
||
|
||
---
|
||
|
||
## 12. Relationship to existing work (so nothing regresses)
|
||
|
||
- `js/zapTotals.js` — extend, don't rewrite. The array/object `pointerKey` fix,
|
||
the always-visible badge, optimistic bump, durable ledger, and "relay wins"
|
||
prune all stay; the tally is a new counted source folded into the same map.
|
||
- `js/app/modalCoordinator.js` / `js/ui/views/videoCardZapTotals.js` — **no
|
||
change**; they render `store.getSnapshot`, which now includes verified tallies.
|
||
- `js/feedEngine/mostZappedFeed.js` — **no change**; its metric is
|
||
`requestVideoZapTotal`, which now reflects tallies.
|
||
- The NIP-57 `zapReceiptValidator` 9735 path — untouched behavior; we only add
|
||
helpers and reuse its bolt11 decode.
|
||
- `js/viewCountChart.js` (§5.9) — the single view series stays; we generalize the
|
||
SVG builder to two series and add the legend + visible date axis the view chart
|
||
never had. The `--color-zap` token (already added for the badges) is reused for
|
||
the orange zap line, so cards, modal badge, and chart all share one zap color.
|
||
```
|