nostr-mcp-server 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,989 +0,0 @@
1
- import { z } from "zod";
2
- import { decode } from "light-bolt11-decoder";
3
- import * as nip19 from "nostr-tools/nip19";
4
- import fetch from "node-fetch";
5
- import { generateSecretKey, getPublicKey, finalizeEvent } from "nostr-tools/pure";
6
- import { KINDS, DEFAULT_RELAYS, FALLBACK_RELAYS, QUERY_TIMEOUT, getFreshPool, npubToHex, hexToNpub } from "./utils/index.js";
7
- // Simple cache implementation for zap receipts
8
- export class ZapCache {
9
- cache = new Map();
10
- maxSize;
11
- ttlMs;
12
- constructor(maxSize = 1000, ttlMinutes = 10) {
13
- this.maxSize = maxSize;
14
- this.ttlMs = ttlMinutes * 60 * 1000;
15
- }
16
- add(zapReceipt, enrichedData) {
17
- // Create enriched zap with processing timestamp
18
- const cachedZap = {
19
- ...zapReceipt,
20
- ...enrichedData,
21
- processedAt: Date.now()
22
- };
23
- // Add to cache
24
- this.cache.set(zapReceipt.id, cachedZap);
25
- // Clean cache if it exceeds max size
26
- if (this.cache.size > this.maxSize) {
27
- this.cleanup();
28
- }
29
- return cachedZap;
30
- }
31
- get(id) {
32
- const cachedZap = this.cache.get(id);
33
- // Return undefined if not found or expired
34
- if (!cachedZap || Date.now() - cachedZap.processedAt > this.ttlMs) {
35
- if (cachedZap) {
36
- // Remove expired entry
37
- this.cache.delete(id);
38
- }
39
- return undefined;
40
- }
41
- return cachedZap;
42
- }
43
- has(id) {
44
- return this.get(id) !== undefined;
45
- }
46
- cleanup() {
47
- const now = Date.now();
48
- // Remove expired entries
49
- for (const [id, zap] of this.cache.entries()) {
50
- if (now - zap.processedAt > this.ttlMs) {
51
- this.cache.delete(id);
52
- }
53
- }
54
- // If still too large, remove oldest entries
55
- if (this.cache.size > this.maxSize) {
56
- const sortedEntries = Array.from(this.cache.entries())
57
- .sort((a, b) => a[1].processedAt - b[1].processedAt);
58
- const entriesToRemove = sortedEntries.slice(0, sortedEntries.length - Math.floor(this.maxSize * 0.75));
59
- for (const [id] of entriesToRemove) {
60
- this.cache.delete(id);
61
- }
62
- }
63
- }
64
- clear() {
65
- this.cache.clear();
66
- }
67
- size() {
68
- return this.cache.size;
69
- }
70
- }
71
- // Create a global cache instance
72
- export const zapCache = new ZapCache();
73
- // Helper function to parse zap request data from description tag in zap receipt
74
- export function parseZapRequestData(zapReceipt) {
75
- try {
76
- // Find the description tag which contains the zap request JSON
77
- const descriptionTag = zapReceipt.tags.find(tag => tag[0] === "description" && tag.length > 1);
78
- if (!descriptionTag || !descriptionTag[1]) {
79
- return undefined;
80
- }
81
- // Parse the zap request JSON - this contains a serialized ZapRequest
82
- const zapRequest = JSON.parse(descriptionTag[1]);
83
- // Convert to the ZapRequestData format
84
- const zapRequestData = {
85
- pubkey: zapRequest.pubkey,
86
- content: zapRequest.content,
87
- created_at: zapRequest.created_at,
88
- id: zapRequest.id,
89
- };
90
- // Extract additional data from ZapRequest tags
91
- zapRequest.tags.forEach(tag => {
92
- if (tag[0] === 'amount' && tag[1]) {
93
- zapRequestData.amount = parseInt(tag[1], 10);
94
- }
95
- else if (tag[0] === 'relays' && tag.length > 1) {
96
- zapRequestData.relays = tag.slice(1);
97
- }
98
- else if (tag[0] === 'e' && tag[1]) {
99
- zapRequestData.event = tag[1];
100
- }
101
- else if (tag[0] === 'lnurl' && tag[1]) {
102
- zapRequestData.lnurl = tag[1];
103
- }
104
- });
105
- return zapRequestData;
106
- }
107
- catch (error) {
108
- console.error("Error parsing zap request data:", error);
109
- return undefined;
110
- }
111
- }
112
- // Helper function to extract and decode bolt11 invoice from a zap receipt
113
- export function decodeBolt11FromZap(zapReceipt) {
114
- try {
115
- // Find the bolt11 tag
116
- const bolt11Tag = zapReceipt.tags.find(tag => tag[0] === "bolt11" && tag.length > 1);
117
- if (!bolt11Tag || !bolt11Tag[1]) {
118
- return undefined;
119
- }
120
- // Decode the bolt11 invoice
121
- const decodedInvoice = decode(bolt11Tag[1]);
122
- return decodedInvoice;
123
- }
124
- catch (error) {
125
- console.error("Error decoding bolt11 invoice:", error);
126
- return undefined;
127
- }
128
- }
129
- // Extract amount in sats from decoded bolt11 invoice
130
- export function getAmountFromDecodedInvoice(decodedInvoice) {
131
- try {
132
- if (!decodedInvoice || !decodedInvoice.sections) {
133
- return undefined;
134
- }
135
- // Find the amount section
136
- const amountSection = decodedInvoice.sections.find((section) => section.name === "amount");
137
- if (!amountSection) {
138
- return undefined;
139
- }
140
- // Convert msats to sats
141
- const amountMsats = amountSection.value;
142
- const amountSats = Math.floor(amountMsats / 1000);
143
- return amountSats;
144
- }
145
- catch (error) {
146
- console.error("Error extracting amount from decoded invoice:", error);
147
- return undefined;
148
- }
149
- }
150
- // Validate a zap receipt according to NIP-57 Appendix F
151
- export function validateZapReceipt(zapReceipt, zapRequest) {
152
- try {
153
- // 1. Must be kind 9735
154
- if (zapReceipt.kind !== KINDS.ZapReceipt) {
155
- return { valid: false, reason: "Not a zap receipt (kind 9735)" };
156
- }
157
- // 2. Must have a bolt11 tag
158
- const bolt11Tag = zapReceipt.tags.find(tag => tag[0] === "bolt11" && tag.length > 1);
159
- if (!bolt11Tag || !bolt11Tag[1]) {
160
- return { valid: false, reason: "Missing bolt11 tag" };
161
- }
162
- // 3. Must have a description tag with the zap request
163
- const descriptionTag = zapReceipt.tags.find(tag => tag[0] === "description" && tag.length > 1);
164
- if (!descriptionTag || !descriptionTag[1]) {
165
- return { valid: false, reason: "Missing description tag" };
166
- }
167
- // 4. Parse the zap request from the description tag if not provided
168
- let parsedZapRequest;
169
- try {
170
- parsedZapRequest = zapRequest || JSON.parse(descriptionTag[1]);
171
- }
172
- catch (e) {
173
- return { valid: false, reason: "Invalid zap request JSON in description tag" };
174
- }
175
- // 5. Validate the zap request structure
176
- if (parsedZapRequest.kind !== KINDS.ZapRequest) {
177
- return { valid: false, reason: "Invalid zap request kind" };
178
- }
179
- // 6. Check that the p tag from the zap request is included in the zap receipt
180
- const requestedRecipientPubkey = parsedZapRequest.tags.find(tag => tag[0] === 'p' && tag.length > 1)?.[1];
181
- const receiptRecipientTag = zapReceipt.tags.find(tag => tag[0] === 'p' && tag.length > 1);
182
- if (!requestedRecipientPubkey || !receiptRecipientTag || receiptRecipientTag[1] !== requestedRecipientPubkey) {
183
- return { valid: false, reason: "Recipient pubkey mismatch" };
184
- }
185
- // 7. Check for optional e tag consistency if present in the zap request
186
- const requestEventTag = parsedZapRequest.tags.find(tag => tag[0] === 'e' && tag.length > 1);
187
- if (requestEventTag) {
188
- const receiptEventTag = zapReceipt.tags.find(tag => tag[0] === 'e' && tag.length > 1);
189
- if (!receiptEventTag || receiptEventTag[1] !== requestEventTag[1]) {
190
- return { valid: false, reason: "Event ID mismatch" };
191
- }
192
- }
193
- // 8. Check for optional amount consistency
194
- const amountTag = parsedZapRequest.tags.find(tag => tag[0] === 'amount' && tag.length > 1);
195
- if (amountTag) {
196
- // Decode the bolt11 invoice to verify the amount
197
- const decodedInvoice = decodeBolt11FromZap(zapReceipt);
198
- if (decodedInvoice) {
199
- const invoiceAmountMsats = decodedInvoice.sections.find((s) => s.name === "amount")?.value;
200
- const requestAmountMsats = parseInt(amountTag[1], 10);
201
- if (invoiceAmountMsats && Math.abs(invoiceAmountMsats - requestAmountMsats) > 10) { // Allow small rounding differences
202
- return { valid: false, reason: "Amount mismatch between request and invoice" };
203
- }
204
- }
205
- }
206
- return { valid: true };
207
- }
208
- catch (error) {
209
- return { valid: false, reason: `Validation error: ${error instanceof Error ? error.message : String(error)}` };
210
- }
211
- }
212
- // Determine the direction of a zap relative to a pubkey
213
- export function determineZapDirection(zapReceipt, pubkey) {
214
- try {
215
- // Check if received via lowercase 'p' tag (recipient)
216
- const isReceived = zapReceipt.tags.some(tag => tag[0] === 'p' && tag[1] === pubkey);
217
- // Check if sent via uppercase 'P' tag (sender, per NIP-57)
218
- let isSent = zapReceipt.tags.some(tag => tag[0] === 'P' && tag[1] === pubkey);
219
- if (!isSent) {
220
- // Fallback: check description tag for the sender pubkey
221
- const descriptionTag = zapReceipt.tags.find(tag => tag[0] === "description" && tag.length > 1);
222
- if (descriptionTag && descriptionTag[1]) {
223
- try {
224
- const zapRequest = JSON.parse(descriptionTag[1]);
225
- isSent = zapRequest && zapRequest.pubkey === pubkey;
226
- }
227
- catch (e) {
228
- // Ignore parsing errors
229
- }
230
- }
231
- }
232
- // Determine direction
233
- if (isSent && isReceived) {
234
- return 'self';
235
- }
236
- else if (isSent) {
237
- return 'sent';
238
- }
239
- else if (isReceived) {
240
- return 'received';
241
- }
242
- else {
243
- return 'unknown';
244
- }
245
- }
246
- catch (error) {
247
- console.error("Error determining zap direction:", error);
248
- return 'unknown';
249
- }
250
- }
251
- // Process a zap receipt into an enriched cached zap
252
- export function processZapReceipt(zapReceipt, pubkey) {
253
- // Check if we already have this zap in the cache
254
- const existingCachedZap = zapCache.get(zapReceipt.id);
255
- if (existingCachedZap) {
256
- return existingCachedZap;
257
- }
258
- try {
259
- // Determine direction relative to the specified pubkey
260
- const direction = determineZapDirection(zapReceipt, pubkey);
261
- // Extract target pubkey (recipient)
262
- const targetPubkey = zapReceipt.tags.find(tag => tag[0] === 'p' && tag.length > 1)?.[1];
263
- // Extract target event if any
264
- const targetEvent = zapReceipt.tags.find(tag => tag[0] === 'e' && tag.length > 1)?.[1];
265
- // Extract target coordinate if any (a tag)
266
- const targetCoordinate = zapReceipt.tags.find(tag => tag[0] === 'a' && tag.length > 1)?.[1];
267
- // Parse zap request to get additional data
268
- const zapRequestData = parseZapRequestData(zapReceipt);
269
- // Decode bolt11 invoice to get amount
270
- const decodedInvoice = decodeBolt11FromZap(zapReceipt);
271
- const amountSats = decodedInvoice ?
272
- getAmountFromDecodedInvoice(decodedInvoice) :
273
- (zapRequestData?.amount ? Math.floor(zapRequestData.amount / 1000) : undefined);
274
- // Create enriched zap and add to cache
275
- return zapCache.add(zapReceipt, {
276
- direction,
277
- amountSats,
278
- targetPubkey,
279
- targetEvent,
280
- targetCoordinate
281
- });
282
- }
283
- catch (error) {
284
- console.error("Error processing zap receipt:", error);
285
- // Still cache the basic zap with unknown direction
286
- return zapCache.add(zapReceipt, { direction: 'unknown' });
287
- }
288
- }
289
- // Helper function to format zap receipt with enhanced information
290
- export function formatZapReceipt(zap, pubkeyContext) {
291
- if (!zap)
292
- return "";
293
- try {
294
- // Cast to ZapReceipt for better type safety since we know we're dealing with kind 9735
295
- const zapReceipt = zap;
296
- // Process the zap receipt with context if provided
297
- let enrichedZap;
298
- if (pubkeyContext) {
299
- enrichedZap = processZapReceipt(zapReceipt, pubkeyContext);
300
- }
301
- else {
302
- // Check if it's already in cache
303
- const cachedZap = zapCache.get(zapReceipt.id);
304
- if (cachedZap) {
305
- enrichedZap = cachedZap;
306
- }
307
- else {
308
- // Process without context - won't have direction information
309
- enrichedZap = {
310
- ...zapReceipt,
311
- processedAt: Date.now()
312
- };
313
- }
314
- }
315
- // Get basic zap info
316
- const created = new Date(zapReceipt.created_at * 1000).toLocaleString();
317
- // Get sender information from P tag or description
318
- let sender = "Unknown";
319
- let senderPubkey;
320
- const senderPTag = zapReceipt.tags.find(tag => tag[0] === 'P' && tag.length > 1);
321
- if (senderPTag && senderPTag[1]) {
322
- senderPubkey = senderPTag[1];
323
- const npub = hexToNpub(senderPubkey);
324
- sender = npub ? `${npub.slice(0, 8)}...${npub.slice(-4)}` : `${senderPubkey.slice(0, 8)}...${senderPubkey.slice(-8)}`;
325
- }
326
- else {
327
- // Try to get from description
328
- const zapRequestData = parseZapRequestData(zapReceipt);
329
- if (zapRequestData?.pubkey) {
330
- senderPubkey = zapRequestData.pubkey;
331
- const npub = hexToNpub(senderPubkey);
332
- sender = npub ? `${npub.slice(0, 8)}...${npub.slice(-4)}` : `${senderPubkey.slice(0, 8)}...${senderPubkey.slice(-8)}`;
333
- }
334
- }
335
- // Get recipient information
336
- const recipient = zapReceipt.tags.find(tag => tag[0] === 'p' && tag.length > 1)?.[1];
337
- let formattedRecipient = "Unknown";
338
- if (recipient) {
339
- const npub = hexToNpub(recipient);
340
- formattedRecipient = npub ? `${npub.slice(0, 8)}...${npub.slice(-4)}` : `${recipient.slice(0, 8)}...${recipient.slice(-8)}`;
341
- }
342
- // Get amount
343
- let amount = enrichedZap.amountSats !== undefined ?
344
- `${enrichedZap.amountSats} sats` :
345
- "Unknown";
346
- // Get comment
347
- let comment = "No comment";
348
- const zapRequestData = parseZapRequestData(zapReceipt);
349
- if (zapRequestData?.content) {
350
- comment = zapRequestData.content;
351
- }
352
- // Check if this zap is for a specific event or coordinate
353
- let zapTarget = "User";
354
- let targetId = "";
355
- if (enrichedZap.targetEvent) {
356
- zapTarget = "Event";
357
- targetId = enrichedZap.targetEvent;
358
- }
359
- else if (enrichedZap.targetCoordinate) {
360
- zapTarget = "Replaceable Event";
361
- targetId = enrichedZap.targetCoordinate;
362
- }
363
- // Format the output with all available information
364
- const lines = [
365
- `From: ${sender}`,
366
- `To: ${formattedRecipient}`,
367
- `Amount: ${amount}`,
368
- `Created: ${created}`,
369
- `Target: ${zapTarget}${targetId ? ` (${targetId.slice(0, 8)}...)` : ''}`,
370
- `Comment: ${comment}`,
371
- ];
372
- // Add payment preimage if available
373
- const preimageTag = zapReceipt.tags.find(tag => tag[0] === "preimage" && tag.length > 1);
374
- if (preimageTag && preimageTag[1]) {
375
- lines.push(`Preimage: ${preimageTag[1].slice(0, 10)}...`);
376
- }
377
- // Add payment hash if available in bolt11 invoice
378
- const decodedInvoice = decodeBolt11FromZap(zapReceipt);
379
- if (decodedInvoice) {
380
- const paymentHashSection = decodedInvoice.sections.find((section) => section.name === "payment_hash");
381
- if (paymentHashSection) {
382
- lines.push(`Payment Hash: ${paymentHashSection.value.slice(0, 10)}...`);
383
- }
384
- }
385
- // Add direction information if available
386
- if (enrichedZap.direction && enrichedZap.direction !== 'unknown') {
387
- const directionLabels = {
388
- 'sent': '↑ SENT',
389
- 'received': '↓ RECEIVED',
390
- 'self': '↻ SELF ZAP'
391
- };
392
- lines.unshift(`[${directionLabels[enrichedZap.direction]}]`);
393
- }
394
- lines.push('---');
395
- return lines.join("\n");
396
- }
397
- catch (error) {
398
- console.error("Error formatting zap receipt:", error);
399
- return "Error formatting zap receipt";
400
- }
401
- }
402
- // Export the tool configurations
403
- export const getReceivedZapsToolConfig = {
404
- pubkey: z.string().describe("Public key of the Nostr user (hex format or npub format)"),
405
- limit: z.number().min(1).max(100).default(10).describe("Maximum number of zaps to fetch"),
406
- relays: z.array(z.string()).optional().describe("Optional list of relays to query"),
407
- validateReceipts: z.boolean().default(true).describe("Whether to validate zap receipts according to NIP-57"),
408
- debug: z.boolean().default(false).describe("Enable verbose debug logging"),
409
- };
410
- export const getSentZapsToolConfig = {
411
- pubkey: z.string().describe("Public key of the Nostr user (hex format or npub format)"),
412
- limit: z.number().min(1).max(100).default(10).describe("Maximum number of zaps to fetch"),
413
- relays: z.array(z.string()).optional().describe("Optional list of relays to query"),
414
- validateReceipts: z.boolean().default(true).describe("Whether to validate zap receipts according to NIP-57"),
415
- debug: z.boolean().default(false).describe("Enable verbose debug logging"),
416
- };
417
- export const getAllZapsToolConfig = {
418
- pubkey: z.string().describe("Public key of the Nostr user (hex format or npub format)"),
419
- limit: z.number().min(1).max(100).default(20).describe("Maximum number of total zaps to fetch"),
420
- relays: z.array(z.string()).optional().describe("Optional list of relays to query"),
421
- validateReceipts: z.boolean().default(true).describe("Whether to validate zap receipts according to NIP-57"),
422
- debug: z.boolean().default(false).describe("Enable verbose debug logging"),
423
- };
424
- // Helper function to decode a note identifier (note, nevent, naddr) to its components
425
- export async function decodeEventId(id) {
426
- if (!id)
427
- return null;
428
- try {
429
- // Clean up input
430
- id = id.trim();
431
- // If it's already a hex event ID
432
- if (/^[0-9a-fA-F]{64}$/i.test(id)) {
433
- return {
434
- type: 'eventId',
435
- eventId: id.toLowerCase()
436
- };
437
- }
438
- // Try to decode as a bech32 entity
439
- if (id.startsWith('note1') || id.startsWith('nevent1') || id.startsWith('naddr1')) {
440
- try {
441
- const decoded = nip19.decode(id);
442
- if (decoded.type === 'note') {
443
- return {
444
- type: 'note',
445
- eventId: decoded.data
446
- };
447
- }
448
- else if (decoded.type === 'nevent') {
449
- const data = decoded.data;
450
- return {
451
- type: 'nevent',
452
- eventId: data.id,
453
- relays: data.relays,
454
- pubkey: data.author
455
- };
456
- }
457
- else if (decoded.type === 'naddr') {
458
- const data = decoded.data;
459
- return {
460
- type: 'naddr',
461
- pubkey: data.pubkey,
462
- kind: data.kind,
463
- relays: data.relays,
464
- identifier: data.identifier
465
- };
466
- }
467
- }
468
- catch (decodeError) {
469
- console.error("Error decoding event identifier:", decodeError);
470
- return null;
471
- }
472
- }
473
- // Not a valid event identifier format
474
- return null;
475
- }
476
- catch (error) {
477
- console.error("Error decoding event identifier:", error);
478
- return null;
479
- }
480
- }
481
- // Function to prepare an anonymous zap
482
- export async function prepareAnonymousZap(target, amountSats, comment = "", relays = DEFAULT_RELAYS) {
483
- try {
484
- // Convert amount to millisats
485
- const amountMsats = amountSats * 1000;
486
- // Determine if target is a pubkey or an event
487
- let hexPubkey = null;
488
- let eventId = null;
489
- let eventCoordinate = null;
490
- // First, try to parse as a pubkey
491
- hexPubkey = npubToHex(target);
492
- // If not a pubkey, try to parse as an event identifier
493
- if (!hexPubkey) {
494
- const decodedEvent = await decodeEventId(target);
495
- if (decodedEvent) {
496
- if (decodedEvent.eventId) {
497
- eventId = decodedEvent.eventId;
498
- }
499
- else if (decodedEvent.pubkey) {
500
- // For naddr, we got a pubkey but no event ID
501
- hexPubkey = decodedEvent.pubkey;
502
- // If this is an naddr, store the information for creating an "a" tag later
503
- if (decodedEvent.type === 'naddr' && decodedEvent.kind) {
504
- eventCoordinate = {
505
- kind: decodedEvent.kind,
506
- pubkey: decodedEvent.pubkey,
507
- identifier: decodedEvent.identifier || ''
508
- };
509
- }
510
- }
511
- }
512
- }
513
- // If we couldn't determine a valid target, return error
514
- if (!hexPubkey && !eventId) {
515
- return {
516
- invoice: "",
517
- success: false,
518
- message: "Invalid target. Please provide a valid npub, hex pubkey, note ID, or event ID."
519
- };
520
- }
521
- // Create a fresh pool for this request
522
- const pool = getFreshPool();
523
- try {
524
- // Find the user's metadata to get their LNURL
525
- let profileFilter = { kinds: [KINDS.Metadata] };
526
- if (hexPubkey) {
527
- profileFilter = {
528
- kinds: [KINDS.Metadata],
529
- authors: [hexPubkey],
530
- };
531
- }
532
- else if (eventId) {
533
- // First get the event to find the author
534
- const eventFilter = { ids: [eventId] };
535
- const eventPromise = pool.get(relays, eventFilter);
536
- const event = await Promise.race([
537
- eventPromise,
538
- new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), QUERY_TIMEOUT))
539
- ]);
540
- if (!event) {
541
- return {
542
- invoice: "",
543
- success: false,
544
- message: `Could not find event with ID ${eventId}`
545
- };
546
- }
547
- hexPubkey = event.pubkey;
548
- profileFilter = {
549
- kinds: [KINDS.Metadata],
550
- authors: [hexPubkey],
551
- };
552
- }
553
- // Collect all relays to try
554
- const allRelays = [...new Set([...relays, ...DEFAULT_RELAYS, ...FALLBACK_RELAYS])];
555
- // Get the user's profile
556
- let profile = null;
557
- for (const relaySet of [relays, DEFAULT_RELAYS, FALLBACK_RELAYS]) {
558
- if (relaySet.length === 0)
559
- continue;
560
- try {
561
- const profilePromise = pool.get(relaySet, profileFilter);
562
- profile = await Promise.race([
563
- profilePromise,
564
- new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), QUERY_TIMEOUT))
565
- ]);
566
- if (profile)
567
- break;
568
- }
569
- catch (error) {
570
- // Continue to next relay set
571
- }
572
- }
573
- if (!profile) {
574
- return {
575
- invoice: "",
576
- success: false,
577
- message: "Could not find profile for the target user. Their profile may not exist on our known relays."
578
- };
579
- }
580
- // Parse the profile to get the lightning address or LNURL
581
- let lnurl = null;
582
- try {
583
- const metadata = JSON.parse(profile.content);
584
- // Check standard LUD-16/LUD-06 fields
585
- lnurl = metadata.lud16 || metadata.lud06 || null;
586
- // Check for alternate capitalizations that some clients might use
587
- if (!lnurl) {
588
- lnurl = metadata.LUD16 || metadata.LUD06 ||
589
- metadata.Lud16 || metadata.Lud06 ||
590
- metadata.lightning || metadata.LIGHTNING ||
591
- metadata.lightningAddress ||
592
- null;
593
- }
594
- if (!lnurl) {
595
- // Check if there's any key that contains "lud" or "lightning"
596
- const ludKey = Object.keys(metadata).find(key => key.toLowerCase().includes('lud') ||
597
- key.toLowerCase().includes('lightning'));
598
- if (ludKey) {
599
- lnurl = metadata[ludKey];
600
- }
601
- }
602
- if (!lnurl) {
603
- return {
604
- invoice: "",
605
- success: false,
606
- message: "Target user does not have a lightning address or LNURL configured in their profile"
607
- };
608
- }
609
- // If it's a lightning address (contains @), convert to LNURL
610
- if (lnurl.includes('@')) {
611
- const [name, domain] = lnurl.split('@');
612
- // Per LUD-16, properly encode username with encodeURIComponent
613
- const encodedName = encodeURIComponent(name);
614
- lnurl = `https://${domain}/.well-known/lnurlp/${encodedName}`;
615
- }
616
- else if (lnurl.toLowerCase().startsWith('lnurl')) {
617
- // Decode bech32 LNURL to URL
618
- try {
619
- lnurl = Buffer.from(bech32ToArray(lnurl.toLowerCase().substring(5))).toString();
620
- }
621
- catch (e) {
622
- return {
623
- invoice: "",
624
- success: false,
625
- message: "Invalid LNURL format"
626
- };
627
- }
628
- }
629
- // Make sure it's HTTP or HTTPS if not already
630
- if (!lnurl.startsWith('http://') && !lnurl.startsWith('https://')) {
631
- // Default to HTTPS
632
- lnurl = 'https://' + lnurl;
633
- }
634
- }
635
- catch (error) {
636
- return {
637
- invoice: "",
638
- success: false,
639
- message: "Error parsing user profile"
640
- };
641
- }
642
- if (!lnurl) {
643
- return {
644
- invoice: "",
645
- success: false,
646
- message: "Could not determine LNURL from user profile"
647
- };
648
- }
649
- // Step 1: Query the LNURL to get the callback URL
650
- let lnurlResponse;
651
- try {
652
- lnurlResponse = await fetch(lnurl, {
653
- headers: {
654
- 'Accept': 'application/json',
655
- 'User-Agent': 'Nostr-MCP-Server/1.0'
656
- }
657
- });
658
- if (!lnurlResponse.ok) {
659
- let errorText = "";
660
- try {
661
- errorText = await lnurlResponse.text();
662
- }
663
- catch (e) {
664
- // Ignore if we can't read the error text
665
- }
666
- return {
667
- invoice: "",
668
- success: false,
669
- message: `LNURL request failed with status ${lnurlResponse.status}${errorText ? `: ${errorText}` : ""}`
670
- };
671
- }
672
- }
673
- catch (error) {
674
- return {
675
- invoice: "",
676
- success: false,
677
- message: `Error connecting to LNURL: ${error instanceof Error ? error.message : "Unknown error"}`
678
- };
679
- }
680
- let lnurlData;
681
- try {
682
- const responseText = await lnurlResponse.text();
683
- lnurlData = JSON.parse(responseText);
684
- }
685
- catch (error) {
686
- return {
687
- invoice: "",
688
- success: false,
689
- message: `Invalid JSON response from LNURL service: ${error instanceof Error ? error.message : "Unknown error"}`
690
- };
691
- }
692
- // Extract metadata for better debugging
693
- const metadataInfo = extractLnurlMetadata(lnurlData);
694
- // Check if the service supports NIP-57 zaps
695
- if (!lnurlData.allowsNostr) {
696
- return {
697
- invoice: "",
698
- success: false,
699
- message: "The target user's lightning service does not support Nostr zaps"
700
- };
701
- }
702
- if (!lnurlData.nostrPubkey) {
703
- return {
704
- invoice: "",
705
- success: false,
706
- message: "The target user's lightning service does not provide a nostrPubkey for zaps"
707
- };
708
- }
709
- // Validate the callback URL
710
- if (!lnurlData.callback || !isValidUrl(lnurlData.callback)) {
711
- return {
712
- invoice: "",
713
- success: false,
714
- message: `Invalid callback URL in LNURL response: ${lnurlData.callback}`
715
- };
716
- }
717
- // Validate amount limits
718
- if (!lnurlData.minSendable || !lnurlData.maxSendable) {
719
- return {
720
- invoice: "",
721
- success: false,
722
- message: "The LNURL service did not provide valid min/max sendable amounts"
723
- };
724
- }
725
- if (amountMsats < lnurlData.minSendable) {
726
- return {
727
- invoice: "",
728
- success: false,
729
- message: `Amount too small. Minimum is ${lnurlData.minSendable / 1000} sats (you tried to send ${amountMsats / 1000} sats)`
730
- };
731
- }
732
- if (amountMsats > lnurlData.maxSendable) {
733
- return {
734
- invoice: "",
735
- success: false,
736
- message: `Amount too large. Maximum is ${lnurlData.maxSendable / 1000} sats (you tried to send ${amountMsats / 1000} sats)`
737
- };
738
- }
739
- // Validate comment length if the service has a limit
740
- if (lnurlData.commentAllowed && comment.length > lnurlData.commentAllowed) {
741
- comment = comment.substring(0, lnurlData.commentAllowed);
742
- }
743
- // Step 2: Create the zap request tags
744
- const zapRequestTags = [
745
- ["relays", ...relays.slice(0, 5)], // Include up to 5 relays
746
- ["amount", amountMsats.toString()],
747
- ["lnurl", lnurl]
748
- ];
749
- // Add p or e tag depending on what we're zapping
750
- if (hexPubkey) {
751
- zapRequestTags.push(["p", hexPubkey]);
752
- }
753
- if (eventId) {
754
- zapRequestTags.push(["e", eventId]);
755
- }
756
- // Add a tag for replaceable events (naddr)
757
- if (eventCoordinate) {
758
- const aTagValue = `${eventCoordinate.kind}:${eventCoordinate.pubkey}:${eventCoordinate.identifier}`;
759
- zapRequestTags.push(["a", aTagValue]);
760
- }
761
- // Create a proper one-time keypair for anonymous zapping
762
- const anonymousSecretKey = generateSecretKey(); // This generates a proper 32-byte private key
763
- const anonymousPubkeyHex = getPublicKey(anonymousSecretKey); // This computes the corresponding public key
764
- // Create the zap request event template
765
- const zapRequestTemplate = {
766
- kind: 9734,
767
- created_at: Math.floor(Date.now() / 1000),
768
- content: comment,
769
- tags: zapRequestTags,
770
- };
771
- // Properly finalize the event (calculates ID and signs it) using nostr-tools
772
- const signedZapRequest = finalizeEvent(zapRequestTemplate, anonymousSecretKey);
773
- // Create different formatted versions of the zap request for compatibility
774
- const completeEventParam = encodeURIComponent(JSON.stringify(signedZapRequest));
775
- const basicEventParam = encodeURIComponent(JSON.stringify({
776
- kind: 9734,
777
- created_at: Math.floor(Date.now() / 1000),
778
- content: comment,
779
- tags: zapRequestTags,
780
- pubkey: anonymousPubkeyHex
781
- }));
782
- const tagsOnlyParam = encodeURIComponent(JSON.stringify({
783
- tags: zapRequestTags
784
- }));
785
- // Try each approach in order
786
- const approaches = [
787
- { name: "Complete event with ID/sig", param: completeEventParam },
788
- { name: "Basic event without ID/sig", param: basicEventParam },
789
- { name: "Tags only", param: tagsOnlyParam },
790
- // Add fallback approach without nostr parameter at all
791
- { name: "No nostr parameter", param: null }
792
- ];
793
- // Flag to track if we've successfully processed any approach
794
- let success = false;
795
- let finalResult = null;
796
- let lastError = "";
797
- for (const approach of approaches) {
798
- if (success)
799
- break; // Skip if we already succeeded
800
- // Create a new URL for each attempt to avoid parameter pollution
801
- const currentCallbackUrl = new URL(lnurlData.callback);
802
- // Add basic parameters - must include amount first per some implementations
803
- currentCallbackUrl.searchParams.append("amount", amountMsats.toString());
804
- // Add comment if provided and allowed
805
- if (comment && (!lnurlData.commentAllowed || lnurlData.commentAllowed > 0)) {
806
- currentCallbackUrl.searchParams.append("comment", comment);
807
- }
808
- // Add the nostr parameter for this approach (if not null)
809
- if (approach.param !== null) {
810
- currentCallbackUrl.searchParams.append("nostr", approach.param);
811
- }
812
- const callbackUrlString = currentCallbackUrl.toString();
813
- try {
814
- const callbackResponse = await fetch(callbackUrlString, {
815
- method: 'GET', // Explicitly use GET as required by LUD-06
816
- headers: {
817
- 'Accept': 'application/json',
818
- 'User-Agent': 'Nostr-MCP-Server/1.0'
819
- }
820
- });
821
- // Attempt to read the response body regardless of status code
822
- let responseText = "";
823
- try {
824
- responseText = await callbackResponse.text();
825
- }
826
- catch (e) {
827
- // Ignore if we can't read the response
828
- }
829
- if (!callbackResponse.ok) {
830
- if (responseText) {
831
- lastError = `Status ${callbackResponse.status}: ${responseText}`;
832
- }
833
- else {
834
- lastError = `Status ${callbackResponse.status}`;
835
- }
836
- continue; // Try the next approach
837
- }
838
- // Successfully got a 2xx response, now parse it
839
- let invoiceData;
840
- try {
841
- invoiceData = JSON.parse(responseText);
842
- }
843
- catch (error) {
844
- lastError = `Invalid JSON in response: ${responseText}`;
845
- continue; // Try the next approach
846
- }
847
- // Check if the response has the expected structure
848
- if (!invoiceData.pr) {
849
- if (invoiceData.reason) {
850
- lastError = invoiceData.reason;
851
- // If the error message mentions the NIP-57/Nostr parameter specifically, try the next approach
852
- if (lastError.toLowerCase().includes('nostr') ||
853
- lastError.toLowerCase().includes('customer') ||
854
- lastError.toLowerCase().includes('wallet')) {
855
- continue; // Try the next approach
856
- }
857
- }
858
- else {
859
- lastError = `Missing 'pr' field in response`;
860
- }
861
- continue; // Try the next approach
862
- }
863
- // We got a valid invoice!
864
- success = true;
865
- finalResult = {
866
- invoice: invoiceData.pr,
867
- success: true,
868
- message: `Successfully generated invoice using ${approach.name}`
869
- };
870
- break; // Exit the loop
871
- }
872
- catch (error) {
873
- lastError = error instanceof Error ? error.message : "Unknown error";
874
- // Continue to the next approach
875
- }
876
- }
877
- // If none of our approaches worked, return an error with the last error message
878
- if (!success) {
879
- return {
880
- invoice: "",
881
- success: false,
882
- message: `Failed to generate invoice: ${lastError}`
883
- };
884
- }
885
- return finalResult;
886
- }
887
- catch (error) {
888
- return {
889
- invoice: "",
890
- success: false,
891
- message: `Error preparing zap: ${error instanceof Error ? error.message : "Unknown error"}`
892
- };
893
- }
894
- finally {
895
- // Clean up any subscriptions and close the pool
896
- pool.close(relays);
897
- }
898
- }
899
- catch (error) {
900
- return {
901
- invoice: "",
902
- success: false,
903
- message: `Fatal error: ${error instanceof Error ? error.message : "Unknown error"}`
904
- };
905
- }
906
- }
907
- // Helper function to decode bech32-encoded LNURL
908
- function bech32ToArray(bech32Str) {
909
- // Extract the 5-bit words
910
- let words = [];
911
- for (let i = 0; i < bech32Str.length; i++) {
912
- const c = bech32Str.charAt(i);
913
- const charCode = c.charCodeAt(0);
914
- if (charCode < 33 || charCode > 126) {
915
- throw new Error(`Invalid character: ${c}`);
916
- }
917
- const value = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".indexOf(c.toLowerCase());
918
- if (value === -1) {
919
- throw new Error(`Invalid character: ${c}`);
920
- }
921
- words.push(value);
922
- }
923
- // Convert 5-bit words to 8-bit bytes
924
- const result = new Uint8Array(Math.floor((words.length * 5) / 8));
925
- let bitIndex = 0;
926
- let byteIndex = 0;
927
- for (let i = 0; i < words.length; i++) {
928
- const value = words[i];
929
- // Extract the bits from this word
930
- for (let j = 0; j < 5; j++) {
931
- const bit = (value >> (4 - j)) & 1;
932
- // Set the bit in the result
933
- if (bit) {
934
- result[byteIndex] |= 1 << (7 - bitIndex);
935
- }
936
- bitIndex++;
937
- if (bitIndex === 8) {
938
- bitIndex = 0;
939
- byteIndex++;
940
- }
941
- }
942
- }
943
- return result;
944
- }
945
- // Export the tool configuration for anonymous zap
946
- export const sendAnonymousZapToolConfig = {
947
- target: z.string().describe("Target to zap - can be a pubkey (hex or npub) or an event ID (nevent, note, naddr, or hex)"),
948
- amountSats: z.number().min(1).describe("Amount to zap in satoshis"),
949
- comment: z.string().default("").describe("Optional comment to include with the zap"),
950
- relays: z.array(z.string()).optional().describe("Optional list of relays to query")
951
- };
952
- // Add a function to verify the callback URL scheme and validate the response
953
- function isValidUrl(urlString) {
954
- try {
955
- const url = new URL(urlString);
956
- return url.protocol === 'https:' || url.protocol === 'http:';
957
- }
958
- catch {
959
- return false;
960
- }
961
- }
962
- // Add this function to extract metadata from the LNURL response
963
- function extractLnurlMetadata(lnurlData) {
964
- if (!lnurlData.metadata)
965
- return {};
966
- try {
967
- const metadata = JSON.parse(lnurlData.metadata);
968
- if (!Array.isArray(metadata))
969
- return {};
970
- let payeeName;
971
- let payeeEmail;
972
- // Extract information from metadata as per LUD-06
973
- for (const entry of metadata) {
974
- if (Array.isArray(entry) && entry.length >= 2) {
975
- if (entry[0] === "text/plain") {
976
- payeeName = entry[1];
977
- }
978
- if (entry[0] === "text/email" || entry[0] === "text/identifier") {
979
- payeeEmail = entry[1];
980
- }
981
- }
982
- }
983
- return { payeeName, payeeEmail };
984
- }
985
- catch (error) {
986
- console.error("Error parsing LNURL metadata:", error);
987
- return {};
988
- }
989
- }