zaps: durably persist sent zaps so they survive reload + rank on Most Zapped

Root cause the user hit: NWC doesn't publish zap receipts, and neither does
bitvid — per NIP-57 the recipient's LNURL server publishes the kind-9735
receipt. Custodial providers (Strike) routinely don't, so a paid zap leaves
NO on-relay record to count, and the in-memory optimistic bump vanished on
reload → zapped videos showed 0 and never rose on Most Zapped.

Add a durable local ledger (localStorage bitvid:sentZaps:v1) of the user's
OWN sent zaps, seeded into the store's optimistic layer at construction so
the badge + Most-Zapped rank survive reloads with no receipt. Reconciliation:
when a real 9735 receipt for a pointer later appears on relays, the relay
becomes authoritative and that pointer's ledger entry is pruned, so a
receipt-publishing recipient never double-counts. ingestLocalZap writes the
ledger; runBatch prunes it.

Live-verified: card badge 0 -> 4.3K sats on send, still 4.3K after a full
page reload, ledger present in localStorage. Tests cover persist-across-
reload and prune-on-real-receipt.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 15:05:01 -04:00
1 parent af09505fb3
commit cbc62306a4
2 files changed
+111 -9

No files matched your search

+71 -9
View File
@@ -15,6 +15,7 @@
import { devLogger } from "./utils/logger.js";
export const ZAP_RECEIPT_KIND = 9735;
export const SENT_ZAPS_STORAGE_KEY = "bitvid:sentZaps:v1";
const FETCH_TTL_MS = 120000;
const BATCH_DELAY_MS = 150;
const MAX_POINTERS_PER_FILTER = 100;
@@ -88,17 +89,53 @@ export function createZapTotalsStore({
getClient = () => null,
getTools = () => null,
schedule = (fn, ms) => setTimeout(fn, ms),
// Durable ledger of the user's OWN sent zaps (localStorage key), so their
// zapped videos keep the count + Most-Zapped rank across reloads even when
// the recipient's LNURL server never publishes a 9735 receipt (custodial
// wallets like Strike routinely don't). Pass null to disable (tests).
persistKey = SENT_ZAPS_STORAGE_KEY,
} = {}) {
// key → { sats, optimisticSats, receiptIds:Set, fetchedAt }
// sats — summed from real relay receipts (deduped by event id)
// optimisticSats — this session's own just-sent zaps, shown instantly
// (like ingestLocalViewEvent), cleared once a real
// receipt for the pointer arrives so it's never double-counted
// optimisticSats — the user's OWN sent zaps (durable ledger + session
// bumps), shown instantly like ingestLocalViewEvent.
// Cleared once a real receipt for the pointer arrives, so
// if the recipient DOES publish, the relay becomes
// authoritative and the value is never double-counted.
const totals = new Map();
const pending = new Map(); // key → pointer
const listeners = new Set();
let batchTimer = null;
// --- durable sent-zap ledger (localStorage: { pointerKey: sats }) ---
const readLedger = () => {
if (!persistKey || typeof localStorage === "undefined") {
return {};
}
try {
const raw = localStorage.getItem(persistKey);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
};
const writeLedger = (ledger) => {
if (!persistKey || typeof localStorage === "undefined") {
return;
}
try {
const keys = Object.keys(ledger || {});
if (!keys.length) {
localStorage.removeItem(persistKey);
} else {
localStorage.setItem(persistKey, JSON.stringify(ledger));
}
} catch (error) {
/* best-effort */
}
};
const entryFor = (key) => {
let entry = totals.get(key);
if (!entry) {
@@ -108,6 +145,14 @@ export function createZapTotalsStore({
return entry;
};
// Seed the optimistic layer from the durable ledger at construction.
for (const [key, sats] of Object.entries(readLedger())) {
const amount = Number(sats);
if (typeof key === "string" && key && Number.isFinite(amount) && amount > 0) {
entryFor(key).optimisticSats = Math.round(amount);
}
}
const totalOf = (entry) =>
(entry?.sats || 0) + (entry?.optimisticSats || 0);
@@ -208,12 +253,24 @@ export function createZapTotalsStore({
}
}
// A pointer that now has real receipts is authoritative — drop its
// optimistic bump so this session's just-sent zap isn't counted twice.
for (const key of keysWithRealReceipt) {
const entry = totals.get(key);
if (entry && entry.optimisticSats > 0) {
entry.optimisticSats = 0;
changed = true;
// optimistic bump AND its durable ledger entry, so once the relay actually
// reflects zaps for this video we trust it and never double-count.
if (keysWithRealReceipt.size) {
const ledger = readLedger();
let ledgerChanged = false;
for (const key of keysWithRealReceipt) {
const entry = totals.get(key);
if (entry && entry.optimisticSats > 0) {
entry.optimisticSats = 0;
changed = true;
}
if (key in ledger) {
delete ledger[key];
ledgerChanged = true;
}
}
if (ledgerChanged) {
writeLedger(ledger);
}
}
if (changed || batch.size) {
@@ -261,6 +318,11 @@ export function createZapTotalsStore({
const entry = entryFor(key);
entry.optimisticSats += amount;
entry.fetchedAt = now();
// Persist to the durable ledger so the badge + Most-Zapped rank survive a
// reload even when no 9735 receipt is ever published for this zap.
const ledger = readLedger();
ledger[key] = (Number(ledger[key]) || 0) + amount;
writeLedger(ledger);
emitChange();
},
onChange(listener) {
+40
View File
@@ -84,6 +84,7 @@ function makeStore({ receipts = [], now = () => 1000 } = {}) {
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"],
@@ -160,6 +161,7 @@ test("optimistic ingest bumps instantly, then a real receipt replaces it (no dou
let scripted = [];
const s = createZapTotalsStore({
now: () => t,
persistKey: null,
getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }),
getClient: () => ({
relays: ["wss://relay.example"],
@@ -189,6 +191,44 @@ test("optimistic ingest bumps instantly, then a real receipt replaces it (no dou
);
});
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);
});
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 },