mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user