mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
af09505fb3
commit
cbc62306a4
2 files changed
+111
-9
No files matched your search
+71
-9
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user