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