Files
bitvid/js/payments/zapReceiptValidator.js
T
thePR0M3TH3ANandClaude Opus 4.8 c5589a1514 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]>
2026-06-23 14:53:38 -04:00

545 lines
15 KiB
JavaScript

// js/payments/zapReceiptValidator.js
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
import {
normalizeToolkitCandidate,
readToolkitFromScope,
shimLegacySimplePoolMethods,
} from "../nostr/toolkit.js";
import { userLogger } from "../utils/logger.js";
import { bech32, bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
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));
}
// 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 "";
}
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() : [],
};
}
// Build the relay pool used to fetch the zap receipt. nostr-tools 2.x SimplePool
// no longer exposes .list(), so a freshly constructed pool can't query — which
// made receipt validation always fail with "Unable to initialize a relay pool"
// after an otherwise-successful zap. Apply the app's legacy .list shim (unless a
// listEvents override supplies its own fetch path).
export function resolveReceiptListPool(tools, overrides = {}) {
let pool = null;
try {
if (typeof overrides.createPool === "function") {
pool = overrides.createPool(tools);
} else if (tools && typeof tools.SimplePool === "function") {
pool = new tools.SimplePool();
}
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to create SimplePool instance.", error);
return null;
}
if (
pool &&
typeof pool.list !== "function" &&
typeof overrides.listEvents !== "function"
) {
try {
shimLegacySimplePoolMethods(pool);
} catch (error) {
userLogger.warn("[zapReceiptValidator] Failed to shim SimplePool.list.", error);
}
}
return pool;
}
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,
});
}
const pool = resolveReceiptListPool(tools, overrides);
const canListEvents =
typeof overrides.listEvents === "function" ||
(pool && typeof pool.list === "function");
if (!pool || !canListEvents) {
return buildValidationResult({
status: "failed",
reason: "Unable to initialize a relay pool for receipt validation.",
relays: relayUrls,
});
}
const filters = buildReceiptFilters(parsedZapRequest, invoiceCandidate);
const normalizedBolt = invoiceCandidate.toLowerCase();
const matchesReceipt = (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;
}
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",
// 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,
});
}
return buildValidationResult({
status: "passed",
event: successfulEvent,
relays: relayUrls,
});
}
export const __TESTING__ = Object.freeze({
computeZapRequestHash,
extractDescriptionHashFromBolt11,
});