zaps: foundation for the bitvid-native zap tally (#47) — schema + verify

First slice of docs/zap-tally-plan.md (decisions D1-D5 resolved in the doc):

- ZAP_TALLY_KIND = 30081 (addressable, d=payment_hash → one canonical event
  per real payment) + NOTE_TYPES.ZAP_TALLY schema + buildZapTallyEvent
  (nostrEventSchemas.js). Video + profile pointers.
- FEATURE_ZAP_TALLY flag (constants.js), default ON per D4.
- zapReceiptValidator.js: extractBolt11Fields (payment_hash + description_hash
  from the existing bech32 walker), verifyPaymentPreimage
  (sha256(preimage)==payment_hash), verifyBitvidZapTally — the security core:
  proves the invoice was paid AND bound to this zap request (description-hash
  binding blocks reusing a leaked preimage for another video). Amount is
  bolt11-derived, never the tag; pointers come from the embedded zap request.
- Schema-doc row for kind 30081.

Tests: tests/zap-tally-verify.test.mjs (8) — preimage check, bech32 field
round-trip, valid/bad-preimage/replay/zero-sats decisions, builder tag shape
(video + profile). Lint + build green.

No publishing or counting wired yet — next slices per the plan checklist.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 23:32:12 -04:00
1 parent ce4904f1a4
commit a6b2996f4e
6 files changed
+542 -16

No files matched your search

+7
View File
@@ -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
+118
View File
@@ -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.
+159
View File
@@ -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) {