diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx index bdf7787..52fda7b 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -158,6 +158,24 @@ export function ZapWindow({ } }; + // Generate QR code for invoice + const generateQrCode = async (invoiceText: string) => { + try { + const qrDataUrl = await QRCode.toDataURL(invoiceText, { + width: 300, + margin: 2, + color: { + dark: "#000000", + light: "#FFFFFF", + }, + }); + return qrDataUrl; + } catch (error) { + console.error("QR code generation error:", error); + throw new Error("Failed to generate QR code"); + } + }; + // Handle zap payment flow const handleZap = async (useWallet: boolean) => { const amount = selectedAmount || parseInt(customAmount); @@ -166,6 +184,11 @@ export function ZapWindow({ return; } + if (!recipientPubkey) { + toast.error("No recipient specified"); + return; + } + setIsProcessing(true); try { // Track usage @@ -179,25 +202,104 @@ export function ZapWindow({ const lud06 = content?.lud06; if (!lud16 && !lud06) { - throw new Error("Recipient has no Lightning address configured"); + throw new Error( + "Recipient has no Lightning address configured in their profile", + ); } - // Step 2: Resolve LNURL to get callback URL - // TODO: Implement full LNURL resolution and zap request creation - // For now, show a placeholder message - toast.error( - "Zap functionality coming soon! Need to implement LNURL resolution and zap request creation.", + // Step 2: Resolve LNURL to get callback URL and nostrPubkey + toast.info("Resolving Lightning address..."); + + let lnurlData; + if (lud16) { + const { resolveLightningAddress, validateZapSupport } = + await import("@/lib/lnurl"); + lnurlData = await resolveLightningAddress(lud16); + validateZapSupport(lnurlData); + } else if (lud06) { + throw new Error( + "LNURL (lud06) not supported. Recipient should use a Lightning address (lud16) instead.", + ); + } + + if (!lnurlData) { + throw new Error("Failed to resolve Lightning address"); + } + + // Validate amount is within acceptable range + const amountMillisats = amount * 1000; + if (amountMillisats < lnurlData.minSendable) { + throw new Error( + `Amount too small. Minimum: ${Math.ceil(lnurlData.minSendable / 1000)} sats`, + ); + } + if (amountMillisats > lnurlData.maxSendable) { + throw new Error( + `Amount too large. Maximum: ${Math.floor(lnurlData.maxSendable / 1000)} sats`, + ); + } + + // Validate comment length if provided + if (comment && lnurlData.commentAllowed) { + if (comment.length > lnurlData.commentAllowed) { + throw new Error( + `Comment too long. Maximum ${lnurlData.commentAllowed} characters.`, + ); + } + } + + // Step 3: Create and sign zap request event (kind 9734) + toast.info("Creating zap request..."); + const { createZapRequest, serializeZapRequest } = + await import("@/lib/create-zap-request"); + + const zapRequest = await createZapRequest({ + recipientPubkey, + amountMillisats, + comment, + eventPointer, + lnurl: lud16 || undefined, + }); + + const serializedZapRequest = serializeZapRequest(zapRequest); + + // Step 4: Fetch invoice from LNURL callback + toast.info("Fetching invoice..."); + const { fetchInvoiceFromCallback } = await import("@/lib/lnurl"); + + const invoiceResponse = await fetchInvoiceFromCallback( + lnurlData.callback, + amountMillisats, + serializedZapRequest, + comment || undefined, ); - // Placeholder for full implementation: - // 1. Resolve LNURL (lud16 or lud06) to get callback URL and nostrPubkey - // 2. Create kind 9734 zap request event - // 3. Sign zap request with user's key - // 4. Send GET request to callback with zap request and amount - // 5. Get invoice from callback - // 6. If useWallet: pay invoice with NWC - // Else: show QR code - // 7. Listen for kind 9735 receipt (optional) + const invoiceText = invoiceResponse.pr; + + // Step 5: Pay or show QR code + if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) { + // Pay with NWC wallet + toast.info("Paying invoice with wallet..."); + await payInvoice(invoiceText); + await refreshBalance(); + + setIsPaid(true); + toast.success( + `⚡ Zapped ${amount} sats to ${content?.name || recipientName}!`, + ); + + // Show success message from LNURL service if available + if (invoiceResponse.successAction?.message) { + toast.info(invoiceResponse.successAction.message); + } + } else { + // Show QR code and invoice + const qrUrl = await generateQrCode(invoiceText); + setQrCodeUrl(qrUrl); + setInvoice(invoiceText); + setShowQrDialog(true); + toast.success("Invoice ready! Scan or copy to pay."); + } } catch (error) { console.error("Zap error:", error); toast.error( diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 5f2a420..9b4986a 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -10,7 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Menu, Copy, Check, FileJson, ExternalLink } from "lucide-react"; +import { Menu, Copy, Check, FileJson, ExternalLink, Zap } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useCopy } from "@/hooks/useCopy"; import { JsonViewer } from "@/components/JsonViewer"; @@ -157,6 +157,29 @@ export function EventMenu({ event }: { event: NostrEvent }) { setJsonDialogOpen(true); }; + const zapEvent = () => { + // Create event pointer for the zap + let eventPointer; + if (isAddressableKind(event.kind)) { + const dTag = getTagValue(event, "d") || ""; + eventPointer = { + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + }; + } else { + eventPointer = { + id: event.id, + }; + } + + // Open zap window with event context + addWindow("zap", { + recipientPubkey: event.pubkey, + eventPointer, + }); + }; + return ( @@ -181,6 +204,10 @@ export function EventMenu({ event }: { event: NostrEvent }) { Open + + + Zap + {copied ? ( diff --git a/src/lib/create-zap-request.ts b/src/lib/create-zap-request.ts new file mode 100644 index 0000000..693dc32 --- /dev/null +++ b/src/lib/create-zap-request.ts @@ -0,0 +1,111 @@ +/** + * Create NIP-57 zap request (kind 9734) + */ + +import { EventFactory } from "applesauce-core/event-factory"; +import { EventTemplate, NostrEvent } from "@/types/nostr"; +import type { EventPointer, AddressPointer } from "./open-parser"; +import accountManager from "@/services/accounts"; +import { relayListCache } from "@/services/relay-list-cache"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; + +export interface ZapRequestParams { + /** Recipient pubkey (who receives the zap) */ + recipientPubkey: string; + /** Amount in millisatoshis */ + amountMillisats: number; + /** Optional comment/message */ + comment?: string; + /** Optional event being zapped */ + eventPointer?: EventPointer | AddressPointer; + /** Relays where zap receipt should be published */ + relays?: string[]; + /** LNURL for the recipient */ + lnurl?: string; +} + +/** + * Create and sign a zap request event (kind 9734) + * This event is NOT published to relays - it's sent to the LNURL callback + */ +export async function createZapRequest( + params: ZapRequestParams, +): Promise { + const account = accountManager.active; + + if (!account) { + throw new Error("No active account. Please log in to send zaps."); + } + + const signer = account.signer; + if (!signer) { + throw new Error("No signer available for active account"); + } + + // Get relays for zap receipt publication + let relays = params.relays; + if (!relays || relays.length === 0) { + // Use sender's read relays (where they want to receive zap receipts) + const senderReadRelays = + (await relayListCache.getInboxRelays(account.pubkey)) || []; + relays = senderReadRelays.length > 0 ? senderReadRelays : AGGREGATOR_RELAYS; + } + + // Build tags + const tags: string[][] = [ + ["p", params.recipientPubkey], + ["amount", params.amountMillisats.toString()], + ["relays", ...relays.slice(0, 10)], // Limit to 10 relays + ]; + + // Add lnurl tag if provided + if (params.lnurl) { + tags.push(["lnurl", params.lnurl]); + } + + // Add event reference if zapping an event + if (params.eventPointer) { + if ("id" in params.eventPointer) { + // Regular event (e tag) + tags.push(["e", params.eventPointer.id]); + // Include author if available + if (params.eventPointer.author) { + tags.push(["p", params.eventPointer.author]); + } + // Include relay hints + if (params.eventPointer.relays && params.eventPointer.relays.length > 0) { + tags.push(["e", params.eventPointer.id, params.eventPointer.relays[0]]); + } + } else { + // Addressable event (a tag) + const coordinate = `${params.eventPointer.kind}:${params.eventPointer.pubkey}:${params.eventPointer.identifier}`; + tags.push(["a", coordinate]); + // Include relay hint if available + if (params.eventPointer.relays && params.eventPointer.relays.length > 0) { + tags.push(["a", coordinate, params.eventPointer.relays[0]]); + } + } + } + + // Create event template + const template: EventTemplate = { + kind: 9734, + content: params.comment || "", + tags, + created_at: Math.floor(Date.now() / 1000), + }; + + // Sign the event + const factory = new EventFactory({ signer }); + const draft = await factory.build(template); + const signedEvent = await factory.sign(draft); + + return signedEvent as NostrEvent; +} + +/** + * Serialize zap request event to URL-encoded JSON for LNURL callback + */ +export function serializeZapRequest(event: NostrEvent): string { + return encodeURIComponent(JSON.stringify(event)); +} diff --git a/src/lib/lnurl.ts b/src/lib/lnurl.ts new file mode 100644 index 0000000..1a735e9 --- /dev/null +++ b/src/lib/lnurl.ts @@ -0,0 +1,145 @@ +/** + * LNURL utilities for Lightning address resolution and zap support (NIP-57) + */ + +export interface LnUrlPayResponse { + callback: string; + maxSendable: number; + minSendable: number; + metadata: string; + tag: "payRequest"; + allowsNostr?: boolean; + nostrPubkey?: string; + commentAllowed?: number; +} + +export interface LnUrlCallbackResponse { + pr: string; // BOLT11 invoice + successAction?: { + tag: string; + message?: string; + }; + routes?: any[]; +} + +/** + * Resolve a Lightning address (lud16) to LNURL-pay endpoint data + * Converts user@domain.com to https://domain.com/.well-known/lnurlp/user + */ +export async function resolveLightningAddress( + address: string, +): Promise { + const parts = address.split("@"); + if (parts.length !== 2) { + throw new Error( + "Invalid Lightning address format. Expected: user@domain.com", + ); + } + + const [username, domain] = parts; + const url = `https://${domain}/.well-known/lnurlp/${username}`; + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch LNURL data: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as LnUrlPayResponse; + + // Validate required fields + if (data.tag !== "payRequest") { + throw new Error( + `Invalid LNURL response: expected tag "payRequest", got "${data.tag}"`, + ); + } + + if (!data.callback) { + throw new Error("LNURL response missing callback URL"); + } + + return data; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error(`Failed to resolve Lightning address: ${error}`); + } +} + +/** + * Decode LNURL (bech32-encoded URL) to plain HTTPS URL + */ +export function decodeLnurl(lnurl: string): string { + // For simplicity, we'll require Lightning addresses (lud16) instead of lud06 + // Most modern wallets use lud16 anyway + throw new Error( + "LNURL (lud06) not supported. Please use a Lightning address (lud16) instead.", + ); +} + +/** + * Fetch invoice from LNURL callback with zap request + * @param callbackUrl - The callback URL from LNURL-pay response + * @param amountMillisats - Amount in millisatoshis + * @param zapRequestEvent - Signed kind 9734 zap request event (URL-encoded JSON) + * @param comment - Optional comment (if allowed by LNURL service) + */ +export async function fetchInvoiceFromCallback( + callbackUrl: string, + amountMillisats: number, + zapRequestEvent: string, + comment?: string, +): Promise { + // Build query parameters + const url = new URL(callbackUrl); + url.searchParams.set("amount", amountMillisats.toString()); + url.searchParams.set("nostr", zapRequestEvent); + if (comment) { + url.searchParams.set("comment", comment); + } + + try { + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error( + `Failed to fetch invoice: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as LnUrlCallbackResponse; + + if (!data.pr) { + throw new Error("LNURL callback response missing invoice (pr field)"); + } + + return data; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error(`Failed to fetch invoice from callback: ${error}`); + } +} + +/** + * Validate that a LNURL service supports Nostr zaps (NIP-57) + */ +export function validateZapSupport(lnurlData: LnUrlPayResponse): void { + if (!lnurlData.allowsNostr) { + throw new Error( + "This Lightning address does not support Nostr zaps (allowsNostr is false)", + ); + } + + if (!lnurlData.nostrPubkey) { + throw new Error("LNURL service missing nostrPubkey (required for zaps)"); + } + + // Validate pubkey format (64 hex chars) + if (!/^[0-9a-f]{64}$/i.test(lnurlData.nostrPubkey)) { + throw new Error("Invalid nostrPubkey format in LNURL response"); + } +}