mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
ce4904f1a4
commit
a6b2996f4e
6 files changed
+542
-16
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user