diff --git a/components/profile-modal.html b/components/profile-modal.html index 39d716a6..a4ee0f30 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -62,6 +62,35 @@ + + +
Trending + + + Most Zapped + Manage
+ +
Provider diff --git a/components/video-modal.html b/components/video-modal.html index f04c6704..6eaafa51 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -211,6 +211,16 @@ –
+
diff --git a/config/instance-config.js b/config/instance-config.js index 61cb1b96..1959fd2a 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -191,7 +191,10 @@ export const PLATFORM_FEE_PERCENT = 30; * Super Admin profile publishing a `lud16` so bitvid still knows where to route * fees when they are enabled. */ -export const PLATFORM_LUD16_OVERRIDE = "adammalin@strike.me"; +// coinos: Nostr-native, publishes NIP-57 zap receipts, reliable routing, +// 1-sat minimum. Replaced adammalin@strike.me, whose LNURL was flaky and never +// published receipts, causing platform-share pay timeouts + missing zap tallies. +export const PLATFORM_LUD16_OVERRIDE = "a4a6b5849bc917b3befd5c81@coinos.io"; /** * Optional list of relays to seed new sessions with instead of the defaults. diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 015e13c5..4392010a 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2904,6 +2904,98 @@ transform-origin: top center; } + /* Zap total on video cards (#47 follow-up): orange, pushed to the row's end + so views/comments stay left-aligned and sats sit right-aligned. Hidden + (via the utility class) until a nonzero total is known. */ + .video-card__zaps { + color: var(--color-zap); + margin-inline-start: auto; + } + + .video-card__zaps .zap-total-text { + font-variant-numeric: tabular-nums; + } + + /* Same treatment in the player modal: orange sats on the far side of the + meta row (the row is justify-between, views stay left). */ + .video-modal__zaps { + color: var(--color-zap); + font-variant-numeric: tabular-nums; + } + + /* #43: always-visible active-identity pill. Rendered after the header in + flow and pulled up with a negative block-start margin so it sits half-on / + half-off the header divider — every pane shows who you're acting as. */ + .profile-modal__identity { + align-self: center; + display: inline-flex; + align-items: center; + gap: var(--space-xs); + max-inline-size: min(80%, calc(var(--profile-modal-active-min-width) * 2)); + padding: var(--space-3xs); + padding-inline-end: var(--space-md); + border-radius: var(--radius-full); + border: var(--border-width-hairline) solid var(--color-border-translucent); + background-color: var(--color-panel); + color: var(--color-text); + box-shadow: var(--shadow-md); + position: relative; + z-index: 2; + margin-block-start: calc(-1 * var(--space-sm)); + margin-block-end: calc(-1 * var(--space-3xs)); + cursor: pointer; + text-align: start; + } + + .profile-modal__identity.hidden { + display: none; + } + + .profile-modal__identity:hover { + border-color: var(--color-accent); + } + + .profile-modal__identity-avatar { + display: flex; + inline-size: var(--space-xl); + block-size: var(--space-xl); + flex-shrink: 0; + overflow: hidden; + border-radius: var(--radius-full); + border: var(--border-width-hairline) solid var(--color-accent); + background-color: var(--color-surface); + } + + .profile-modal__identity-avatar img { + inline-size: 100%; + block-size: 100%; + object-fit: cover; + } + + .profile-modal__identity-copy { + display: flex; + flex-direction: column; + min-inline-size: 0; + } + + .profile-modal__identity-kicker { + font-size: var(--font-size-3xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: var(--tracking-extra-wide); + color: var(--color-accent); + line-height: var(--line-height-tight); + } + + .profile-modal__identity-name { + font-size: var(--font-size-sm); + font-weight: 600; + line-height: var(--line-height-tight); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .profile-modal__active-profile { @apply focus-ring flex flex-wrap items-center gap-4 p-4; } diff --git a/css/tokens.css b/css/tokens.css index 7cc22a09..d874fc9c 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -509,6 +509,8 @@ --color-accent: rgb(99 102 241); --color-accent-strong: rgb(79 70 229); --color-accent-pressed: rgb(67 56 202); + /* Zap totals (sats) — bitcoin orange, darker for light-bg contrast. */ + --color-zap: rgb(217 119 6); --color-accent-override: rgb(84 0 17); --color-accent-strong-override: rgb(254 0 50); --color-accent-pressed-override: rgb(169 0 33); @@ -696,6 +698,8 @@ --color-accent: rgb(129 140 248); --color-accent-strong: rgb(99 102 241); --color-accent-pressed: rgb(79 70 229); + /* Zap totals (sats) — bitcoin orange. */ + --color-zap: rgb(247 147 26); --color-accent-override: rgb(84 0 17); --color-accent-strong-override: rgb(254 0 50); --color-accent-pressed-override: rgb(169 0 33); diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 04e6346c..9c4198a5 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -196,6 +196,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema | Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p', ]` | NIP-04 encrypted ciphertext | | Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', ]` plus optional `['e', ]`, `['a', ]`, `['amount', ]`, `['lnurl', ]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note | | Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', ]`, `['description', ]`, `['p', ]` plus optional `['e', ]` and `['a', ]` | Empty content; receipts are published by the recipient's LNURL server | +| Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', ]`, `['p', ]`, optional `['e', ]`/`['a', ]`, `['amount', ]`, `['bolt11', ]`, `['preimage', ]`, `['description', ]`, `['client', 'bitvid']`, `['t', 'zap']` | bitvid-native, payer-signed, **preimage-verified** stand-in for a 9735 when the recipient's wallet doesn't publish one. Verified via `sha256(preimage)==payment_hash` + `description_hash==sha256(zapRequest)`. Counted alongside 9735s (deduped by `payment_hash`). See `docs/zap-tally-plan.md`. | | HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', ]`, `['method', ]`, `['payload', ]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) | | Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', , ]`, `['p', , ]`, and optional `['t', ]` | Plaintext report reason | diff --git a/docs/zap-tally-plan.md b/docs/zap-tally-plan.md new file mode 100644 index 00000000..068dbb1a --- /dev/null +++ b/docs/zap-tally-plan.md @@ -0,0 +1,515 @@ +# 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: // the sender's active signer +created_at: +content: "" // (optional: mirror the zap comment) +tags: + ["p", ] // zapped party + ["e", ] (video zaps) // from the zap request + ["a", "30078::"] (video zaps) // stable coordinate + ["amount", ""] // echoes the zap request + ["bolt11", ""] // carries payment_hash + description_hash + ["preimage", ""] // proof of settlement + ["description", ] // 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:`. 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 `` + + 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 `` + 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 `` 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:** + - [ ] **Profile-zap counting:** the store queries `#a`/`#e` only — add + `#p` batches so a profile-only tally (no video pointer) is fetched, + and a channel-page total UI surface (D2 chose video+profile). + - [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess` + so retried shares publish tallies too. (commit afed041f) + - [ ] **Popularity chart §5.9:** orange zaps line + legend + date axis. + - [ ] 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 ``s, legend nodes, date-tick `` 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. +``` diff --git a/js/app.js b/js/app.js index 592c0fcb..7a05fc74 100644 --- a/js/app.js +++ b/js/app.js @@ -92,6 +92,7 @@ import { assertAnyRelayAcceptedOrUnconfirmed, describePublishOutcome, readRelayPublishSummary, + anyRelayAcceptedInSummaries, } from "./nostrPublish.js"; import { getActiveSigner, @@ -118,6 +119,12 @@ import { formatViewCount, ingestLocalViewEvent, } from "./viewCounter.js"; +import { + initZapTotals, + requestVideoZapTotal, + getVideoZapTotalSnapshot, + onZapTotalsChanged, +} from "./zapTotals.js"; import { formatAbsoluteTimestamp as formatAbsoluteTimestampUtil, formatAbsoluteDateWithOrdinal as formatAbsoluteDateWithOrdinalUtil, @@ -697,6 +704,9 @@ class Application { unsubscribeFromVideoViewCount, formatViewCount, ingestLocalViewEvent, + requestVideoZapTotal, + getVideoZapTotalSnapshot, + onZapTotalsChanged, pointerArrayToKey, pointerKey: pointerKey, getCanonicalDesignSystemMode, @@ -981,6 +991,11 @@ class Application { } catch (error) { devLogger.warn("Failed to initialize view counter:", error); } + try { + initZapTotals({ nostrClient }); + } catch (error) { + devLogger.warn("Failed to initialize zap totals:", error); + } } async _initAccessControl() { @@ -4358,6 +4373,11 @@ class Application { return this._feed.registerTrendingFeed(...args); } + registerMostZappedFeed(...args) { + this._initCoordinators(); + return this._feed.registerMostZappedFeed(...args); + } + registerSubscriptionsFeed(...args) { this._initCoordinators(); return this._feed.registerSubscriptionsFeed(...args); @@ -4408,6 +4428,11 @@ class Application { return this._feed.refreshFeed(FEED_TYPES.TRENDING, ...args); } + refreshMostZappedFeed(...args) { + this._initCoordinators(); + return this._feed.refreshFeed(FEED_TYPES.MOST_ZAPPED, ...args); + } + refreshRecentFeed(...args) { this._initCoordinators(); return this._feed.refreshFeed("recent", ...args); @@ -4456,6 +4481,11 @@ class Application { return this._feed.loadTrendingVideos(...args); } + async loadMostZappedVideos(...args) { + this._initCoordinators(); + return this._feed.loadMostZappedVideos(...args); + } + async loadOlderVideos(...args) { this._initCoordinators(); return this._feed.loadOlderVideos(...args); @@ -5073,7 +5103,7 @@ class Application { this.showStatus("Deleting. Please wait.", { showSpinner: true }); try { - await this.nostrService.handleFullDeleteVideo({ + const deleteDetail = await this.nostrService.handleFullDeleteVideo({ videoRootId: rootId, video: targetVideo, pubkey: this.pubkey, @@ -5110,7 +5140,21 @@ class Application { } await this.loadVideos(); - this.showSuccess("All versions deleted successfully!"); + // #49 conservative delete semantics: only claim "deleted" when at least + // one relay ACKed something. An all-timeout run (tombstones + kind-5 sent + // but unconfirmed) must not read as a confirmed delete — the video may + // linger on relays until they catch up. + const deleteSummaries = [ + ...(deleteDetail?.result?.reverts || []), + ...(deleteDetail?.result?.deletes || []), + ].map((entry) => entry?.summary); + if (anyRelayAcceptedInSummaries(deleteSummaries)) { + this.showSuccess("All versions deleted successfully!"); + } else { + this.showSuccess( + "Delete request sent, but no relay has confirmed it yet — the video may reappear until relays catch up. Check again in a bit.", + ); + } if (storageLockedRemnant) { this.showStatus( "Heads up: the hosted file is still in your storage bucket because storage is locked. Unlock storage and delete again, or remove it from your bucket manually.", diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 2ac93bbc..e46d31ed 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -14,6 +14,10 @@ import { registerTrendingFeed as registerTrendingFeedPipeline, buildTrendingFeedRuntime, } from "../feedEngine/trendingFeed.js"; +import { + registerMostZappedFeed as registerMostZappedFeedPipeline, + buildMostZappedFeedRuntime, +} from "../feedEngine/mostZappedFeed.js"; /** * @param {object} deps - Injected dependencies. @@ -876,6 +880,11 @@ export function createFeedCoordinator(deps) { // Preserve the view-count ranking (otherwise re-sorted by date). metadataModifier = (m) => ({ ...m, preserveOrder: true }); break; + case FEED_TYPES.MOST_ZAPPED: + runtime = buildMostZappedFeedRuntime(this); + // Preserve the zap-total ranking (otherwise re-sorted by date). + metadataModifier = (m) => ({ ...m, preserveOrder: true }); + break; case "recent": runtime = this.buildRecentFeedRuntime(); break; @@ -1041,6 +1050,12 @@ export function createFeedCoordinator(deps) { refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.TRENDING, opts); shouldCheckRelayHealth = false; break; + case FEED_TYPES.MOST_ZAPPED: + includeTags = false; + loadingMessage = "Fetching most-zapped videos\u2026"; + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.MOST_ZAPPED, opts); + shouldCheckRelayHealth = false; + break; default: devLogger.warn( `[Application] Unknown feed type for loadFeedVideos: ${feedType}`, @@ -1143,6 +1158,14 @@ export function createFeedCoordinator(deps) { return this.loadFeedVideos(FEED_TYPES.TRENDING, forceFetch); }, + registerMostZappedFeed() { + return registerMostZappedFeedPipeline(this); + }, + + async loadMostZappedVideos(forceFetch = false) { + return this.loadFeedVideos(FEED_TYPES.MOST_ZAPPED, forceFetch); + }, + async loadOlderVideos(lastTimestamp) { const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, { blacklistedEventIds: this.blacklistedEventIds, diff --git a/js/app/modalCoordinator.js b/js/app/modalCoordinator.js index cf844aa7..ac4e9460 100644 --- a/js/app/modalCoordinator.js +++ b/js/app/modalCoordinator.js @@ -25,12 +25,78 @@ export function createModalCoordinator(deps) { unsubscribeFromVideoViewCount, formatViewCount, ingestLocalViewEvent, + requestVideoZapTotal, + getVideoZapTotalSnapshot, + onZapTotalsChanged, pointerArrayToKey, pointerKey, getCanonicalDesignSystemMode, BITVID_WEBSITE_URL, } = deps; + // Zap-total badge helpers are LOCAL closures, NOT coordinator methods: + // coordinator methods run with `this` bound to the Application, so a + // cross-method `this.helper()` call only works when app.js also adds an + // explicit delegate for it — a silent landmine (a missing delegate here + // threw on EVERY modal open and broke video playback entirely). + let modalZapTotalUnsub = null; + + function teardownModalZapTotal() { + if (typeof modalZapTotalUnsub === "function") { + try { + modalZapTotalUnsub(); + } catch (error) { + devLogger.warn("[zapTotals] Failed to tear down modal listener:", error); + } + } + modalZapTotalUnsub = null; + const el = + typeof document !== "undefined" + ? document.getElementById("videoZapTotal") + : null; + if (el) { + el.classList.add("hidden"); + const text = el.querySelector("[data-zap-total-text]"); + if (text) { + text.textContent = ""; + } + } + } + + // Orange sats badge on the modal's meta row (right side; views stay left). + // Mirrors the card badge: cached total now (also schedules the batched + // receipt fetch), re-rendered when a batch lands, hidden while zero. + function subscribeModalZapTotal(pointer) { + if ( + typeof requestVideoZapTotal !== "function" || + typeof getVideoZapTotalSnapshot !== "function" || + typeof onZapTotalsChanged !== "function" + ) { + return; + } + const el = + typeof document !== "undefined" + ? document.getElementById("videoZapTotal") + : null; + if (!el || !pointer) { + return; + } + // Always visible while a video is open (mirrors the view counter); a zero + // is a real value, not noise to hide. + const render = () => { + const sats = getVideoZapTotalSnapshot(pointer); + const value = Number.isFinite(sats) && sats > 0 ? sats : 0; + const text = el.querySelector("[data-zap-total-text]"); + if (text) { + text.textContent = `${formatViewCount(value)} sats`; + } + el.classList.remove("hidden"); + }; + requestVideoZapTotal(pointer); + render(); + modalZapTotalUnsub = onZapTotalsChanged(render); + } + return { normalizeModalTrigger(candidate) { if (!candidate) { @@ -117,6 +183,7 @@ export function createModalCoordinator(deps) { } } this.modalViewCountUnsub = null; + teardownModalZapTotal(); if (this.videoModal) { this.videoModal.updateViewCountLabel("\u2013 views"); this.videoModal.setViewCountPointer(null); @@ -139,6 +206,7 @@ export function createModalCoordinator(deps) { this.videoModal.updateViewCountLabel("Loading views\u2026"); this.videoModal.setViewCountPointer(pointerKey); } + subscribeModalZapTotal(pointer); try { const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => { const latestViewEl = this.videoModal?.getViewCountElement() || null; diff --git a/js/constants.js b/js/constants.js index b0bb8663..13e7d0f4 100644 --- a/js/constants.js +++ b/js/constants.js @@ -199,6 +199,8 @@ const DEFAULT_FLAGS = Object.freeze({ FEATURE_TRUSTED_HIDE_CONTROLS: true, FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab. + FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab. + FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false. TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: DEFAULT_WSS_TRACKERS, @@ -222,6 +224,8 @@ const runtimeFlags = (() => { FEATURE_IMPROVED_COMMENT_FETCHING: DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING, FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED, + FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED, + FEATURE_ZAP_TALLY: DEFAULT_FLAGS.FEATURE_ZAP_TALLY, TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], @@ -267,6 +271,16 @@ export let FEATURE_TRENDING_FEED = coerceBoolean( DEFAULT_FLAGS.FEATURE_TRENDING_FEED ); +export let FEATURE_MOST_ZAPPED_FEED = coerceBoolean( + runtimeFlags.FEATURE_MOST_ZAPPED_FEED, + DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED +); + +export let FEATURE_ZAP_TALLY = coerceBoolean( + runtimeFlags.FEATURE_ZAP_TALLY, + DEFAULT_FLAGS.FEATURE_ZAP_TALLY +); + export let FEATURE_TRUST_SEEDS = coerceBoolean( runtimeFlags.FEATURE_TRUST_SEEDS, DEFAULT_FLAGS.FEATURE_TRUST_SEEDS @@ -578,6 +592,7 @@ export const FEED_TYPES = Object.freeze({ KIDS: "kids", EXPLORE: "explore", TRENDING: "trending", + MOST_ZAPPED: "most-zapped", SUBSCRIPTIONS: "subscriptions", HISTORY: "history", CHANNEL: "channel-profile", diff --git a/js/feedEngine/mostZappedFeed.js b/js/feedEngine/mostZappedFeed.js new file mode 100644 index 00000000..7fcde6ec --- /dev/null +++ b/js/feedEngine/mostZappedFeed.js @@ -0,0 +1,126 @@ +// js/feedEngine/mostZappedFeed.js +// +// The "Most Zapped" tab (#47): the recently-added (active) source ranked by +// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives +// outside feedCoordinator.js (size cap) and is wired in via thin delegations. +// The metric getter uses requestVideoZapTotal, which returns the cached total +// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped +// view re-runs the feed on zapTotals' change signal so the order settles as +// totals stream in. + +import { FEED_TYPES } from "../constants.js"; +import { requestVideoZapTotal } from "../zapTotals.js"; +import { + createActiveNostrSource, + createBlacklistFilterStage, + createDedupeByRootStage, + createModerationStage, + createResolvePostedAtStage, +} from "./index.js"; +import { createMostZappedSorter } from "./sorters.js"; + +// Resolve a video's zap total via the app's canonical pointer derivation (the +// same pointer keys the zap flow tags receipts with, so ranking and receipts +// agree). +function makeGetZapTotal(app) { + return (video) => { + if (!video || typeof video !== "object") { + return 0; + } + try { + const info = + typeof app.deriveVideoPointerInfo === "function" + ? app.deriveVideoPointerInfo(video) + : null; + if (!info?.pointer) { + return 0; + } + const total = requestVideoZapTotal(info.pointer); + return Number.isFinite(total) ? Number(total) : 0; + } catch (error) { + return 0; + } + }; +} + +export function buildMostZappedFeedRuntime(app) { + const blacklist = + app.blacklistedEventIds instanceof Set + ? new Set(app.blacklistedEventIds) + : new Set(); + const moderationThresholds = + typeof app.getActiveModerationThresholds === "function" + ? app.getActiveModerationThresholds() + : null; + return { + blacklistedEventIds: blacklist, + isAuthorBlocked: (pubkey) => app.isAuthorBlocked(pubkey), + moderationThresholds: moderationThresholds + ? { ...moderationThresholds } + : undefined, + getZapTotal: makeGetZapTotal(app), + }; +} + +export function registerMostZappedFeed(app) { + if (!app?.feedEngine || typeof app.feedEngine.registerFeed !== "function") { + return null; + } + const existing = + typeof app.feedEngine.getFeedDefinition === "function" + ? app.feedEngine.getFeedDefinition(FEED_TYPES.MOST_ZAPPED) + : null; + if (existing) { + return existing; + } + + const resolveThreshold = (key) => ({ runtimeValue, defaultValue }) => { + if ( + Number.isFinite(runtimeValue) || + runtimeValue === Number.POSITIVE_INFINITY + ) { + return runtimeValue; + } + if (typeof app.getActiveModerationThresholds === "function") { + const active = app.getActiveModerationThresholds(); + const candidate = + active && typeof active === "object" ? active[key] : undefined; + if (Number.isFinite(candidate) || candidate === Number.POSITIVE_INFINITY) { + return candidate; + } + } + return defaultValue; + }; + + try { + return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, { + source: createActiveNostrSource({ service: app.nostrService }), + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo: (video, options) => + app.nostrService.shouldIncludeVideo(video, options), + }), + createDedupeByRootStage({ + dedupe: (videos) => app.dedupeVideosByRoot(videos), + }), + createModerationStage({ + getService: () => app.nostrService.getModerationService(), + autoplayThreshold: resolveThreshold("autoplayBlockThreshold"), + blurThreshold: resolveThreshold("blurThreshold"), + trustedMuteHideThreshold: resolveThreshold("trustedMuteHideThreshold"), + trustedReportHideThreshold: resolveThreshold("trustedSpamHideThreshold"), + }), + createResolvePostedAtStage(), + ], + sorter: createMostZappedSorter(), + hooks: { + timestamps: { + getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video), + resolveVideoPostedAt: (video) => app.resolveVideoPostedAt(video), + }, + }, + }); + } catch (error) { + return null; + } +} diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index f23bd52c..042c2dc6 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -3,21 +3,20 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { markAsNormalized } from "./utils.js"; -// Ranks the active feed by VIEW COUNT (the "Trending" tab). Counts come from the -// injected runtime.getViewCount(video) — the shared viewCounter cache populated -// by the grid cards' per-card subscriptions. Unknown/not-yet-loaded counts are -// treated as 0, so on a cold cache the feed reads as recency and settles into -// true trending as counts stream in (the Trending view re-runs the feed on -// viewCounter's debounced change signal). Trusted-muted always sinks last. -export function createTrendingSorter() { - return function trendingSorter(items = [], context = {}) { +// Shared count-ranked sorter: rank by an injected per-video metric (desc), +// tie-break by recency, spread authors, sink trusted-muted last. Unknown / +// not-yet-loaded counts are treated as 0, so on a cold cache the feed reads +// as recency and settles into the true ranking as counts stream in (the view +// re-runs the feed on the metric cache's debounced change signal). +// - Trending (#27): runtime.getViewCount (viewCounter cache) +// - Most Zapped (#47): runtime.getZapTotal (zapTotals cache) +function createCountRankedSorter(resolveMetric) { + return function countRankedSorter(items = [], context = {}) { if (!Array.isArray(items)) { return []; } - const getViewCount = - typeof context?.runtime?.getViewCount === "function" - ? context.runtime.getViewCount - : () => 0; + const metricFn = resolveMetric(context); + const getViewCount = typeof metricFn === "function" ? metricFn : () => 0; const isMuted = (entry) => entry?.metadata?.moderation?.trustedMuted === true || @@ -55,12 +54,21 @@ export function createTrendingSorter() { const live = items.filter((entry) => !isMuted(entry)); const muted = items.filter((entry) => isMuted(entry)); - // Rank by views, then spread authors so the tab isn't walls of one creator. - // The most-viewed item is still chosen first, so it stays topmost. + // Rank by the metric, then spread authors so the tab isn't walls of one + // creator. The top-ranked item is still chosen first, so it stays topmost. return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)]; }; } +export function createTrendingSorter() { + return createCountRankedSorter((context) => context?.runtime?.getViewCount); +} + +// "Most Zapped" (#47): same ranking mechanics over sats totals. +export function createMostZappedSorter() { + return createCountRankedSorter((context) => context?.runtime?.getZapTotal); +} + // Round-robin interleave by author: one video per creator per pass, each pass in // the caller's pre-sorted order. Consecutive cards are from different creators, // so a feed reads as a varied "discovery" mix instead of a chronological wall — diff --git a/js/mostZappedView.js b/js/mostZappedView.js new file mode 100644 index 00000000..19dc0ce1 --- /dev/null +++ b/js/mostZappedView.js @@ -0,0 +1,65 @@ +// js/mostZappedView.js +// +// The "Most Zapped" tab presentation hook (#47). The ranking lives in the feed +// engine (createMostZappedSorter); zap totals arrive asynchronously as the +// feed's metric getter schedules batched receipt fetches. While this view is +// active we listen for zapTotals' change signal and re-run the feed (a cheap +// re-rank over the in-memory active set, no relay re-fetch) so the order +// visibly settles as totals stream in — same pattern as trendingView. + +import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; +import { onZapTotalsChanged } from "./zapTotals.js"; + +const MOST_ZAPPED_RERANK_DEBOUNCE_MS = 700; + +let unsubscribeTotals = null; +let rerankTimer = null; + +export function teardownMostZappedView() { + if (typeof unsubscribeTotals === "function") { + try { + unsubscribeTotals(); + } catch (error) { + // best effort + } + unsubscribeTotals = null; + } + if (rerankTimer) { + clearTimeout(rerankTimer); + rerankTimer = null; + } +} + +export function initMostZappedView({ getApp, isActive } = {}) { + const infoTrigger = document.getElementById("mostZappedInfoTrigger"); + if (infoTrigger) { + attachFeedInfoPopover( + infoTrigger, + "Most Zapped ranks recently-added videos by their zap total (sats). The order settles as zap receipts load in." + ); + } + + // Reset any prior subscription (re-entering the tab). + teardownMostZappedView(); + + const stillActive = typeof isActive === "function" ? isActive : () => true; + + unsubscribeTotals = onZapTotalsChanged(() => { + if (rerankTimer) { + return; + } + rerankTimer = setTimeout(() => { + rerankTimer = null; + if (!stillActive()) { + teardownMostZappedView(); + return; + } + const app = typeof getApp === "function" ? getApp() : null; + if (app && typeof app.refreshMostZappedFeed === "function") { + Promise.resolve( + app.refreshMostZappedFeed({ reason: "zap-total-update" }) + ).catch(() => {}); + } + }, MOST_ZAPPED_RERANK_DEBOUNCE_MS); + }); +} diff --git a/js/nostr/client.js b/js/nostr/client.js index 41f72c4b..e48f4b92 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -111,7 +111,7 @@ import "./maxListenerDiagnostics.js"; import { publishEventToRelay, publishEventToRelays, - assertAnyRelayAccepted, + assertAnyRelayAcceptedOrUnconfirmed, attachRelayPublishSummary, } from "../nostrPublish.js"; import { @@ -2960,13 +2960,17 @@ export class NostrClient { await this.ensureActiveSignerForPubkey(userPubkeyLower); try { - const { signedEvent } = await this.signAndPublishEvent(context.event, { + const { signedEvent, summary } = await this.signAndPublishEvent(context.event, { context: "edited video note", logName: "Edited video note", devLogLabel: "edited video note", resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p), }); + // Same tally as publishVideo so the edit UI can tell an unconfirmed + // (all-timeout, #49) update apart from a confirmed one. + attachRelayPublishSummary(signedEvent, summary); + await handlePublishNip94(this, signedEvent, context); await handlePublishNip71(this, signedEvent, context); @@ -3216,7 +3220,10 @@ export class NostrClient { let publishSummary; try { - publishSummary = assertAnyRelayAccepted(publishResults, { + // #49: an all-timeout tombstone publish is an unconfirmed soft success + // (summary.unconfirmed=true) so a slow-ack revert/delete doesn't abort + // midway; explicit relay rejections still throw below. + publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, { context: "video revert", }); } catch (publishError) { @@ -3237,6 +3244,12 @@ export class NostrClient { devLogger.log(`Revert event published to ${url}`) ); + if (publishSummary.unconfirmed) { + userLogger.warn( + "[nostr] Video revert was sent but no relay confirmed it within the timeout — proceeding as unconfirmed.", + ); + } + if (publishSummary.failed.length) { publishSummary.failed.forEach(({ url, error: relayError }) => { const reason = diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index 2b2e05f4..7c62b6e2 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -1,7 +1,7 @@ import { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } from "../config.js"; import { publishEventToRelays, - assertAnyRelayAccepted, + assertAnyRelayAcceptedOrUnconfirmed, summarizePublishResults, } from "../nostrPublish.js"; import { @@ -469,7 +469,10 @@ export async function signAndPublishEvent({ let publishSummary; try { - publishSummary = assertAnyRelayAccepted(publishResults, { context }); + // #49: an all-timeout publish (sent, nothing rejected) is treated as an + // unconfirmed soft success — summary.unconfirmed=true — instead of a false + // "failed to publish". Explicit relay rejections still throw below. + publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, { context }); } catch (publishError) { if (publishError?.relayFailures?.length) { const logLevel = rejectionLogLevel === "warn" ? "warn" : "error"; @@ -485,6 +488,12 @@ export async function signAndPublishEvent({ devLogger.log(`${logName} published to ${url}`); }); + if (publishSummary.unconfirmed) { + userLogger.warn( + `[nostr] ${logName} was sent but no relay confirmed it within the timeout — treating as an unconfirmed publish.`, + ); + } + if (publishSummary.failed.length) { publishSummary.failed.forEach(({ url, error: relayError }) => { const reason = @@ -1331,7 +1340,11 @@ export async function rebroadcastEvent({ client, eventId, options = {} }) { const publishResults = await publishEventToRelays(client?.pool, relays, rawEvent); try { - const summary = assertAnyRelayAccepted(publishResults, { context: "rebroadcast" }); + // #49: rebroadcast re-publishes an existing event, so an all-timeout is a + // safe unconfirmed success; only explicit rejections fall to the catch. + const summary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, { + context: "rebroadcast", + }); return { ok: true, rebroadcast: true, diff --git a/js/nostr/reqTelemetry.js b/js/nostr/reqTelemetry.js new file mode 100644 index 00000000..16ad768f --- /dev/null +++ b/js/nostr/reqTelemetry.js @@ -0,0 +1,184 @@ +// js/nostr/reqTelemetry.js +// +// Dev-gated REQ tracer for TODO #9 (cold-login relay-REQ storm). Every +// subscription that goes through the shimmed pool.sub/pool.list choke point is +// recorded — kinds, relay fan-out, and the first call site outside the nostr +// plumbing — so one traced cold login tells us exactly WHICH code paths emit +// the burst (the storm's remaining sources are unpinned; see the TODO). +// +// Zero-cost when off: recordReq is a single boolean check. Enable with +// localStorage.setItem("bitvid:reqTrace", "1") (then reload) +// or automatically in dev mode. Read results from the periodic +// "[req-trace]" logs or on demand via window.__bitvidReqTrace.report(). + +import { isDevMode } from "../config.js"; +import { userLogger } from "../utils/logger.js"; + +const STORAGE_FLAG = "bitvid:reqTrace"; +const REPORT_WINDOW_MS = 10000; +const PLUMBING_FRAME = /reqTelemetry\.js|toolkit\.js|relaySubscriptionService|subscriptionManager|nostrPublish\.js|node_modules|^Error/i; + +function readStorageFlag() { + try { + return ( + typeof localStorage !== "undefined" && + localStorage.getItem(STORAGE_FLAG) === "1" + ); + } catch (error) { + return false; + } +} + +// First stack frame that is application code rather than pool plumbing. +export function extractCallSite(stack) { + if (typeof stack !== "string" || !stack) { + return "unknown"; + } + for (const rawLine of stack.split("\n")) { + const line = rawLine.trim(); + if (!line || PLUMBING_FRAME.test(line)) { + continue; + } + // "at fn (url:line:col)" / "fn@url:line:col" → keep it short but unique + // enough to identify the module (strip origin, keep path:line). + const match = line.match(/((?:[\w./-]+\/)?[\w.-]+\.m?js):(\d+)(?::\d+)?/); + if (match) { + const fnMatch = line.match(/at ([\w.<>$[\]]+) |^([\w.<>$[\]]+)@/); + const fn = fnMatch ? fnMatch[1] || fnMatch[2] : ""; + return `${fn ? `${fn} ` : ""}${match[1]}:${match[2]}`.trim(); + } + return line.slice(0, 120); + } + return "unknown"; +} + +export function createReqTelemetry({ + now = () => Date.now(), + log = (payload) => userLogger.info("[req-trace]", payload), + windowMs = REPORT_WINDOW_MS, +} = {}) { + const state = { + enabled: false, + entries: new Map(), // key: `${kinds}|${callSite}` → { kinds, callSite, count, relayFanout } + total: 0, + relayFanout: 0, + windowStart: 0, + timer: null, + }; + + const reset = () => { + state.entries.clear(); + state.total = 0; + state.relayFanout = 0; + state.windowStart = now(); + }; + + const record = (relayCount, filters, stack) => { + if (!state.enabled) { + return; + } + const list = Array.isArray(filters) ? filters : filters ? [filters] : []; + const kinds = Array.from( + new Set( + list.flatMap((filter) => + Array.isArray(filter?.kinds) ? filter.kinds : [], + ), + ), + ) + .sort((a, b) => a - b) + .join(","); + const callSite = extractCallSite(stack); + const key = `${kinds || "?"}|${callSite}`; + const entry = state.entries.get(key) || { + kinds: kinds || "?", + callSite, + count: 0, + relayFanout: 0, + }; + entry.count += 1; + entry.relayFanout += Number.isFinite(relayCount) ? relayCount : 0; + state.entries.set(key, entry); + state.total += 1; + state.relayFanout += Number.isFinite(relayCount) ? relayCount : 0; + }; + + const report = ({ emit = true } = {}) => { + const elapsedMs = Math.max(1, now() - state.windowStart); + const rows = Array.from(state.entries.values()).sort( + (a, b) => b.count - a.count, + ); + const payload = { + windowSeconds: Math.round(elapsedMs / 100) / 10, + totalSubs: state.total, + totalRelayReqs: state.relayFanout, + perSecond: Math.round((state.relayFanout / elapsedMs) * 10000) / 10, + byKindsAndCaller: rows.slice(0, 15).map((row) => ({ + kinds: row.kinds, + caller: row.callSite, + subs: row.count, + relayReqs: row.relayFanout, + })), + }; + if (emit && state.total > 0) { + log(payload); + } + return payload; + }; + + const start = () => { + if (state.enabled) { + return; + } + state.enabled = true; + reset(); + if (typeof setInterval === "function" && !state.timer) { + state.timer = setInterval(() => { + report(); + reset(); + }, windowMs); + if (typeof state.timer?.unref === "function") { + state.timer.unref(); + } + } + }; + + const stop = () => { + state.enabled = false; + if (state.timer) { + clearInterval(state.timer); + state.timer = null; + } + }; + + return { + get enabled() { + return state.enabled; + }, + start, + stop, + reset, + record, + report, + }; +} + +const defaultTelemetry = createReqTelemetry(); + +// Auto-arm in dev mode or when the localStorage flag is set; expose a console +// handle either way so it can be started on a production build too. +if (isDevMode || readStorageFlag()) { + defaultTelemetry.start(); +} +if (typeof window !== "undefined") { + window.__bitvidReqTrace = defaultTelemetry; +} + +// Hot-path hook for the pool shim: one boolean check when tracing is off. +export function recordReq(relayCount, filters) { + if (!defaultTelemetry.enabled) { + return; + } + defaultTelemetry.record(relayCount, filters, new Error().stack); +} + +export default defaultTelemetry; diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js index 05d47e0b..b7e21855 100644 --- a/js/nostr/toolkit.js +++ b/js/nostr/toolkit.js @@ -3,6 +3,7 @@ import { isDevMode } from "../config.js"; import { nostrToolsReady } from "../nostrToolsBootstrap.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { recordReq } from "./reqTelemetry.js"; /** * The default relay set bitvid bootstraps with before loading a user's @@ -524,6 +525,10 @@ export function shimLegacySimplePoolMethods(pool) { const { relayList, filterList, requests } = createLegacySubscriptionContext(relays, filters); + // #9 REQ tracer: every sub/list passes through here — record kinds, + // fan-out, and the emitting call site (no-op unless tracing is armed). + recordReq(relayList.length, filterList); + const listeners = { event: new Set(), eose: new Set(), diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 1fbedef7..ff6f553b 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -47,6 +47,7 @@ export const NOTE_TYPES = Object.freeze({ DM_TYPING: "dmTypingIndicator", ZAP_REQUEST: "zapRequest", ZAP_RECEIPT: "zapReceipt", + ZAP_TALLY: "zapTally", WATCH_HISTORY: "watchHistory", SUBSCRIPTION_LIST: "subscriptionList", SUBSCRIPTION_BACKUP: "subscriptionBackup", @@ -92,6 +93,11 @@ export { ADMIN_COMMUNITY_BLACKLIST_PREFIX }; const DEFAULT_APPEND_TAGS = []; +// bitvid-native zap tally event kind. Addressable (30000–39999) so `d = +// payment_hash` yields one canonical event per real payment. In bitvid's family +// (30078 video, 30079 view). See docs/zap-tally-plan.md. +export const ZAP_TALLY_KIND = 30081; + let cachedUtf8Encoder = null; const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]); @@ -527,6 +533,23 @@ const BASE_SCHEMAS = { "Zap receipts are published by lightning wallets to confirm zap requests.", }, }, + [NOTE_TYPES.ZAP_TALLY]: { + // bitvid-native, payer-signed, preimage-verified zap tally (docs/zap-tally-plan.md). + // Addressable (d = payment_hash) so each real payment maps to one canonical + // event: relay-level idempotency, re-publish replaces rather than duplicates. + // Counted alongside NIP-57 9735 receipts (deduped by payment_hash), so a + // Most-Zapped tally survives when the recipient's wallet never publishes a 9735. + type: NOTE_TYPES.ZAP_TALLY, + label: "Zap tally", + kind: ZAP_TALLY_KIND, + identifierTag: { name: "d" }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: + "Optional zap message; the authoritative amount + proof live in the tags.", + }, + }, [NOTE_TYPES.WATCH_HISTORY]: { type: NOTE_TYPES.WATCH_HISTORY, label: "Watch history month", @@ -2347,6 +2370,101 @@ export function buildZapRequestEvent(params) { return event; } +/** + * Builds a bitvid-native zap tally event (kind ZAP_TALLY_KIND, addressable by + * d=payment_hash). Payer-signed, preimage-verified stand-in for a NIP-57 receipt + * when the recipient's LNURL server doesn't publish one. Carries the same + * {bolt11, preimage, description(zap request)} a 9735 would, so any client can + * verify sha256(preimage)==payment_hash and description_hash==sha256(zapRequest). + * See docs/zap-tally-plan.md §3. Returns an UNSIGNED event; the caller signs. + * + * @param {object} params + * @param {string} params.pubkey payer pubkey + * @param {number} params.created_at + * @param {string} params.paymentHash hex; becomes the `d` tag (idempotency) + * @param {string} params.recipientPubkey the zapped party (`p`) + * @param {string} [params.eventId] zapped video event id (`e`) + * @param {string} [params.coordinate] zapped video coordinate kind:pubkey:d (`a`) + * @param {number} [params.amountMsats] echoes the zap request amount tag + * @param {string} params.bolt11 paid invoice (carries payment_hash + description_hash) + * @param {string} params.preimage NWC pay_invoice preimage (proof of settlement) + * @param {string} params.zapRequestJson the signed 9734 JSON (NIP-57 `description`) + * @param {string} [params.comment] + * @returns {object} Unsigned event. + */ +export function buildZapTallyEvent(params) { + const { + pubkey, + created_at, + paymentHash, + recipientPubkey, + eventId, + coordinate, + amountMsats, + bolt11, + preimage, + zapRequestJson, + comment = "", + } = params || {}; + + const schema = getNostrEventSchema(NOTE_TYPES.ZAP_TALLY); + const tags = []; + + const normalizedHash = normalizePointerIdentifier(paymentHash); + if (normalizedHash) { + tags.push(["d", normalizedHash]); // addressable identifier = payment_hash + } + + const normalizedRecipient = normalizePointerIdentifier(recipientPubkey); + if (normalizedRecipient) { + tags.push(["p", normalizedRecipient]); + } + + const normalizedEventId = normalizePointerIdentifier(eventId); + if (normalizedEventId) { + tags.push(["e", normalizedEventId]); + } + + if (typeof coordinate === "string" && coordinate.trim()) { + tags.push(["a", coordinate.trim()]); + } + + if (Number.isFinite(amountMsats)) { + tags.push(["amount", String(Math.max(0, Math.round(amountMsats)))]); + } + + if (typeof bolt11 === "string" && bolt11.trim()) { + tags.push(["bolt11", bolt11.trim()]); + } + + if (typeof preimage === "string" && preimage.trim()) { + tags.push(["preimage", preimage.trim()]); + } + + if (typeof zapRequestJson === "string" && zapRequestJson.trim()) { + tags.push(["description", zapRequestJson.trim()]); + } + + tags.push(["client", "bitvid"]); + tags.push(["t", "zap"]); + + appendSchemaTags(tags, schema); + + const event = { + kind: schema?.kind ?? ZAP_TALLY_KIND, + pubkey, + created_at, + tags, + content: ensureValidUtf8Content(typeof comment === "string" ? comment : ""), + }; + + if (isDevMode) { + validateEventAgainstSchema(NOTE_TYPES.ZAP_TALLY, event); + } + + return event; +} + /** * Builds a Reaction event (Kind 7). * E.g., Like ('+'), Dislike ('-'), or emoji. diff --git a/js/nostrPublish.js b/js/nostrPublish.js index a9a7a751..c146f464 100644 --- a/js/nostrPublish.js +++ b/js/nostrPublish.js @@ -278,9 +278,16 @@ export function attachRelayPublishSummary(event, summary) { } const accepted = Array.isArray(summary?.accepted) ? summary.accepted.length : 0; const failed = Array.isArray(summary?.failed) ? summary.failed.length : 0; + // #49: carry the unconfirmed flag (all relays timed out, nothing rejected) + // so the outcome describer can say "sent, unconfirmed" instead of lying in + // either direction ("published to N relays" / "failed"). + const tally = + summary?.unconfirmed === true + ? { accepted, total: accepted + failed, unconfirmed: true } + : { accepted, total: accepted + failed }; try { Object.defineProperty(event, RELAY_PUBLISH_SUMMARY_KEY, { - value: { accepted, total: accepted + failed }, + value: tally, enumerable: false, configurable: true, writable: true, @@ -308,7 +315,11 @@ export function readRelayPublishSummary(event) { * @param {{ accepted?: number, total?: number }} tally * @returns {{ tone: "success"|"warning"|"error", message: string }} */ -export function describePublishOutcome({ accepted = 0, total = 0 } = {}) { +export function describePublishOutcome({ + accepted = 0, + total = 0, + unconfirmed = false, +} = {}) { const a = Number.isFinite(accepted) ? Math.max(0, Math.floor(accepted)) : 0; const t = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0; @@ -317,6 +328,16 @@ export function describePublishOutcome({ accepted = 0, total = 0 } = {}) { return { tone: "success", message: "Video shared successfully!" }; } if (a <= 0) { + // #49: every relay timed out without rejecting — the event was sent and + // almost always persisted (it shows up after a refresh). Say exactly that: + // neither a hard failure nor a fake "published to N relays". + if (unconfirmed === true) { + return { + tone: "warning", + message: + "Video sent, but no relay has confirmed receiving it yet. It should appear shortly — refresh in a moment to double-check.", + }; + } return { tone: "error", message: @@ -335,6 +356,24 @@ export function describePublishOutcome({ accepted = 0, total = 0 } = {}) { }; } +// #49 delete semantics: a delete flow publishes several events (tombstone +// reverts + kind-5 chunks) and the UI must NOT claim "deleted" unless at least +// one relay actually ACKed something — a false "deleted" is worse than a false +// "failed". An empty list (nothing needed publishing) counts as confirmed. +export function anyRelayAcceptedInSummaries(summaries = []) { + const list = Array.isArray(summaries) ? summaries.filter(Boolean) : []; + if (!list.length) { + return true; + } + return list.some((summary) => { + const accepted = summary?.accepted; + if (Array.isArray(accepted)) { + return accepted.length > 0; + } + return Number.isFinite(accepted) && accepted > 0; + }); +} + // A RelayPublishError whose per-relay failures are ALL "publish timeout" means the // event was sent but not ACKed in the window — NOT that any relay rejected it. For // an idempotent, replaceable event this almost always persisted (users see it diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js index 00a9cc5f..b3e6ef0b 100644 --- a/js/payments/lnurl.js +++ b/js/payments/lnurl.js @@ -228,10 +228,42 @@ function normalizeNumeric(value, fallback = 0) { return numeric; } -export async function fetchPayServiceData(url, { fetcher } = {}) { +// LNURL servers (Strike among them) intermittently return transient failures — +// network errors, 5xx, or {status:"ERROR"} bodies like "Could not get user +// information" — for addresses that resolve fine seconds later (verified live +// 2026-07-04 against strike.me: ERROR on one request, OK with a callback on +// the retry). One short retry keeps a real zap from dying on such a blip; a +// genuinely bad address still fails after at most one extra request. +export async function fetchPayServiceData( + url, + { fetcher, retries = 1, retryDelayMs = 600 } = {}, +) { const targetUrl = sanitizeUrl(url); const fetchFn = ensureFetchFunction(fetcher); + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (attempt > 0 && retryDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + try { + return await fetchPayServiceDataOnce(targetUrl, fetchFn); + } catch (error) { + lastError = error; + if (error?.transient !== true) { + throw error; + } + } + } + throw lastError; +} + +function markTransient(error) { + error.transient = true; + return error; +} + +async function fetchPayServiceDataOnce(targetUrl, fetchFn) { let response; try { response = await fetchFn(targetUrl, { @@ -247,18 +279,21 @@ export async function fetchPayServiceData(url, { fetcher } = {}) { ); friendly.cause = error; friendly.code = "lnurl-unreachable"; - throw friendly; + throw markTransient(friendly); } if (!response.ok) { - throw new Error(`Failed to load LNURL metadata (${response.status}).`); + throw markTransient( + new Error(`Failed to load LNURL metadata (${response.status}).`), + ); } let payload; try { payload = await response.json(); } catch (error) { - throw new Error("LNURL endpoint did not return JSON."); + // A non-JSON body is usually an edge/CDN interstitial — treat as a blip. + throw markTransient(new Error("LNURL endpoint did not return JSON.")); } if (!payload || typeof payload !== "object") { @@ -270,7 +305,7 @@ export async function fetchPayServiceData(url, { fetcher } = {}) { typeof payload.reason === "string" && payload.reason.trim() ? payload.reason.trim() : "LNURL endpoint returned an error."; - throw new Error(reason); + throw markTransient(new Error(reason)); } const callback = sanitizeUrl(payload.callback); diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js index 8933b7b4..0f0020ca 100644 --- a/js/payments/nwcClient.js +++ b/js/payments/nwcClient.js @@ -926,7 +926,7 @@ async function ensureEncryption(context) { throw new Error("No compatible wallet encryption scheme available."); } -function shouldRetryWithFallback(context, error, encryption, { hasRetried }) { +function shouldRetryWithFallback(context, error, encryption, { hasRetried, method } = {}) { if (!context || !encryption || hasRetried) { return false; } @@ -934,6 +934,8 @@ function shouldRetryWithFallback(context, error, encryption, { hasRetried }) { const code = typeof error?.code === "string" ? error.code : ""; const message = typeof error?.message === "string" ? error.message : ""; + // An explicit "unsupported encryption" rejection means the wallet never + // processed the request, so re-sending under nip04 is safe for any method. if (code === "UNSUPPORTED_ENCRYPTION" || code === "unsupported_encryption") { return true; } @@ -947,6 +949,13 @@ function shouldRetryWithFallback(context, error, encryption, { hasRetried }) { return false; } + // A TIMEOUT is ambiguous: the wallet may have received the request and be + // paying it. NEVER auto-resend a spending method on timeout — the encryption + // fallback would double-pay. Read-only methods stay retryable. + if (NWC_SPENDING_METHODS.has(method)) { + return false; + } + const state = ensureEncryptionState(context); if (state.unsupported.has("nip04")) { return false; @@ -1358,6 +1367,15 @@ async function dispatchWalletRequest(context, payload, { timeoutMs } = {}) { return promise; } +// NWC methods that MOVE money. A timeout on these is ambiguous — the payment +// may already be in flight — so we must NOT auto-resend them (double-spend). +const NWC_SPENDING_METHODS = new Set([ + "pay_invoice", + "pay_keysend", + "multi_pay_invoice", + "multi_pay_keysend", +]); + async function sendWalletRequest(context, payload, { timeoutMs, __internalRetry = false } = {}) { try { return await dispatchWalletRequest(context, payload, { timeoutMs }); @@ -1366,6 +1384,7 @@ async function sendWalletRequest(context, payload, { timeoutMs, __internalRetry if ( shouldRetryWithFallback(context, error, encryption, { hasRetried: __internalRetry === true, + method: payload?.method, }) ) { rememberUnsupportedEncryption(context, encryption.scheme); @@ -1647,4 +1666,6 @@ export const __TESTING__ = Object.freeze({ buildPayInvoiceParams, getActiveState: () => activeState, ensureEncryptionForContext: ensureEncryption, + shouldRetryWithFallback, + NWC_SPENDING_METHODS, }); diff --git a/js/payments/zapReceiptValidator.js b/js/payments/zapReceiptValidator.js index fb1d8c30..0ba2945b 100644 --- a/js/payments/zapReceiptValidator.js +++ b/js/payments/zapReceiptValidator.js @@ -58,6 +58,97 @@ function computeZapRequestHash(zapRequest) { return bytesToHex(sha256(data)); } +function hexToBytes(hex) { + const clean = typeof hex === "string" ? hex.trim().toLowerCase() : ""; + if (!clean || clean.length % 2 !== 0 || /[^0-9a-f]/.test(clean)) { + return null; + } + const bytes = new Uint8Array(clean.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +// Proof of settlement: a Lightning payment_hash is sha256(preimage). Anyone can +// check it — this is what makes a payer-signed tally as trustworthy as a +// recipient-signed 9735 (docs/zap-tally-plan.md §3, step 2). +export function verifyPaymentPreimage(preimageHex, paymentHashHex) { + const preimage = hexToBytes(preimageHex); + const expected = + typeof paymentHashHex === "string" ? paymentHashHex.trim().toLowerCase() : ""; + if (!preimage || preimage.length !== 32 || expected.length !== 64) { + return false; + } + return bytesToHex(sha256(preimage)) === expected; +} + +const tagValue = (tags, name) => { + const list = Array.isArray(tags) ? tags : []; + const tag = list.find((t) => Array.isArray(t) && t[0] === name && t[1] != null); + return tag ? String(tag[1]) : ""; +}; + +// Verify a bitvid zap-tally event (kind ZAP_TALLY_KIND). Returns +// { ok, sats, paymentHash, pointerTags } — pointerTags come from the EMBEDDED +// zap request (a/e/p), not the outer wrapper, so a mangled wrapper can't +// retarget the credit. `getSats` defaults to nostr-tools' bolt11 amount parser; +// injectable for tests. See docs/zap-tally-plan.md §3. +export function verifyBitvidZapTally(event, { getSats, extractFields } = {}) { + const fail = { ok: false, sats: 0, paymentHash: null, pointerTags: [] }; + if (!event || typeof event !== "object" || !Array.isArray(event.tags)) { + return fail; + } + const bolt11 = tagValue(event.tags, "bolt11"); + const preimage = tagValue(event.tags, "preimage"); + const zapRequestJson = tagValue(event.tags, "description"); + if (!bolt11 || !preimage) { + return fail; + } + + const decode = typeof extractFields === "function" ? extractFields : extractBolt11Fields; + const { paymentHash, descriptionHash } = decode(bolt11); + if (!paymentHash) { + return fail; + } + // Step 2: the invoice was actually paid. + if (!verifyPaymentPreimage(preimage, paymentHash)) { + return fail; + } + // Step 3: that payment was for THIS zap request (binds pointer + amount; + // blocks reusing a leaked preimage against a different video). + if (descriptionHash) { + if (!zapRequestJson || computeZapRequestHash(zapRequestJson) !== descriptionHash) { + return fail; + } + } + + // Amount is authoritative from the bolt11, never the tag. + let sats = 0; + try { + const fn = + typeof getSats === "function" + ? getSats + : cachedTools?.nip57?.getSatoshisAmountFromBolt11; + const parsed = typeof fn === "function" ? Number(fn(bolt11)) : NaN; + sats = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0; + } catch (error) { + sats = 0; + } + if (sats <= 0) { + return fail; + } + + // Pointer tags from the embedded, hash-bound zap request. + const req = parseZapRequest(zapRequestJson); + const reqTags = Array.isArray(req?.tags) ? req.tags : event.tags; + const pointerTags = reqTags.filter( + (t) => Array.isArray(t) && (t[0] === "a" || t[0] === "e" || t[0] === "p"), + ); + + return { ok: true, sats, paymentHash, pointerTags }; +} + function normalizeRelayList(relays) { if (!Array.isArray(relays)) { return []; @@ -161,6 +252,74 @@ function normalizePubkey(pubkey) { return trimmed ? trimmed.toLowerCase() : ""; } +// Walk a bolt11's bech32 tagged fields once, returning the fields we care about: +// paymentHash — bolt11 tag 'p' (proves settlement when sha256(preimage)===it) +// descriptionHash — bolt11 tag 'h' (NIP-57 binds the invoice to the zap request) +// Both hex, or null if absent/undecodable. Shared by the 9735 path and the +// bitvid zap-tally verifier (docs/zap-tally-plan.md §3). +export function extractBolt11Fields(bolt11) { + const out = { paymentHash: null, descriptionHash: null }; + const normalized = normalizeInvoiceValue(bolt11).toLowerCase(); + if (!normalized) { + return out; + } + + let decoded; + try { + decoded = bech32.decode(normalized, 2000); + } catch (error) { + return out; + } + + const words = Array.isArray(decoded?.words) ? decoded.words.slice() : []; + if (words.length <= 104) { + return out; + } + + const dataWords = words.slice(0, -104); + if (dataWords.length <= 7) { + return out; + } + + let index = 7; // skip timestamp words + while (index < dataWords.length) { + const tagCode = dataWords[index]; + index += 1; + if (typeof tagCode !== "number" || tagCode < 0 || tagCode >= BOLT11_CHARSET.length) { + return out; + } + if (index + 1 >= dataWords.length) { + return out; + } + const length = (dataWords[index] << 5) + dataWords[index + 1]; + index += 2; + if (length < 0 || index + length > dataWords.length) { + return out; + } + const dataSlice = dataWords.slice(index, index + length); + index += length; + const tag = BOLT11_CHARSET[tagCode]; + if (tag === "h" && !out.descriptionHash) { + try { + out.descriptionHash = bytesToHex(Uint8Array.from(bech32.fromWords(dataSlice))); + } catch (error) { + /* leave null */ + } + } else if (tag === "p" && !out.paymentHash) { + try { + out.paymentHash = bytesToHex(Uint8Array.from(bech32.fromWords(dataSlice))); + } catch (error) { + /* leave null */ + } + } + if (out.paymentHash && out.descriptionHash) { + break; + } + } + + return out; +} + function extractDescriptionHashFromBolt11(bolt11) { const normalized = normalizeInvoiceValue(bolt11).toLowerCase(); if (!normalized) { diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index d6ce95fb..ee74a449 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -486,6 +486,15 @@ async function processShare({ metadata, invoice, payment, + // Proof of settlement from NWC pay_invoice (result.preimage), surfaced so + // the caller can publish a preimage-verified bitvid zap tally + // (docs/zap-tally-plan.md §5.3). null when the wallet returns none. + preimage: + (payment && typeof payment === "object" && payment.result && + typeof payment.result.preimage === "string" && + payment.result.preimage.trim()) + ? payment.result.preimage.trim() + : null, zapRequest, status: "success", }; diff --git a/js/payments/zapTallyPublisher.js b/js/payments/zapTallyPublisher.js new file mode 100644 index 00000000..a996cfcd --- /dev/null +++ b/js/payments/zapTallyPublisher.js @@ -0,0 +1,87 @@ +// js/payments/zapTallyPublisher.js +// +// Publishes bitvid-native zap tallies (kind ZAP_TALLY_KIND) after a successful +// zap, so the zapped video/profile's total is global + durable even when the +// recipient's LNURL server never publishes a NIP-57 9735 receipt +// (docs/zap-tally-plan.md §5.4-5.5). Each settled share (creator + platform) +// carries its own invoice/preimage, so each becomes one payer-signed, +// preimage-verified tally. Best-effort: a publish failure never affects the +// payment UX (the sats already moved). + +import { buildZapTallyEvent } from "../nostrEventSchemas.js"; +import { extractBolt11Fields } from "./zapReceiptValidator.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +const tagFromReq = (req, name) => { + const tags = Array.isArray(req?.tags) ? req.tags : []; + const tag = tags.find((t) => Array.isArray(t) && t[0] === name && t[1] != null); + return tag ? String(tag[1]) : ""; +}; + +// Pure: build the unsigned tally event for one settled zap share, or null when +// it can't be made verifiable (no decodable payment_hash / missing proof). +export function buildTallyFromShare({ share, pubkey, now = () => Math.floor(Date.now() / 1000) } = {}) { + if (!share || typeof share !== "object" || !pubkey) { + return null; + } + const bolt11 = typeof share.bolt11 === "string" ? share.bolt11.trim() : ""; + const preimage = typeof share.preimage === "string" ? share.preimage.trim() : ""; + const zapRequestJson = + typeof share.zapRequest === "string" ? share.zapRequest.trim() : ""; + if (!bolt11 || !preimage || !zapRequestJson) { + return null; + } + + const { paymentHash } = extractBolt11Fields(bolt11); + if (!paymentHash) { + return null; // needs payment_hash for the `d` tag + verification + } + + let req = null; + try { + req = JSON.parse(zapRequestJson); + } catch (error) { + return null; + } + + const amountSats = Math.max(0, Math.round(Number(share.amountSats) || 0)); + + return buildZapTallyEvent({ + pubkey, + created_at: now(), + paymentHash, + recipientPubkey: tagFromReq(req, "p"), + eventId: tagFromReq(req, "e"), + coordinate: tagFromReq(req, "a"), + amountMsats: amountSats > 0 ? amountSats * 1000 : undefined, + bolt11, + preimage, + zapRequestJson, + }); +} + +// Publish a tally per settled share. `publish(event)` signs + broadcasts (best +// effort); `enabled` gates the whole thing (FEATURE_ZAP_TALLY). Never throws. +export async function publishZapTallies({ shares, pubkey, publish, enabled = true, now } = {}) { + if (!enabled || typeof publish !== "function" || !pubkey) { + return { published: 0 }; + } + const list = Array.isArray(shares) ? shares : []; + let published = 0; + for (const share of list) { + const event = buildTallyFromShare({ share, pubkey, now }); + if (!event) { + continue; + } + try { + await publish(event); + published += 1; + devLogger.log("[zapTally] Published tally for a settled share."); + } catch (error) { + // The payment already succeeded; a failed tally publish is non-fatal — + // the durable local ledger still shows the zap to the sender. + userLogger.warn("[zapTally] Failed to publish zap tally:", error); + } + } + return { published }; +} diff --git a/js/services/encryptedSyncItem.js b/js/services/encryptedSyncItem.js index 5307fcb6..50cace7b 100644 --- a/js/services/encryptedSyncItem.js +++ b/js/services/encryptedSyncItem.js @@ -16,7 +16,12 @@ // logLabel short label for warnings import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; -import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; +import { + isSyncEnabled, + setSyncEnabled, + getSyncPushedAt, + setSyncPushedAt, +} from "./settingsSyncFlags.js"; import { pushWithConflictCheck } from "./syncConflict.js"; import { userLogger } from "../utils/logger.js"; @@ -100,6 +105,36 @@ export function createEncryptedSyncItem({ return { ...result, imported: true }; } + // #15 follow-up ("doesn't sync unless I click Restore"): a device with sync + // ENABLED auto-pulls a NEWER remote note at login. Previously enabled devices + // only pushed-on-save — edits from another device sat unseen until the manual + // Restore click. The per-device freshness marker doubles as "local state + // matches note X": it's recorded on push AND after a successful pull, so the + // next conflict check stays honest and the same note is never re-pulled. + async function autoPullIfNewer(pubkey) { + const key = normalizePubkey(pubkey); + if (!key || !isAvailable() || !isEnabled(key)) { + return { pulled: false, reason: "not-enabled" }; + } + let remoteCreatedAt = 0; + try { + const peek = await encryptedSync.exists(dtag); + remoteCreatedAt = Number(peek?.createdAt) || 0; + } catch (error) { + return { pulled: false, reason: "peek-failed", cause: error }; + } + const lastSynced = getSyncPushedAt(key, id); + if (!remoteCreatedAt || remoteCreatedAt <= lastSynced) { + return { pulled: false, reason: "up-to-date" }; + } + const result = await pull(key); + if (result?.imported) { + setSyncPushedAt(key, id, remoteCreatedAt); + return { pulled: true, remoteCreatedAt }; + } + return { pulled: false, reason: result?.error || "pull-failed" }; + } + async function enable(pubkey, options = {}) { setEnabledFlag(pubkey, true); return push(pubkey, options); @@ -118,6 +153,7 @@ export function createEncryptedSyncItem({ hasRemote, push, pull, + autoPullIfNewer, enable, disable, }; diff --git a/js/services/playbackService.js b/js/services/playbackService.js index 6abb2d5d..c927ed82 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -988,15 +988,35 @@ class PlaybackSession extends SimpleEventEmitter { { once: true } ); - const probeResult = await this.service.probeHostedUrl({ - url: candidateUrl, - magnet: this.trimmedMagnet, - probeUrl, - }); + // The probe (fetch HEAD) only helps DECIDE whether to fail over to P2P + // fast. When the URL is the only source there's nothing to fail over + // to — and the probe's timeout (URL_PROBE_TIMEOUT_MS, 4s) is LONGER than + // the playback-start window (PLAYBACK_START_TIMEOUT, 3s), so a slow + // no-cors HEAD (e.g. archive.org) would consume the whole budget and the + // attempt would time out before `src` is ever set → a false "No playable + // source found." So skip the probe when there's no magnet and let the + // video element (the real arbiter) load the URL directly. + const probeResult = + this.magnetForPlayback + ? await this.service.probeHostedUrl({ + url: candidateUrl, + magnet: this.trimmedMagnet, + probeUrl, + }) + : { outcome: "skipped-url-only" }; const probeOutcome = probeResult?.outcome || "error"; const probeStatus = probeResult?.status || "unknown"; + // A `
', + ); + const doc = dom.window.document; + global.HTMLElement = dom.window.HTMLElement; + return { + modal: { + selectedConnectionId: null, + inputs: { storageConnection: doc.getElementById("sel") }, + sourceSections: { connectionPicker: doc.getElementById("wrap") }, + getProviderLabel(provider) { + return provider === "cloudflare_r2" ? "Cloudflare R2" : provider; + }, + }, + doc, + }; +} + +test("picker renders options (default labelled, target selected); hidden under two connections", () => { + const { modal } = makeModal(); + + renderConnectionPicker(modal, [r2, b2, s3], b2, b2); + const wrap = modal.sourceSections.connectionPicker; + const options = Array.from(modal.inputs.storageConnection.options); + assert.equal(wrap.classList.contains("hidden"), false, "picker shown"); + assert.deepEqual( + options.map((o) => o.value), + ["c1", "c2", "c3"], + ); + assert.match(options[0].textContent, /Cloudflare R2 — vids/); + assert.match(options[1].textContent, /\(default\)$/, "default is labelled"); + assert.equal( + modal.inputs.storageConnection.value, + "c2", + "target connection pre-selected", + ); + + renderConnectionPicker(modal, [r2, b2, s3], s3, b2); + assert.equal(modal.inputs.storageConnection.value, "c3", "re-render follows the target"); + + renderConnectionPicker(modal, [r2], r2, null); + assert.equal( + wrap.classList.contains("hidden"), + true, + "hidden when there is nothing to choose", + ); +}); diff --git a/tests/video-card-zap-totals.test.mjs b/tests/video-card-zap-totals.test.mjs new file mode 100644 index 00000000..69ac5799 --- /dev/null +++ b/tests/video-card-zap-totals.test.mjs @@ -0,0 +1,89 @@ +// #47 follow-up: video cards show the zap total (sats, orange, right-aligned) +// next to the view count. The badge is ALWAYS visible once bound (mirroring the +// view counter — a zero is a real, informative value), showing "0 sats" until a +// receipt batch lands, then the summed total. +// +// test_integrity_note: +// change_type: ["spec_correction"] +// scenarios: +// - id: SCN-card-zap-badge +// given: "cards bound to pointers with zero then nonzero cached totals" +// when: "bind() runs and a later zapTotals change fires" +// then: "badge visible from bind showing 0 sats; updates to the summed total in place; detached cards pruned" +// observable_outcomes: +// - "badge textContent per state; wrapper visible after bind" +// determinism_controls: +// - "JSDOM; the real zapTotals singleton driven through its store test hook; scripted subscription-manager list double" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false +// if_true_explain_spec_basis: "hidden-when-zero replaced with always-visible (matches the view counter the feature sits beside; the maintainer wants a persistent counter). Equally strict: still asserts exact textContent per state." + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { createZapTotalBinder } from "../js/ui/views/videoCardZapTotals.js"; +import zapTotalsStore, { + initZapTotals, +} from "../js/zapTotals.js"; + +const A1 = "30078:aaaa:video-1"; + +function makeCard(doc) { + const card = doc.createElement("div"); + card.innerHTML = + ''; + doc.body.appendChild(card); + return card; +} + +test("binder: zero stays hidden, receipts reveal and format, prune drops detached", async () => { + const dom = new JSDOM("", { pretendToBeVisual: true }); + const doc = dom.window.document; + + // Drive the REAL singleton store: scripted receipts arrive via its batched + // fetch (subscription-manager list double), so the binder sees the same + // cache + change signal production uses. + let receipts = []; + initZapTotals({ + nostrClient: { + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ list: async () => receipts }), + }, + tools: { nip57: { getSatoshisAmountFromBolt11: () => NaN } }, + }); + + const binder = createZapTotalBinder(); + const card = makeCard(doc); + const badge = card.querySelector("[data-zap-total]"); + const wrapper = card.querySelector(".video-card__zaps"); + + // Tag-style ARRAY pointer — the shape VideoListView actually passes. + const pointerInfo = { key: `a:${A1}`, pointer: ["a", A1] }; + binder.bind(card, pointerInfo); + assert.equal(wrapper.classList.contains("hidden"), false, "visible from bind"); + assert.match(badge.textContent, /^0 sats$/, "shows 0 sats until receipts arrive"); + + // A receipt batch lands: 2100 sats via the zap request's amount tag. + receipts = [ + { + id: "r1", + kind: 9735, + tags: [ + ["a", A1], + ["description", JSON.stringify({ tags: [["amount", "2100000"]] })], + ], + }, + ]; + await zapTotalsStore.flush(); + + assert.equal(wrapper.classList.contains("hidden"), false, "stays visible"); + assert.match(badge.textContent, /2,?100 sats|2\.1K sats/); + + // Detached cards are dropped by prune (no leak, no stale writes). + card.remove(); + binder.prune(); + binder.destroy(); +}); diff --git a/tests/zap-tally-publisher.test.mjs b/tests/zap-tally-publisher.test.mjs new file mode 100644 index 00000000..42b91e08 --- /dev/null +++ b/tests/zap-tally-publisher.test.mjs @@ -0,0 +1,129 @@ +// Publishing side of the bitvid zap tally (docs/zap-tally-plan.md §5.4-5.5): +// each settled zap share becomes one payer-signed tally, built from its own +// invoice/preimage/zap-request. Best-effort — a publish failure must not throw. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-zap-tally-publish +// given: "settled zap shares (with/without a decodable bolt11 or preimage) and an injected publisher" +// when: "buildTallyFromShare / publishZapTallies run" +// then: "a verifiable tally is built + published per settleable share; unpublishable shares are skipped; a publish throw is swallowed; disabled → nothing published" +// observable_outcomes: +// - "built event kind/tags (d=payment_hash, p/e/a, bolt11, preimage, description)" +// - "published count; publish() called once per settleable share" +// determinism_controls: +// - "in-test bech32 bolt11 vector; injected publish(); no network" +// anti_cheat_rationale: +// prevents: ["over-mocking internal logic", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { bech32 } from "../vendor/crypto-helpers.bundle.min.js"; +import { + buildTallyFromShare, + publishZapTallies, +} from "../js/payments/zapTallyPublisher.js"; +import { ZAP_TALLY_KIND } from "../js/nostrEventSchemas.js"; + +const PUB = "a".repeat(64); +const CREATOR = "b".repeat(64); +const EVENT_ID = "e".repeat(64); +const COORD = `30078:${CREATOR}:vid-1`; + +// Minimal valid bolt11 carrying a known payment_hash (bech32 'p' field = code 1). +const lenWords = (n) => [n >> 5, n & 31]; +function makeBolt11(paymentHashBytes) { + const pWords = bech32.toWords(paymentHashBytes); + const words = [ + 0, 0, 0, 0, 0, 0, 0, + 1, ...lenWords(pWords.length), ...pWords, + ...new Array(104).fill(0), + ]; + return bech32.encode("lnbc", words, 2000); +} +const BOLT11 = makeBolt11(new Uint8Array(32).fill(0x11)); + +const zapRequestJson = JSON.stringify({ + kind: 9734, + pubkey: PUB, + tags: [ + ["p", CREATOR], + ["e", EVENT_ID], + ["a", COORD], + ["amount", "2100000"], + ], + content: "", +}); + +const goodShare = { + recipientType: "creator", + amountSats: 2100, + bolt11: BOLT11, + preimage: "cd".repeat(32), + zapRequest: zapRequestJson, +}; + +test("buildTallyFromShare: builds an addressable, proof-carrying tally", () => { + const event = buildTallyFromShare({ share: goodShare, pubkey: PUB, now: () => 1000 }); + assert.ok(event); + assert.equal(event.kind, ZAP_TALLY_KIND); + const tag = (n) => event.tags.find((t) => t[0] === n); + assert.equal(tag("d")[1], "11".repeat(32), "d = payment_hash from the bolt11"); + assert.deepEqual(tag("p"), ["p", CREATOR]); + assert.deepEqual(tag("e"), ["e", EVENT_ID]); + assert.deepEqual(tag("a"), ["a", COORD]); + assert.equal(tag("bolt11")[1], BOLT11); + assert.equal(tag("preimage")[1], "cd".repeat(32)); + assert.ok(tag("description"), "embeds the zap request"); +}); + +test("buildTallyFromShare: skips shares that can't be made verifiable", () => { + assert.equal(buildTallyFromShare({ share: { ...goodShare, preimage: "" }, pubkey: PUB }), null); + assert.equal(buildTallyFromShare({ share: { ...goodShare, bolt11: "not-bolt11" }, pubkey: PUB }), null, "undecodable bolt11 → no payment_hash"); + assert.equal(buildTallyFromShare({ share: { ...goodShare, zapRequest: "" }, pubkey: PUB }), null); + assert.equal(buildTallyFromShare({ share: goodShare, pubkey: "" }), null); +}); + +test("publishZapTallies: one publish per settleable share; unpublishable skipped", async () => { + const published = []; + const result = await publishZapTallies({ + shares: [goodShare, { ...goodShare, preimage: "" }, { ...goodShare, amountSats: 42 }], + pubkey: PUB, + enabled: true, + publish: async (event) => { + published.push(event); + }, + }); + assert.equal(result.published, 2, "two settleable shares published, the no-preimage one skipped"); + assert.equal(published.length, 2); + assert.ok(published.every((e) => e.kind === ZAP_TALLY_KIND)); +}); + +test("publishZapTallies: a publish failure is swallowed (payment already settled)", async () => { + const result = await publishZapTallies({ + shares: [goodShare], + pubkey: PUB, + enabled: true, + publish: async () => { + throw new Error("relay down"); + }, + }); + assert.equal(result.published, 0, "no throw; count reflects the failure"); +}); + +test("publishZapTallies: disabled → nothing published", async () => { + let calls = 0; + const result = await publishZapTallies({ + shares: [goodShare], + pubkey: PUB, + enabled: false, + publish: async () => { + calls += 1; + }, + }); + assert.equal(result.published, 0); + assert.equal(calls, 0); +}); diff --git a/tests/zap-tally-verify.test.mjs b/tests/zap-tally-verify.test.mjs new file mode 100644 index 00000000..3977fd5e --- /dev/null +++ b/tests/zap-tally-verify.test.mjs @@ -0,0 +1,279 @@ +// Foundation for the bitvid-native zap tally (docs/zap-tally-plan.md §3): +// the payer-signed, preimage-verified stand-in for a NIP-57 receipt. These +// cover the security core — a tally is only counted if the invoice was really +// paid (sha256(preimage)===payment_hash) AND that payment was bound to this +// zap request (description_hash===sha256(zapRequest), the replay guard) — plus +// the bolt11 field decode and the event builder's tag shape. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-zap-tally-verify +// given: "a tally event with a bolt11/preimage/zap-request, valid or tampered" +// when: "verifyBitvidZapTally / verifyPaymentPreimage / extractBolt11Fields run" +// then: "valid → ok+sats+pointers from the embedded request; bad preimage or mismatched description hash (replay) → rejected; amount is bolt11-derived not tag-claimed" +// observable_outcomes: +// - "verifyPaymentPreimage true only when sha256(preimage)===payment_hash" +// - "extractBolt11Fields round-trips a bech32-encoded p/h field to hex" +// - "verifyBitvidZapTally ok/sats/pointerTags per tamper case" +// - "buildZapTallyEvent emits d(payment_hash)/p/e/a/amount/bolt11/preimage/description" +// determinism_controls: +// - "sha256 via node:crypto; bech32 vector constructed in-test; no network" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { createHash, randomBytes } from "node:crypto"; +import { bech32, bytesToHex } from "../vendor/crypto-helpers.bundle.min.js"; +import { + extractBolt11Fields, + verifyPaymentPreimage, + verifyBitvidZapTally, +} from "../js/payments/zapReceiptValidator.js"; +import { buildZapTallyEvent, ZAP_TALLY_KIND } from "../js/nostrEventSchemas.js"; + +const sha256hex = (input) => + createHash("sha256") + .update(typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input)) + .digest("hex"); + +// --- verifyPaymentPreimage --------------------------------------------------- + +test("verifyPaymentPreimage: true only when sha256(preimage)===payment_hash", () => { + const preimage = randomBytes(32); + const preimageHex = preimage.toString("hex"); + const paymentHash = sha256hex(preimage); + + assert.equal(verifyPaymentPreimage(preimageHex, paymentHash), true); + assert.equal(verifyPaymentPreimage(preimageHex, "0".repeat(64)), false, "wrong hash"); + assert.equal(verifyPaymentPreimage("ab", paymentHash), false, "preimage not 32 bytes"); + assert.equal(verifyPaymentPreimage(preimageHex, "not-hex"), false); + assert.equal(verifyPaymentPreimage("", paymentHash), false); +}); + +// --- extractBolt11Fields (bech32 round-trip, no external vector) -------------- + +// bech32 charset positions are the bolt11 tagged-field type codes: p=1, h=23. +const lenWords = (n) => [n >> 5, n & 31]; + +function makeBolt11({ paymentHash, descriptionHash }) { + const pWords = bech32.toWords(paymentHash); // 32 bytes -> 52 words + const hWords = bech32.toWords(descriptionHash); + const words = [ + 0, 0, 0, 0, 0, 0, 0, // 35-bit timestamp + 1, ...lenWords(pWords.length), ...pWords, // 'p' payment_hash + 23, ...lenWords(hWords.length), ...hWords, // 'h' description_hash + ...new Array(104).fill(0), // 520-bit signature placeholder + ]; + return bech32.encode("lnbc", words, 2000); +} + +test("extractBolt11Fields decodes payment_hash + description_hash from a bolt11", () => { + const paymentHash = new Uint8Array(32).fill(0xab); + const descriptionHash = new Uint8Array(32).fill(0xcd); + const invoice = makeBolt11({ paymentHash, descriptionHash }); + + const fields = extractBolt11Fields(invoice); + assert.equal(fields.paymentHash, bytesToHex(paymentHash)); + assert.equal(fields.descriptionHash, bytesToHex(descriptionHash)); + + assert.deepEqual(extractBolt11Fields(""), { paymentHash: null, descriptionHash: null }); + assert.deepEqual(extractBolt11Fields("not-an-invoice"), { + paymentHash: null, + descriptionHash: null, + }); +}); + +// --- verifyBitvidZapTally (decision logic; fields injected) ------------------- + +const PUB = "a".repeat(64); +const CREATOR = "b".repeat(64); +const EVENT_ID = "e".repeat(64); +const COORD = `30078:${CREATOR}:vid-1`; + +function makeTally({ preimageHex, zapRequestJson, bolt11 = "lnbcTEST" }) { + return buildZapTallyEvent({ + pubkey: PUB, + created_at: 1000, + paymentHash: sha256hex(Buffer.from(preimageHex, "hex")), + recipientPubkey: CREATOR, + eventId: EVENT_ID, + coordinate: COORD, + amountMsats: 2100000, + bolt11, + preimage: preimageHex, + zapRequestJson, + }); +} + +function zapRequestJson({ amount = 2100000 } = {}) { + return JSON.stringify({ + kind: 9734, + pubkey: PUB, + tags: [ + ["p", CREATOR], + ["e", EVENT_ID], + ["a", COORD], + ["amount", String(amount)], + ], + content: "", + }); +} + +test("verifyBitvidZapTally: valid tally → ok, bolt11-derived sats, request pointers", () => { + const preimageHex = randomBytes(32).toString("hex"); + const reqJson = zapRequestJson(); + const event = makeTally({ preimageHex, zapRequestJson: reqJson }); + + const inject = { + getSats: () => 2100, // authoritative amount from the bolt11 (2100 sats) + extractFields: () => ({ + paymentHash: sha256hex(Buffer.from(preimageHex, "hex")), + descriptionHash: sha256hex(reqJson), + }), + }; + const result = verifyBitvidZapTally(event, inject); + assert.equal(result.ok, true); + assert.equal(result.sats, 2100, "sats from bolt11, not the amount tag"); + assert.ok(result.paymentHash); + // Pointers come from the embedded (hash-bound) zap request. + const kinds = result.pointerTags.map((t) => t[0]).sort(); + assert.deepEqual(kinds, ["a", "e", "p"]); +}); + +test("verifyBitvidZapTally: bad preimage → rejected", () => { + const preimageHex = randomBytes(32).toString("hex"); + const reqJson = zapRequestJson(); + const event = makeTally({ preimageHex, zapRequestJson: reqJson }); + const result = verifyBitvidZapTally(event, { + getSats: () => 2100, + extractFields: () => ({ + paymentHash: "f".repeat(64), // does NOT match sha256(preimage) + descriptionHash: sha256hex(reqJson), + }), + }); + assert.equal(result.ok, false); +}); + +test("verifyBitvidZapTally: mismatched description hash (replay) → rejected", () => { + const preimageHex = randomBytes(32).toString("hex"); + const reqJson = zapRequestJson(); + const event = makeTally({ preimageHex, zapRequestJson: reqJson }); + // A leaked preimage retargeted to a DIFFERENT request: description_hash no + // longer matches the embedded zap request → binding check fails. + const result = verifyBitvidZapTally(event, { + getSats: () => 2100, + extractFields: () => ({ + paymentHash: sha256hex(Buffer.from(preimageHex, "hex")), + descriptionHash: sha256hex(zapRequestJson({ amount: 999999 })), // different request + }), + }); + assert.equal(result.ok, false, "cannot reuse a preimage for another zap request"); +}); + +test("verifyBitvidZapTally: zero/undecodable sats → rejected; missing proof → rejected", () => { + const preimageHex = randomBytes(32).toString("hex"); + const reqJson = zapRequestJson(); + const event = makeTally({ preimageHex, zapRequestJson: reqJson }); + assert.equal( + verifyBitvidZapTally(event, { + getSats: () => 0, + extractFields: () => ({ + paymentHash: sha256hex(Buffer.from(preimageHex, "hex")), + descriptionHash: sha256hex(reqJson), + }), + }).ok, + false, + ); + assert.equal(verifyBitvidZapTally({ tags: [] }).ok, false, "no bolt11/preimage"); + assert.equal(verifyBitvidZapTally(null).ok, false); +}); + +// --- buildZapTallyEvent ------------------------------------------------------ + +test("buildZapTallyEvent: addressable by payment_hash + carries the proof tags", () => { + const event = buildZapTallyEvent({ + pubkey: PUB, + created_at: 1000, + paymentHash: "ab".repeat(32), + recipientPubkey: CREATOR, + eventId: EVENT_ID, + coordinate: COORD, + amountMsats: 2100000, + bolt11: "lnbc21u1...", + preimage: "cd".repeat(32), + zapRequestJson: zapRequestJson(), + }); + assert.equal(event.kind, ZAP_TALLY_KIND); + const tag = (name) => event.tags.find((t) => t[0] === name); + assert.deepEqual(tag("d"), ["d", "ab".repeat(32)], "d = payment_hash (addressable idempotency)"); + assert.deepEqual(tag("p"), ["p", CREATOR]); + assert.deepEqual(tag("e"), ["e", EVENT_ID]); + assert.deepEqual(tag("a"), ["a", COORD]); + assert.equal(tag("bolt11")[1], "lnbc21u1..."); + assert.equal(tag("preimage")[1], "cd".repeat(32)); + assert.ok(tag("description"), "embeds the zap request"); + assert.deepEqual(tag("client"), ["client", "bitvid"]); +}); + +// End-to-end with the REAL verifier + a REAL amount-bearing bolt11: proves the +// pieces compose (no injected extractFields/getSats). The invoice encodes an +// amount (lnbc21u = 2100 sats), a payment_hash = sha256(preimage), and a +// description_hash = sha256(zap request); the real nip57 amount parser reads it. +test("verifyBitvidZapTally: accepts a fully valid tally end to end (real decode + amount)", async () => { + const { nip57 } = await import("nostr-tools"); + const preimage = randomBytes(32); + const preimageHex = preimage.toString("hex"); + const paymentHashBytes = createHash("sha256").update(preimage).digest(); + const reqJson = zapRequestJson(); + const descHashBytes = createHash("sha256").update(Buffer.from(reqJson, "utf8")).digest(); + + const pWords = bech32.toWords(new Uint8Array(paymentHashBytes)); + const hWords = bech32.toWords(new Uint8Array(descHashBytes)); + const words = [ + 0, 0, 0, 0, 0, 0, 0, + 1, ...lenWords(pWords.length), ...pWords, // 'p' payment_hash + 23, ...lenWords(hWords.length), ...hWords, // 'h' description_hash + ...new Array(104).fill(0), + ]; + const bolt11 = bech32.encode("lnbc21u", words, 2000); // 21 micro-BTC = 2100 sats + + const event = buildZapTallyEvent({ + pubkey: PUB, + created_at: 1000, + paymentHash: paymentHashBytes.toString("hex"), + recipientPubkey: CREATOR, + eventId: EVENT_ID, + coordinate: COORD, + amountMsats: 2100000, + bolt11, + preimage: preimageHex, + zapRequestJson: reqJson, + }); + + const result = verifyBitvidZapTally(event, { + getSats: nip57.getSatoshisAmountFromBolt11, // real parser, no injection of fields + }); + assert.equal(result.ok, true, "real verifier accepts a genuinely valid tally"); + assert.equal(result.sats, 2100, "amount parsed from the real bolt11 hrp"); + assert.equal(result.paymentHash, paymentHashBytes.toString("hex")); +}); + +test("buildZapTallyEvent: profile-only zap has p but no a/e", () => { + const event = buildZapTallyEvent({ + pubkey: PUB, + created_at: 1000, + paymentHash: "ab".repeat(32), + recipientPubkey: CREATOR, + amountMsats: 1000, + bolt11: "lnbc10n1...", + preimage: "cd".repeat(32), + zapRequestJson: JSON.stringify({ kind: 9734, tags: [["p", CREATOR]] }), + }); + assert.ok(event.tags.find((t) => t[0] === "p")); + assert.equal(event.tags.find((t) => t[0] === "e"), undefined); + assert.equal(event.tags.find((t) => t[0] === "a"), undefined); +}); diff --git a/tests/zap-totals.test.mjs b/tests/zap-totals.test.mjs new file mode 100644 index 00000000..15e34ca9 --- /dev/null +++ b/tests/zap-totals.test.mjs @@ -0,0 +1,337 @@ +// #47 "Most Zapped": per-video zap totals aggregated from kind-9735 receipts. +// Covers the amount extraction (bolt11 first, zap-request amount fallback), +// the batched store (request → scheduled fetch → totals; receipt dedupe; +// zero-receipt answers cached; change signal), and the feed sorter that ranks +// by the injected zap total. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-zap-total-aggregation +// given: "a fake relay pool returning scripted 9735 receipts for requested #a/#e pointers" +// when: "pointers are requested and the batch flushes" +// then: "totals sum per pointer, duplicate receipt ids never double-count, empty answers cache as 0, listeners fire" +// - id: SCN-most-zapped-sorter +// given: "feed items with differing zap totals / authors / mute states" +// when: "createMostZappedSorter runs" +// then: "sats desc, recency tie-break, trusted-muted sink last" +// observable_outcomes: +// - "getSnapshot/request return the summed sats" +// - "sorted order of feed items" +// determinism_controls: +// - "injected now/schedule; manual flush(); no timers or sockets" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { + createZapTotalsStore, + extractReceiptAmountSats, + pointerKey, + ZAP_RECEIPT_KIND, +} from "../js/zapTotals.js"; +import { createMostZappedSorter } from "../js/feedEngine/sorters.js"; + +const A1 = "30078:aaaa:video-1"; +const A2 = "30078:bbbb:video-2"; + +const receipt = (id, tags) => ({ id, kind: ZAP_RECEIPT_KIND, tags }); + +test("amount extraction: bolt11 first, zap-request amount fallback, garbage → 0", () => { + const tools = { + nip57: { getSatoshisAmountFromBolt11: (b) => (b === "lnbc210n1..." ? 21 : NaN) }, + }; + assert.equal( + extractReceiptAmountSats(receipt("r1", [["bolt11", "lnbc210n1..."]]), tools), + 21, + ); + // bolt11 unparseable → embedded zap request amount (msats → sats) + const description = JSON.stringify({ tags: [["amount", "50000"]] }); + assert.equal( + extractReceiptAmountSats( + receipt("r2", [["bolt11", "garbage"], ["description", description]]), + tools, + ), + 50, + ); + assert.equal(extractReceiptAmountSats(receipt("r3", []), tools), 0); + assert.equal( + extractReceiptAmountSats(receipt("r4", [["description", "not json"]]), tools), + 0, + ); +}); + +test("pointerKey accepts BOTH app shapes and rejects unusable pointers", () => { + // Tag-style ARRAY — what deriveVideoPointerInfo/resolveVideoPointer actually + // produce (regression: the object-only version silently zeroed every card + // badge and the Most Zapped ranking). + assert.equal(pointerKey(["a", A1]), `a:${A1}`); + assert.equal(pointerKey(["e", "evt1", "wss://relay.example"]), "e:evt1"); + assert.equal(pointerKey(["x", "nope"]), ""); + // Object form. + assert.equal(pointerKey({ type: "a", value: A1 }), `a:${A1}`); + assert.equal(pointerKey({ type: "e", value: "evt1" }), "e:evt1"); + assert.equal(pointerKey({ type: "x", value: "nope" }), ""); + assert.equal(pointerKey(null), ""); +}); + +function makeStore({ receipts = [], now = () => 1000 } = {}) { + const listCalls = []; + const scheduled = []; + const store = createZapTotalsStore({ + now, + persistKey: null, // in-memory; the durable ledger has its own test + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ + list: async ({ relays, filters }) => { + listCalls.push({ relays, filters }); + return receipts; + }, + }), + }), + schedule: (fn) => { + scheduled.push(fn); + return 1; + }, + }); + return { store, listCalls, scheduled }; +} + +const amountTag = (msats) => [ + "description", + JSON.stringify({ tags: [["amount", String(msats)]] }), +]; + +test("batched fetch sums receipts per pointer and dedupes by receipt id", async () => { + const { store, listCalls } = makeStore({ + receipts: [ + receipt("r1", [["a", A1], amountTag(21000)]), + receipt("r2", [["a", A1], amountTag(100000)]), + receipt("r3", [["a", A2], amountTag(5000)]), + receipt("r3", [["a", A2], amountTag(5000)]), // duplicate id — ignored + ], + }); + + assert.equal(store.request({ type: "a", value: A1 }), 0, "cold cache reads 0"); + store.request({ type: "a", value: A2 }); + await store.flush(); + + assert.equal(store.getSnapshot({ type: "a", value: A1 }), 121); + assert.equal(store.getSnapshot({ type: "a", value: A2 }), 5); + assert.equal(listCalls.length, 1, "one batched fetch for both pointers"); + assert.equal( + listCalls[0].filters[0]["#a"].length, + 2, + "both pointers in one #a filter", + ); +}); + +test("zero-receipt answers are cached (no immediate refetch) and listeners fire", async () => { + let t = 1000; + const { store, listCalls, scheduled } = makeStore({ receipts: [], now: () => t }); + let changes = 0; + store.onChange(() => { + changes += 1; + }); + + store.request({ type: "e", value: "evt1" }); + await store.flush(); + assert.equal(changes, 1, "an answered batch notifies even when empty"); + assert.equal(store.getSnapshot({ type: "e", value: "evt1" }), 0); + + const schedulesBefore = scheduled.length; + store.request({ type: "e", value: "evt1" }); + assert.equal(scheduled.length, schedulesBefore, "fresh zero answer is not refetched"); + assert.equal(listCalls.length, 1); + + // After the TTL, the same pointer refetches. + t += 10 * 60 * 1000; + store.request({ type: "e", value: "evt1" }); + assert.equal(scheduled.length, schedulesBefore + 1, "stale entry schedules again"); +}); + +test("optimistic ingest bumps instantly, then a real receipt replaces it (no double-count)", async () => { + let t = 1000; + let scripted = []; + const s = createZapTotalsStore({ + now: () => t, + persistKey: null, + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ list: async () => scripted }), + }), + schedule: (fn) => { fn(); return 1; }, + }); + let changes = 0; + s.onChange(() => { changes += 1; }); + + const pointer = { type: "a", value: A1 }; + // Optimistic bump shows immediately, no fetch needed. + s.ingestLocalZap(pointer, 2100); + assert.equal(s.getSnapshot(pointer), 2100, "optimistic sats visible instantly"); + assert.ok(changes >= 1, "change emitted for the badge"); + + // Later, past the fetch TTL, the real receipt for the same zap lands → + // replaces the optimistic portion (total stays 2100, not 4200). + t += 5 * 60 * 1000; + scripted = [receipt("real-1", [["a", A1], amountTag(2100000)])]; + s.request(pointer); // stale now → schedules; schedule() runs it inline + await s.flush(); + assert.equal( + s.getSnapshot(pointer), + 2100, + "real receipt replaced the optimistic bump — not added to it", + ); +}); + +test("durable ledger: sent zaps persist across reload; a real receipt prunes them", async () => { + const KEY = "bitvid:sentZaps:test"; + localStorage.removeItem(KEY); + const pointer = { type: "a", value: A1 }; + + // Session 1: send a zap. No receipt is ever published (Strike-style). + const s1 = createZapTotalsStore({ persistKey: KEY }); + s1.ingestLocalZap(pointer, 2100); + assert.equal(s1.getSnapshot(pointer), 2100); + + // Session 2 (reload): a fresh store seeds from the durable ledger — the zap + // still shows, so the badge + Most-Zapped rank survive with no relay receipt. + const s2 = createZapTotalsStore({ persistKey: KEY }); + assert.equal(s2.getSnapshot(pointer), 2100, "sent zap persisted across reload"); + + // Session 3: now a real receipt for this video DOES appear on relays → the + // relay becomes authoritative and the ledger entry is pruned (no double-count). + let scripted = [receipt("r-1", [["a", A1], amountTag(2100000)])]; + const s3 = createZapTotalsStore({ + persistKey: KEY, + now: () => 10 * 60 * 1000, // past the fetch TTL so the seeded entry is stale + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ list: async () => scripted }), + }), + schedule: (fn) => { fn(); return 1; }, + }); + s3.request(pointer); + await s3.flush(); + assert.equal(s3.getSnapshot(pointer), 2100, "relay receipt now the source (not 4200)"); + + // Session 4 (reload after prune): ledger cleared, so only relay data remains. + const s4 = createZapTotalsStore({ persistKey: KEY }); + assert.equal(s4.getSnapshot(pointer), 0, "ledger pruned once the relay had a receipt"); + localStorage.removeItem(KEY); +}); + +// bitvid tally counting (docs/zap-tally-plan.md §4): tallies are verified, +// deduped against 9735s by payment_hash, and gated by isTallyEnabled. +const tallyEvent = (id, { pointerA, paymentHash, valid = true }) => ({ + id, + kind: 30081, + tags: [ + ["d", paymentHash], + ["a", pointerA], + ["bolt11", `lnbc-${paymentHash}`], + ["preimage", `pre-${paymentHash}`], + ["description", "{}"], + ], + __valid: valid, +}); + +function makeTallyStore({ receipts = [], enabled = true } = {}) { + // Inject a verifier that trusts our fixtures' __valid flag and reports a + // fixed sats amount + the event's own a/e pointer tags + payment_hash (d tag). + const verifyTally = (event) => { + if (!event?.__valid) return { ok: false }; + const d = event.tags.find((t) => t[0] === "d")?.[1]; + const pointerTags = event.tags.filter((t) => t[0] === "a" || t[0] === "e" || t[0] === "p"); + return { ok: true, sats: 500, paymentHash: d, pointerTags }; + }; + return createZapTotalsStore({ + persistKey: null, + isTallyEnabled: () => enabled, + verifyTally, + tallyKind: 30081, + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ list: async () => receipts }), + }), + schedule: (fn) => { fn(); return 1; }, + }); +} + +test("tally counting: a verified tally adds sats; an invalid one is ignored", async () => { + const store = makeTallyStore({ + receipts: [ + tallyEvent("t1", { pointerA: A1, paymentHash: "ph-1", valid: true }), + tallyEvent("t2", { pointerA: A1, paymentHash: "ph-2", valid: false }), + ], + }); + store.request({ type: "a", value: A1 }); + await store.flush(); + assert.equal(store.getSnapshot({ type: "a", value: A1 }), 500, "only the valid tally counts"); +}); + +test("cross-source dedup: a 9735 and a tally with the same payment_hash count once", async () => { + // The 9735 carries a bolt11 whose payment_hash the store extracts; make the + // tally's d (payment_hash) match so it's recognized as the same payment. + // Here the verifier reports paymentHash = the tally's d tag; the 9735's + // payment_hash is derived from its bolt11 — for the test we align them by + // using a store whose 9735 amount path yields 500 and a shared hash "dup". + const shared = "dupHASH"; + // A 9735 whose extractBolt11Fields yields a payment_hash is hard to fake + // without a real bolt11, so this test asserts the tally-vs-tally dedup (same + // payment_hash across two tally events) which uses the identical code path. + const store = makeTallyStore({ + receipts: [ + tallyEvent("t1", { pointerA: A1, paymentHash: shared, valid: true }), + tallyEvent("t2", { pointerA: A1, paymentHash: shared, valid: true }), // same payment + ], + }); + store.request({ type: "a", value: A1 }); + await store.flush(); + assert.equal( + store.getSnapshot({ type: "a", value: A1 }), + 500, + "same payment_hash counted once, not 1000", + ); +}); + +test("tally counting is gated: disabled → tallies ignored", async () => { + const store = makeTallyStore({ + enabled: false, + receipts: [tallyEvent("t1", { pointerA: A1, paymentHash: "ph-1", valid: true })], + }); + store.request({ type: "a", value: A1 }); + await store.flush(); + assert.equal(store.getSnapshot({ type: "a", value: A1 }), 0, "flag off → not counted"); +}); + +test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => { + const item = (id, author, sats, createdAt, muted = false) => ({ + video: { id, pubkey: author, created_at: createdAt }, + metadata: muted ? { moderation: { trustedMuted: true } } : {}, + sats, + }); + const items = [ + item("low", "a1", 5, 300), + item("high", "a2", 500, 100), + item("tie-new", "a3", 50, 200), + item("tie-old", "a4", 50, 100), + item("muted-whale", "a5", 9999, 400, true), + ]; + const sorter = createMostZappedSorter(); + const sorted = sorter(items, { runtime: { getZapTotal: (video) => items.find((i) => i.video === video)?.sats || 0 } }); + assert.deepEqual( + sorted.map((entry) => entry.video.id), + ["high", "tie-new", "tie-old", "low", "muted-whale"], + "sats desc, newer wins ties, trusted-muted last regardless of sats", + ); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index a94824a9..079a95dd 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -555,21 +555,34 @@ toward freshness and looked identical. Gave each a structural identity: the active signer if ever reused. ### 43. Logged-in profile card at the top of the profile modal (UX) -- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the - profile modal, styled as a "profile card" that sits **half-on / half-off** the - modal edge (overlapping the top border) for a polished look. Goal: users can see - WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name - from the profile cache for the active pubkey; update on profile switch. +- [x] **DONE 2026-07-03.** An "Acting as " identity pill now renders + top-center on EVERY pane, straddling the header divider (negative + block-start margin — the modal is full-screen, so the header edge is the + overlap surface). Avatar + name come from the same resolveMeta path as + the Account pane card (profile cache first), populated in + renderSavedProfiles so it refreshes on every profile switch; hidden when + logged out; clicking it jumps to the Account pane. Markup in + components/profile-modal.html, styles in css/tailwind.source.css + (.profile-modal__identity*), wiring in profileModalController. Test in + tests/profile-modal-controller.test.mjs (26 pass). Lint + build green. +- [ ] **VERIFY on unstable:** open any pane → pill shows the active identity; + switch accounts → it updates; log out → it disappears; click → Account. ### 44. In-modal storage selector for uploads (UX) -- [ ] Make storage unlock + selection easier from the **video upload modal**: let the - user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it - **without leaving the upload modal** — today they must close it, open Profile → - Storage, change the default/selected connection, then reopen upload. Add a provider - picker (driven by `storageService.listConnections`) + inline unlock in the upload - modal's storage section; selecting one sets it as the active upload target for this - upload (and optionally the default). Reuse `loadFromStorage` / the per-provider - connection model. +- [x] **DONE 2026-07-03.** The upload modal's storage summary now has an + "Upload destination"