mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
1921356558
commit
c5589a1514
9 files changed
+448
-101
No files matched your search
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+44
-40
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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");
|
||||
+139
-1
@@ -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");
|
||||
Reference in new issue
Block a user