From c5589a1514e9a7d2571864de83ef6fa45da5fcf6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:53:38 -0400 Subject: [PATCH] zap: fix NWC connection, dialog UX, and receipt reporting/discovery - NWC connection was fully broken: parseNwcUri passed a hex secret to nostr-tools getPublicKey (needs Uint8Array) and threw. Convert hex->bytes. - A successful zap was shown as a red error when the kind-9735 receipt couldn't be validated. The NWC preimage already proves payment, so a paid-but-unconfirmed share is now a success-with-note, not a failure. - Receipt discovery: query by the reliably-indexed #e/#a/#p tags instead of #bolt11 (which most relays don't index), and poll a few times since the receipt is published shortly after payment. - In-flight "Sending..." status no longer mis-toned as a warning; success path fully resets the form; an in-flight-open click no longer cancels the open (which made the popover need many clicks); fee fallback verified no-bypass. Co-Authored-By: Claude Opus 4.8 --- js/payments/nwcClient.js | 11 +- js/payments/zapReceiptValidator.js | 152 +++++++++++++----- js/ui/components/video-modal/zapController.js | 11 +- js/ui/zapController.js | 84 +++++----- tests/nwc-parse-uri.test.mjs | 47 ++++++ tests/video-modal-zap.test.mjs | 46 ++++-- tests/zap-receipt-pool.test.mjs | 44 ++++- tests/zap-shared-state.test.mjs | 14 +- tests/zap-split.test.mjs | 140 +++++++++++++++- 9 files changed, 448 insertions(+), 101 deletions(-) create mode 100644 tests/nwc-parse-uri.test.mjs diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js index 5a616cbd..8933b7b4 100644 --- a/js/payments/nwcClient.js +++ b/js/payments/nwcClient.js @@ -329,7 +329,16 @@ function parseNwcUri(uri) { } const tools = assertNostrTools(["getPublicKey"]); - const clientPubkey = tools.getPublicKey(secretKey); + // nostr-tools >= 2.x requires the secret key as bytes (Uint8Array), not a hex + // string — passing hex throws "expected Uint8Array". Try hex first for forward/ + // backward compat with toolkits that still accept it, then fall back to bytes. + // Without this, parseNwcUri throws and NWC wallet connection fails entirely. + let clientPubkey; + try { + clientPubkey = tools.getPublicKey(secretKey); + } catch (error) { + clientPubkey = tools.getPublicKey(hexToBytesCompat(secretKey)); + } const queryParams = {}; for (const [key, values] of additionalParams.entries()) { diff --git a/js/payments/zapReceiptValidator.js b/js/payments/zapReceiptValidator.js index f35d46b2..fb1d8c30 100644 --- a/js/payments/zapReceiptValidator.js +++ b/js/payments/zapReceiptValidator.js @@ -100,6 +100,51 @@ function resolveRelayUrls(zapEvent) { return normalizeRelayList(relaysTag.slice(1)); } +// Build the relay query to FIND the zap receipt (kind 9735). The receipt copies +// the zap request's #e (event) / #a (coordinate) / #p (recipient) tags, and those +// are the tags relays actually index. A previous implementation filtered ONLY on +// "#bolt11": [invoice] — but most relays do NOT index arbitrary tags like bolt11, +// so the query returned nothing even when the receipt was published, and a +// successful zap looked unconfirmed. Prefer the indexed anchors; the precise +// match (author pubkey + bolt11 + description) still confirms the right receipt. +export function buildReceiptFilters(parsedZapRequest, invoiceCandidate) { + const tags = Array.isArray(parsedZapRequest?.tags) ? parsedZapRequest.tags : []; + const findTag = (name) => { + const tag = tags.find( + (entry) => + Array.isArray(entry) && + entry[0] === name && + typeof entry[1] === "string" && + entry[1].trim() + ); + return tag ? tag[1].trim() : ""; + }; + + const recipient = findTag("p"); + const eventId = findTag("e"); + const coordinate = findTag("a"); + + const filter = { kinds: [ZAP_RECEIPT_KIND], limit: 20 }; + if (eventId) { + filter["#e"] = [eventId]; + } else if (coordinate) { + filter["#a"] = [coordinate]; + } + if (recipient) { + filter["#p"] = [recipient]; + } + + // Last resort: no reliably-indexed anchor available — fall back to the bolt11 + // tag (works only on relays that happen to index it). + if (!filter["#e"] && !filter["#a"] && !filter["#p"]) { + const bolt = + typeof invoiceCandidate === "string" ? invoiceCandidate.toLowerCase() : ""; + return [{ kinds: [ZAP_RECEIPT_KIND], "#bolt11": [bolt], limit: 10 }]; + } + + return [filter]; +} + function normalizeInvoiceValue(invoice) { if (typeof invoice !== "string") { return ""; @@ -377,44 +422,10 @@ export async function validateZapReceipt(context = {}, overrides = {}) { }); } - 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 filters = buildReceiptFilters(parsedZapRequest, invoiceCandidate); const normalizedBolt = invoiceCandidate.toLowerCase(); - const successfulEvent = events.find((event) => { + const matchesReceipt = (event) => { if (!event || typeof event !== "object") { return false; } @@ -447,14 +458,75 @@ export async function validateZapReceipt(context = {}, overrides = {}) { if (!descriptionTag) { return false; } - const descriptionValue = descriptionTag[1]; - return descriptionValue === zapRequestString; - }); + return descriptionTag[1] === zapRequestString; + }; + + // The recipient's Lightning service publishes the 9735 receipt asynchronously + // AFTER the invoice is paid — it can land a few seconds after our first query + // (which resolves on EOSE). One immediate lookup therefore misses slow receipts. + // Poll a few times with a short delay before giving up. Injectable for tests. + const attempts = Number.isFinite(overrides.receiptLookupAttempts) + ? Math.max(1, Math.floor(overrides.receiptLookupAttempts)) + : 3; + const retryDelayMs = Number.isFinite(overrides.receiptLookupDelayMs) + ? Math.max(0, Math.floor(overrides.receiptLookupDelayMs)) + : 1200; + const sleep = + typeof overrides.sleep === "function" + ? overrides.sleep + : (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + let successfulEvent = null; + let sawAnyEvents = false; + try { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (attempt > 0 && retryDelayMs > 0) { + await sleep(retryDelayMs); + } + + 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 = []; + } + + if (Array.isArray(events) && events.length) { + sawAnyEvents = true; + const match = events.find(matchesReceipt); + if (match) { + successfulEvent = match; + break; + } + } + } + } finally { + try { + if (typeof pool.close === "function") { + pool.close(relayUrls); + } + } catch (error) { + // Ignore close errors. + } + } if (!successfulEvent) { return buildValidationResult({ status: "failed", - reason: "No compliant zap receipt matched the zap request.", + // Distinguish "nothing was there at all" from "events existed but none + // matched" — the latter is more suspicious; the former is usually just + // relay coverage/timing. + reason: sawAnyEvents + ? "No compliant zap receipt matched the zap request." + : "No zap receipt was published on the advertised relays.", relays: relayUrls, }); } diff --git a/js/ui/components/video-modal/zapController.js b/js/ui/components/video-modal/zapController.js index ea46ec70..abd5a712 100644 --- a/js/ui/components/video-modal/zapController.js +++ b/js/ui/components/video-modal/zapController.js @@ -116,8 +116,15 @@ export class ZapController { const popoverIsOpen = this.isZapDialogOpen(); if (this.modalZapOpenPromise) { - this.modalZapPendingToggle = "close"; - this.closeZapDialog({ silent: true, restoreFocus: false }); + // An open is in flight. Only honor a click as "close" if the dialog is + // already visibly open; otherwise IGNORE the extra click. Canceling an + // in-flight open made rapid double-clicks net to "closed", so the popover + // needed many clicks to actually appear (the engine sets isOpen only + // after positioning, so a mid-open click reads as not-yet-open here). + if (popoverIsOpen) { + this.modalZapPendingToggle = "close"; + this.closeZapDialog({ silent: true, restoreFocus: false }); + } return; } diff --git a/js/ui/zapController.js b/js/ui/zapController.js index c4985272..34425ae8 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -213,7 +213,9 @@ export default class ZapController { this.modalZapInFlight = true; this.videoModal?.setZapPending(true); - this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "warning"); + // In-flight/pending status is informational, not a warning — "warning" colored + // it alarmingly (text-warning-strong) and counted as a warning in the flow. + this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "neutral"); this.videoModal?.clearZapReceipts(); try { @@ -351,54 +353,37 @@ export default class ZapController { 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 }); + // NOTE: `unvalidatedReceipts` are shares whose PAYMENT SUCCEEDED but whose + // on-relay zap receipt (kind 9735) couldn't be validated. This is NOT a + // failure: the NWC payment already returned a preimage (proof the invoice + // was paid), and the 9735 is supplementary — the recipient's service may + // publish it to relays we don't watch, or late, or not at all. Treating it + // as an error wrongly told users a successful zap had failed. So we report + // SUCCESS and only append a soft, non-alarming note that the receipt + // couldn't be confirmed on relays. + this.videoModal?.renderZapReceipts(receipts, { + partial: unvalidatedReceipts.length > 0, + }); const creatorShare = context.shares.creatorShare; const platformShare = context.shares.platformShare; - const summary = platformShare + let summary = platformShare ? `Sent ${context.shares.total} sats (creator ${creatorShare}, platform ${platformShare}).` : `Sent ${context.shares.total} sats to the creator.`; + if (unvalidatedReceipts.length) { + summary += + " Payment went through; couldn't confirm the zap receipt on relays."; + } this.videoModal?.setZapStatus(summary, "success"); this.notifySuccess("Zap sent successfully!"); this.resetRetryState(); this.modalZapCommentValue = ""; - this.videoModal?.setZapComment(""); + // Reset the whole form (amount + comment) after a successful send, matching + // the validation-warning terminal path above. Previously this only cleared + // the comment via setZapComment, leaving the form in a half-reset state + // inconsistent with the other post-send paths and the modal's reset API. + this.videoModal?.resetZapForm({ amount: "", comment: "" }); this.videoModal?.setZapCompleted(true); this.applyDefaultAmount(); } catch (error) { @@ -486,9 +471,28 @@ export default class ZapController { const lightningAddress = this.getCurrentVideo()?.lightningAddress || ""; if (lightningAddress) { try { - await fetchLightningMetadata(lightningAddress); + const entry = await fetchLightningMetadata(lightningAddress); + // Reachable from the browser. If the host doesn't advertise Nostr zaps, + // a payment may still go through but won't yield a zap receipt — note it. + if (entry?.metadata && entry.metadata.allowsNostr === false) { + this.videoModal?.setZapStatus( + "This creator's Lightning host doesn't advertise Nostr zaps — a payment may not produce a zap receipt.", + "warning", + ); + } } catch (error) { devLogger.warn("[zap] Failed to preload creator metadata:", error); + // Passive heads-up to the zapper: if WE can't reach the creator's LNURL + // host from the browser, the actual zap will fail the same way. Surface it + // up front so they aren't surprised mid-send. (sendZap overwrites this with + // its own status once they try.) + if (error?.code === "lnurl-unreachable") { + this.videoModal?.setZapStatus( + "This creator's Lightning address can't be reached from a browser (its host may be " + + "offline or blocking browser requests), so zapping them on bitvid may fail.", + "warning", + ); + } } } diff --git a/tests/nwc-parse-uri.test.mjs b/tests/nwc-parse-uri.test.mjs new file mode 100644 index 00000000..6468d330 --- /dev/null +++ b/tests/nwc-parse-uri.test.mjs @@ -0,0 +1,47 @@ +// Real-tools regression test for the NWC URI parse fix. Unlike nwc-client.test.mjs +// (which mocks nostr-tools and is currently shadowed by the frozen canonical +// toolkit the bootstrap installs), this runs against the real nostr-tools — which +// is exactly the environment the production bug occurs in. +import test from "node:test"; +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +const { __TESTING__ } = await import("../js/payments/nwcClient.js"); +const { parseNwcUri } = __TESTING__; + +test("parseNwcUri derives clientPubkey from a hex secret without throwing", () => { + // Regression (#3 NWC): nostr-tools >= 2.x getPublicKey requires a Uint8Array, + // not a hex string. parseNwcUri passed the hex secret directly, so it threw + // "expected Uint8Array, got type=string" and NWC wallet connection failed + // entirely (the only zap payment path). The fix converts hex -> bytes. + const walletPubkey = "b".repeat(64); + const secretKey = "a".repeat(64); // a valid secp256k1 scalar (0 < k < n) + const relay = "wss://relay.example"; + const uri = + `nostr+walletconnect://${walletPubkey}` + + `?relay=${encodeURIComponent(relay)}` + + `&secret=${secretKey}`; + + const parsed = parseNwcUri(uri); + + assert.equal(parsed.secretKey, secretKey); + assert.equal(parsed.walletPubkey, walletPubkey); + assert.deepEqual(parsed.relays, [relay]); + assert.match( + parsed.clientPubkey, + /^[0-9a-f]{64}$/, + "clientPubkey must be derived as 64-char hex (getPublicKey accepted the secret)", + ); +}); + +test("parseNwcUri rejects a non-hex / wrong-length secret", () => { + const walletPubkey = "b".repeat(64); + const uri = + `nostr+walletconnect://${walletPubkey}` + + `?relay=${encodeURIComponent("wss://relay.example")}` + + `&secret=not-a-valid-secret`; + assert.throws(() => parseNwcUri(uri), /secret must be a 64 character hex string/i); +}); diff --git a/tests/video-modal-zap.test.mjs b/tests/video-modal-zap.test.mjs index c529d0fd..005c1ced 100644 --- a/tests/video-modal-zap.test.mjs +++ b/tests/video-modal-zap.test.mjs @@ -773,7 +773,10 @@ await (async () => { })(); await (async () => { - // Test: validation failure surfaces warning and resets form + // Test: a PAID share whose 9735 receipt couldn't be validated is reported as a + // SUCCESS (with a soft "couldn't confirm the receipt" note), NOT an error. The + // payment already returned a preimage — proof the invoice was paid — so the + // missing on-relay receipt must not be surfaced as a failure. const validationReceipts = [ { recipientType: "creator", @@ -824,27 +827,39 @@ await (async () => { ); const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1]; - assert.equal(lastStatus.tone, "warning", "validation failure should emit warning status"); + assert.equal( + lastStatus.tone, + "success", + "a paid-but-unvalidated receipt is a success, not a warning", + ); assert.match( lastStatus.message, - /awaiting validated zap receipt/i, - "status message should highlight missing validated receipt", + /sent 750 sats/i, + "status should confirm the payment amount", + ); + assert.match( + lastStatus.message, + /couldn't confirm the zap receipt/i, + "status should note the receipt couldn't be confirmed (soft, non-failure)", ); assert.equal( modalStub.resetForms.length, initialResetCount + 1, - "zap form should reset after validation warning", + "zap form should reset after a successful send", ); + // A completing send records TWO completion-state changes: a reset to false at + // the start of sendZap, then true at the terminal success path. The meaningful + // invariant is the end state == true, asserted next. assert.equal( modalStub.completedStates.length, - initialCompleted + 1, - "completion state should record the validation attempt", + initialCompleted + 2, + "completion state should reset at start then mark the send completed", ); assert.equal( modalStub.completedStates[modalStub.completedStates.length - 1], true, - "validation warning should mark zap attempt as completed", + "a successful (receipt-unconfirmed) send should be marked completed", ); app.destroy(); @@ -932,7 +947,10 @@ const { VideoModal } = await import("../js/ui/components/VideoModal.js"); (() => { const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop }); - modal.modalZapReceipts = new DummyElement(); + // renderZapReceipts delegates to the inner zapController, which renders into ITS + // own modalZapReceipts element (the VideoModal -> sub-controller refactor); set + // and assert on the controller's element, not the outer modal's. + const receiptsEl = (modal.zapController.modalZapReceipts = new DummyElement()); const receipts = [ { @@ -949,15 +967,15 @@ const { VideoModal } = await import("../js/ui/components/VideoModal.js"); modal.renderZapReceipts(receipts, { partial: false }); - assert.equal(modal.modalZapReceipts.children.length, 1, "validated receipt should render one entry"); - const entry = modal.modalZapReceipts.children[0]; + assert.equal(receiptsEl.children.length, 1, "validated receipt should render one entry"); + const entry = receiptsEl.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 receiptsEl = (modal.zapController.modalZapReceipts = new DummyElement()); const receipts = [ { @@ -975,11 +993,11 @@ const { VideoModal } = await import("../js/ui/components/VideoModal.js"); modal.renderZapReceipts(receipts, { partial: true }); assert.equal( - modal.modalZapReceipts.children.length, + receiptsEl.children.length, 1, "validation failure should render a single warning entry", ); - const warning = modal.modalZapReceipts.children[0]; + const warning = receiptsEl.children[0]; assert.match( warning.textContent, /no validated zap receipts yet/i, diff --git a/tests/zap-receipt-pool.test.mjs b/tests/zap-receipt-pool.test.mjs index 83444bd4..abc59293 100644 --- a/tests/zap-receipt-pool.test.mjs +++ b/tests/zap-receipt-pool.test.mjs @@ -7,7 +7,10 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; import test from "node:test"; -import { resolveReceiptListPool } from "../js/payments/zapReceiptValidator.js"; +import { + resolveReceiptListPool, + buildReceiptFilters, +} from "../js/payments/zapReceiptValidator.js"; // Mimics a nostr-tools 2.x SimplePool: instances have subscribeMany/close but NO // .list() (the method was removed in 2.x). @@ -59,3 +62,42 @@ test("returns null when there is no SimplePool and no override", () => { const pool = resolveReceiptListPool({}, {}); assert.equal(pool, null); }); + +// Receipt discovery: a previous filter queried ONLY by "#bolt11", which most +// relays don't index, so the receipt was never found and a successful zap looked +// unconfirmed. buildReceiptFilters must query by the reliably-indexed #e/#a/#p. +test("buildReceiptFilters: prefers #e (event) + #p (recipient), not #bolt11", () => { + const zapRequest = { + tags: [ + ["p", "a".repeat(64)], + ["e", "c".repeat(64)], + ["amount", "900000"], + ["relays", "wss://relay.example"], + ], + }; + const [filter] = buildReceiptFilters(zapRequest, "lnbc-invoice"); + assert.deepEqual(filter.kinds, [9735]); + assert.deepEqual(filter["#e"], ["c".repeat(64)]); + assert.deepEqual(filter["#p"], ["a".repeat(64)]); + assert.ok(!filter["#bolt11"], "must not depend on the unindexed #bolt11 tag"); +}); + +test("buildReceiptFilters: uses #a (coordinate) when there is no #e", () => { + const zapRequest = { + tags: [ + ["p", "a".repeat(64)], + ["a", "30078:" + "a".repeat(64) + ":video-d-tag"], + ], + }; + const [filter] = buildReceiptFilters(zapRequest, "lnbc-invoice"); + assert.deepEqual(filter["#a"], ["30078:" + "a".repeat(64) + ":video-d-tag"]); + assert.ok(!filter["#e"]); + assert.deepEqual(filter["#p"], ["a".repeat(64)]); +}); + +test("buildReceiptFilters: falls back to #bolt11 only with no e/a/p anchor", () => { + const zapRequest = { tags: [["amount", "1000"]] }; + const [filter] = buildReceiptFilters(zapRequest, "LNBC-Invoice"); + assert.deepEqual(filter["#bolt11"], ["lnbc-invoice"]); + assert.deepEqual(filter.kinds, [9735]); +}); diff --git a/tests/zap-shared-state.test.mjs b/tests/zap-shared-state.test.mjs index f3f11e98..454074b7 100644 --- a/tests/zap-shared-state.test.mjs +++ b/tests/zap-shared-state.test.mjs @@ -4,7 +4,7 @@ const sharedStateModule = await import("../js/payments/zapSharedState.js"); const feeModule = await import("../js/payments/platformFee.js"); const { calculateZapShares } = sharedStateModule; -const { resolvePlatformFeePercent } = feeModule; +const { resolvePlatformFeePercent, getDefaultPlatformFeePercent } = feeModule; (function testCalculateZapSharesSupportsRatioOverride() { const result = calculateZapShares(35, "70/30"); @@ -22,8 +22,18 @@ const { resolvePlatformFeePercent } = feeModule; })(); (function testResolvePlatformFeePercentFallsBackToDefault() { + // An UNPARSEABLE override must fall back to the configured platform default + // (getDefaultPlatformFeePercent), NOT to 0. Falling back to 0 would let a + // garbage override silently DISABLE the platform fee (a bypass). Assert the + // contract against the configured default rather than a hard-coded number so + // the test stays correct if PLATFORM_FEE_PERCENT changes. + const expected = getDefaultPlatformFeePercent(); const percent = resolvePlatformFeePercent("not-a-number"); - assert.equal(percent, 0); + assert.equal(percent, expected); + // Sanity: with the platform fee configured (>0), the fallback must be non-zero + // (i.e. a junk override cannot zero out the fee). + assert.ok(expected > 0, "platform fee default should be configured > 0"); + assert.ok(percent > 0, "junk override must not disable the platform fee"); })(); console.log("zap-shared-state tests passed"); diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index 44a44757..dfaca74b 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -712,6 +712,7 @@ async function testValidateZapReceiptSuccess() { created_at: 1_700_000_000, tags: [ ["p", "a".repeat(64)], + ["e", "c".repeat(64)], ["amount", String(900_000)], ["relays", ...relays], ], @@ -735,7 +736,15 @@ async function testValidateZapReceiptSuccess() { SimplePool: class { async list(requestedRelays, filters) { assert.deepEqual(requestedRelays, relays); - assert.equal(filters[0]["#bolt11"][0], bolt11.toLowerCase()); + // 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() {} @@ -761,6 +770,133 @@ async function testValidateZapReceiptSuccess() { 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 = { @@ -835,6 +971,8 @@ await testStringFeeOverride(); await testPlatformShareFailure(); await testValidateZapReceiptSuccess(); await testValidateZapReceiptRejectsMismatchedDescriptionHash(); +await testValidateZapReceiptRetriesForLateReceipt(); +await testValidateZapReceiptGivesUpAfterRetries(); testExtractDescriptionHashFromBolt11(); console.log("zap-split tests passed");