mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 10:22:25 +00:00
zaps: count bitvid tallies in the zap-totals store (#47)
Second slice of docs/zap-tally-plan.md §4. runBatch now queries kinds [9735, ZAP_TALLY_KIND] in one call and folds both sources: - 9735 receipts: unchanged sum, but now also records each receipt's payment_hash (from its bolt11) for cross-source dedup. - bitvid tallies: verifyBitvidZapTally (preimage + description-hash bound), amount from the bolt11, pointers from the embedded zap request; counted once per payment_hash so a payment that yields BOTH a 9735 and a tally is never double-counted. Entries gain a paymentHashes set. Counting is gated by isTallyEnabled (singleton reads FEATURE_ZAP_TALLY live). The existing "relay wins" prune clears the optimistic/ledger overlay once a real tally or receipt lands, so the sender's own published tally reconciles the instant bump with no extra plumbing. Tests: cross-source/same-payment dedup, invalid-tally rejection, flag gating (tests/zap-totals.test.mjs). Lint + build green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
a6b2996f4e
commit
025ab3487d
2 files changed
+164
-4
No files matched your search
+79
-4
@@ -13,6 +13,12 @@
|
|||||||
// double-count.
|
// double-count.
|
||||||
|
|
||||||
import { devLogger } from "./utils/logger.js";
|
import { devLogger } from "./utils/logger.js";
|
||||||
|
import {
|
||||||
|
verifyBitvidZapTally,
|
||||||
|
extractBolt11Fields,
|
||||||
|
} from "./payments/zapReceiptValidator.js";
|
||||||
|
import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js";
|
||||||
|
import { FEATURE_ZAP_TALLY } from "./constants.js";
|
||||||
|
|
||||||
export const ZAP_RECEIPT_KIND = 9735;
|
export const ZAP_RECEIPT_KIND = 9735;
|
||||||
export const SENT_ZAPS_STORAGE_KEY = "bitvid:sentZaps:v1";
|
export const SENT_ZAPS_STORAGE_KEY = "bitvid:sentZaps:v1";
|
||||||
@@ -89,6 +95,12 @@ export function createZapTotalsStore({
|
|||||||
getClient = () => null,
|
getClient = () => null,
|
||||||
getTools = () => null,
|
getTools = () => null,
|
||||||
schedule = (fn, ms) => setTimeout(fn, ms),
|
schedule = (fn, ms) => setTimeout(fn, ms),
|
||||||
|
// bitvid-native zap tally (docs/zap-tally-plan.md): count kind-30081
|
||||||
|
// preimage-verified tallies alongside 9735s, deduped by payment_hash.
|
||||||
|
// Injectable for tests; the singleton wires the real verifier + flag.
|
||||||
|
isTallyEnabled = () => true,
|
||||||
|
verifyTally = verifyBitvidZapTally,
|
||||||
|
tallyKind = ZAP_TALLY_KIND,
|
||||||
// Durable ledger of the user's OWN sent zaps (localStorage key), so their
|
// 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
|
// zapped videos keep the count + Most-Zapped rank across reloads even when
|
||||||
// the recipient's LNURL server never publishes a 9735 receipt (custodial
|
// the recipient's LNURL server never publishes a 9735 receipt (custodial
|
||||||
@@ -139,7 +151,15 @@ export function createZapTotalsStore({
|
|||||||
const entryFor = (key) => {
|
const entryFor = (key) => {
|
||||||
let entry = totals.get(key);
|
let entry = totals.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { sats: 0, optimisticSats: 0, receiptIds: new Set(), fetchedAt: 0 };
|
entry = {
|
||||||
|
sats: 0,
|
||||||
|
optimisticSats: 0,
|
||||||
|
receiptIds: new Set(),
|
||||||
|
// Cross-source dedup: a single payment can yield BOTH a 9735 and a
|
||||||
|
// bitvid tally; count its sats once (docs/zap-tally-plan.md §4).
|
||||||
|
paymentHashes: new Set(),
|
||||||
|
fetchedAt: 0,
|
||||||
|
};
|
||||||
totals.set(key, entry);
|
totals.set(key, entry);
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
@@ -192,17 +212,26 @@ export function createZapTotalsStore({
|
|||||||
else if (pointer.type === "e") eValues.push(pointer.value);
|
else if (pointer.type === "e") eValues.push(pointer.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One query for both sources: real 9735 receipts + bitvid tallies.
|
||||||
|
const tallyOn = (() => {
|
||||||
|
try {
|
||||||
|
return isTallyEnabled() !== false;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const kinds = tallyOn ? [ZAP_RECEIPT_KIND, tallyKind] : [ZAP_RECEIPT_KIND];
|
||||||
const filters = [];
|
const filters = [];
|
||||||
for (let i = 0; i < aValues.length; i += MAX_POINTERS_PER_FILTER) {
|
for (let i = 0; i < aValues.length; i += MAX_POINTERS_PER_FILTER) {
|
||||||
filters.push({
|
filters.push({
|
||||||
kinds: [ZAP_RECEIPT_KIND],
|
kinds,
|
||||||
"#a": aValues.slice(i, i + MAX_POINTERS_PER_FILTER),
|
"#a": aValues.slice(i, i + MAX_POINTERS_PER_FILTER),
|
||||||
limit: RECEIPTS_PER_FILTER_LIMIT,
|
limit: RECEIPTS_PER_FILTER_LIMIT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (let i = 0; i < eValues.length; i += MAX_POINTERS_PER_FILTER) {
|
for (let i = 0; i < eValues.length; i += MAX_POINTERS_PER_FILTER) {
|
||||||
filters.push({
|
filters.push({
|
||||||
kinds: [ZAP_RECEIPT_KIND],
|
kinds,
|
||||||
"#e": eValues.slice(i, i + MAX_POINTERS_PER_FILTER),
|
"#e": eValues.slice(i, i + MAX_POINTERS_PER_FILTER),
|
||||||
limit: RECEIPTS_PER_FILTER_LIMIT,
|
limit: RECEIPTS_PER_FILTER_LIMIT,
|
||||||
});
|
});
|
||||||
@@ -227,7 +256,11 @@ export function createZapTotalsStore({
|
|||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const keysWithRealReceipt = new Set();
|
const keysWithRealReceipt = new Set();
|
||||||
for (const event of Array.isArray(events) ? events : []) {
|
const list = Array.isArray(events) ? events : [];
|
||||||
|
|
||||||
|
// 1) Real NIP-57 receipts (9735). Record each payment_hash so a matching
|
||||||
|
// bitvid tally for the same payment isn't also counted.
|
||||||
|
for (const event of list) {
|
||||||
if (!event || event.kind !== ZAP_RECEIPT_KIND || !event.id) {
|
if (!event || event.kind !== ZAP_RECEIPT_KIND || !event.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -239,6 +272,8 @@ export function createZapTotalsStore({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const sats = extractReceiptAmountSats(event, getTools());
|
const sats = extractReceiptAmountSats(event, getTools());
|
||||||
|
const paymentHash = extractBolt11Fields(tagValues(event, "bolt11")[0] || "")
|
||||||
|
.paymentHash;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const entry = entryFor(key);
|
const entry = entryFor(key);
|
||||||
keysWithRealReceipt.add(key);
|
keysWithRealReceipt.add(key);
|
||||||
@@ -246,12 +281,50 @@ export function createZapTotalsStore({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
entry.receiptIds.add(event.id);
|
entry.receiptIds.add(event.id);
|
||||||
|
if (paymentHash) {
|
||||||
|
entry.paymentHashes.add(paymentHash);
|
||||||
|
}
|
||||||
if (sats > 0) {
|
if (sats > 0) {
|
||||||
entry.sats += sats;
|
entry.sats += sats;
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) bitvid tallies (verified; deduped by payment_hash vs. 9735s + earlier
|
||||||
|
// tallies). Pointers come from the embedded, hash-bound zap request.
|
||||||
|
if (tallyOn) {
|
||||||
|
for (const event of list) {
|
||||||
|
if (!event || event.kind !== tallyKind) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let verdict;
|
||||||
|
try {
|
||||||
|
verdict = verifyTally(event, {
|
||||||
|
getSats: getTools()?.nip57?.getSatoshisAmountFromBolt11,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
verdict = null;
|
||||||
|
}
|
||||||
|
if (!verdict?.ok || !(verdict.sats > 0) || !verdict.paymentHash) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const keys = verdict.pointerTags
|
||||||
|
.filter((t) => t[0] === "a" || t[0] === "e")
|
||||||
|
.map((t) => `${t[0]}:${t[1]}`)
|
||||||
|
.filter((key) => batch.has(key) || totals.has(key));
|
||||||
|
for (const key of keys) {
|
||||||
|
const entry = entryFor(key);
|
||||||
|
keysWithRealReceipt.add(key);
|
||||||
|
if (entry.paymentHashes.has(verdict.paymentHash)) {
|
||||||
|
continue; // already counted via a 9735 or an earlier tally
|
||||||
|
}
|
||||||
|
entry.paymentHashes.add(verdict.paymentHash);
|
||||||
|
entry.sats += verdict.sats;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// A pointer that now has real receipts is authoritative — drop its
|
// A pointer that now has real receipts is authoritative — drop its
|
||||||
// optimistic bump AND its durable ledger entry, so once the relay actually
|
// optimistic bump AND its durable ledger entry, so once the relay actually
|
||||||
// reflects zaps for this video we trust it and never double-count.
|
// reflects zaps for this video we trust it and never double-count.
|
||||||
@@ -353,6 +426,8 @@ const store = createZapTotalsStore({
|
|||||||
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ ||
|
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ ||
|
||||||
globalThis.NostrTools ||
|
globalThis.NostrTools ||
|
||||||
null,
|
null,
|
||||||
|
// Read the flag live so a runtime toggle takes effect on the next fetch.
|
||||||
|
isTallyEnabled: () => FEATURE_ZAP_TALLY,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function initZapTotals({ nostrClient, tools } = {}) {
|
export function initZapTotals({ nostrClient, tools } = {}) {
|
||||||
|
|||||||
@@ -229,6 +229,91 @@ test("durable ledger: sent zaps persist across reload; a real receipt prunes the
|
|||||||
localStorage.removeItem(KEY);
|
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", () => {
|
test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => {
|
||||||
const item = (id, author, sats, createdAt, muted = false) => ({
|
const item = (id, author, sats, createdAt, muted = false) => ({
|
||||||
video: { id, pubkey: author, created_at: createdAt },
|
video: { id, pubkey: author, created_at: createdAt },
|
||||||
|
|||||||
Reference in new issue
Block a user