diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx index 861327e..2580eff 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -42,12 +42,13 @@ import { import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useProfileSearch } from "@/hooks/useProfileSearch"; import LoginDialog from "./nostr/LoginDialog"; -import { resolveLightningAddress, validateZapSupport } from "@/lib/lnurl"; +import { validateZapSupport } from "@/lib/lnurl"; import { createZapRequest, serializeZapRequest, } from "@/lib/create-zap-request"; import { fetchInvoiceFromCallback } from "@/lib/lnurl"; +import { useLnurlCache } from "@/hooks/useLnurlCache"; export interface ZapWindowProps { /** Recipient pubkey (who receives the zap) */ @@ -117,9 +118,13 @@ export function ZapWindow({ } }, [wallet, getInfo]); + // Cache LNURL data for recipient's Lightning address + const { data: lnurlData } = useLnurlCache(recipientProfile?.lud16); + const [selectedAmount, setSelectedAmount] = useState(null); const [customAmount, setCustomAmount] = useState(""); const [isProcessing, setIsProcessing] = useState(false); + const [isPayingWithWallet, setIsPayingWithWallet] = useState(false); const [isPaid, setIsPaid] = useState(false); const [qrCodeUrl, setQrCodeUrl] = useState(""); const [invoice, setInvoice] = useState(""); @@ -287,7 +292,7 @@ export function ZapWindow({ // Track usage trackAmountUsage(amount); - // Step 1: Get Lightning address from recipient profile + // Step 1: Verify recipient has Lightning address configured const lud16 = recipientProfile?.lud16; const lud06 = recipientProfile?.lud06; @@ -297,21 +302,21 @@ export function ZapWindow({ ); } - // Step 2: Resolve LNURL to get callback URL and nostrPubkey - let lnurlData; - if (lud16) { - lnurlData = await resolveLightningAddress(lud16); - validateZapSupport(lnurlData); - } else if (lud06) { + if (lud06) { throw new Error( "LNURL (lud06) not supported. Recipient should use a Lightning address (lud16) instead.", ); } + // Step 2: Use cached LNURL data (fetched by useLnurlCache hook) if (!lnurlData) { - throw new Error("Failed to resolve Lightning address"); + throw new Error( + "Failed to resolve Lightning address. Please wait and try again.", + ); } + validateZapSupport(lnurlData); + // Validate amount is within acceptable range const amountMillisats = amount * 1000; if (amountMillisats < lnurlData.minSendable) { @@ -365,9 +370,15 @@ export function ZapWindow({ const invoiceText = invoiceResponse.pr; + // Generate QR code upfront so we can show it immediately on error + const qrUrl = await generateQrCode(invoiceText); + setQrCodeUrl(qrUrl); + setInvoice(invoiceText); + // Step 5: Pay or show QR code if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) { // Pay with NWC wallet with timeout + setIsPayingWithWallet(true); try { // Race between payment and 30 second timeout const paymentPromise = payInvoice(invoiceText); @@ -379,25 +390,27 @@ export function ZapWindow({ await refreshBalance(); setIsPaid(true); + setIsPayingWithWallet(false); toast.success(`⚡ Zapped ${amount} sats to ${recipientName}!`); } catch (error) { + // Payment failed or timed out - show QR code immediately + setIsPayingWithWallet(false); + setPaymentTimedOut(true); + setShowQrDialog(true); + + // Show specific error message if (error instanceof Error && error.message === "TIMEOUT") { - // Payment timed out - show QR code with retry option - setPaymentTimedOut(true); - const qrUrl = await generateQrCode(invoiceText); - setQrCodeUrl(qrUrl); - setInvoice(invoiceText); - setShowQrDialog(true); + toast.error("Payment timed out. Use QR code or retry with wallet."); } else { - // Other payment error - re-throw - throw error; + toast.error( + error instanceof Error + ? `Payment failed: ${error.message}` + : "Payment failed. Use QR code or retry.", + ); } } } else { - // Show QR code and invoice - const qrUrl = await generateQrCode(invoiceText); - setQrCodeUrl(qrUrl); - setInvoice(invoiceText); + // Show QR code and invoice directly setShowQrDialog(true); } } catch (error) { @@ -435,6 +448,7 @@ export function ZapWindow({ if (!invoice || !wallet) return; setIsProcessing(true); + setIsPayingWithWallet(true); setShowQrDialog(false); setPaymentTimedOut(false); @@ -452,18 +466,19 @@ export function ZapWindow({ setShowQrDialog(false); toast.success("⚡ Payment successful!"); } catch (error) { + setShowQrDialog(true); + setPaymentTimedOut(true); + if (error instanceof Error && error.message === "TIMEOUT") { toast.error("Payment timed out. Please try manually."); - setPaymentTimedOut(true); - setShowQrDialog(true); } else { toast.error( error instanceof Error ? error.message : "Failed to retry payment", ); - setShowQrDialog(true); } } finally { setIsProcessing(false); + setIsPayingWithWallet(false); } }; @@ -495,6 +510,16 @@ export function ZapWindow({ )} + {/* Amount Preview */} + {(selectedAmount || customAmount) && ( +
+
+ {formatAmount(selectedAmount || parseInt(customAmount))} +
+
sats
+
+ )} + {/* Invoice */}
@@ -515,38 +540,43 @@ export function ZapWindow({
{/* Actions */} - +
+ {/* Retry with wallet button if payment failed/timed out */} + {paymentTimedOut && + wallet && + walletInfo?.methods.includes("pay_invoice") && ( + + )} - {/* Retry with wallet button if payment timed out */} - {paymentTimedOut && - wallet && - walletInfo?.methods.includes("pay_invoice") && ( - - )} + {/* Always show option to open in external wallet */} + +
) : ( <> @@ -614,7 +644,7 @@ export function ZapWindow({ placeholder="Say something nice..." searchProfiles={searchProfiles} searchEmojis={searchEmojis} - className="rounded-md border border-input bg-background px-3 py-2" + className="rounded-md border border-input bg-background px-3 py-1 text-base md:text-sm min-h-9" /> )} @@ -658,7 +688,9 @@ export function ZapWindow({ {isProcessing ? ( <> - Processing... + {isPayingWithWallet + ? "Paying with wallet..." + : "Processing..."} ) : isPaid ? ( <> diff --git a/src/hooks/useLnurlCache.ts b/src/hooks/useLnurlCache.ts new file mode 100644 index 0000000..2ca5677 --- /dev/null +++ b/src/hooks/useLnurlCache.ts @@ -0,0 +1,72 @@ +import db from "@/services/db"; +import { resolveLightningAddress, type LnUrlPayResponse } from "@/lib/lnurl"; +import { useLiveQuery } from "dexie-react-hooks"; +import { useEffect, useState } from "react"; + +// Cache TTL: 24 hours (LNURL configs rarely change) +const CACHE_TTL = 24 * 60 * 60 * 1000; + +/** + * Hook to fetch and cache LNURL address resolution data + * Similar to useNip05 but for Lightning addresses + * + * Benefits: + * - Instant zap UI on subsequent zaps (no 10s network delay) + * - Offline capability (show limits/amounts from cache) + * - Reduced load on LNURL servers + * - Better UX for frequently zapped users + */ +export function useLnurlCache(address: string | undefined) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Get cached data from Dexie + const cached = useLiveQuery( + () => (address ? db.lnurlCache.get(address) : undefined), + [address], + ); + + useEffect(() => { + if (!address) return; + + // Check if cache is fresh (within TTL) + const isFresh = cached && Date.now() - cached.fetchedAt < CACHE_TTL; + if (isFresh) { + setError(null); + return; + } + + // Fetch and cache LNURL data + setIsLoading(true); + setError(null); + + resolveLightningAddress(address) + .then((data: LnUrlPayResponse) => { + db.lnurlCache.put({ + address, + callback: data.callback, + minSendable: data.minSendable, + maxSendable: data.maxSendable, + metadata: data.metadata, + tag: data.tag, + allowsNostr: data.allowsNostr, + nostrPubkey: data.nostrPubkey, + commentAllowed: data.commentAllowed, + fetchedAt: Date.now(), + }); + setIsLoading(false); + }) + .catch((err) => { + console.error("Failed to resolve Lightning address:", err); + setError(err instanceof Error ? err : new Error(String(err))); + setIsLoading(false); + }); + }, [address, cached]); + + return { + data: cached, + isLoading, + error, + isCached: !!cached, + }; +} diff --git a/src/lib/lnurl.test.ts b/src/lib/lnurl.test.ts new file mode 100644 index 0000000..d2b6d2e --- /dev/null +++ b/src/lib/lnurl.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { validateZapSupport, type LnUrlPayResponse } from "./lnurl"; + +describe("validateZapSupport", () => { + const validLnurlData: LnUrlPayResponse = { + callback: "https://example.com/lnurl/callback", + maxSendable: 100000000, + minSendable: 1000, + metadata: '[["text/plain","Zap me!"]]', + tag: "payRequest", + allowsNostr: true, + nostrPubkey: + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + }; + + describe("valid zap support", () => { + it("should pass validation for valid zap-enabled LNURL", () => { + expect(() => validateZapSupport(validLnurlData)).not.toThrow(); + }); + + it("should pass validation with commentAllowed field", () => { + const withComment: LnUrlPayResponse = { + ...validLnurlData, + commentAllowed: 280, + }; + expect(() => validateZapSupport(withComment)).not.toThrow(); + }); + }); + + describe("allowsNostr validation", () => { + it("should throw if allowsNostr is false", () => { + const noZaps: LnUrlPayResponse = { + ...validLnurlData, + allowsNostr: false, + }; + + expect(() => validateZapSupport(noZaps)).toThrow( + "This Lightning address does not support Nostr zaps", + ); + }); + + it("should throw if allowsNostr is missing", () => { + const noFlag: LnUrlPayResponse = { + ...validLnurlData, + allowsNostr: undefined, + }; + + expect(() => validateZapSupport(noFlag)).toThrow( + "This Lightning address does not support Nostr zaps", + ); + }); + }); + + describe("nostrPubkey validation", () => { + it("should throw if nostrPubkey is missing", () => { + const noPubkey: LnUrlPayResponse = { + ...validLnurlData, + nostrPubkey: undefined, + }; + + expect(() => validateZapSupport(noPubkey)).toThrow( + "LNURL service missing nostrPubkey", + ); + }); + + it("should throw if nostrPubkey is invalid hex (too short)", () => { + const shortPubkey: LnUrlPayResponse = { + ...validLnurlData, + nostrPubkey: "abc123", + }; + + expect(() => validateZapSupport(shortPubkey)).toThrow( + "Invalid nostrPubkey format", + ); + }); + + it("should throw if nostrPubkey is invalid hex (too long)", () => { + const longPubkey: LnUrlPayResponse = { + ...validLnurlData, + nostrPubkey: + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459dextra", + }; + + expect(() => validateZapSupport(longPubkey)).toThrow( + "Invalid nostrPubkey format", + ); + }); + + it("should throw if nostrPubkey contains non-hex characters", () => { + const invalidChars: LnUrlPayResponse = { + ...validLnurlData, + nostrPubkey: + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa45zz", + }; + + expect(() => validateZapSupport(invalidChars)).toThrow( + "Invalid nostrPubkey format", + ); + }); + + it("should accept uppercase hex pubkey", () => { + const uppercasePubkey: LnUrlPayResponse = { + ...validLnurlData, + nostrPubkey: + "3BF0C63FCB93463407AF97A5E5EE64FA883D107EF9E558472C4EB9AAAEFA459D", + }; + + expect(() => validateZapSupport(uppercasePubkey)).not.toThrow(); + }); + + it("should accept mixed case hex pubkey", () => { + const mixedCasePubkey: LnUrlPayResponse = { + ...validLnurlData, + nostrPubkey: + "3Bf0C63fcB93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459D", + }; + + expect(() => validateZapSupport(mixedCasePubkey)).not.toThrow(); + }); + }); + + describe("edge cases", () => { + it("should handle all optional fields as undefined", () => { + const minimal: LnUrlPayResponse = { + callback: "https://example.com/callback", + maxSendable: 1000000, + minSendable: 1000, + metadata: '[["text/plain","Minimal"]]', + tag: "payRequest", + allowsNostr: true, + nostrPubkey: + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + commentAllowed: undefined, + }; + + expect(() => validateZapSupport(minimal)).not.toThrow(); + }); + }); +}); diff --git a/src/services/db.ts b/src/services/db.ts index 6e9ee79..7523d98 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -87,6 +87,19 @@ export interface LocalSpellbook { deletedAt?: number; } +export interface LnurlCache { + address: string; // Primary key (e.g., "user@domain.com") + callback: string; // LNURL callback URL + minSendable: number; // Min amount in millisats + maxSendable: number; // Max amount in millisats + metadata: string; // LNURL metadata + tag: "payRequest"; // LNURL tag (always "payRequest" for LNURL-pay) + allowsNostr?: boolean; // Zap support + nostrPubkey?: string; // Pubkey for zap receipts + commentAllowed?: number; // Max comment length + fetchedAt: number; // Timestamp for cache invalidation +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -98,6 +111,7 @@ class GrimoireDb extends Dexie { blossomServers!: Table; spells!: Table; spellbooks!: Table; + lnurlCache!: Table; constructor(name: string) { super(name); @@ -333,6 +347,21 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 16: Add LNURL address caching + this.version(16).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + lnurlCache: "&address, fetchedAt", + }); } } @@ -348,7 +377,7 @@ export const relayLivenessStorage = { if (!entry) return null; // Return RelayState object without the url field - const { url, ...state } = entry; + const { url: _url, ...state } = entry; return state; },