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 && (
+
+

+
+ )}
+
+ {/* 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",