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.
This commit is contained in:
Claude
2026-01-20 09:29:39 +00:00
parent c2f6f1bcd2
commit 6d3f45d4ae
2 changed files with 112 additions and 1 deletions

View File

@@ -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<string>("");
const [checkingPayment, setCheckingPayment] = useState(false);
// Editor ref and search functions
const editorRef = useRef<MentionEditorHandle>(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({
</div>
)}
{/* Payment Verification Status */}
{verifyUrl && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
{checkingPayment && (
<Loader2 className="size-3 animate-spin" />
)}
<span>
{checkingPayment
? "Checking for payment..."
: "Waiting for payment (auto-checking every 30s)"}
</span>
</div>
)}
{/* Amount Preview */}
{(selectedAmount || customAmount) && (
<div className="text-center">

View File

@@ -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<LnUrlVerifyResponse> {
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}`);
}
}