diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 068aae6..834bed7 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -303,6 +303,34 @@ function generateRawCommand(appId: string, props: any): string { case "spells": return "spells"; + case "zap": + if (props.recipientPubkey) { + try { + const npub = nip19.npubEncode(props.recipientPubkey); + let result = `zap ${npub}`; + if (props.eventPointer) { + if ("id" in props.eventPointer) { + const nevent = nip19.neventEncode({ id: props.eventPointer.id }); + result += ` ${nevent}`; + } else if ( + "kind" in props.eventPointer && + "pubkey" in props.eventPointer + ) { + const naddr = nip19.naddrEncode({ + kind: props.eventPointer.kind, + pubkey: props.eventPointer.pubkey, + identifier: props.eventPointer.identifier || "", + }); + result += ` ${naddr}`; + } + } + return result; + } catch { + return `zap ${props.recipientPubkey.slice(0, 16)}...`; + } + } + return "zap"; + default: return appId; } @@ -446,6 +474,23 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { const countHashtags = appId === "count" && props.filter?.["#t"] ? props.filter["#t"] : []; + // Zap titles + const zapRecipientPubkey = appId === "zap" ? props.recipientPubkey : null; + const zapRecipientProfile = useProfile(zapRecipientPubkey || ""); + const zapTitle = useMemo(() => { + if (appId !== "zap" || !zapRecipientPubkey) return null; + + if (zapRecipientProfile) { + const name = + zapRecipientProfile.display_name || + zapRecipientProfile.name || + `${zapRecipientPubkey.slice(0, 8)}...`; + return `Zap ${name}`; + } + + return `Zap ${zapRecipientPubkey.slice(0, 8)}...`; + }, [appId, zapRecipientPubkey, zapRecipientProfile]); + // REQ titles const reqTitle = useMemo(() => { if (appId !== "req") return null; @@ -755,7 +800,11 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { } // Priority order for title selection (dynamic titles based on data) - if (profileTitle) { + if (zapTitle) { + title = zapTitle; + icon = getCommandIcon("zap"); + tooltip = rawCommand; + } else if (profileTitle) { title = profileTitle; icon = getCommandIcon("profile"); tooltip = rawCommand; @@ -829,6 +878,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { props, event, customTitle, + zapTitle, profileTitle, eventTitle, kindTitle, diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index 8ca1c36..f607b82 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -10,6 +10,7 @@ import { Send, Wifi, HardDrive, + Zap, } from "lucide-react"; import { kinds, nip19 } from "nostr-tools"; import { useEventStore, use$ } from "applesauce-react/hooks"; @@ -440,7 +441,18 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
Lightning Address
- {profile.lud16} + )} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 4d4671f..22378c6 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -43,6 +43,9 @@ const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); const WalletViewer = lazy(() => import("./WalletViewer")); +const ZapWindow = lazy(() => + import("./ZapWindow").then((m) => ({ default: m.ZapWindow })), +); const CountViewer = lazy(() => import("./CountViewer")); // Loading fallback component @@ -226,6 +229,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { case "wallet": content = ; break; + case "zap": + content = ( + + ); + break; default: content = (
diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx new file mode 100644 index 0000000..861327e --- /dev/null +++ b/src/components/ZapWindow.tsx @@ -0,0 +1,691 @@ +/** + * ZapWindow Component + * + * UI for sending Lightning zaps to Nostr users and events (NIP-57) + * + * Features: + * - Send zaps to profiles or events + * - Preset and custom amounts + * - Remembers most-used amounts + * - NWC wallet payment or QR code fallback + * - Shows feed render of zapped event + */ + +import { useState, useMemo, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { + Zap, + Wallet, + Copy, + ExternalLink, + Loader2, + CheckCircle2, + LogIn, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import QRCode from "qrcode"; +import { useProfile } from "@/hooks/useProfile"; +import { use$ } from "applesauce-react/hooks"; +import eventStore from "@/services/event-store"; +import { useWallet } from "@/hooks/useWallet"; +import { getDisplayName } from "@/lib/nostr-utils"; +import { KindRenderer } from "./nostr/kinds"; +import { UserName } from "./nostr/UserName"; +import type { EventPointer, AddressPointer } from "@/lib/open-parser"; +import accountManager from "@/services/accounts"; +import { + MentionEditor, + type MentionEditorHandle, +} from "./editor/MentionEditor"; +import { useEmojiSearch } from "@/hooks/useEmojiSearch"; +import { useProfileSearch } from "@/hooks/useProfileSearch"; +import LoginDialog from "./nostr/LoginDialog"; +import { resolveLightningAddress, validateZapSupport } from "@/lib/lnurl"; +import { + createZapRequest, + serializeZapRequest, +} from "@/lib/create-zap-request"; +import { fetchInvoiceFromCallback } from "@/lib/lnurl"; + +export interface ZapWindowProps { + /** Recipient pubkey (who receives the zap) */ + recipientPubkey: string; + /** Optional event being zapped (adds context) */ + eventPointer?: EventPointer | AddressPointer; + /** Callback to close the window */ + onClose?: () => void; +} + +// Default preset amounts in sats +const DEFAULT_PRESETS = [21, 420, 2100, 42000]; + +// LocalStorage keys +const STORAGE_KEY_CUSTOM_AMOUNTS = "grimoire_zap_custom_amounts"; +const STORAGE_KEY_AMOUNT_USAGE = "grimoire_zap_amount_usage"; + +/** + * Format amount with k/m suffix for large numbers + */ +function formatAmount(amount: number): string { + if (amount >= 1000000) { + return `${(amount / 1000000).toFixed(amount % 1000000 === 0 ? 0 : 1)}m`; + } + if (amount >= 1000) { + return `${(amount / 1000).toFixed(amount % 1000 === 0 ? 0 : 1)}k`; + } + return amount.toString(); +} + +export function ZapWindow({ + recipientPubkey: initialRecipientPubkey, + eventPointer, + onClose, +}: ZapWindowProps) { + // Load event if we have a pointer and no recipient pubkey (derive from event author) + const event = use$(() => { + if (!eventPointer) return undefined; + if ("id" in eventPointer) { + return eventStore.event(eventPointer.id); + } + // AddressPointer + return eventStore.replaceable( + eventPointer.kind, + eventPointer.pubkey, + eventPointer.identifier, + ); + }, [eventPointer]); + + // Resolve recipient: use provided pubkey or derive from event author + const recipientPubkey = initialRecipientPubkey || event?.pubkey || ""; + + const recipientProfile = useProfile(recipientPubkey); + + const activeAccount = accountManager.active; + const canSign = !!activeAccount?.signer; + + const { wallet, payInvoice, refreshBalance, getInfo } = useWallet(); + + // Fetch wallet info + const [walletInfo, setWalletInfo] = useState(null); + useEffect(() => { + if (wallet) { + getInfo() + .then((info) => setWalletInfo(info)) + .catch((error) => console.error("Failed to get wallet info:", error)); + } + }, [wallet, getInfo]); + + const [selectedAmount, setSelectedAmount] = useState(null); + const [customAmount, setCustomAmount] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); + const [isPaid, setIsPaid] = useState(false); + const [qrCodeUrl, setQrCodeUrl] = useState(""); + const [invoice, setInvoice] = useState(""); + const [showQrDialog, setShowQrDialog] = useState(false); + const [showLogin, setShowLogin] = useState(false); + const [paymentTimedOut, setPaymentTimedOut] = useState(false); + + // Editor ref and search functions + const editorRef = useRef(null); + const { searchProfiles } = useProfileSearch(); + const { searchEmojis } = useEmojiSearch(); + + // Load custom amounts and usage stats from localStorage + const [customAmounts, setCustomAmounts] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY_CUSTOM_AMOUNTS); + return stored ? JSON.parse(stored) : []; + }); + + const [amountUsage, setAmountUsage] = useState>(() => { + const stored = localStorage.getItem(STORAGE_KEY_AMOUNT_USAGE); + return stored ? JSON.parse(stored) : {}; + }); + + // Combine preset and custom amounts, sort by usage + const availableAmounts = useMemo(() => { + const all = [...DEFAULT_PRESETS, ...customAmounts]; + const unique = Array.from(new Set(all)); + // Sort by usage count (descending), then by amount + return unique.sort((a, b) => { + const usageA = amountUsage[a] || 0; + const usageB = amountUsage[b] || 0; + if (usageA !== usageB) return usageB - usageA; + return a - b; + }); + }, [customAmounts, amountUsage]); + + // Get recipient name for display + const recipientName = useMemo(() => { + return recipientProfile + ? getDisplayName(recipientPubkey, recipientProfile) + : recipientPubkey.slice(0, 8); + }, [recipientPubkey, recipientProfile]); + + // Check if recipient has a lightning address + const hasLightningAddress = !!( + recipientProfile?.lud16 || recipientProfile?.lud06 + ); + + // Track amount usage + const trackAmountUsage = (amount: number) => { + const newUsage = { + ...amountUsage, + [amount]: (amountUsage[amount] || 0) + 1, + }; + setAmountUsage(newUsage); + localStorage.setItem(STORAGE_KEY_AMOUNT_USAGE, JSON.stringify(newUsage)); + + // If it's a custom amount not in our list, add it + if (!DEFAULT_PRESETS.includes(amount) && !customAmounts.includes(amount)) { + const newCustomAmounts = [...customAmounts, amount]; + setCustomAmounts(newCustomAmounts); + localStorage.setItem( + STORAGE_KEY_CUSTOM_AMOUNTS, + JSON.stringify(newCustomAmounts), + ); + } + }; + + // Generate QR code for invoice with optional profile picture overlay + const generateQrCode = async (invoiceText: string) => { + try { + const qrDataUrl = await QRCode.toDataURL(invoiceText, { + width: 300, + margin: 2, + color: { + dark: "#000000", + light: "#FFFFFF", + }, + }); + + // If profile has picture, overlay it in the center + const profilePicUrl = recipientProfile?.picture; + if (!profilePicUrl) { + return qrDataUrl; + } + + // Create canvas to overlay profile picture + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return qrDataUrl; + + // Load QR code image + const qrImage = new Image(); + await new Promise((resolve, reject) => { + qrImage.onload = resolve; + qrImage.onerror = reject; + qrImage.src = qrDataUrl; + }); + + canvas.width = qrImage.width; + canvas.height = qrImage.height; + + // Draw QR code + ctx.drawImage(qrImage, 0, 0); + + // Load and draw profile picture + const profileImage = new Image(); + profileImage.crossOrigin = "anonymous"; + + await new Promise((resolve) => { + profileImage.onload = resolve; + profileImage.onerror = () => resolve(null); // Silently fail if image doesn't load + profileImage.src = profilePicUrl; + }); + + // Only overlay if image loaded successfully + if (profileImage.complete && profileImage.naturalHeight !== 0) { + const size = canvas.width * 0.25; // 25% of QR code size + const x = (canvas.width - size) / 2; + const y = (canvas.height - size) / 2; + + // Draw white background circle + ctx.fillStyle = "#FFFFFF"; + ctx.beginPath(); + ctx.arc( + canvas.width / 2, + canvas.height / 2, + size / 2 + 4, + 0, + 2 * Math.PI, + ); + ctx.fill(); + + // Clip to circle for profile picture + ctx.save(); + ctx.beginPath(); + ctx.arc(canvas.width / 2, canvas.height / 2, size / 2, 0, 2 * Math.PI); + ctx.clip(); + ctx.drawImage(profileImage, x, y, size, size); + ctx.restore(); + } + + return canvas.toDataURL(); + } 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); + if (!amount || amount <= 0) { + toast.error("Please enter a valid amount"); + return; + } + + if (!recipientPubkey) { + toast.error("No recipient specified"); + return; + } + + setIsProcessing(true); + try { + // Track usage + trackAmountUsage(amount); + + // Step 1: Get Lightning address from recipient profile + const lud16 = recipientProfile?.lud16; + const lud06 = recipientProfile?.lud06; + + if (!lud16 && !lud06) { + throw new Error( + "Recipient has no Lightning address configured in their profile", + ); + } + + // Step 2: Resolve LNURL to get callback URL and nostrPubkey + let lnurlData; + if (lud16) { + 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`, + ); + } + + // Get comment and emoji tags from editor + const serialized = editorRef.current?.getSerializedContent() || { + text: "", + emojiTags: [], + blobAttachments: [], + }; + const comment = serialized.text; + const emojiTags = serialized.emojiTags; + + // 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) + const zapRequest = await createZapRequest({ + recipientPubkey, + amountMillisats, + comment, + eventPointer, + lnurl: lud16 || undefined, + emojiTags, + }); + + const serializedZapRequest = serializeZapRequest(zapRequest); + + // Step 4: Fetch invoice from LNURL callback + const invoiceResponse = await fetchInvoiceFromCallback( + lnurlData.callback, + amountMillisats, + serializedZapRequest, + comment || undefined, + ); + + const invoiceText = invoiceResponse.pr; + + // Step 5: Pay or show QR code + if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) { + // Pay with NWC wallet with timeout + try { + // Race between payment and 30 second timeout + const paymentPromise = payInvoice(invoiceText); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("TIMEOUT")), 30000), + ); + + await Promise.race([paymentPromise, timeoutPromise]); + await refreshBalance(); + + setIsPaid(true); + toast.success(`⚡ Zapped ${amount} sats to ${recipientName}!`); + } catch (error) { + 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); + } else { + // Other payment error - re-throw + throw error; + } + } + } else { + // Show QR code and invoice + const qrUrl = await generateQrCode(invoiceText); + setQrCodeUrl(qrUrl); + setInvoice(invoiceText); + setShowQrDialog(true); + } + } catch (error) { + console.error("Zap error:", error); + toast.error( + error instanceof Error ? error.message : "Failed to send zap", + ); + } finally { + setIsProcessing(false); + } + }; + + // Copy to clipboard + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + } catch { + toast.error("Failed to copy"); + } + }; + + // Open in wallet + const openInWallet = (invoice: string) => { + window.open(`lightning:${invoice}`, "_blank"); + }; + + // Open login dialog + const handleLogin = () => { + setShowLogin(true); + }; + + // Retry wallet payment + const handleRetryWallet = async () => { + if (!invoice || !wallet) return; + + setIsProcessing(true); + setShowQrDialog(false); + setPaymentTimedOut(false); + + try { + // Try again with timeout + const paymentPromise = payInvoice(invoice); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("TIMEOUT")), 30000), + ); + + await Promise.race([paymentPromise, timeoutPromise]); + await refreshBalance(); + + setIsPaid(true); + setShowQrDialog(false); + toast.success("⚡ Payment successful!"); + } catch (error) { + 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); + } + }; + + return ( +
+
+
+ {/* Show QR Code View if invoice exists */} + {showQrDialog ? ( +
+ {/* Header */} +
+
+ Zap +
+
+ Scan with your Lightning wallet or copy the invoice +
+
+ + {/* QR Code */} + {qrCodeUrl && ( +
+ Lightning Invoice QR Code +
+ )} + + {/* Invoice */} +
+ +
+ + +
+
+ + {/* Actions */} + + + {/* Retry with wallet button if payment timed out */} + {paymentTimedOut && + wallet && + walletInfo?.methods.includes("pay_invoice") && ( + + )} +
+ ) : ( + <> + {/* Show event preview if zapping an event */} + {event && } + + {/* Show recipient info if not zapping an event */} + {!event && ( +
+
+ +
+ {recipientProfile?.lud16 && ( +
+ {recipientProfile.lud16} +
+ )} +
+ )} + + {/* Amount Selection */} +
+ {/* Preset amounts - single row */} +
+ {availableAmounts.map((amount) => ( + + ))} +
+ + {/* Custom amount - separate line */} + { + setCustomAmount(e.target.value); + setSelectedAmount(null); + }} + min="1" + disabled={!hasLightningAddress} + className="w-full" + /> + + {/* Comment with emoji support */} + {hasLightningAddress && ( + + )} +
+ + {/* No Lightning Address Warning */} + {!hasLightningAddress && ( +
+ This user has not configured a Lightning address +
+ )} + + {/* Payment Button */} + {!canSign ? ( + + ) : ( + + )} + + )} +
+
+ + {/* Login Dialog */} + +
+ ); +} 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/constants/command-icons.ts b/src/constants/command-icons.ts index c45d5f9..860963f 100644 --- a/src/constants/command-icons.ts +++ b/src/constants/command-icons.ts @@ -16,6 +16,7 @@ import { Wifi, MessageSquare, Hash, + Zap, type LucideIcon, } from "lucide-react"; @@ -80,6 +81,10 @@ export const COMMAND_ICONS: Record = { icon: MessageSquare, description: "Join and participate in NIP-29 relay-based group chats", }, + zap: { + icon: Zap, + description: "Send a Lightning zap to a Nostr user or event", + }, // Utility commands encode: { diff --git a/src/lib/create-zap-request.ts b/src/lib/create-zap-request.ts new file mode 100644 index 0000000..fb486cb --- /dev/null +++ b/src/lib/create-zap-request.ts @@ -0,0 +1,125 @@ +/** + * Create NIP-57 zap request (kind 9734) + */ + +import { EventFactory } from "applesauce-core/event-factory"; +import type { 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 EmojiTag { + shortcode: string; + url: string; +} + +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; + /** NIP-30 custom emoji tags */ + emojiTags?: EmojiTag[]; +} + +/** + * 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]]); + } + } + } + + // Add NIP-30 emoji tags + if (params.emojiTags) { + for (const emoji of params.emojiTags) { + tags.push(["emoji", emoji.shortcode, emoji.url]); + } + } + + // Create event template + const template = { + 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..cb8713f --- /dev/null +++ b/src/lib/lnurl.ts @@ -0,0 +1,166 @@ +/** + * 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 { + // Add timeout to prevent hanging + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + 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) { + if (error.name === "AbortError") { + throw new Error( + "Lightning address request timed out. Please try again.", + ); + } + 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 { + // Add timeout to prevent hanging + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout + + const response = await fetch(url.toString(), { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error( + `Failed to fetch invoice (${response.status}): ${errorText || 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) { + if (error.name === "AbortError") { + throw new Error("Invoice request timed out. Please try again."); + } + 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"); + } +} diff --git a/src/lib/zap-parser.ts b/src/lib/zap-parser.ts new file mode 100644 index 0000000..060ada7 --- /dev/null +++ b/src/lib/zap-parser.ts @@ -0,0 +1,187 @@ +import { nip19 } from "nostr-tools"; +import { isNip05, resolveNip05 } from "./nip05"; +import { + isValidHexPubkey, + isValidHexEventId, + normalizeHex, +} from "./nostr-validation"; +import { normalizeRelayURL } from "./relay-url"; +import type { EventPointer, AddressPointer } from "./open-parser"; + +export interface ParsedZapCommand { + /** Recipient pubkey (who receives the zap) */ + recipientPubkey: string; + /** Optional event being zapped (adds context to the zap) */ + eventPointer?: EventPointer | AddressPointer; +} + +/** + * Parse ZAP command arguments + * + * Supports: + * - `zap ` - Zap a person + * - `zap ` - Zap an event (recipient derived from event author) + * - `zap ` - Zap a specific person for a specific event + * + * Profile formats: npub, nprofile, hex pubkey, user@domain.com, $me + * Event formats: note, nevent, naddr, hex event ID + */ +export async function parseZapCommand( + args: string[], + activeAccountPubkey?: string, +): Promise { + if (args.length === 0) { + throw new Error( + "Recipient or event required. Usage: zap or zap or zap ", + ); + } + + const firstArg = args[0]; + const secondArg = args[1]; + + // Case 1: Two arguments - zap + if (secondArg) { + const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); + const eventPointer = parseEventPointer(secondArg); + return { recipientPubkey, eventPointer }; + } + + // Case 2: One argument - try event first, then profile + // Events have more specific patterns (nevent, naddr, note) + const eventPointer = tryParseEventPointer(firstArg); + if (eventPointer) { + // For events, we'll need to fetch the event to get the author + // For now, we'll return a placeholder and let the component fetch it + return { + recipientPubkey: "", // Will be filled in by component from event author + eventPointer, + }; + } + + // Must be a profile + const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); + return { recipientPubkey }; +} + +/** + * Parse a profile identifier into a pubkey + */ +async function parseProfile( + identifier: string, + activeAccountPubkey?: string, +): Promise { + // Handle $me alias + if (identifier.toLowerCase() === "$me") { + if (!activeAccountPubkey) { + throw new Error("No active account. Please log in to use $me alias."); + } + return activeAccountPubkey; + } + + // Try bech32 decode (npub, nprofile) + if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) { + try { + const decoded = nip19.decode(identifier); + if (decoded.type === "npub") { + return decoded.data; + } + if (decoded.type === "nprofile") { + return decoded.data.pubkey; + } + } catch (error) { + throw new Error(`Invalid npub/nprofile: ${error}`); + } + } + + // Check if it's a hex pubkey + if (isValidHexPubkey(identifier)) { + return normalizeHex(identifier); + } + + // Check if it's a NIP-05 identifier + if (isNip05(identifier)) { + const pubkey = await resolveNip05(identifier); + if (!pubkey) { + throw new Error(`Failed to resolve NIP-05 identifier: ${identifier}`); + } + return pubkey; + } + + throw new Error( + `Invalid profile identifier: ${identifier}. Supported: npub, nprofile, hex pubkey, user@domain.com`, + ); +} + +/** + * Parse an event identifier into a pointer + */ +function parseEventPointer(identifier: string): EventPointer | AddressPointer { + const result = tryParseEventPointer(identifier); + if (!result) { + throw new Error( + `Invalid event identifier: ${identifier}. Supported: note, nevent, naddr, hex ID`, + ); + } + return result; +} + +/** + * Try to parse an event identifier, returning null if it doesn't match event patterns + */ +function tryParseEventPointer( + identifier: string, +): EventPointer | AddressPointer | null { + // Try bech32 decode (note, nevent, naddr) + if ( + identifier.startsWith("note") || + identifier.startsWith("nevent") || + identifier.startsWith("naddr") + ) { + try { + const decoded = nip19.decode(identifier); + + if (decoded.type === "note") { + return { id: decoded.data }; + } + + if (decoded.type === "nevent") { + return { + ...decoded.data, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + + if (decoded.type === "naddr") { + return { + ...decoded.data, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + } catch { + return null; + } + } + + // Check if it's a hex event ID + if (isValidHexEventId(identifier)) { + return { id: normalizeHex(identifier) }; + } + + return null; +} diff --git a/src/types/app.ts b/src/types/app.ts index 681b678..1f8b561 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -22,6 +22,7 @@ export type AppId = | "spellbooks" | "blossom" | "wallet" + | "zap" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index 6493e5e..230a292 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -8,6 +8,7 @@ import { parseRelayCommand } from "@/lib/relay-parser"; import { resolveNip05Batch, resolveDomainDirectoryBatch } from "@/lib/nip05"; import { parseChatCommand } from "@/lib/chat-parser"; import { parseBlossomCommand } from "@/lib/blossom-parser"; +import { parseZapCommand } from "@/lib/zap-parser"; export interface ManPageEntry { name: string; @@ -614,6 +615,38 @@ export const manPages: Record = { return parsed; }, }, + zap: { + name: "zap", + section: "1", + synopsis: "zap [event]", + description: + "Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.", + options: [ + { + flag: "", + description: + "Recipient: npub, nprofile, hex pubkey, user@domain.com, $me", + }, + { + flag: "", + description: "Event to zap: note, nevent, naddr, hex ID (optional)", + }, + ], + examples: [ + "zap fiatjaf.com Zap a user by NIP-05", + "zap npub1... Zap a user by npub", + "zap nevent1... Zap an event (recipient = event author)", + "zap npub1... nevent1... Zap a specific user for a specific event", + "zap alice@domain.com naddr1... Zap with event context", + ], + seeAlso: ["profile", "open", "wallet"], + appId: "zap", + category: "Nostr", + argParser: async (args: string[], activeAccountPubkey?: string) => { + const parsed = await parseZapCommand(args, activeAccountPubkey); + return parsed; + }, + }, encode: { name: "encode", section: "1",