Files
bitvid/tests/zap-split.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 96228a451b zaps: fix zap totals counting zero — coordinate used kind 0 instead of 30078
The zap write path (zapSplit.js derivePointerTag) built the `a` coordinate
tag straight from `videoEvent.kind`. bitvid's internal video objects don't
always carry the Nostr kind — it can be 0 or undefined — and the inline
builder only rejected non-finite kinds, so a kind-0 object produced a
`0:pubkey:d` coordinate (Number.isFinite(0) === true). This flowed into both
the kind-9734 zap request and the kind-30081 bitvid tally.

The read side (zapTotals / mostZappedFeed) fetches totals by querying
`30078:pubkey:d` via buildVideoAddressPointer, which defaults invalid/zero
kinds to 30078. So the writer emitted `0:...` while the reader queried
`30078:...` — they never matched and every zap counted as zero, even after
a cache clear + refresh. Confirmed against live relays: 4 published bitvid
tallies all carried `0:...` coordinates while the real video events are
kind 30078.

Fix: derivePointerTag now delegates to the same canonical
buildVideoAddressPointer the reader uses, keeping writer and reader
coordinates in lockstep. Adds a regression test driving splitAndZap with
kind 0 / undefined / non-numeric video objects, asserting the zap request's
`a` tag defaults to `30078:pubkey:d`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 09:18:38 -04:00

1113 lines
33 KiB
JavaScript

