diff --git a/js/payments/zapReceiptValidator.js b/js/payments/zapReceiptValidator.js new file mode 100644 index 00000000..be9a3d99 --- /dev/null +++ b/js/payments/zapReceiptValidator.js @@ -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, +}); diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index 2a1f9be4..d9109855 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -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.`, diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 2683cd40..8d0a6210 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -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) { diff --git a/js/ui/zapController.js b/js/ui/zapController.js index 8734fb94..8aa302a2 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -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; diff --git a/tests/video-modal-zap.test.mjs b/tests/video-modal-zap.test.mjs index 5869df5b..1011fb1c 100644 --- a/tests/video-modal-zap.test.mjs +++ b/tests/video-modal-zap.test.mjs @@ -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: "creator@example.com", + payment: { result: { preimage: "dd".repeat(16) } }, + validation: { + status: "skipped", + reason: "Zap request unavailable", + }, + }, + { + recipientType: "platform", + status: "success", + amount: 100, + address: "platform@example.com", + 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: "creator@example.com", 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: "creator@example.com", + 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: "creator@example.com", + 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: "creator@example.com", + 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); diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index 814cad95..9eb95665 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -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 = "platform@example.com", @@ -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");