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 index c9e3ecd0..bafb8076 100644 --- a/docs/zap-tally-plan.md +++ b/docs/zap-tally-plan.md @@ -426,23 +426,28 @@ tallies simply stop being counted. No data migration. --- -## 10. Open decisions (need a call before/at build time) +## 10. Decisions (RESOLVED 2026-07-04) -- **D1 — event kind.** Proposed `9736` (adjacent to the 9735 receipt, "bitvid zap - tally"). Must confirm it's unassigned in the NIP kind registry; if risky, pick a - clearly app-specific regular kind and reserve it in `docs/nostr-event-schemas.md`. - Everything references `ZAP_TALLY_KIND` so the number is a one-line change. -- **D2 — profile zaps in v1?** Video path is the ask; profile path is the same - primitives + a channel-page badge. Ship video-only first, profile as a - follow-up? (Recommended: yes, video-only v1.) -- **D3 — publish relays.** Publish the tally to the user's write relays (same as - video publishing) so bitvid clients querying those relays see it. Confirm the - store queries a superset (it queries `client.relays`). Likely fine; verify. -- **D4 — privacy default.** Default the feature flag off and add one line of UI - copy ("your zap will be publicly attributed on bitvid") before default-on? -- **D5 — chart Y-axis (§5.9).** Independent per-series scaling (each cumulative - line filling the height — simplest, trend-focused, **recommended**) vs. a dual - labeled Y-axis (left=views, right=sats — more precise, busier in a small modal). +- **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. --- diff --git a/js/constants.js b/js/constants.js index 9e43bcb4..13e7d0f4 100644 --- a/js/constants.js +++ b/js/constants.js @@ -200,6 +200,7 @@ const DEFAULT_FLAGS = Object.freeze({ 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, @@ -224,6 +225,7 @@ const runtimeFlags = (() => { 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], @@ -274,6 +276,11 @@ export let FEATURE_MOST_ZAPPED_FEED = coerceBoolean( 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 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/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/tests/zap-tally-verify.test.mjs b/tests/zap-tally-verify.test.mjs new file mode 100644 index 00000000..1c42fe63 --- /dev/null +++ b/tests/zap-tally-verify.test.mjs @@ -0,0 +1,236 @@ +// 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"]); +}); + +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); +});