Files
bitvid/tests/zap-tally-verify.test.mjs
T
thePR0M3TH3ANandClaude Fable 5 1d732479ed zaps: end-to-end verify test + plan checklist status (#47)
Adds an integration test exercising the REAL verifier against a genuinely
valid, amount-bearing bolt11 (real bech32 decode + nip57 amount parse + real
preimage/description-hash math, no injected fields) — proves the tally pieces
compose. Marks the shipped slices in docs/zap-tally-plan.md §11 and lists the
remaining v1 work: profile-zap #p counting + channel-page UI, the retry-path
publish hook, and the popularity chart (§5.9).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:40:26 -04:00

280 lines
11 KiB
JavaScript

// 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"]);
});
// End-to-end with the REAL verifier + a REAL amount-bearing bolt11: proves the
// pieces compose (no injected extractFields/getSats). The invoice encodes an
// amount (lnbc21u = 2100 sats), a payment_hash = sha256(preimage), and a
// description_hash = sha256(zap request); the real nip57 amount parser reads it.
test("verifyBitvidZapTally: accepts a fully valid tally end to end (real decode + amount)", async () => {
const { nip57 } = await import("nostr-tools");
const preimage = randomBytes(32);
const preimageHex = preimage.toString("hex");
const paymentHashBytes = createHash("sha256").update(preimage).digest();
const reqJson = zapRequestJson();
const descHashBytes = createHash("sha256").update(Buffer.from(reqJson, "utf8")).digest();
const pWords = bech32.toWords(new Uint8Array(paymentHashBytes));
const hWords = bech32.toWords(new Uint8Array(descHashBytes));
const words = [
0, 0, 0, 0, 0, 0, 0,
1, ...lenWords(pWords.length), ...pWords, // 'p' payment_hash
23, ...lenWords(hWords.length), ...hWords, // 'h' description_hash
...new Array(104).fill(0),
];
const bolt11 = bech32.encode("lnbc21u", words, 2000); // 21 micro-BTC = 2100 sats
const event = buildZapTallyEvent({
pubkey: PUB,
created_at: 1000,
paymentHash: paymentHashBytes.toString("hex"),
recipientPubkey: CREATOR,
eventId: EVENT_ID,
coordinate: COORD,
amountMsats: 2100000,
bolt11,
preimage: preimageHex,
zapRequestJson: reqJson,
});
const result = verifyBitvidZapTally(event, {
getSats: nip57.getSatoshisAmountFromBolt11, // real parser, no injection of fields
});
assert.equal(result.ok, true, "real verifier accepts a genuinely valid tally");
assert.equal(result.sats, 2100, "amount parsed from the real bolt11 hrp");
assert.equal(result.paymentHash, paymentHashBytes.toString("hex"));
});
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);
});