sendAnonymousZap
Send anonymous satoshi payments (zaps) to Nostr profiles or events without revealing your identity. Specify the target, amount, and optional comment or relays for secure transactions.
Instructions
Prepare an anonymous zap to a profile or event
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| amountSats | Yes | Amount to zap in satoshis | |
| comment | No | Optional comment to include with the zap | |
| relays | No | Optional list of relays to query | |
| target | Yes | Target to zap - can be a pubkey (hex or npub) or an event ID (nevent, note, naddr, or hex) |
Implementation Reference
- index.ts:942-997 (registration)MCP tool registration for sendAnonymousZap, including input handler that wraps prepareAnonymousZap and formats response.server.tool( "sendAnonymousZap", "Prepare an anonymous zap to a profile or event", sendAnonymousZapToolConfig, async ({ target, amountSats, comment, relays }) => { // Use supplied relays or defaults const relaysToUse = relays || DEFAULT_RELAYS; try { // console.error(`Preparing anonymous zap to ${target} for ${amountSats} sats`); // Prepare the anonymous zap const zapResult = await prepareAnonymousZap(target, amountSats, comment, relaysToUse); if (!zapResult || !zapResult.success) { return { content: [ { type: "text", text: `Failed to prepare anonymous zap: ${zapResult?.message || "Unknown error"}`, }, ], }; } return { content: [ { type: "text", text: `Anonymous zap prepared successfully!\n\nAmount: ${amountSats} sats${comment ? `\nComment: "${comment}"` : ""}\nTarget: ${target}\n\nInvoice:\n${zapResult.invoice}\n\nCopy this invoice into your Lightning wallet to pay. After payment, the recipient will receive the zap anonymously.`, }, ], }; } catch (error) { console.error("Error in sendAnonymousZap tool:", error); let errorMessage = error instanceof Error ? error.message : "Unknown error"; // Provide a more helpful message for common errors if (errorMessage.includes("ENOTFOUND") || errorMessage.includes("ETIMEDOUT")) { errorMessage = `Could not connect to the Lightning service. This might be a temporary network issue or the service might be down. Error: ${errorMessage}`; } else if (errorMessage.includes("Timeout")) { errorMessage = "The operation timed out. This might be due to slow relays or network connectivity issues."; } return { content: [ { type: "text", text: `Error preparing anonymous zap: ${errorMessage}`, }, ], }; } }, );
- zap/zap-tools.ts:601-606 (schema)Zod schema definition for sendAnonymousZap tool inputs.export const sendAnonymousZapToolConfig = { target: z.string().describe("Target to zap - can be a pubkey (hex or npub) or an event ID (nevent, note, naddr, or hex)"), amountSats: z.number().min(1).describe("Amount to zap in satoshis"), comment: z.string().default("").describe("Optional comment to include with the zap"), relays: z.array(z.string()).optional().describe("Optional list of relays to query") };
- zap/zap-tools.ts:695-1169 (handler)Main handler logic: prepares anonymous zap by resolving target pubkey/event, fetching LNURLp from profile, creating NIP-57 zap request, and obtaining Lightning invoice via LNURL-pay callback.export async function prepareAnonymousZap( target: string, amountSats: number, comment: string = "", relays: string[] = DEFAULT_RELAYS ): Promise<{ invoice: string, success: boolean, message: string } | null> { try { // Convert amount to millisats const amountMsats = amountSats * 1000; // Determine if target is a pubkey or an event let hexPubkey: string | null = null; let eventId: string | null = null; let eventCoordinate: { kind: number, pubkey: string, identifier: string } | null = null; // First, try to parse as a pubkey hexPubkey = npubToHex(target); // If not a pubkey, try to parse as an event identifier if (!hexPubkey) { const decodedEvent = await decodeEventId(target); if (decodedEvent) { if (decodedEvent.eventId) { eventId = decodedEvent.eventId; } else if (decodedEvent.pubkey) { // For naddr, we got a pubkey but no event ID hexPubkey = decodedEvent.pubkey; // If this is an naddr, store the information for creating an "a" tag later if (decodedEvent.type === 'naddr' && decodedEvent.kind) { eventCoordinate = { kind: decodedEvent.kind, pubkey: decodedEvent.pubkey, identifier: decodedEvent.identifier || '' }; } } } } // If we couldn't determine a valid target, return error if (!hexPubkey && !eventId) { return { invoice: "", success: false, message: "Invalid target. Please provide a valid npub, hex pubkey, note ID, or event ID." }; } // Create a fresh pool for this request const pool = getFreshPool(relays); try { // Find the user's metadata to get their LNURL let profileFilter: NostrFilter = { kinds: [KINDS.Metadata] }; if (hexPubkey) { profileFilter = { kinds: [KINDS.Metadata], authors: [hexPubkey], }; } else if (eventId) { // First get the event to find the author const eventFilter = { ids: [eventId] }; const eventPromise = pool.get(relays, eventFilter as NostrFilter); const event = await Promise.race([ eventPromise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), QUERY_TIMEOUT)) ]) as NostrEvent; if (!event) { return { invoice: "", success: false, message: `Could not find event with ID ${eventId}` }; } hexPubkey = event.pubkey; profileFilter = { kinds: [KINDS.Metadata], authors: [hexPubkey], }; } // Get the user's profile let profile: NostrEvent | null = null; for (const relaySet of [relays, DEFAULT_RELAYS, FALLBACK_RELAYS]) { if (relaySet.length === 0) continue; try { const profilePromise = pool.get(relaySet, profileFilter as NostrFilter); profile = await Promise.race([ profilePromise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), QUERY_TIMEOUT)) ]) as NostrEvent; if (profile) break; } catch (error) { // Continue to next relay set } } if (!profile) { return { invoice: "", success: false, message: "Could not find profile for the target user. Their profile may not exist on our known relays." }; } // Parse the profile to get the lightning address or LNURL let lnurl: string | null = null; try { const metadata = JSON.parse(profile.content); // Check standard LUD-16/LUD-06 fields lnurl = metadata.lud16 || metadata.lud06 || null; // Check for alternate capitalizations that some clients might use if (!lnurl) { lnurl = metadata.LUD16 || metadata.LUD06 || metadata.Lud16 || metadata.Lud06 || metadata.lightning || metadata.LIGHTNING || metadata.lightningAddress || null; } if (!lnurl) { // Check if there's any key that contains "lud" or "lightning" const ludKey = Object.keys(metadata).find(key => key.toLowerCase().includes('lud') || key.toLowerCase().includes('lightning') ); if (ludKey) { lnurl = metadata[ludKey]; } } if (!lnurl) { return { invoice: "", success: false, message: "Target user does not have a lightning address or LNURL configured in their profile" }; } // If it's a lightning address (contains @), convert to LNURL if (lnurl.includes('@')) { const [name, domain] = lnurl.split('@'); // Per LUD-16, properly encode username with encodeURIComponent const encodedName = encodeURIComponent(name); lnurl = `https://${domain}/.well-known/lnurlp/${encodedName}`; } else if (lnurl.toLowerCase().startsWith('lnurl')) { // Decode bech32 LNURL to URL try { lnurl = Buffer.from(bech32ToArray(lnurl.toLowerCase().substring(5))).toString(); } catch (e) { return { invoice: "", success: false, message: "Invalid LNURL format" }; } } // Make sure it's HTTP or HTTPS if not already if (!lnurl.startsWith('http://') && !lnurl.startsWith('https://')) { // Default to HTTPS lnurl = 'https://' + lnurl; } } catch (error) { return { invoice: "", success: false, message: "Error parsing user profile" }; } if (!lnurl) { return { invoice: "", success: false, message: "Could not determine LNURL from user profile" }; } // Step 1: Query the LNURL to get the callback URL let lnurlResponse; try { lnurlResponse = await fetch(lnurl, { headers: { 'Accept': 'application/json', 'User-Agent': 'Nostr-MCP-Server/1.0' } }); if (!lnurlResponse.ok) { let errorText = ""; try { errorText = await lnurlResponse.text(); } catch (e) { // Ignore if we can't read the error text } return { invoice: "", success: false, message: `LNURL request failed with status ${lnurlResponse.status}${errorText ? `: ${errorText}` : ""}` }; } } catch (error) { return { invoice: "", success: false, message: `Error connecting to LNURL: ${error instanceof Error ? error.message : "Unknown error"}` }; } let lnurlData; try { const responseText = await lnurlResponse.text(); lnurlData = JSON.parse(responseText) as LnurlPayResponse; } catch (error) { return { invoice: "", success: false, message: `Invalid JSON response from LNURL service: ${error instanceof Error ? error.message : "Unknown error"}` }; } // Check if the service supports NIP-57 zaps if (!lnurlData.allowsNostr) { return { invoice: "", success: false, message: "The target user's lightning service does not support Nostr zaps" }; } if (!lnurlData.nostrPubkey) { return { invoice: "", success: false, message: "The target user's lightning service does not provide a nostrPubkey for zaps" }; } // Validate the callback URL if (!lnurlData.callback || !isValidUrl(lnurlData.callback)) { return { invoice: "", success: false, message: `Invalid callback URL in LNURL response: ${lnurlData.callback}` }; } // Validate amount limits if (!lnurlData.minSendable || !lnurlData.maxSendable) { return { invoice: "", success: false, message: "The LNURL service did not provide valid min/max sendable amounts" }; } if (amountMsats < lnurlData.minSendable) { return { invoice: "", success: false, message: `Amount too small. Minimum is ${lnurlData.minSendable / 1000} sats (you tried to send ${amountMsats / 1000} sats)` }; } if (amountMsats > lnurlData.maxSendable) { return { invoice: "", success: false, message: `Amount too large. Maximum is ${lnurlData.maxSendable / 1000} sats (you tried to send ${amountMsats / 1000} sats)` }; } // Validate comment length if the service has a limit if (lnurlData.commentAllowed && comment.length > lnurlData.commentAllowed) { comment = comment.substring(0, lnurlData.commentAllowed); } // Step 2: Create the zap request tags const zapRequestTags: string[][] = [ ["relays", ...relays.slice(0, 5)], // Include up to 5 relays ["amount", amountMsats.toString()], ["lnurl", lnurl] ]; // Add p or e tag depending on what we're zapping if (hexPubkey) { zapRequestTags.push(["p", hexPubkey]); } if (eventId) { zapRequestTags.push(["e", eventId]); } // Add a tag for replaceable events (naddr) if (eventCoordinate) { const aTagValue = `${eventCoordinate.kind}:${eventCoordinate.pubkey}:${eventCoordinate.identifier}`; zapRequestTags.push(["a", aTagValue]); } // Create a proper one-time keypair for anonymous zapping const anonymousKeys = await generateKeypair(); const anonymousPubkeyHex = anonymousKeys.publicKey; // Create the zap request event template const zapRequestTemplate = createEvent({ kind: 9734, content: comment, tags: zapRequestTags }, anonymousKeys.publicKey); // Get event hash and sign it const zapEventId = await getEventHash(zapRequestTemplate); const signature = await signEvent(zapEventId, anonymousKeys.privateKey); // Create complete signed event const signedZapRequest = { ...zapRequestTemplate, id: zapEventId, sig: signature }; // Create different formatted versions of the zap request for compatibility const completeEventParam = encodeURIComponent(JSON.stringify(signedZapRequest)); const basicEventParam = encodeURIComponent(JSON.stringify({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: comment, tags: zapRequestTags, pubkey: anonymousPubkeyHex })); const tagsOnlyParam = encodeURIComponent(JSON.stringify({ tags: zapRequestTags })); // Try each approach in order const approaches = [ { name: "Complete event with ID/sig", param: completeEventParam }, { name: "Basic event without ID/sig", param: basicEventParam }, { name: "Tags only", param: tagsOnlyParam }, // Add fallback approach without nostr parameter at all { name: "No nostr parameter", param: null } ]; // Flag to track if we've successfully processed any approach let success = false; let finalResult = null; let lastError = ""; for (const approach of approaches) { if (success) break; // Skip if we already succeeded // Create a new URL for each attempt to avoid parameter pollution const currentCallbackUrl = new URL(lnurlData.callback); // Add basic parameters - must include amount first per some implementations currentCallbackUrl.searchParams.append("amount", amountMsats.toString()); // Add comment if provided and allowed if (comment && (!lnurlData.commentAllowed || lnurlData.commentAllowed > 0)) { currentCallbackUrl.searchParams.append("comment", comment); } // Add the nostr parameter for this approach (if not null) if (approach.param !== null) { currentCallbackUrl.searchParams.append("nostr", approach.param); } const callbackUrlString = currentCallbackUrl.toString(); try { const callbackResponse = await fetch(callbackUrlString, { method: 'GET', // Explicitly use GET as required by LUD-06 headers: { 'Accept': 'application/json', 'User-Agent': 'Nostr-MCP-Server/1.0' } }); // Attempt to read the response body regardless of status code let responseText = ""; try { responseText = await callbackResponse.text(); } catch (e) { // Ignore if we can't read the response } if (!callbackResponse.ok) { if (responseText) { lastError = `Status ${callbackResponse.status}: ${responseText}`; } else { lastError = `Status ${callbackResponse.status}`; } continue; // Try the next approach } // Successfully got a 2xx response, now parse it let invoiceData; try { invoiceData = JSON.parse(responseText) as LnurlCallbackResponse; } catch (error) { lastError = `Invalid JSON in response: ${responseText}`; continue; // Try the next approach } // Check if the response has the expected structure if (!invoiceData.pr) { if (invoiceData.reason) { lastError = invoiceData.reason; // If the error message mentions the NIP-57/Nostr parameter specifically, try the next approach if (lastError.toLowerCase().includes('nostr') || lastError.toLowerCase().includes('customer') || lastError.toLowerCase().includes('wallet')) { continue; // Try the next approach } } else { lastError = `Missing 'pr' field in response`; } continue; // Try the next approach } // We got a valid invoice! success = true; finalResult = { invoice: invoiceData.pr, success: true, message: `Successfully generated invoice using ${approach.name}` }; break; // Exit the loop } catch (error) { lastError = error instanceof Error ? error.message : "Unknown error"; // Continue to the next approach } } // If none of our approaches worked, return an error with the last error message if (!success) { return { invoice: "", success: false, message: `Failed to generate invoice: ${lastError}` }; } return finalResult; } catch (error) { return { invoice: "", success: false, message: `Error preparing zap: ${error instanceof Error ? error.message : "Unknown error"}` }; } finally { // Clean up any subscriptions and close the pool await pool.close(); } } catch (error) { return { invoice: "", success: false, message: `Fatal error: ${error instanceof Error ? error.message : "Unknown error"}` }; } }
- zap/zap-tools.ts:543-597 (helper)Helper function to decode NIP-19 event identifiers (nevent, naddr, note) used to determine zap target pubkey and event ID.export async function decodeEventId(id: string): Promise<{ type: string, eventId?: string, pubkey?: string, kind?: number, relays?: string[], identifier?: string } | null> { if (!id) return null; try { // Clean up input id = id.trim(); // If it's already a hex event ID if (/^[0-9a-fA-F]{64}$/i.test(id)) { return { type: 'eventId', eventId: id.toLowerCase() }; } // Try to decode as a bech32 entity if (id.startsWith('note1') || id.startsWith('nevent1') || id.startsWith('naddr1')) { try { const decoded = nip19decode(id as `${string}1${string}`); if (decoded.type === 'note') { return { type: 'note', eventId: decoded.data as string }; } else if (decoded.type === 'nevent') { const data = decoded.data as { id: string, relays?: string[], author?: string }; return { type: 'nevent', eventId: data.id, relays: data.relays, pubkey: data.author }; } else if (decoded.type === 'naddr') { const data = decoded.data as { identifier: string, pubkey: string, kind: number, relays?: string[] }; return { type: 'naddr', pubkey: data.pubkey, kind: data.kind, relays: data.relays, identifier: data.identifier }; } } catch (decodeError) { console.error("Error decoding event identifier:", decodeError); return null; } } // Not a valid event identifier format return null; } catch (error) { console.error("Error decoding event identifier:", error); return null; }