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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-23 14:53:38 -04:00
1 parent 1921356558
commit c5589a1514
9 files changed
+448 -101

No files matched your search

+47
View File
@@ -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);
});
+32 -14
View File
@@ -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,
+43 -1
View File
@@ -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]);
});
+12 -2
View File
@@ -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
View File
@@ -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");