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.
- package/LICENSE +21 -0
- package/README.md +498 -0
- package/build/__tests__/basic.test.js +87 -0
- package/build/__tests__/error-handling.test.js +145 -0
- package/build/__tests__/format-conversion.test.js +137 -0
- package/build/__tests__/integration.test.js +163 -0
- package/build/__tests__/mocks.js +109 -0
- package/build/__tests__/nip19-conversion.test.js +268 -0
- package/build/__tests__/nips-search.test.js +109 -0
- package/build/__tests__/note-creation.test.js +148 -0
- package/build/__tests__/note-tools-functions.test.js +173 -0
- package/build/__tests__/note-tools-unit.test.js +97 -0
- package/build/__tests__/profile-notes-simple.test.js +78 -0
- package/build/__tests__/profile-postnote.test.js +120 -0
- package/build/__tests__/profile-tools.test.js +90 -0
- package/build/__tests__/relay-specification.test.js +136 -0
- package/build/__tests__/search-nips-simple.test.js +96 -0
- package/build/__tests__/websocket-integration.test.js +257 -0
- package/build/__tests__/zap-tools-simple.test.js +72 -0
- package/build/__tests__/zap-tools-tests.test.js +197 -0
- package/build/index.js +1285 -0
- package/build/nips/nips-tools.js +567 -0
- package/build/nips-tools.js +421 -0
- package/build/note/note-tools.js +296 -0
- package/build/note-tools.js +53 -0
- package/build/profile/profile-tools.js +260 -0
- package/build/utils/constants.js +27 -0
- package/build/utils/conversion.js +332 -0
- package/build/utils/ephemeral-relay.js +438 -0
- package/build/utils/formatting.js +34 -0
- package/build/utils/index.js +6 -0
- package/build/utils/nip19-tools.js +117 -0
- package/build/utils/pool.js +55 -0
- package/build/zap/zap-tools.js +980 -0
- package/build/zap-tools.js +989 -0
- package/package.json +59 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, test, jest, beforeEach } from '@jest/globals';
|
|
2
|
+
import { NostrRelay } from '../utils/ephemeral-relay.js';
|
|
3
|
+
// Mock the getRelayPool function
|
|
4
|
+
const mockGetRelayPool = jest.fn();
|
|
5
|
+
// Define a mock implementation
|
|
6
|
+
const createMockPool = (relays) => {
|
|
7
|
+
return {
|
|
8
|
+
connect: jest.fn(),
|
|
9
|
+
close: jest.fn(),
|
|
10
|
+
relayUrls: relays,
|
|
11
|
+
subscribeMany: jest.fn().mockImplementation(() => {
|
|
12
|
+
return {
|
|
13
|
+
on: jest.fn(),
|
|
14
|
+
off: jest.fn()
|
|
15
|
+
};
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
// Set up the mock implementation
|
|
20
|
+
mockGetRelayPool.mockImplementation((relays) => createMockPool(relays || []));
|
|
21
|
+
// Mock the entire module
|
|
22
|
+
jest.mock('../utils/pool.js', () => {
|
|
23
|
+
return {
|
|
24
|
+
getRelayPool: (relays) => mockGetRelayPool(relays)
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
// Create a helper to simulate fetching profile data
|
|
28
|
+
const fetchProfileWithRelays = (pubkey, customRelays) => {
|
|
29
|
+
// This simulates how the actual code would use the pool
|
|
30
|
+
const relayPool = mockGetRelayPool(customRelays);
|
|
31
|
+
// Track if we've connected to the pool
|
|
32
|
+
relayPool.connect();
|
|
33
|
+
// Simulate the subscription
|
|
34
|
+
const sub = relayPool.subscribeMany([{ kinds: [0], authors: [pubkey] }]);
|
|
35
|
+
// Return the relays that were used
|
|
36
|
+
return {
|
|
37
|
+
relaysUsed: relayPool.relayUrls,
|
|
38
|
+
pool: relayPool
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
// Define default relays for comparison
|
|
42
|
+
const DEFAULT_RELAYS = [
|
|
43
|
+
'wss://relay.damus.io',
|
|
44
|
+
'wss://relay.nostr.band',
|
|
45
|
+
'wss://relay.primal.net',
|
|
46
|
+
'wss://nos.lol',
|
|
47
|
+
'wss://relay.current.fyi',
|
|
48
|
+
'wss://nostr.bitcoiner.social'
|
|
49
|
+
];
|
|
50
|
+
describe('Custom Relay Specification', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
// Clear all mocks before each test
|
|
53
|
+
jest.clearAllMocks();
|
|
54
|
+
// Set the default mockGetRelayPool implementation to use DEFAULT_RELAYS when no custom relays
|
|
55
|
+
mockGetRelayPool.mockImplementation((relays) => {
|
|
56
|
+
const trackedRelays = relays && relays.length > 0 ? relays : DEFAULT_RELAYS;
|
|
57
|
+
return createMockPool(trackedRelays);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
test('uses default relays when no custom relays specified', () => {
|
|
61
|
+
const { relaysUsed } = fetchProfileWithRelays('test-pubkey');
|
|
62
|
+
// When no custom relays are specified, should use defaults
|
|
63
|
+
expect(relaysUsed).toEqual(DEFAULT_RELAYS);
|
|
64
|
+
});
|
|
65
|
+
test('uses custom relays when specified', () => {
|
|
66
|
+
const customRelays = ['wss://custom1.example.com', 'wss://custom2.example.com'];
|
|
67
|
+
const { relaysUsed } = fetchProfileWithRelays('test-pubkey', customRelays);
|
|
68
|
+
// Should use exactly the custom relays
|
|
69
|
+
expect(relaysUsed).toEqual(customRelays);
|
|
70
|
+
expect(relaysUsed).not.toEqual(DEFAULT_RELAYS);
|
|
71
|
+
});
|
|
72
|
+
test('empty custom relays array falls back to defaults', () => {
|
|
73
|
+
const { relaysUsed } = fetchProfileWithRelays('test-pubkey', []);
|
|
74
|
+
// When empty array is provided, should fall back to defaults
|
|
75
|
+
expect(relaysUsed).toEqual(DEFAULT_RELAYS);
|
|
76
|
+
});
|
|
77
|
+
test('maintains relay connection pool during operation', () => {
|
|
78
|
+
const customRelays = ['wss://custom1.example.com', 'wss://custom2.example.com'];
|
|
79
|
+
const { pool } = fetchProfileWithRelays('test-pubkey', customRelays);
|
|
80
|
+
// The connect method should be called
|
|
81
|
+
expect(pool.connect).toHaveBeenCalled();
|
|
82
|
+
// The subscribeMany method should be called with appropriate filters
|
|
83
|
+
expect(pool.subscribeMany).toHaveBeenCalledWith(expect.arrayContaining([
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
kinds: expect.arrayContaining([0]),
|
|
86
|
+
authors: expect.arrayContaining(['test-pubkey'])
|
|
87
|
+
})
|
|
88
|
+
]));
|
|
89
|
+
});
|
|
90
|
+
test('invalid relay URLs are filtered out', () => {
|
|
91
|
+
// Create a special mock implementation for this test
|
|
92
|
+
mockGetRelayPool.mockImplementationOnce((relays) => {
|
|
93
|
+
// Filter invalid URLs (this simulates what the real implementation should do)
|
|
94
|
+
const filteredRelays = relays ? relays.filter((url) => url.startsWith('wss://') && url.includes('.')) : [];
|
|
95
|
+
return createMockPool(filteredRelays);
|
|
96
|
+
});
|
|
97
|
+
// Include some invalid relay URLs
|
|
98
|
+
const mixedRelays = [
|
|
99
|
+
'wss://valid.example.com',
|
|
100
|
+
'invalid-url',
|
|
101
|
+
'http://not-secure.example.com', // Not WSS
|
|
102
|
+
'wss://another-valid.example.com'
|
|
103
|
+
];
|
|
104
|
+
const { relaysUsed } = fetchProfileWithRelays('test-pubkey', mixedRelays);
|
|
105
|
+
// Only valid WSS URLs should remain
|
|
106
|
+
expect(relaysUsed).toContain('wss://valid.example.com');
|
|
107
|
+
expect(relaysUsed).toContain('wss://another-valid.example.com');
|
|
108
|
+
expect(relaysUsed).not.toContain('invalid-url');
|
|
109
|
+
expect(relaysUsed).not.toContain('http://not-secure.example.com');
|
|
110
|
+
});
|
|
111
|
+
test('relay fallback behavior with ephemeral relay', async () => {
|
|
112
|
+
// Create an ephemeral relay for testing - using port 9000 for test
|
|
113
|
+
const relay = new NostrRelay(9000);
|
|
114
|
+
// Create a request that specifies two relays:
|
|
115
|
+
// 1. A non-existent relay that will fail
|
|
116
|
+
// 2. Our ephemeral relay that will work
|
|
117
|
+
const testRelays = [
|
|
118
|
+
'wss://non-existent-relay.example.com',
|
|
119
|
+
relay.url
|
|
120
|
+
];
|
|
121
|
+
// This is a more advanced test that would require actual implementation
|
|
122
|
+
// of relay fallback logic. We'll outline the concept here.
|
|
123
|
+
// In a real test with actual implementation:
|
|
124
|
+
// 1. Send a request to the specified relays
|
|
125
|
+
// 2. The first relay fails to connect
|
|
126
|
+
// 3. The code falls back to the second relay
|
|
127
|
+
// 4. We get a successful response from the second relay
|
|
128
|
+
// For now, we'll just verify that both relays are attempted
|
|
129
|
+
const { relaysUsed } = fetchProfileWithRelays('test-pubkey', testRelays);
|
|
130
|
+
expect(relaysUsed).toEqual(testRelays);
|
|
131
|
+
// In a full implementation, we would:
|
|
132
|
+
// 1. Set up a callback for events from the ephemeral relay
|
|
133
|
+
// 2. Publish a test profile to the ephemeral relay
|
|
134
|
+
// 3. Verify we receive it even though the first relay fails
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { searchNips } from '../nips/nips-tools.js';
|
|
2
|
+
// Since the NIPs tool uses a real cache, we'll test with the actual cache behavior
|
|
3
|
+
describe('Search NIPs Tool - Simple Tests', () => {
|
|
4
|
+
describe('basic search functionality', () => {
|
|
5
|
+
it('should search NIPs by keyword', async () => {
|
|
6
|
+
// This will use the real cache if available, or fetch from GitHub
|
|
7
|
+
const result = await searchNips('protocol', 5);
|
|
8
|
+
expect(Array.isArray(result)).toBe(true);
|
|
9
|
+
// Should find NIP-01 when searching for "protocol"
|
|
10
|
+
const nip01 = result.find(searchResult => searchResult.nip.number === 1);
|
|
11
|
+
if (nip01) {
|
|
12
|
+
expect(nip01.nip.title.toLowerCase()).toContain('protocol');
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
it('should return limited results', async () => {
|
|
16
|
+
const limit = 3;
|
|
17
|
+
const result = await searchNips('event', limit);
|
|
18
|
+
expect(Array.isArray(result)).toBe(true);
|
|
19
|
+
expect(result.length).toBeLessThanOrEqual(limit);
|
|
20
|
+
});
|
|
21
|
+
it('should return results with correct structure', async () => {
|
|
22
|
+
// Use a more common search term that's likely to return results
|
|
23
|
+
const result = await searchNips('event', 5);
|
|
24
|
+
expect(Array.isArray(result)).toBe(true);
|
|
25
|
+
// Each result should have the expected structure
|
|
26
|
+
if (result.length > 0) {
|
|
27
|
+
result.forEach(searchResult => {
|
|
28
|
+
expect(searchResult).toHaveProperty('nip');
|
|
29
|
+
expect(searchResult).toHaveProperty('relevance');
|
|
30
|
+
expect(searchResult).toHaveProperty('matchedTerms');
|
|
31
|
+
// Check the nested nip structure
|
|
32
|
+
expect(searchResult.nip).toHaveProperty('number');
|
|
33
|
+
expect(searchResult.nip).toHaveProperty('title');
|
|
34
|
+
expect(searchResult.nip).toHaveProperty('status');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('should handle no results gracefully', async () => {
|
|
39
|
+
const result = await searchNips('xyz123nonexistentterm456', 10);
|
|
40
|
+
expect(Array.isArray(result)).toBe(true);
|
|
41
|
+
expect(result).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
it('should handle case-insensitive search', async () => {
|
|
44
|
+
const result1 = await searchNips('PROTOCOL', 5);
|
|
45
|
+
const result2 = await searchNips('protocol', 5);
|
|
46
|
+
expect(Array.isArray(result1)).toBe(true);
|
|
47
|
+
expect(Array.isArray(result2)).toBe(true);
|
|
48
|
+
// Both searches should return results
|
|
49
|
+
if (result1.length > 0 && result2.length > 0) {
|
|
50
|
+
// The results should be similar (may not be identical due to scoring)
|
|
51
|
+
expect(result1.length).toBeGreaterThan(0);
|
|
52
|
+
expect(result2.length).toBeGreaterThan(0);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('search relevance', () => {
|
|
57
|
+
it('should return relevant results for common terms', async () => {
|
|
58
|
+
// Test with terms we know exist in NIPs
|
|
59
|
+
const result = await searchNips('event', 5);
|
|
60
|
+
expect(Array.isArray(result)).toBe(true);
|
|
61
|
+
if (result.length > 0) {
|
|
62
|
+
// At least one result should have a relevance score
|
|
63
|
+
const hasRelevantResult = result.some(searchResult => searchResult.relevance > 0);
|
|
64
|
+
expect(hasRelevantResult).toBe(true);
|
|
65
|
+
// Results should be sorted by relevance score
|
|
66
|
+
expect(result[0].relevance).toBeGreaterThan(0);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it('should rank results by relevance score', async () => {
|
|
70
|
+
const result = await searchNips('encryption', 10);
|
|
71
|
+
expect(Array.isArray(result)).toBe(true);
|
|
72
|
+
if (result.length > 1) {
|
|
73
|
+
// Results should be sorted by relevance score (descending)
|
|
74
|
+
for (let i = 1; i < result.length; i++) {
|
|
75
|
+
if (result[i - 1].relevance !== undefined && result[i].relevance !== undefined) {
|
|
76
|
+
expect(result[i - 1].relevance).toBeGreaterThanOrEqual(result[i].relevance);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('error handling', () => {
|
|
83
|
+
it('should handle empty search query', async () => {
|
|
84
|
+
const result = await searchNips('', 10);
|
|
85
|
+
// Empty query should return empty array
|
|
86
|
+
expect(Array.isArray(result)).toBe(true);
|
|
87
|
+
expect(result).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
it('should handle very long search queries gracefully', async () => {
|
|
90
|
+
const longQuery = 'a'.repeat(1000);
|
|
91
|
+
const result = await searchNips(longQuery, 10);
|
|
92
|
+
// Should return an array (possibly empty)
|
|
93
|
+
expect(Array.isArray(result)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { NostrRelay } from '../utils/ephemeral-relay.js';
|
|
2
|
+
import { schnorr } from '@noble/curves/secp256k1';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
// Generate a keypair for testing
|
|
7
|
+
function generatePrivateKey() {
|
|
8
|
+
return Buffer.from(randomBytes(32)).toString('hex');
|
|
9
|
+
}
|
|
10
|
+
function getPublicKey(privateKey) {
|
|
11
|
+
return Buffer.from(schnorr.getPublicKey(privateKey)).toString('hex');
|
|
12
|
+
}
|
|
13
|
+
// Create a signed event
|
|
14
|
+
function createSignedEvent(privateKey, kind, content, tags = []) {
|
|
15
|
+
const pubkey = getPublicKey(privateKey);
|
|
16
|
+
const created_at = Math.floor(Date.now() / 1000);
|
|
17
|
+
// Create event
|
|
18
|
+
const event = {
|
|
19
|
+
pubkey,
|
|
20
|
+
created_at,
|
|
21
|
+
kind,
|
|
22
|
+
tags,
|
|
23
|
+
content,
|
|
24
|
+
};
|
|
25
|
+
// Calculate event ID
|
|
26
|
+
const eventData = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
|
|
27
|
+
const id = Buffer.from(sha256(eventData)).toString('hex');
|
|
28
|
+
// Sign the event
|
|
29
|
+
const sig = Buffer.from(schnorr.sign(id, privateKey)).toString('hex');
|
|
30
|
+
return {
|
|
31
|
+
...event,
|
|
32
|
+
id,
|
|
33
|
+
sig
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
describe('WebSocket Nostr Integration Tests', () => {
|
|
37
|
+
let relay;
|
|
38
|
+
const testPort = 9800;
|
|
39
|
+
let relayUrl;
|
|
40
|
+
let ws;
|
|
41
|
+
let privateKey;
|
|
42
|
+
let publicKey;
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
privateKey = generatePrivateKey();
|
|
45
|
+
publicKey = getPublicKey(privateKey);
|
|
46
|
+
// Start the ephemeral relay
|
|
47
|
+
relay = new NostrRelay(testPort);
|
|
48
|
+
await relay.start();
|
|
49
|
+
relayUrl = `ws://localhost:${testPort}`;
|
|
50
|
+
});
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
// Create a new WebSocket connection before each test
|
|
53
|
+
const connectPromise = new Promise((resolve, reject) => {
|
|
54
|
+
ws = new WebSocket(relayUrl);
|
|
55
|
+
ws.on('open', () => resolve());
|
|
56
|
+
ws.on('error', reject);
|
|
57
|
+
});
|
|
58
|
+
await connectPromise;
|
|
59
|
+
});
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
// Close WebSocket connection after each test
|
|
62
|
+
if (ws) {
|
|
63
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
64
|
+
ws.close();
|
|
65
|
+
// Wait for close to complete
|
|
66
|
+
await new Promise(resolve => {
|
|
67
|
+
ws.on('close', resolve);
|
|
68
|
+
setTimeout(resolve, 100); // Fallback timeout
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
ws.removeAllListeners();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
// Ensure all WebSocket connections are closed
|
|
76
|
+
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
77
|
+
ws.close();
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
79
|
+
}
|
|
80
|
+
// Shutdown relay
|
|
81
|
+
await relay.close();
|
|
82
|
+
// Give time for all async operations to complete
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
84
|
+
});
|
|
85
|
+
// Helper function to send a message and wait for response
|
|
86
|
+
const sendAndWait = (message) => {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const responses = [];
|
|
89
|
+
const responseHandler = (data) => {
|
|
90
|
+
try {
|
|
91
|
+
const response = JSON.parse(data.toString());
|
|
92
|
+
responses.push(response);
|
|
93
|
+
// EOSE or OK messages indicate we can resolve
|
|
94
|
+
if ((response[0] === 'EOSE' && response[1] === 'test-sub') ||
|
|
95
|
+
(response[0] === 'OK')) {
|
|
96
|
+
resolve(responses);
|
|
97
|
+
ws.off('message', responseHandler);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
reject(e);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
ws.on('message', responseHandler);
|
|
105
|
+
ws.send(JSON.stringify(message));
|
|
106
|
+
// Add a timeout
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
resolve(responses);
|
|
109
|
+
ws.off('message', responseHandler);
|
|
110
|
+
}, 2000);
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
test('should connect to the relay', () => {
|
|
114
|
+
expect(ws.readyState).toBe(WebSocket.OPEN);
|
|
115
|
+
});
|
|
116
|
+
test('should publish an event and get OK response', async () => {
|
|
117
|
+
// Create a test event
|
|
118
|
+
const event = createSignedEvent(privateKey, 1, 'WebSocket test note');
|
|
119
|
+
// Send EVENT message
|
|
120
|
+
const responses = await sendAndWait(['EVENT', event]);
|
|
121
|
+
// Check for OK response
|
|
122
|
+
const okResponse = responses.find(resp => resp[0] === 'OK' && resp[1] === event.id);
|
|
123
|
+
expect(okResponse).toBeDefined();
|
|
124
|
+
expect(okResponse[2]).toBe(true); // Success flag
|
|
125
|
+
});
|
|
126
|
+
test('should publish an event and retrieve it with REQ', async () => {
|
|
127
|
+
// Create a test event with unique content
|
|
128
|
+
const uniqueContent = `WebSocket REQ test note ${Date.now()}`;
|
|
129
|
+
const event = createSignedEvent(privateKey, 1, uniqueContent);
|
|
130
|
+
// Send EVENT message
|
|
131
|
+
await sendAndWait(['EVENT', event]);
|
|
132
|
+
// Now send a REQ to get this event
|
|
133
|
+
const subId = 'test-sub';
|
|
134
|
+
const responses = await sendAndWait([
|
|
135
|
+
'REQ',
|
|
136
|
+
subId,
|
|
137
|
+
{
|
|
138
|
+
kinds: [1],
|
|
139
|
+
authors: [publicKey],
|
|
140
|
+
}
|
|
141
|
+
]);
|
|
142
|
+
// Check that we got an EVENT response with our event
|
|
143
|
+
const eventResponse = responses.find(resp => resp[0] === 'EVENT' &&
|
|
144
|
+
resp[1] === subId &&
|
|
145
|
+
resp[2].content === uniqueContent);
|
|
146
|
+
expect(eventResponse).toBeDefined();
|
|
147
|
+
expect(eventResponse[2].id).toBe(event.id);
|
|
148
|
+
// Check that we got an EOSE response
|
|
149
|
+
const eoseResponse = responses.find(resp => resp[0] === 'EOSE' && resp[1] === subId);
|
|
150
|
+
expect(eoseResponse).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
test('should handle multiple subscriptions', async () => {
|
|
153
|
+
// Create events of different kinds
|
|
154
|
+
const profileEvent = createSignedEvent(privateKey, 0, JSON.stringify({ name: 'WebSocket Test' }));
|
|
155
|
+
const noteEvent = createSignedEvent(privateKey, 1, 'WebSocket multi-subscription test');
|
|
156
|
+
// Publish both events
|
|
157
|
+
await sendAndWait(['EVENT', profileEvent]);
|
|
158
|
+
await sendAndWait(['EVENT', noteEvent]);
|
|
159
|
+
// Subscribe to profiles only
|
|
160
|
+
const profileSubId = 'profile-sub';
|
|
161
|
+
const profileResponses = await sendAndWait([
|
|
162
|
+
'REQ',
|
|
163
|
+
profileSubId,
|
|
164
|
+
{
|
|
165
|
+
kinds: [0],
|
|
166
|
+
authors: [publicKey],
|
|
167
|
+
}
|
|
168
|
+
]);
|
|
169
|
+
// Subscribe to notes only
|
|
170
|
+
const noteSubId = 'note-sub';
|
|
171
|
+
const noteResponses = await sendAndWait([
|
|
172
|
+
'REQ',
|
|
173
|
+
noteSubId,
|
|
174
|
+
{
|
|
175
|
+
kinds: [1],
|
|
176
|
+
authors: [publicKey],
|
|
177
|
+
}
|
|
178
|
+
]);
|
|
179
|
+
// Check profile subscription got profile event
|
|
180
|
+
const profileEventResponse = profileResponses.find(resp => resp[0] === 'EVENT' &&
|
|
181
|
+
resp[1] === profileSubId &&
|
|
182
|
+
resp[2].kind === 0);
|
|
183
|
+
expect(profileEventResponse).toBeDefined();
|
|
184
|
+
// Check note subscription got note event
|
|
185
|
+
const noteEventResponse = noteResponses.find(resp => resp[0] === 'EVENT' &&
|
|
186
|
+
resp[1] === noteSubId &&
|
|
187
|
+
resp[2].kind === 1);
|
|
188
|
+
expect(noteEventResponse).toBeDefined();
|
|
189
|
+
});
|
|
190
|
+
test('should support subscription closing', async () => {
|
|
191
|
+
// Create a test event
|
|
192
|
+
const event = createSignedEvent(privateKey, 1, 'Subscription close test');
|
|
193
|
+
// Publish the event
|
|
194
|
+
await sendAndWait(['EVENT', event]);
|
|
195
|
+
// Create a subscription
|
|
196
|
+
const subId = 'close-test-sub';
|
|
197
|
+
await sendAndWait([
|
|
198
|
+
'REQ',
|
|
199
|
+
subId,
|
|
200
|
+
{
|
|
201
|
+
kinds: [1],
|
|
202
|
+
authors: [publicKey],
|
|
203
|
+
}
|
|
204
|
+
]);
|
|
205
|
+
// Close the subscription
|
|
206
|
+
ws.send(JSON.stringify(['CLOSE', subId]));
|
|
207
|
+
// Create a new subscription with the same ID
|
|
208
|
+
// This should work if the previous subscription was properly closed
|
|
209
|
+
const newResponses = await sendAndWait([
|
|
210
|
+
'REQ',
|
|
211
|
+
subId,
|
|
212
|
+
{
|
|
213
|
+
kinds: [1],
|
|
214
|
+
authors: [publicKey],
|
|
215
|
+
}
|
|
216
|
+
]);
|
|
217
|
+
// Verify we got an EOSE for the new subscription
|
|
218
|
+
const eoseResponse = newResponses.find(resp => resp[0] === 'EOSE' && resp[1] === subId);
|
|
219
|
+
expect(eoseResponse).toBeDefined();
|
|
220
|
+
});
|
|
221
|
+
test('should reject events with invalid signatures or silently ignore them', async () => {
|
|
222
|
+
// Create an event with invalid signature
|
|
223
|
+
const invalidEvent = {
|
|
224
|
+
pubkey: publicKey,
|
|
225
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
226
|
+
kind: 1,
|
|
227
|
+
tags: [],
|
|
228
|
+
content: 'Event with invalid signature',
|
|
229
|
+
id: Buffer.from(sha256(JSON.stringify([0, publicKey, Math.floor(Date.now() / 1000), 1, [], 'Event with invalid signature']))).toString('hex'),
|
|
230
|
+
sig: 'invalid_signature_0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'
|
|
231
|
+
};
|
|
232
|
+
// Send EVENT message with invalid signature
|
|
233
|
+
const responses = await sendAndWait(['EVENT', invalidEvent]);
|
|
234
|
+
// Check for OK response with failure flag, or no response which means silent rejection
|
|
235
|
+
const okResponse = responses.find(resp => resp[0] === 'OK' &&
|
|
236
|
+
resp[1] === invalidEvent.id);
|
|
237
|
+
// If the relay responds to invalid events, it should be with failure
|
|
238
|
+
if (okResponse) {
|
|
239
|
+
expect(okResponse[2]).toBe(false); // Success flag should be false
|
|
240
|
+
}
|
|
241
|
+
// Now verify that a valid event works properly
|
|
242
|
+
const validEvent = createSignedEvent(privateKey, 1, 'Event with valid signature');
|
|
243
|
+
// Send EVENT message with valid signature
|
|
244
|
+
const validResponses = await sendAndWait(['EVENT', validEvent]);
|
|
245
|
+
// Check for OK response with success flag
|
|
246
|
+
const validOkResponse = validResponses.find(resp => resp[0] === 'OK' &&
|
|
247
|
+
resp[1] === validEvent.id);
|
|
248
|
+
expect(validOkResponse).toBeDefined();
|
|
249
|
+
expect(validOkResponse[2]).toBe(true); // Success flag should be true
|
|
250
|
+
// Verify the valid event made it to the relay's cache
|
|
251
|
+
const eventInCache = relay.cache.find(e => e.id === validEvent.id);
|
|
252
|
+
expect(eventInCache).toBeDefined();
|
|
253
|
+
// Verify the invalid event didn't make it to the relay's cache
|
|
254
|
+
const invalidEventInCache = relay.cache.find(e => e.id === invalidEvent.id);
|
|
255
|
+
expect(invalidEventInCache).toBeUndefined();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Mock the processZapReceipt function
|
|
2
|
+
const processZapReceipt = (receipt, targetPubkey) => {
|
|
3
|
+
const targetTag = receipt.tags?.find(tag => tag[0] === 'p' && tag[1] === targetPubkey);
|
|
4
|
+
const direction = targetTag ? 'received' : 'sent';
|
|
5
|
+
const amountTag = receipt.tags?.find(tag => tag[0] === 'amount');
|
|
6
|
+
const amountSats = amountTag ? parseInt(amountTag[1]) / 1000 : 0; // Convert millisats to sats
|
|
7
|
+
return {
|
|
8
|
+
id: receipt.id,
|
|
9
|
+
direction,
|
|
10
|
+
amountSats,
|
|
11
|
+
created_at: receipt.created_at,
|
|
12
|
+
targetPubkey
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
// Simple prepareAnonymousZap function for testing
|
|
16
|
+
const prepareAnonymousZap = (target, amount, comment = '') => {
|
|
17
|
+
return Promise.resolve({
|
|
18
|
+
success: true,
|
|
19
|
+
invoice: `lnbc${amount}`,
|
|
20
|
+
targetData: {
|
|
21
|
+
type: target.startsWith('note') ? 'event' : 'profile'
|
|
22
|
+
},
|
|
23
|
+
comment
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
describe('Zap Tools Functions', () => {
|
|
27
|
+
const testPubkey = '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e';
|
|
28
|
+
test('processZapReceipt adds targetPubkey to receipt', () => {
|
|
29
|
+
// Create mock zap receipt
|
|
30
|
+
const mockZapReceipt = {
|
|
31
|
+
id: 'test-zap-id',
|
|
32
|
+
created_at: Math.floor(Date.now() / 1000) - 3600,
|
|
33
|
+
tags: [
|
|
34
|
+
['p', testPubkey],
|
|
35
|
+
['amount', '100000'] // 100 sats in millisats
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
// Process the receipt
|
|
39
|
+
const result = processZapReceipt(mockZapReceipt, testPubkey);
|
|
40
|
+
// Check the result
|
|
41
|
+
expect(result).toHaveProperty('targetPubkey', testPubkey);
|
|
42
|
+
expect(result.id).toBe(mockZapReceipt.id);
|
|
43
|
+
expect(result.direction).toBe('received');
|
|
44
|
+
expect(result.amountSats).toBe(100);
|
|
45
|
+
});
|
|
46
|
+
test('prepareAnonymousZap returns invoice for profile', async () => {
|
|
47
|
+
// Test with an npub target
|
|
48
|
+
const npubTarget = 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6';
|
|
49
|
+
const amount = 100;
|
|
50
|
+
const comment = 'Test zap';
|
|
51
|
+
// Prepare anonymous zap
|
|
52
|
+
const result = await prepareAnonymousZap(npubTarget, amount, comment);
|
|
53
|
+
// Check the result
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
expect(result.invoice).toBe(`lnbc${amount}`);
|
|
56
|
+
expect(result.targetData.type).toBe('profile');
|
|
57
|
+
expect(result.comment).toBe(comment);
|
|
58
|
+
});
|
|
59
|
+
test('prepareAnonymousZap returns invoice for event', async () => {
|
|
60
|
+
// Test with a note ID target
|
|
61
|
+
const noteTarget = 'note1abcdef';
|
|
62
|
+
const amount = 200;
|
|
63
|
+
// Prepare anonymous zap with default empty comment
|
|
64
|
+
const result = await prepareAnonymousZap(noteTarget, amount);
|
|
65
|
+
// Check the result
|
|
66
|
+
expect(result.success).toBe(true);
|
|
67
|
+
expect(result.invoice).toBe(`lnbc${amount}`);
|
|
68
|
+
expect(result.targetData.type).toBe('event');
|
|
69
|
+
expect(result.comment).toBe('');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
export {};
|