From 6d3f45d4aec6821392bbdd77e37672c86e1f2958 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 09:29:39 +0000 Subject: [PATCH] feat: add LNURL verify polling for zap QR payment detection Implements automatic payment completion detection for zap QR codes using the optional LNURL verify endpoint when available. Changes: - Add verify URL field to LnUrlCallbackResponse type - Add LnUrlVerifyResponse type for verify endpoint responses - Add verifyPayment() function to poll verify endpoint - Store verify URL when generating invoice in ZapWindow - Poll verify endpoint every 30 seconds when QR dialog is open - Auto-close QR dialog and show success toast when payment detected - Add UI feedback showing "Checking for payment..." status - Silently ignore verification errors to allow retry on next interval The polling activates automatically when: 1. LNURL provider includes a verify URL in the callback response 2. QR code dialog is open 3. Payment hasn't been completed yet Users see a subtle status message showing when payment verification is actively checking vs waiting for next check interval. --- src/components/ZapWindow.tsx | 64 +++++++++++++++++++++++++++++++++++- src/lib/lnurl.ts | 49 +++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx index b4eb3c4..41200f3 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -46,7 +46,7 @@ import { import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useProfileSearch } from "@/hooks/useProfileSearch"; import LoginDialog from "./nostr/LoginDialog"; -import { validateZapSupport } from "@/lib/lnurl"; +import { validateZapSupport, verifyPayment } from "@/lib/lnurl"; import { createZapRequest, serializeZapRequest, @@ -129,6 +129,47 @@ export function ZapWindow({ } }, [wallet, getInfo]); + // Poll for payment completion via LNURL verify endpoint + const PAYMENT_CHECK_INTERVAL = 30000; // Check every 30 seconds + useEffect(() => { + if (!verifyUrl || !showQrDialog || isPaid) return; + + const checkPayment = async () => { + setCheckingPayment(true); + try { + const result = await verifyPayment(verifyUrl); + + // If payment is settled, mark as paid and show success + if (result.status === "OK" && result.settled) { + setIsPaid(true); + setShowQrDialog(false); + const amount = selectedAmount || parseInt(customAmount); + toast.success( + `⚡ Payment received! Zapped ${amount} sats to ${recipientName}!`, + ); + } + } catch (error) { + // Silently ignore errors - will retry on next interval + console.debug("Payment verification check failed:", error); + } finally { + setCheckingPayment(false); + } + }; + + // Check immediately on mount, then every 30 seconds + checkPayment(); + const intervalId = setInterval(checkPayment, PAYMENT_CHECK_INTERVAL); + + return () => clearInterval(intervalId); + }, [ + verifyUrl, + showQrDialog, + isPaid, + selectedAmount, + customAmount, + recipientName, + ]); + // Cache LNURL data for recipient's Lightning address const { data: lnurlData } = useLnurlCache(recipientProfile?.lud16); @@ -143,6 +184,8 @@ export function ZapWindow({ const [showLogin, setShowLogin] = useState(false); const [paymentTimedOut, setPaymentTimedOut] = useState(false); const [zapAnonymously, setZapAnonymously] = useState(false); + const [verifyUrl, setVerifyUrl] = useState(""); + const [checkingPayment, setCheckingPayment] = useState(false); // Editor ref and search functions const editorRef = useRef(null); @@ -398,6 +441,11 @@ export function ZapWindow({ setQrCodeUrl(qrUrl); setInvoice(invoiceText); + // Store verify URL if available for payment polling + if (invoiceResponse.verify) { + setVerifyUrl(invoiceResponse.verify); + } + // Step 5: Pay or show QR code if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) { // Pay with NWC wallet with timeout @@ -533,6 +581,20 @@ export function ZapWindow({ )} + {/* Payment Verification Status */} + {verifyUrl && ( +
+ {checkingPayment && ( + + )} + + {checkingPayment + ? "Checking for payment..." + : "Waiting for payment (auto-checking every 30s)"} + +
+ )} + {/* Amount Preview */} {(selectedAmount || customAmount) && (
diff --git a/src/lib/lnurl.ts b/src/lib/lnurl.ts index cb8713f..25f41a1 100644 --- a/src/lib/lnurl.ts +++ b/src/lib/lnurl.ts @@ -20,6 +20,14 @@ export interface LnUrlCallbackResponse { message?: string; }; routes?: any[]; + verify?: string; // Optional URL to poll for payment verification +} + +export interface LnUrlVerifyResponse { + status: "OK" | "ERROR"; + settled: boolean; + preimage?: string; + pr?: string; } /** @@ -164,3 +172,44 @@ export function validateZapSupport(lnurlData: LnUrlPayResponse): void { throw new Error("Invalid nostrPubkey format in LNURL response"); } } + +/** + * Check payment status via LNURL verify endpoint + * @param verifyUrl - The verify URL from LNURL callback response + * @returns Promise resolving to verification response + */ +export async function verifyPayment( + verifyUrl: string, +): Promise { + try { + // Add timeout to prevent hanging + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout + + const response = await fetch(verifyUrl, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error( + `Failed to verify payment: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as LnUrlVerifyResponse; + + // Validate response format + if (data.status !== "OK" && data.status !== "ERROR") { + throw new Error("Invalid verify response: missing or invalid status"); + } + + return data; + } catch (error) { + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new Error("Payment verification timed out. Please try again."); + } + throw error; + } + throw new Error(`Failed to verify payment: ${error}`); + } +}