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

+236
View File
@@ -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);
});