import assert from "node:assert/strict";
import { bech32 } from "@scure/base";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (typeof globalThis.localStorage === "undefined") {
const storage = new Map();
globalThis.localStorage = {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(String(key), String(value));
},
removeItem(key) {
storage.delete(String(key));
},
clear() {
storage.clear();
},
};
}
if (typeof window.localStorage === "undefined") {
window.localStorage = globalThis.localStorage;
}
const nostrToolsStub = {
getEventHash(event) {
const base = JSON.stringify(event);
let hash = 0;
for (let i = 0; i < base.length; i += 1) {
hash = (hash * 33 + base.charCodeAt(i)) % 0xffffffff;
}
return hash.toString(16).padStart(8, "0");
},
signEvent(event, secret) {
return `sig-${(secret || "").slice(0, 8)}-${event.kind}`;
},
nip19: {
decode(value) {
if (typeof value !== "string") {
throw new Error("Invalid nip19 input");
}
if (value.startsWith("npub")) {
return { type: "npub", data: "f".repeat(64) };
}
return null;
},
},
SimplePool: class {
async list() {
return [];
}
sub() {
return {
on() {},
unsub() {},
};
}
async get() {
return null;
}
async ensureRelay() {
return { close() {}, url: "" };
}
},
};
window.NostrTools = nostrToolsStub;
globalThis.NostrTools = nostrToolsStub;
globalThis.fetch = async () => {
throw new Error("Unexpected fetch call during zap split tests.");
};
const { splitAndZap } = await import("../js/payments/zapSplit.js");
const {
validateZapReceipt,
__TESTING__: validatorTesting,
} = await import("../js/payments/zapReceiptValidator.js");
const platformAddressModule = await import("../js/payments/platformAddress.js");
const { __resetPlatformAddressCache } = platformAddressModule;
const lnurlModule = await import("../js/payments/lnurl.js");
const {
encodeLnurlBech32,
resolveLightningAddress: baseResolveLightningAddress,
requestInvoice,
} = lnurlModule;
const { decodeLnurlBech32 } = lnurlModule.__TESTING__;
const { nostrClient } = await import("../js/nostrClientFacade.js");
const DEFAULT_WALLET_RELAYS = [
"wss://wallet.primary.example",
"wss://wallet.secondary.example",
"wss://wallet.primary.example",
];
const BOLT11_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
function createDeps({
commentAllowed = 120,
platformAddress = "[email protected]",
minSendable = 1000,
maxSendable = 2_000_000,
allowsNostr = true,
sendPaymentImplementation,
walletRelays = DEFAULT_WALLET_RELAYS,
validatorImplementation,
} = {}) {
let ensureWalletCalls = 0;
const sendCalls = [];
const validatorCalls = [];
return {
deps: {
lnurl: {
resolveLightningAddress(address) {
return { ...baseResolveLightningAddress(address) };
},
encodeLnurlBech32(url) {
return encodeLnurlBech32(url);
},
async fetchPayServiceData(url) {
const isPlatform = url.includes("platform");
return {
callback: `${url}/callback`,
minSendable,
maxSendable,
commentAllowed,
allowsNostr,
nostrPubkey: isPlatform ? "b".repeat(64) : "a".repeat(64),
raw: {},
};
},
validateInvoiceAmount(metadata, amount) {
if (amount * 1000 < metadata.minSendable) {
throw new Error("below-minimum");
}
if (amount * 1000 > metadata.maxSendable) {
throw new Error("above-maximum");
}
return { amountMsats: amount * 1000 };
},
async requestInvoice(metadata, { amountMsats, comment, zapRequest, lnurl }) {
return {
invoice: `bolt11-${metadata.callback}-${amountMsats}-${comment}-${Boolean(
zapRequest
)}-${lnurl || ""}`,
raw: {},
};
},
},
wallet: {
async ensureWallet() {
ensureWalletCalls += 1;
return {
clientPubkey: "c".repeat(64),
secretKey: "1".repeat(64),
relayUrl: walletRelays[0] || null,
relayUrls: walletRelays.slice(),
relays: walletRelays.slice(),
};
},
async sendPayment(invoice, { amountSats, zapRequest, lnurl }) {
sendCalls.push({ invoice, amountSats, zapRequest, lnurl });
if (typeof sendPaymentImplementation === "function") {
return sendPaymentImplementation({
invoice,
amountSats,
zapRequest,
lnurl,
});
}
return {
invoice,
amountSats,
zapRequest,
lnurl,
};
},
},
platformAddress: {
async getPlatformLightningAddress() {
return platformAddress;
},
},
validator: {
async validateZapReceipt(payload) {
validatorCalls.push(payload);
if (typeof validatorImplementation === "function") {
return validatorImplementation(payload);
}
return {
status: "skipped",
reason: "Zap receipt validation is disabled in tests.",
};
},
},
},
getEnsureWalletCalls() {
return ensureWalletCalls;
},
getSendCalls() {
return sendCalls;
},
getValidatorCalls() {
return validatorCalls;
},
};
}
function buildTestBolt11Invoice({ amountCode = "10u", descriptionHashHex }) {
const hrp = `lnbc${amountCode}`;
const timestamp = 1_700_000_000;
const timestampWords = [];
let remaining = timestamp;
for (let i = 0; i < 7; i += 1) {
timestampWords.unshift(remaining & 31);
remaining >>= 5;
}
const words = [...timestampWords];
const pushTag = (tag, dataWords) => {
const code = BOLT11_CHARSET.indexOf(tag);
words.push(code);
const length = dataWords.length;
words.push((length >> 5) & 31);
words.push(length & 31);
words.push(...dataWords);
};
const paymentHashHex = "11".repeat(32);
pushTag("p", bech32.toWords(Buffer.from(paymentHashHex, "hex")));
if (descriptionHashHex) {
pushTag("h", bech32.toWords(Buffer.from(descriptionHashHex, "hex")));
}
const signatureWords = new Array(104).fill(0);
const data = [...words, ...signatureWords];
return bech32.encode(hrp, data, 2000);
}
async function testSplitMath() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10;
const { deps, getSendCalls, getEnsureWalletCalls, getValidatorCalls } = createDeps();
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
tags: [["d", "pointer"]],
kind: 30078,
};
const result = await splitAndZap(
{ videoEvent, amountSats: 1000, comment: "Great video!" },
deps
);
const creatorResolved = baseResolveLightningAddress(videoEvent.lightningAddress);
const platformResolved = baseResolveLightningAddress("[email protected]");
assert.equal(result.totalAmount, 1000);
assert.equal(result.creatorShare, 900);
assert.equal(result.platformShare, 100);
assert.equal(result.receipts.length, 2);
assert.equal(getEnsureWalletCalls(), 1);
assert.equal(getValidatorCalls().length, 2, "validator should run for each share");
const sendCalls = getSendCalls();
assert.equal(sendCalls.length, 2, "should send two payments");
assert.equal(sendCalls[0].amountSats, 900);
assert.equal(sendCalls[1].amountSats, 100);
const creatorReceipt = result.receipts[0];
assert.equal(creatorReceipt.recipientType, "creator");
assert.equal(creatorReceipt.status, "success");
assert.equal(creatorReceipt.validation?.status, "skipped");
assert(creatorReceipt.zapRequest, "creator zap request should be present");
const parsedZap = JSON.parse(creatorReceipt.zapRequest);
assert.equal(parsedZap.kind, 9734);
assert(parsedZap.tags.some((tag) => tag[0] === "amount" && tag[1] === "900000"));
const relaysTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "relays");
assert(relaysTag, "relays tag should be present in zap request");
const expectedRelays = Array.from(
new Set(
DEFAULT_WALLET_RELAYS.map((relay) =>
typeof relay === "string" ? relay.trim() : ""
).filter((relay) => relay)
)
);
assert.deepEqual(
relaysTag.slice(1),
expectedRelays,
"relays tag should match wallet relay list"
);
const creatorLnurlTag = parsedZap.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "lnurl"
);
assert(creatorLnurlTag, "creator zap should include lnurl tag");
assert.equal(creatorLnurlTag[1], creatorLnurlTag[1].toLowerCase());
assert(creatorLnurlTag[1].startsWith("lnurl"));
assert.equal(
decodeLnurlBech32(creatorLnurlTag[1]),
creatorResolved.url
);
const platformReceipt = result.receipts[1];
assert.equal(platformReceipt.recipientType, "platform");
assert.equal(platformReceipt.status, "success");
assert.equal(platformReceipt.validation?.status, "skipped");
const platformZap = JSON.parse(platformReceipt.zapRequest);
const platformLnurlTag = platformZap.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "lnurl"
);
assert(platformLnurlTag, "platform zap should include lnurl tag");
assert.equal(platformLnurlTag[1], platformLnurlTag[1].toLowerCase());
assert(platformLnurlTag[1].startsWith("lnurl"));
assert.equal(
decodeLnurlBech32(platformLnurlTag[1]),
platformResolved.url
);
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
// Regression: bitvid's internal video objects don't always carry the Nostr
// `kind` — it can be 0 or undefined. The zap request's `a` (coordinate) tag
// MUST still address the video as kind 30078, because the read side
// (zapTotals / mostZappedFeed) fetches totals by querying `30078:pubkey:d`.
// A kind-0 coordinate (`0:pubkey:d`) publishes fine but can never be matched,
// so zaps silently count as zero. This asserts the coordinate defaults to
// 30078 regardless of the source object's kind.
async function testZapPointerDefaultsToVideoKindWhenMissing() {
const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0;
const { deps } = createDeps();
const callbackUrl = "https://lnurl.example/api/callback";
const bech32Address = encodeLnurlBech32(callbackUrl);
const pubkey = "d".repeat(64);
for (const brokenKind of [0, undefined, "not-a-number"]) {
const videoEvent = {
id: "event-id",
pubkey,
lightningAddress: bech32Address,
tags: [["d", "1782939097186-elrw1x3fdvg"]],
kind: brokenKind,
};
const result = await splitAndZap(
{ videoEvent, amountSats: 500, comment: "PointerKind" },
deps
);
const parsedZap = JSON.parse(result.receipts[0].zapRequest);
const aTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "a");
assert(aTag, `zap request should carry an 'a' coordinate (kind=${brokenKind})`);
assert.equal(
aTag[1],
`30078:${pubkey}:1782939097186-elrw1x3fdvg`,
`coordinate must address the video as kind 30078, not '${brokenKind}'`
);
}
if (previousOverride === undefined) {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
} else {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
}
}
async function testBech32LightningAddressZapTag() {
const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0;
const { deps } = createDeps();
const callbackUrl = "https://lnurl.example/api/callback";
const bech32Address = encodeLnurlBech32(callbackUrl);
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: bech32Address,
tags: [["d", "pointer"]],
kind: 30078,
};
const result = await splitAndZap({ videoEvent, amountSats: 500, comment: "Bech32" }, deps);
assert.equal(result.platformShare, 0);
assert.equal(result.receipts.length, 1);
const parsedZap = JSON.parse(result.receipts[0].zapRequest);
const lnurlTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "lnurl");
assert(lnurlTag, "bech32 address zap should include lnurl tag");
assert.equal(lnurlTag[1], lnurlTag[1].toLowerCase());
assert.equal(lnurlTag[1], bech32Address.toLowerCase());
assert.equal(decodeLnurlBech32(lnurlTag[1]), callbackUrl);
if (previousOverride === undefined) {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
} else {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
}
}
async function testUrlLightningAddressZapTag() {
const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0;
const { deps } = createDeps();
const directUrl = "https://direct-lnurl.example/api/pay";
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: directUrl,
tags: [["d", "pointer"]],
kind: 30078,
};
const result = await splitAndZap({ videoEvent, amountSats: 800, comment: "Direct" }, deps);
assert.equal(result.platformShare, 0);
assert.equal(result.receipts.length, 1);
const parsedZap = JSON.parse(result.receipts[0].zapRequest);
const lnurlTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "lnurl");
assert(lnurlTag, "direct url zap should include lnurl tag");
assert.equal(lnurlTag[1], lnurlTag[1].toLowerCase());
assert(lnurlTag[1].startsWith("lnurl"));
assert.equal(decodeLnurlBech32(lnurlTag[1]), directUrl);
if (previousOverride === undefined) {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
} else {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
}
}
async function testRequestInvoiceIncludesLnurlParam() {
const callback = "https://callback-lnurl.example/api/pay";
const lnurlValue = encodeLnurlBech32(callback);
const zapRequest = JSON.stringify({ kind: 9734, content: "zap" });
const requestedUrls = [];
const metadata = {
callback,
commentAllowed: 0,
};
const fetcher = async (url) => {
requestedUrls.push(url);
return {
ok: true,
async json() {
return { pr: "bolt11-invoice" };
},
};
};
const result = await requestInvoice(metadata, {
amountMsats: 123_000,
zapRequest,
lnurl: lnurlValue,
fetcher,
});
assert.equal(result.invoice, "bolt11-invoice");
assert.equal(requestedUrls.length, 1, "should call fetcher once");
const [url] = requestedUrls;
const parsed = new URL(url);
assert.equal(parsed.searchParams.get("amount"), "123000");
assert.equal(parsed.searchParams.get("nostr"), zapRequest);
assert.equal(parsed.searchParams.get("lnurl"), lnurlValue);
}
async function testWaitsForPoolBeforePlatformLookup() {
const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10;
const originalEnsurePool = nostrClient.ensurePool;
const originalPool = nostrClient.pool;
const originalPoolPromise = nostrClient.poolPromise;
nostrClient.pool = null;
nostrClient.poolPromise = null;
let poolReady = false;
let listCalls = 0;
let ensureCalls = 0;
const poolStub = {
async list(relays, filters) {
assert(poolReady, "should wait for ensurePool to resolve before listing metadata");
listCalls += 1;
return [
{
pubkey: "f".repeat(64),
content: JSON.stringify({ lud16: "[email protected]" }),
created_at: Math.floor(Date.now() / 1000),
},
];
},
};
nostrClient.ensurePool = () => {
ensureCalls += 1;
if (nostrClient.poolPromise) {
return nostrClient.poolPromise;
}
const promise = new Promise((resolve) => {
setTimeout(() => {
poolReady = true;
nostrClient.pool = poolStub;
nostrClient.poolPromise = Promise.resolve(poolStub);
resolve(poolStub);
}, 10);
});
nostrClient.poolPromise = promise;
return promise;
};
const { deps: baseDeps } = createDeps();
const deps = {
lnurl: baseDeps.lnurl,
wallet: baseDeps.wallet,
platformAddress: {
async getPlatformLightningAddress() {
const pool = await nostrClient.ensurePool();
if (pool && typeof pool.list === "function") {
await pool.list([], []);
}
return "[email protected]";
},
},
};
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
tags: [["d", "pointer"]],
kind: 30078,
};
try {
const result = await splitAndZap(
{ videoEvent, amountSats: 1000, comment: "Delayed pool" },
deps
);
assert.equal(ensureCalls, 1, "should initialize the pool once");
assert.equal(listCalls, 1, "should fetch platform metadata once the pool is ready");
assert.equal(result.platformShare, 100);
assert.equal(result.receipts.length, 2);
} finally {
nostrClient.ensurePool = originalEnsurePool;
nostrClient.pool = originalPool;
nostrClient.poolPromise = originalPoolPromise;
__resetPlatformAddressCache();
if (previousOverride === undefined) {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
} else {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
}
}
}
async function testStringFeeOverride() {
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
tags: [["d", "pointer"]],
kind: 30078,
};
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = "70/30";
const seventyThirty = createDeps();
const ratioResult = await splitAndZap(
{ videoEvent, amountSats: 1000, comment: "Great video!" },
seventyThirty.deps
);
assert.equal(ratioResult.creatorShare, 700);
assert.equal(ratioResult.platformShare, 300);
const ratioSendCalls = seventyThirty.getSendCalls();
assert.equal(ratioSendCalls.length, 2, "should send two payments for ratio");
assert.equal(ratioSendCalls[0].amountSats, 700);
assert.equal(ratioSendCalls[1].amountSats, 300);
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = "30%";
const percentOverride = createDeps();
const percentResult = await splitAndZap(
{ videoEvent, amountSats: 200, comment: "Another zap" },
percentOverride.deps
);
assert.equal(percentResult.creatorShare, 140);
assert.equal(percentResult.platformShare, 60);
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
async function testPlatformShareFailure() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10;
const platformCallbackMarker = `${
baseResolveLightningAddress("[email protected]").url
}/callback`;
const { deps, getSendCalls } = createDeps({
sendPaymentImplementation: ({ invoice, amountSats, zapRequest }) => {
if (invoice.includes(platformCallbackMarker)) {
const error = new Error("Budget exceeded");
error.code = "budget_exceeded";
throw error;
}
return { invoice, amountSats, zapRequest };
},
});
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
tags: [["d", "pointer"]],
kind: 30078,
};
const result = await splitAndZap(
{ videoEvent, amountSats: 1000, comment: "Budget capped" },
deps
);
assert.equal(result.receipts.length, 2, "should return receipts for both shares");
const [creatorReceipt, platformReceipt] = result.receipts;
assert.equal(creatorReceipt.recipientType, "creator");
assert.equal(creatorReceipt.status, "success");
assert(creatorReceipt.payment, "creator share should still have a payment record");
assert.equal(platformReceipt.recipientType, "platform");
assert.equal(platformReceipt.status, "error");
assert.equal(platformReceipt.payment, null);
assert(platformReceipt.error instanceof Error);
assert.match(platformReceipt.error.message, /Budget exceeded/);
const sendCalls = getSendCalls();
assert.equal(sendCalls.length, 2, "should attempt each share exactly once");
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
async function testLnurlBounds() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0;
const depsWrapper = createDeps({ minSendable: 5_000 });
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent, amountSats: 4 }, depsWrapper.deps),
/below-minimum/
);
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
async function testMissingAddress() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 15;
const depsWrapper = createDeps({ platformAddress: null });
const noCreatorEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent: noCreatorEvent, amountSats: 100 }, depsWrapper.deps),
/Lightning address/
);
const creatorEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent: creatorEvent, amountSats: 100 }, depsWrapper.deps),
/Platform Lightning address is unavailable/
);
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
async function testWalletFailure() {
const deps = {
lnurl: createDeps().deps.lnurl,
wallet: {
async ensureWallet() {
throw new Error("wallet offline");
},
async sendPayment() {
throw new Error("should not call sendPayment");
},
},
platformAddress: createDeps().deps.platformAddress,
};
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent, amountSats: 100 }, deps),
/wallet offline/
);
}
async function testValidateZapReceiptSuccess() {
const relays = ["wss://relay.validation"];
const zapRequestEvent = {
kind: 9734,
pubkey: "f".repeat(64),
content: "",
created_at: 1_700_000_000,
tags: [
["p", "a".repeat(64)],
["e", "c".repeat(64)],
["amount", String(900_000)],
["relays", ...relays],
],
};
const zapRequestString = JSON.stringify(zapRequestEvent);
const descriptionHash = validatorTesting.computeZapRequestHash(zapRequestString);
const bolt11 = buildTestBolt11Invoice({ amountCode: "9u", descriptionHashHex: descriptionHash });
const receiptEvent = {
kind: 9735,
pubkey: "b".repeat(64),
tags: [
["bolt11", bolt11],
["description", zapRequestString],
],
};
const nostrTools = {
validateEvent: () => true,
verifyEvent: () => true,
SimplePool: class {
async list(requestedRelays, filters) {
assert.deepEqual(requestedRelays, relays);
// Receipts are queried by the reliably-indexed #e (event) + #p (recipient)
// tags, NOT #bolt11 (which most relays don't index — querying by it found
// nothing and made successful zaps look unconfirmed).
assert.deepEqual(filters[0]["#e"], ["c".repeat(64)]);
assert.deepEqual(filters[0]["#p"], ["a".repeat(64)]);
assert.ok(
!filters[0]["#bolt11"],
"must not depend on the unindexed #bolt11 tag to find the receipt",
);
return [receiptEvent];
}
close() {}
},
};
const result = await validateZapReceipt(
{
zapRequest: zapRequestString,
amountSats: 900,
metadata: { nostrPubkey: receiptEvent.pubkey },
invoice: { invoice: bolt11 },
payment: { invoice: bolt11 },
},
{
nostrTools,
getAmountFromBolt11: () => 900,
}
);
assert.equal(result.status, "passed");
assert.equal(result.event, receiptEvent);
assert.deepEqual(result.checkedRelays, relays);
}
async function testValidateZapReceiptRetriesForLateReceipt() {
// The recipient's service publishes the 9735 receipt a moment AFTER payment, so
// it can be absent on the first lookup (which resolves on EOSE). The validator
// must poll a few times before giving up — here the receipt only appears on the
// 3rd lookup, and validation should still pass. `sleep` is stubbed so the test
// doesn't actually wait.
const relays = ["wss://relay.validation"];
const zapRequestEvent = {
kind: 9734,
pubkey: "f".repeat(64),
content: "",
created_at: 1_700_000_000,
tags: [
["p", "a".repeat(64)],
["e", "c".repeat(64)],
["amount", String(900_000)],
["relays", ...relays],
],
};
const zapRequestString = JSON.stringify(zapRequestEvent);
const descriptionHash = validatorTesting.computeZapRequestHash(zapRequestString);
const bolt11 = buildTestBolt11Invoice({ amountCode: "9u", descriptionHashHex: descriptionHash });
const receiptEvent = {
kind: 9735,
pubkey: "b".repeat(64),
tags: [
["bolt11", bolt11],
["description", zapRequestString],
],
};
let calls = 0;
let sleeps = 0;
const nostrTools = {
validateEvent: () => true,
verifyEvent: () => true,
SimplePool: class {
async list() {
calls += 1;
// Empty on the first two polls, receipt present on the third.
return calls >= 3 ? [receiptEvent] : [];
}
close() {}
},
};
const result = await validateZapReceipt(
{
zapRequest: zapRequestString,
amountSats: 900,
metadata: { nostrPubkey: receiptEvent.pubkey },
invoice: { invoice: bolt11 },
payment: { invoice: bolt11 },
},
{
nostrTools,
getAmountFromBolt11: () => 900,
receiptLookupAttempts: 3,
receiptLookupDelayMs: 5,
sleep: async () => {
sleeps += 1;
},
}
);
assert.equal(result.status, "passed", "a late receipt found on a retry validates");
assert.equal(calls, 3, "should poll until the receipt appears (3 lookups)");
assert.equal(sleeps, 2, "should wait between retries (2 delays for 3 attempts)");
}
async function testValidateZapReceiptGivesUpAfterRetries() {
// If no receipt ever appears, the validator gives up after its attempts with the
// benign "not published" reason (the caller treats a paid-but-unconfirmed zap as
// a success-with-note, not a failure).
const relays = ["wss://relay.validation"];
const zapRequestEvent = {
kind: 9734,
pubkey: "f".repeat(64),
content: "",
created_at: 1_700_000_000,
tags: [
["p", "a".repeat(64)],
["e", "c".repeat(64)],
["amount", String(900_000)],
["relays", ...relays],
],
};
const zapRequestString = JSON.stringify(zapRequestEvent);
const descriptionHash = validatorTesting.computeZapRequestHash(zapRequestString);
const bolt11 = buildTestBolt11Invoice({ amountCode: "9u", descriptionHashHex: descriptionHash });
let calls = 0;
const nostrTools = {
validateEvent: () => true,
verifyEvent: () => true,
SimplePool: class {
async list() {
calls += 1;
return [];
}
close() {}
},
};
const result = await validateZapReceipt(
{
zapRequest: zapRequestString,
amountSats: 900,
metadata: { nostrPubkey: "b".repeat(64) },
invoice: { invoice: bolt11 },
payment: { invoice: bolt11 },
},
{
nostrTools,
getAmountFromBolt11: () => 900,
receiptLookupAttempts: 3,
receiptLookupDelayMs: 0,
sleep: async () => {},
}
);
assert.equal(result.status, "failed");
assert.match(result.reason, /no zap receipt was published/i);
assert.equal(calls, 3, "should exhaust all configured attempts");
}
async function testValidateZapReceiptRejectsMismatchedDescriptionHash() {
const relays = ["wss://relay.validation"];
const zapRequestEvent = {
kind: 9734,
pubkey: "f".repeat(64),
content: "",
created_at: 1_700_000_000,
tags: [
["p", "a".repeat(64)],
["amount", String(1_000_000)],
["relays", ...relays],
],
};
const zapRequestString = JSON.stringify(zapRequestEvent);
const mismatchHash = "aa".repeat(32);
const bolt11 = buildTestBolt11Invoice({ amountCode: "10u", descriptionHashHex: mismatchHash });
const receiptEvent = {
kind: 9735,
pubkey: "b".repeat(64),
tags: [
["bolt11", bolt11],
["description", zapRequestString],
],
};
const nostrTools = {
validateEvent: () => true,
verifyEvent: () => true,
SimplePool: class {
async list() {
return [receiptEvent];
}
close() {}
},
};
const result = await validateZapReceipt(
{
zapRequest: zapRequestString,
amountSats: 1_000,
metadata: { nostrPubkey: receiptEvent.pubkey },
invoice: { invoice: bolt11 },
payment: { invoice: bolt11 },
},
{
nostrTools,
getAmountFromBolt11: () => 1_000,
}
);
assert.equal(result.status, "failed");
assert.match(result.reason || "", /description hash/i);
}
function testExtractDescriptionHashFromBolt11() {
const descriptionHashHex = "bb".repeat(32);
const invoice = buildTestBolt11Invoice({ amountCode: "1m", descriptionHashHex });
const extracted = validatorTesting.extractDescriptionHashFromBolt11(invoice);
assert.equal(extracted, descriptionHashHex);
}
// When the platform fee would be paid to the SAME Lightning address as the
// creator, the split must collapse to a single full-amount payment instead of
// sending two invoices to one wallet (extra routing fee + duplicate receipt).
async function testCreatorEqualsPlatformCollapsesToOnePayment() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 30;
const { deps, getSendCalls, getValidatorCalls } = createDeps({
platformAddress: "[email protected]", // identical to the creator below
});
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]", // mixed case → must still collapse
tags: [["d", "pointer"]],
kind: 30078,
};
const result = await splitAndZap(
{ videoEvent, amountSats: 1000, comment: "self-owned platform" },
deps
);
assert.equal(result.totalAmount, 1000);
assert.equal(result.creatorShare, 1000, "creator receives the full amount");
assert.equal(result.platformShare, 0, "no separate platform fee is split out");
assert.equal(result.receipts.length, 1, "exactly one payment is made");
const sendCalls = getSendCalls();
assert.equal(sendCalls.length, 1, "must not double-pay the same wallet");
assert.equal(sendCalls[0].amountSats, 1000);
assert.equal(
getValidatorCalls().length,
1,
"only one receipt to validate after collapsing"
);
}
// The split must never lose or invent sats: creatorShare + platformShare always
// equals the total, and a fee that rounds below 1 sat falls entirely to the
// creator rather than silently dropping value.
async function testSplitRoundingPreservesEverySat() {
// 33% of 1000 = 330 exactly-floored; creator gets the remainder.
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 33;
{
const { deps, getSendCalls } = createDeps();
const videoEvent = {
id: "e1",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
tags: [["d", "p"]],
kind: 30078,
};
const result = await splitAndZap({ videoEvent, amountSats: 1000 }, deps);
assert.equal(result.platformShare, 330);
assert.equal(result.creatorShare, 670);
assert.equal(
result.creatorShare + result.platformShare,
result.totalAmount,
"no sats lost or invented"
);
const amounts = getSendCalls().map((c) => c.amountSats);
assert.deepEqual(amounts, [670, 330]);
}
// 10% of 5 sats floors to 0 → the whole zap goes to the creator as one payment.
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10;
{
const { deps, getSendCalls } = createDeps();
const videoEvent = {
id: "e2",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
tags: [["d", "p"]],
kind: 30078,
};
const result = await splitAndZap({ videoEvent, amountSats: 5 }, deps);
assert.equal(result.platformShare, 0, "sub-1-sat fee is not charged");
assert.equal(result.creatorShare, 5);
const sendCalls = getSendCalls();
assert.equal(sendCalls.length, 1, "no zero-amount platform payment is sent");
assert.equal(sendCalls[0].amountSats, 5);
}
}
await testCreatorEqualsPlatformCollapsesToOnePayment();
await testSplitRoundingPreservesEverySat();
await testSplitMath();
await testZapPointerDefaultsToVideoKindWhenMissing();
await testBech32LightningAddressZapTag();
await testUrlLightningAddressZapTag();
await testRequestInvoiceIncludesLnurlParam();
await testWaitsForPoolBeforePlatformLookup();
await testLnurlBounds();
await testMissingAddress();
await testWalletFailure();
await testStringFeeOverride();
await testPlatformShareFailure();
await testValidateZapReceiptSuccess();
await testValidateZapReceiptRejectsMismatchedDescriptionHash();
await testValidateZapReceiptRetriesForLateReceipt();
await testValidateZapReceiptGivesUpAfterRetries();
testExtractDescriptionHashFromBolt11();
console.log("zap-split tests passed");