Merge pull request #997 from PR0M3TH3AN/codex/add-zap-receipt-validation-helper-and-tests

Add zap receipt validation workflow
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-29 15:59:54 -04:00
commit ca8db602ad
6 files changed
+1071 -17

No files matched your search

+447
View File
@@ -0,0 +1,447 @@
// js/payments/zapReceiptValidator.js
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
import { normalizeToolkitCandidate, readToolkitFromScope } from "../nostr/toolkit.js";
import { userLogger } from "../utils/logger.js";
import { bech32 } from "@scure/base";
import { sha256 } from "@noble/hashes/sha256";
import { bytesToHex } from "@noble/hashes/utils";
const ZAP_RECEIPT_KIND = 9735;
const BOLT11_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const textEncoder = new TextEncoder();
const globalScope =
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: null;
const nostrToolsReadySource =
globalScope &&
globalScope.nostrToolsReady &&
typeof globalScope.nostrToolsReady.then === "function"
? globalScope.nostrToolsReady
: nostrToolsReady;
let cachedTools = null;
function rememberTools(candidate) {
const normalized = normalizeToolkitCandidate(candidate);
if (normalized) {
cachedTools = normalized;
}
}
async function ensureNostrTools() {
if (cachedTools) {
return cachedTools;
}
try {
const result = await nostrToolsReadySource;
rememberTools(result);
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to await nostr tools bootstrap.", error);
}
if (!cachedTools) {
rememberTools(readToolkitFromScope(globalScope));
}
return cachedTools;
}
function computeZapRequestHash(zapRequest) {
const payload = typeof zapRequest === "string" ? zapRequest : "";
const data = textEncoder.encode(payload);
return bytesToHex(sha256(data));
}
function normalizeRelayList(relays) {
if (!Array.isArray(relays)) {
return [];
}
const seen = new Set();
const normalized = [];
for (const relay of relays) {
if (typeof relay !== "string") {
continue;
}
const trimmed = relay.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
function parseZapRequest(zapRequest) {
if (typeof zapRequest !== "string" || !zapRequest.trim()) {
return null;
}
try {
return JSON.parse(zapRequest);
} catch (error) {
return null;
}
}
function resolveRelayUrls(zapEvent) {
if (!zapEvent || typeof zapEvent !== "object" || !Array.isArray(zapEvent.tags)) {
return [];
}
const relaysTag = zapEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "relays");
if (!relaysTag) {
return [];
}
return normalizeRelayList(relaysTag.slice(1));
}
function normalizeInvoiceValue(invoice) {
if (typeof invoice !== "string") {
return "";
}
const trimmed = invoice.trim();
return trimmed ? trimmed : "";
}
function normalizePubkey(pubkey) {
if (typeof pubkey !== "string") {
return "";
}
const trimmed = pubkey.trim();
return trimmed ? trimmed.toLowerCase() : "";
}
function extractDescriptionHashFromBolt11(bolt11) {
const normalized = normalizeInvoiceValue(bolt11).toLowerCase();
if (!normalized) {
return null;
}
let decoded;
try {
decoded = bech32.decode(normalized, 2000);
} catch (error) {
return null;
}
const words = Array.isArray(decoded?.words) ? decoded.words.slice() : [];
if (!words.length) {
return null;
}
if (words.length <= 104) {
return null;
}
const dataWords = words.slice(0, -104);
if (dataWords.length <= 7) {
return null;
}
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 null;
}
if (index + 1 >= dataWords.length) {
return null;
}
const length = (dataWords[index] << 5) + dataWords[index + 1];
index += 2;
if (length < 0 || index + length > dataWords.length) {
return null;
}
const dataSlice = dataWords.slice(index, index + length);
index += length;
const tag = BOLT11_CHARSET[tagCode];
if (tag === "h") {
try {
const bytes = bech32.fromWords(dataSlice);
return bytesToHex(Uint8Array.from(bytes));
} catch (error) {
return null;
}
}
}
return null;
}
function getInvoiceAmountSats(bolt11, tools, override) {
if (typeof override === "function") {
try {
const overrideResult = override(bolt11);
const normalized = Number(overrideResult);
if (Number.isFinite(normalized)) {
return Math.max(0, Math.round(normalized));
}
} catch (error) {
userLogger.warn("[zapReceiptValidator] Custom bolt11 amount parser failed.", error);
}
}
const fn = tools?.nip57?.getSatoshisAmountFromBolt11;
if (typeof fn !== "function") {
return null;
}
try {
const result = Number(fn(bolt11));
if (!Number.isFinite(result)) {
return null;
}
return Math.max(0, Math.round(result));
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to parse bolt11 amount via nostr-tools.", error);
return null;
}
}
function buildValidationResult({
status = "skipped",
reason = null,
event = null,
relays = [],
} = {}) {
return {
status,
reason,
event,
checkedRelays: Array.isArray(relays) ? relays.slice() : [],
};
}
export async function validateZapReceipt(context = {}, overrides = {}) {
const {
zapRequest,
amountSats,
metadata,
invoice,
payment,
} = context || {};
const zapRequestString = typeof zapRequest === "string" ? zapRequest : "";
if (!zapRequestString) {
return buildValidationResult({
status: "skipped",
reason: "Zap request was not provided.",
});
}
const parsedZapRequest = parseZapRequest(zapRequestString);
if (!parsedZapRequest) {
return buildValidationResult({
status: "failed",
reason: "Zap request JSON could not be parsed.",
});
}
const relayUrls = resolveRelayUrls(parsedZapRequest);
if (!relayUrls.length) {
return buildValidationResult({
status: "failed",
reason: "Zap request did not specify any relays.",
});
}
const invoiceCandidate =
normalizeInvoiceValue(invoice?.invoice ?? invoice) ||
normalizeInvoiceValue(payment?.invoice ?? payment?.bolt11 ?? "");
if (!invoiceCandidate) {
return buildValidationResult({
status: "failed",
reason: "Zap invoice was not available for validation.",
relays: relayUrls,
});
}
const expectedHash = computeZapRequestHash(zapRequestString);
const decodeHash = overrides.decodeDescriptionHash || extractDescriptionHashFromBolt11;
let invoiceHash = null;
try {
invoiceHash = decodeHash(invoiceCandidate);
} catch (error) {
userLogger.warn(
"[zapReceiptValidator] Custom description hash decoder threw an error.",
error
);
invoiceHash = null;
}
if (!invoiceHash) {
return buildValidationResult({
status: "failed",
reason: "Zap invoice did not include a description hash.",
relays: relayUrls,
});
}
if (invoiceHash !== expectedHash) {
return buildValidationResult({
status: "failed",
reason: "Zap invoice description hash did not match the zap request.",
relays: relayUrls,
});
}
const tools = overrides.nostrTools || (await ensureNostrTools());
if (!tools || typeof tools !== "object") {
return buildValidationResult({
status: "failed",
reason: "nostr-tools helpers are unavailable.",
relays: relayUrls,
});
}
if (typeof tools.validateEvent !== "function" || typeof tools.verifyEvent !== "function") {
return buildValidationResult({
status: "failed",
reason: "nostr-tools validation helpers are missing.",
relays: relayUrls,
});
}
const expectedAmount = Math.max(0, Math.round(Number(amountSats || 0)));
if (expectedAmount > 0) {
const amount = getInvoiceAmountSats(invoiceCandidate, tools, overrides.getAmountFromBolt11);
if (!Number.isFinite(amount) || amount <= 0) {
return buildValidationResult({
status: "failed",
reason: "Zap invoice amount could not be determined.",
relays: relayUrls,
});
}
if (amount !== expectedAmount) {
return buildValidationResult({
status: "failed",
reason: "Zap invoice amount did not match the expected share.",
relays: relayUrls,
});
}
}
const expectedPubkey = normalizePubkey(metadata?.nostrPubkey);
if (!expectedPubkey) {
return buildValidationResult({
status: "failed",
reason: "LNURL metadata is missing a nostrPubkey for receipt validation.",
relays: relayUrls,
});
}
let pool;
try {
if (typeof overrides.createPool === "function") {
pool = overrides.createPool(tools);
} else if (typeof tools.SimplePool === "function") {
pool = new tools.SimplePool();
}
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to create SimplePool instance.", error);
pool = null;
}
if (!pool || typeof pool.list !== "function") {
return buildValidationResult({
status: "failed",
reason: "Unable to initialize a relay pool for receipt validation.",
relays: relayUrls,
});
}
const filters = [
{
kinds: [ZAP_RECEIPT_KIND],
"#bolt11": [invoiceCandidate.toLowerCase()],
limit: 10,
},
];
let events = [];
try {
if (typeof overrides.listEvents === "function") {
events = await overrides.listEvents(pool, relayUrls, filters);
} else {
events = await pool.list(relayUrls, filters);
}
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to fetch zap receipts from relays.", error);
events = [];
} finally {
try {
if (typeof pool.close === "function") {
pool.close(relayUrls);
}
} catch (error) {
// Ignore close errors.
}
}
if (!Array.isArray(events) || !events.length) {
return buildValidationResult({
status: "failed",
reason: "No zap receipt was published on the advertised relays.",
relays: relayUrls,
});
}
const normalizedBolt = invoiceCandidate.toLowerCase();
const successfulEvent = events.find((event) => {
if (!event || typeof event !== "object") {
return false;
}
if (event.kind !== ZAP_RECEIPT_KIND) {
return false;
}
if (!tools.validateEvent(event) || !tools.verifyEvent(event)) {
return false;
}
const pubkey = normalizePubkey(event.pubkey);
if (!pubkey || pubkey !== expectedPubkey) {
return false;
}
if (!Array.isArray(event.tags)) {
return false;
}
const boltTag = event.tags.find(
(tag) =>
Array.isArray(tag) &&
tag[0] === "bolt11" &&
typeof tag[1] === "string" &&
tag[1].trim().toLowerCase() === normalizedBolt
);
if (!boltTag) {
return false;
}
const descriptionTag = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "description" && typeof tag[1] === "string"
);
if (!descriptionTag) {
return false;
}
const descriptionValue = descriptionTag[1];
return descriptionValue === zapRequestString;
});
if (!successfulEvent) {
return buildValidationResult({
status: "failed",
reason: "No compliant zap receipt matched the zap request.",
relays: relayUrls,
});
}
return buildValidationResult({
status: "passed",
event: successfulEvent,
relays: relayUrls,
});
}
export const __TESTING__ = Object.freeze({
computeZapRequestHash,
extractDescriptionHashFromBolt11,
});
+53 -2
View File
@@ -13,6 +13,7 @@ import { ensureWallet, sendPayment } from "./nwcClient.js";
import { getPlatformLightningAddress } from "./platformAddress.js";
import { RELAY_URLS } from "../nostr/toolkit.js";
import { userLogger } from "../utils/logger.js";
import { validateZapReceipt } from "./zapReceiptValidator.js";
import {
clampPercent,
parsePercentValue,
@@ -31,6 +32,7 @@ const DEFAULT_DEPS = Object.freeze({
}),
wallet: Object.freeze({ ensureWallet, sendPayment }),
platformAddress: Object.freeze({ getPlatformLightningAddress }),
validator: Object.freeze({ validateZapReceipt }),
});
const globalScope =
@@ -209,7 +211,17 @@ function mergeDependencies(overrides = {}) {
...(overrides.platformAddress || {}),
};
return { lnurl: lnurlDeps, wallet: walletDeps, platformAddress: platformDeps };
const validatorDeps = {
...DEFAULT_DEPS.validator,
...(overrides.validator || {}),
};
return {
lnurl: lnurlDeps,
wallet: walletDeps,
platformAddress: platformDeps,
validator: validatorDeps,
};
}
function getNostrTools() {
@@ -443,7 +455,35 @@ async function processShare({
lnurl: lnurlTag,
});
return {
let validation = null;
if (typeof deps.validator.validateZapReceipt === "function") {
try {
validation = await deps.validator.validateZapReceipt(
{
zapRequest,
amountSats,
metadata,
invoice,
payment,
lnurl: resolved,
recipientType,
address,
}
);
} catch (validationError) {
userLogger.warn(
`[zapSplit] Zap receipt validation threw for ${recipientType} share.`,
validationError
);
validation = {
status: "failed",
reason: "Zap receipt validation encountered an unexpected error.",
event: null,
};
}
}
const receipt = {
recipientType,
address,
amount: amountSats,
@@ -454,6 +494,17 @@ async function processShare({
zapRequest,
status: "success",
};
if (validation && typeof validation === "object") {
receipt.validation = validation;
if (validation.status === "passed" && validation.event) {
receipt.validatedEvent = validation.event;
} else if (validation.status === "failed") {
receipt.validationFailed = true;
}
}
return receipt;
} catch (error) {
userLogger.warn(
`[zapSplit] Failed to send ${recipientType} zap share.`,
+103 -14
View File
@@ -3506,7 +3506,43 @@ export class VideoModal {
return;
}
receipts.forEach((receipt) => {
const normalized = receipts.filter(
(receipt) => receipt && typeof receipt === "object"
);
const validatedReceipts = normalized.filter(
(receipt) => receipt?.validation?.status === "passed" && receipt.validation.event
);
const paymentFailures = normalized.filter((receipt) => {
if (!receipt) {
return false;
}
const status =
typeof receipt.status === "string" ? receipt.status.toLowerCase() : "";
return status && status !== "success";
});
const unvalidatedReceipts = normalized.filter((receipt) => {
if (!receipt) {
return false;
}
const status =
typeof receipt.status === "string" ? receipt.status.toLowerCase() : "success";
if (status !== "success" && status !== "") {
return false;
}
const validationStatus =
typeof receipt.validation?.status === "string"
? receipt.validation.status.toLowerCase()
: "";
if (!validationStatus || validationStatus === "skipped") {
return false;
}
return validationStatus !== "passed";
});
const renderReceiptItem = ({ receipt, tone }) => {
const li = this.document.createElement("li");
li.className = "rounded border border-border p-3 bg-overlay-panel-soft";
@@ -3528,11 +3564,16 @@ export class VideoModal {
)} sats`;
const status = this.document.createElement("span");
const isSuccess = receipt.status
? receipt.status === "success"
: !receipt.error;
status.textContent = isSuccess ? "Success" : "Failed";
status.className = isSuccess ? "text-info" : "text-critical";
if (tone === "validated") {
status.textContent = "Validated";
status.className = "text-info";
} else if (tone === "failed") {
status.textContent = "Failed";
status.className = "text-critical";
} else {
status.textContent = "Pending";
status.className = "text-muted";
}
header.appendChild(shareLabel);
header.appendChild(status);
@@ -3547,7 +3588,14 @@ export class VideoModal {
const detail = this.document.createElement("p");
detail.className = "mt-2 text-xs text-muted";
if (isSuccess) {
if (tone === "failed") {
const errorMessage =
(receipt.error && receipt.error.message) ||
(typeof receipt.error === "string"
? receipt.error
: "Payment failed.");
detail.textContent = errorMessage;
} else {
let detailMessage = "Invoice settled.";
const preimage = receipt.payment?.result?.preimage;
if (typeof preimage === "string" && preimage) {
@@ -3556,18 +3604,59 @@ export class VideoModal {
}`;
}
detail.textContent = detailMessage;
} else {
const errorMessage =
(receipt.error && receipt.error.message) ||
(typeof receipt.error === "string"
? receipt.error
: "Payment failed.");
detail.textContent = errorMessage;
}
li.appendChild(detail);
this.modalZapReceipts.appendChild(li);
};
validatedReceipts.forEach((receipt) => {
renderReceiptItem({ receipt, tone: "validated" });
});
paymentFailures.forEach((receipt) => {
renderReceiptItem({ receipt, tone: "failed" });
});
if (!validatedReceipts.length && !paymentFailures.length && !unvalidatedReceipts.length) {
const empty = this.document.createElement("li");
empty.className = "text-sm text-text";
empty.textContent = partial
? "No zap receipts available."
: "No zap receipts published yet.";
this.modalZapReceipts.appendChild(empty);
return;
}
if (unvalidatedReceipts.length) {
const warning = this.document.createElement("li");
warning.className =
"rounded border border-border p-3 bg-overlay-panel-soft text-xs text-warning-strong";
const summaries = unvalidatedReceipts.map((receipt) => {
const shareType = receipt.recipientType || receipt.type || "creator";
const label =
shareType === "platform"
? "Platform"
: shareType === "creator"
? "Creator"
: "Lightning";
const address =
typeof receipt.address === "string" && receipt.address
? ` (${receipt.address})`
: "";
const reason =
typeof receipt.validation?.reason === "string" && receipt.validation.reason
? ` — ${receipt.validation.reason}`
: " — Awaiting compliant receipt.";
return `${label}${address}${reason}`;
});
const intro = validatedReceipts.length
? "Awaiting validated zap receipt for remaining share(s)."
: "No validated zap receipts yet.";
warning.textContent = `${intro} ${summaries.join(" ")}`.trim();
this.modalZapReceipts.appendChild(warning);
}
}
setZapPending(pending) {
+54
View File
@@ -332,6 +332,60 @@ export default class ZapController {
return;
}
const unvalidatedReceipts = receipts.filter((receipt) => {
if (!receipt || typeof receipt !== "object") {
return false;
}
const status =
typeof receipt.status === "string" ? receipt.status.toLowerCase() : "success";
if (status !== "success" && status !== "") {
return false;
}
const validationStatus =
typeof receipt.validation?.status === "string"
? receipt.validation.status.toLowerCase()
: "";
if (!validationStatus || validationStatus === "skipped") {
return false;
}
return validationStatus !== "passed";
});
if (unvalidatedReceipts.length) {
this.videoModal?.renderZapReceipts(receipts, { partial: true });
const warningSummary = unvalidatedReceipts
.map((receipt) => {
const type = receipt.recipientType || receipt.type || "creator";
const label =
type === "platform" ? "Platform" : type === "creator" ? "Creator" : "Lightning";
const address =
typeof receipt.address === "string" && receipt.address
? ` (${receipt.address})`
: "";
const reason =
typeof receipt.validation?.reason === "string" && receipt.validation.reason
? ` — ${receipt.validation.reason}`
: " — Awaiting compliant receipt.";
return `${label}${address}${reason}`;
})
.join(" ");
const warningMessage = warningSummary
? `Awaiting validated zap receipt. ${warningSummary}`
: "Awaiting validated zap receipt from the recipient.";
this.videoModal?.setZapStatus(warningMessage, "warning");
this.notifyError(warningMessage);
this.resetRetryState();
this.modalZapCommentValue = "";
this.videoModal?.resetZapForm({ amount: "", comment: "" });
this.videoModal?.setZapCompleted(true);
this.applyDefaultAmount();
return;
}
this.videoModal?.renderZapReceipts(receipts, { partial: false });
const creatorShare = context.shares.creatorShare;
+228
View File
@@ -505,9 +505,14 @@ await (async () => {
receipts: [
{
recipientType: "creator",
status: "success",
amount: payload.amountSats,
address: payload.videoEvent.lightningAddress,
payment: { result: { preimage: "ff".repeat(16) } },
validation: {
status: "passed",
event: { kind: 9735, id: "creator-receipt" },
},
},
],
};
@@ -563,6 +568,84 @@ await (async () => {
app.destroy();
})();
await (async () => {
// Test: validation skipped receipts do not trigger warnings
const receipts = [
{
recipientType: "creator",
status: "success",
amount: 600,
address: "[email protected]",
payment: { result: { preimage: "dd".repeat(16) } },
validation: {
status: "skipped",
reason: "Zap request unavailable",
},
},
{
recipientType: "platform",
status: "success",
amount: 100,
address: "[email protected]",
validation: {
status: "skipped",
reason: "Zap request unavailable",
},
},
];
const { app, modalStub, creatorAddress } = await createApp({
splitAndZap: async () => ({ receipts }),
});
const pubkeyHex = "d".repeat(64);
app.pubkey = pubkeyHex;
const normalized = app.normalizeHexPubkey(pubkeyHex);
app.nwcSettingsService.cache.set(normalized, {
nwcUri: "nostr+walletconnect://example",
defaultZap: null,
lastChecked: null,
version: "",
});
app.currentVideo = {
id: "event901",
pubkey: pubkeyHex,
tags: [["d", "video901"]],
content: "",
created_at: 1_700_000_150,
lightningAddress: creatorAddress,
};
const initialWarningCount = modalStub.statusMessages.filter(
(entry) => entry.tone === "warning"
).length;
await app.zapController.sendZap({ amount: 700, comment: "No receipt support" });
const warningsAfter = modalStub.statusMessages.filter(
(entry) => entry.tone === "warning"
).length;
assert.equal(
warningsAfter,
initialWarningCount,
"skipped validation should not emit new warnings",
);
const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1];
assert.equal(lastStatus.tone, "success", "zap should report success when validation skipped");
assert(!/awaiting validated zap receipt/i.test(lastStatus.message));
const lastReceiptEntry = modalStub.receipts[modalStub.receipts.length - 1];
assert.equal(
lastReceiptEntry?.options?.partial,
false,
"receipt rendering should complete when validation is skipped",
);
app.destroy();
})();
await (async () => {
// Test: platform receipt failure keeps retry state active and surfaces warning
const platformError = new Error("Budget exceeded");
@@ -573,6 +656,10 @@ await (async () => {
amount: 900,
address: "[email protected]",
payment: { result: { preimage: "cc".repeat(16) } },
validation: {
status: "passed",
event: { kind: 9735, id: "creator-receipt" },
},
},
{
recipientType: "platform",
@@ -676,6 +763,84 @@ await (async () => {
app.destroy();
})();
await (async () => {
// Test: validation failure surfaces warning and resets form
const validationReceipts = [
{
recipientType: "creator",
status: "success",
amount: 750,
address: "[email protected]",
payment: { result: { preimage: "dd".repeat(16) } },
validation: {
status: "failed",
reason: "No compliant receipt published",
},
},
];
const { app, modalStub, creatorAddress } = await createApp({
splitAndZap: async () => ({ receipts: validationReceipts }),
});
const pubkeyHex = "d".repeat(64);
app.pubkey = pubkeyHex;
const normalized = app.normalizeHexPubkey(pubkeyHex);
app.nwcSettingsService.cache.set(normalized, {
nwcUri: "nostr+walletconnect://example",
defaultZap: null,
lastChecked: null,
version: "",
});
app.currentVideo = {
id: "event990",
pubkey: pubkeyHex,
tags: [["d", "video990"]],
content: "",
created_at: 1_700_000_200,
lightningAddress: creatorAddress,
};
const initialResetCount = modalStub.resetForms.length;
const initialCompleted = modalStub.completedStates.length;
await app.zapController.sendZap({ amount: 750, comment: "Pending receipt" });
const lastReceiptEntry = modalStub.receipts[modalStub.receipts.length - 1];
assert.equal(
lastReceiptEntry.options?.partial,
true,
"should render zap receipts in partial mode for validation issues",
);
const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1];
assert.equal(lastStatus.tone, "warning", "validation failure should emit warning status");
assert.match(
lastStatus.message,
/awaiting validated zap receipt/i,
"status message should highlight missing validated receipt",
);
assert.equal(
modalStub.resetForms.length,
initialResetCount + 1,
"zap form should reset after validation warning",
);
assert.equal(
modalStub.completedStates.length,
initialCompleted + 1,
"completion state should record the validation attempt",
);
assert.equal(
modalStub.completedStates[modalStub.completedStates.length - 1],
true,
"validation warning should mark zap attempt as completed",
);
app.destroy();
})();
await (async () => {
// Test: allowance exhaustion surfaces guidance and skips retry queue
const { app, modalStub, creatorAddress, platformAddress } = await createApp({
@@ -690,6 +855,10 @@ await (async () => {
amount: 900,
address: creatorAddress,
payment: { preimage: "aa".repeat(16) },
validation: {
status: "passed",
event: { kind: 9735, id: "creator-receipt" },
},
},
{
type: "platform",
@@ -750,4 +919,63 @@ await (async () => {
app.destroy();
})();
const { VideoModal } = await import("../js/ui/components/VideoModal.js");
(() => {
const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop });
modal.modalZapReceipts = new DummyElement();
const receipts = [
{
recipientType: "creator",
amount: 600,
address: "[email protected]",
payment: { result: { preimage: "aa".repeat(16) } },
validation: {
status: "passed",
event: { kind: 9735, id: "validated" },
},
},
];
modal.renderZapReceipts(receipts, { partial: false });
assert.equal(modal.modalZapReceipts.children.length, 1, "validated receipt should render one entry");
const entry = modal.modalZapReceipts.children[0];
const header = entry.children[0];
assert.equal(header.children[1].textContent, "Validated", "validated receipt should flag status");
})();
(() => {
const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop });
modal.modalZapReceipts = new DummyElement();
const receipts = [
{
recipientType: "creator",
amount: 450,
address: "[email protected]",
payment: { result: { preimage: "bb".repeat(16) } },
validation: {
status: "failed",
reason: "No compliant receipt",
},
},
];
modal.renderZapReceipts(receipts, { partial: true });
assert.equal(
modal.modalZapReceipts.children.length,
1,
"validation failure should render a single warning entry",
);
const warning = modal.modalZapReceipts.children[0];
assert.match(
warning.textContent,
/no validated zap receipts yet/i,
"warning message should note missing validated receipts",
);
})();
process.exit(0);
+186 -1
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { bech32 } from "@scure/base";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
@@ -76,6 +77,10 @@ globalThis.fetch = async () => {
};
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");
@@ -93,6 +98,8 @@ const DEFAULT_WALLET_RELAYS = [
"wss://wallet.primary.example",
];
const BOLT11_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
function createDeps({
commentAllowed = 120,
platformAddress = "[email protected]",
@@ -101,9 +108,11 @@ function createDeps({
allowsNostr = true,
sendPaymentImplementation,
walletRelays = DEFAULT_WALLET_RELAYS,
validatorImplementation,
} = {}) {
let ensureWalletCalls = 0;
const sendCalls = [];
const validatorCalls = [];
return {
deps: {
@@ -178,6 +187,18 @@ function createDeps({
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;
@@ -185,12 +206,50 @@ function createDeps({
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 } = createDeps();
const { deps, getSendCalls, getEnsureWalletCalls, getValidatorCalls } = createDeps();
const videoEvent = {
id: "event-id",
@@ -213,6 +272,7 @@ async function testSplitMath() {
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");
@@ -222,6 +282,7 @@ async function testSplitMath() {
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);
@@ -256,6 +317,7 @@ async function testSplitMath() {
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(
@@ -641,6 +703,126 @@ async function testWalletFailure() {
);
}
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)],
["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);
assert.equal(filters[0]["#bolt11"][0], bolt11.toLowerCase());
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 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);
}
await testSplitMath();
await testBech32LightningAddressZapTag();
await testUrlLightningAddressZapTag();
@@ -651,5 +833,8 @@ await testMissingAddress();
await testWalletFailure();
await testStringFeeOverride();
await testPlatformShareFailure();
await testValidateZapReceiptSuccess();
await testValidateZapReceiptRejectsMismatchedDescriptionHash();
testExtractDescriptionHashFromBolt11();
console.log("zap-split tests passed");