nwc-client.ts raw
1 /* eslint-disable @typescript-eslint/no-explicit-any */
2 import { NostrHelper } from '@common';
3 import { finalizeEvent, nip04, nip44, getPublicKey } from 'nostr-tools';
4 import {
5 NwcRequest,
6 NwcResponse,
7 NwcGetBalanceResult,
8 NwcGetInfoResult,
9 NwcPayInvoiceParams,
10 NwcPayInvoiceResult,
11 NwcMakeInvoiceParams,
12 NwcMakeInvoiceResult,
13 NwcListTransactionsParams,
14 NwcListTransactionsResult,
15 NWC_METHODS,
16 } from './types';
17
18 export interface NwcConnectionData {
19 walletPubkey: string;
20 relayUrl: string;
21 secret: string;
22 }
23
24 export type NwcLogLevel = 'info' | 'warn' | 'error';
25 export type NwcLogCallback = (level: NwcLogLevel, message: string) => void;
26
27 interface PendingRequest {
28 resolve: (value: NwcResponse) => void;
29 reject: (reason: Error) => void;
30 timeout: ReturnType<typeof setTimeout>;
31 request: NwcRequest;
32 isRetry: boolean;
33 }
34
35 type EncryptionMode = 'nip44' | 'nip04';
36
37 /**
38 * NWC Client for communicating with NIP-47 wallet services
39 */
40 export class NwcClient {
41 private ws: WebSocket | null = null;
42 private connected = false;
43 private pendingRequests = new Map<string, PendingRequest>();
44 private subscriptionId: string | null = null;
45 private conversationKey: Uint8Array;
46 private clientPubkey: string;
47 private encryptionMode: EncryptionMode = 'nip44';
48 private logCallback: NwcLogCallback | null = null;
49
50 constructor(
51 private connectionData: NwcConnectionData,
52 logCallback?: NwcLogCallback
53 ) {
54 this.logCallback = logCallback ?? null;
55 // Derive the conversation key for NIP-44 encryption
56 this.conversationKey = nip44.v2.utils.getConversationKey(
57 NostrHelper.hex2bytes(connectionData.secret),
58 connectionData.walletPubkey
59 );
60 // Derive our public key from the secret
61 this.clientPubkey = getPublicKey(
62 NostrHelper.hex2bytes(connectionData.secret)
63 );
64 }
65
66 private log(level: NwcLogLevel, message: string): void {
67 if (this.logCallback) {
68 this.logCallback(level, message);
69 }
70 }
71
72 /**
73 * Connect to the NWC relay
74 */
75 async connect(): Promise<void> {
76 if (this.connected) {
77 return;
78 }
79
80 return new Promise((resolve, reject) => {
81 try {
82 this.log('info', `Connecting to ${this.connectionData.relayUrl}...`);
83 this.ws = new WebSocket(this.connectionData.relayUrl);
84
85 const timeout = setTimeout(() => {
86 this.log('error', 'Connection timeout');
87 reject(new Error('Connection timeout'));
88 this.disconnect();
89 }, 10000);
90
91 this.ws.onopen = () => {
92 clearTimeout(timeout);
93 this.connected = true;
94 this.log('info', 'Connected to relay');
95 this.subscribe();
96 resolve();
97 };
98
99 this.ws.onerror = () => {
100 clearTimeout(timeout);
101 this.log('error', 'WebSocket error');
102 reject(new Error('WebSocket error'));
103 };
104
105 this.ws.onclose = () => {
106 this.connected = false;
107 this.subscriptionId = null;
108 // Reject all pending requests
109 for (const [, pending] of this.pendingRequests) {
110 clearTimeout(pending.timeout);
111 pending.reject(new Error('Connection closed'));
112 }
113 this.pendingRequests.clear();
114 };
115
116 this.ws.onmessage = (event) => {
117 this.handleMessage(event.data);
118 };
119 } catch (error) {
120 reject(error);
121 }
122 });
123 }
124
125 /**
126 * Disconnect from the relay
127 */
128 disconnect(): void {
129 if (this.ws) {
130 if (this.subscriptionId) {
131 this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
132 }
133 this.ws.close();
134 this.ws = null;
135 }
136 this.connected = false;
137 this.subscriptionId = null;
138 }
139
140 /**
141 * Check if connected
142 */
143 isConnected(): boolean {
144 return this.connected && this.ws?.readyState === WebSocket.OPEN;
145 }
146
147 /**
148 * Get wallet info
149 */
150 async getInfo(): Promise<NwcGetInfoResult> {
151 const response = await this.sendRequest({
152 method: NWC_METHODS.GET_INFO,
153 });
154
155 if (response.error) {
156 throw new Error(response.error.message);
157 }
158
159 return response.result as unknown as NwcGetInfoResult;
160 }
161
162 /**
163 * Get wallet balance
164 */
165 async getBalance(): Promise<NwcGetBalanceResult> {
166 const response = await this.sendRequest({
167 method: NWC_METHODS.GET_BALANCE,
168 });
169
170 if (response.error) {
171 throw new Error(response.error.message);
172 }
173
174 return response.result as unknown as NwcGetBalanceResult;
175 }
176
177 /**
178 * Pay a Lightning invoice
179 */
180 async payInvoice(params: NwcPayInvoiceParams): Promise<NwcPayInvoiceResult> {
181 const response = await this.sendRequest({
182 method: NWC_METHODS.PAY_INVOICE,
183 params: params as unknown as Record<string, unknown>,
184 });
185
186 if (response.error) {
187 throw new Error(response.error.message);
188 }
189
190 return response.result as unknown as NwcPayInvoiceResult;
191 }
192
193 /**
194 * Create a Lightning invoice
195 */
196 async makeInvoice(
197 params: NwcMakeInvoiceParams
198 ): Promise<NwcMakeInvoiceResult> {
199 const response = await this.sendRequest({
200 method: NWC_METHODS.MAKE_INVOICE,
201 params: params as unknown as Record<string, unknown>,
202 });
203
204 if (response.error) {
205 throw new Error(response.error.message);
206 }
207
208 return response.result as unknown as NwcMakeInvoiceResult;
209 }
210
211 /**
212 * List transaction history
213 */
214 async listTransactions(
215 params?: NwcListTransactionsParams
216 ): Promise<NwcListTransactionsResult> {
217 const response = await this.sendRequest({
218 method: NWC_METHODS.LIST_TRANSACTIONS,
219 params: params as unknown as Record<string, unknown>,
220 });
221
222 if (response.error) {
223 throw new Error(response.error.message);
224 }
225
226 return response.result as unknown as NwcListTransactionsResult;
227 }
228
229 /**
230 * Encrypt content using current encryption mode
231 */
232 private async encryptContent(plaintext: string): Promise<string> {
233 if (this.encryptionMode === 'nip04') {
234 return nip04.encrypt(
235 this.connectionData.secret,
236 this.connectionData.walletPubkey,
237 plaintext
238 );
239 } else {
240 return nip44.v2.encrypt(plaintext, this.conversationKey);
241 }
242 }
243
244 /**
245 * Send a request to the wallet
246 */
247 private async sendRequest(
248 request: NwcRequest,
249 timeoutMs = 30000,
250 isRetry = false
251 ): Promise<NwcResponse> {
252 if (!this.isConnected()) {
253 await this.connect();
254 }
255
256 // Encrypt the request content
257 const plaintext = JSON.stringify(request);
258 this.log(
259 'info',
260 `Sending ${request.method} request (using ${this.encryptionMode.toUpperCase()})`
261 );
262 const ciphertext = await this.encryptContent(plaintext);
263
264 // Create the NIP-47 request event (kind 23194)
265 const eventTemplate = {
266 kind: 23194,
267 created_at: Math.floor(Date.now() / 1000),
268 tags: [['p', this.connectionData.walletPubkey]],
269 content: ciphertext,
270 };
271
272 // Sign with the client secret
273 const signedEvent = finalizeEvent(
274 eventTemplate,
275 NostrHelper.hex2bytes(this.connectionData.secret)
276 );
277
278 return new Promise((resolve, reject) => {
279 const timeout = setTimeout(() => {
280 this.pendingRequests.delete(signedEvent.id);
281 this.log('error', `Request timeout for ${request.method}`);
282 reject(new Error('Request timeout'));
283 }, timeoutMs);
284
285 this.pendingRequests.set(signedEvent.id, {
286 resolve,
287 reject,
288 timeout,
289 request,
290 isRetry,
291 });
292
293 // Send the event
294 this.ws!.send(JSON.stringify(['EVENT', signedEvent]));
295 });
296 }
297
298 /**
299 * Retry a request with NIP-04 encryption
300 */
301 private async retryWithNip04(request: NwcRequest): Promise<NwcResponse> {
302 this.log('warn', 'Retrying with NIP-04 encryption...');
303 this.encryptionMode = 'nip04';
304 return this.sendRequest(request, 30000, true);
305 }
306
307 /**
308 * Subscribe to response events from the wallet
309 */
310 private subscribe(): void {
311 if (!this.ws || !this.connected) {
312 return;
313 }
314
315 // Generate a subscription ID
316 this.subscriptionId = Math.random().toString(36).substring(2, 15);
317
318 // Subscribe to kind 23195 (response) events addressed to us
319 const filter = {
320 kinds: [23195],
321 '#p': [this.clientPubkey],
322 since: Math.floor(Date.now() / 1000) - 10, // Last 10 seconds
323 };
324
325 this.ws.send(JSON.stringify(['REQ', this.subscriptionId, filter]));
326 }
327
328 /**
329 * Handle incoming WebSocket messages
330 */
331 private handleMessage(data: string): void {
332 try {
333 const message = JSON.parse(data);
334
335 if (!Array.isArray(message)) {
336 return;
337 }
338
339 const [type, ...rest] = message;
340
341 switch (type) {
342 case 'EVENT':
343 this.handleEvent(rest[1]);
344 break;
345 case 'OK':
346 // Event was received by relay
347 break;
348 case 'EOSE':
349 // End of stored events
350 break;
351 case 'NOTICE':
352 this.log('warn', `Relay notice: ${rest[0]}`);
353 break;
354 }
355 } catch (error) {
356 this.log('error', `Error parsing message: ${(error as Error).message}`);
357 }
358 }
359
360 /**
361 * Check if an error indicates a decryption/encryption problem
362 */
363 private isEncryptionError(errorMsg: string): boolean {
364 const lowerMsg = errorMsg.toLowerCase();
365 return (
366 lowerMsg.includes('decrypt') ||
367 lowerMsg.includes('initialization vector') ||
368 lowerMsg.includes('iv') ||
369 lowerMsg.includes('encrypt') ||
370 lowerMsg.includes('cipher') ||
371 lowerMsg.includes('parse')
372 );
373 }
374
375 /**
376 * Handle an incoming event (response from wallet)
377 */
378 private async handleEvent(event: any): Promise<void> {
379 if (!event || event.kind !== 23195) {
380 return;
381 }
382
383 // Check if this event is from the wallet
384 if (event.pubkey !== this.connectionData.walletPubkey) {
385 return;
386 }
387
388 // Find the request ID from the 'e' tag
389 const eTag = event.tags?.find((t: string[]) => t[0] === 'e');
390 if (!eTag) {
391 return;
392 }
393
394 const requestId = eTag[1];
395 const pending = this.pendingRequests.get(requestId);
396
397 if (!pending) {
398 // Response for unknown request (might be old or from another session)
399 return;
400 }
401
402 // Clear the timeout and remove from pending
403 clearTimeout(pending.timeout);
404 this.pendingRequests.delete(requestId);
405
406 try {
407 // Try to decrypt the response
408 let decrypted: string;
409
410 // First, check if content looks like plain JSON (unencrypted error)
411 if (
412 event.content.startsWith('{') ||
413 event.content.startsWith('"')
414 ) {
415 // Might be unencrypted error response
416 try {
417 const parsed = JSON.parse(event.content);
418 // If it has an error field, this is an unencrypted error response
419 if (parsed.error) {
420 this.log(
421 'error',
422 `Wallet error: ${parsed.error.message || JSON.stringify(parsed.error)}`
423 );
424
425 // Check if it's an encryption error and we haven't retried yet
426 const errorMsg =
427 parsed.error.message || JSON.stringify(parsed.error);
428 if (
429 !pending.isRetry &&
430 this.encryptionMode === 'nip44' &&
431 this.isEncryptionError(errorMsg)
432 ) {
433 this.log(
434 'warn',
435 'Wallet returned encryption error, switching to NIP-04'
436 );
437 try {
438 const retryResponse = await this.retryWithNip04(pending.request);
439 pending.resolve(retryResponse);
440 return;
441 } catch (retryError) {
442 pending.reject(retryError as Error);
443 return;
444 }
445 }
446
447 pending.resolve(parsed as NwcResponse);
448 return;
449 }
450 } catch {
451 // Not valid JSON, continue with decryption
452 }
453 }
454
455 // Detect encryption format and decrypt
456 // NIP-04 format contains "?iv=" in the ciphertext
457 if (event.content.includes('?iv=')) {
458 this.log('info', 'Decrypting response (NIP-04 format)');
459 decrypted = await nip04.decrypt(
460 this.connectionData.secret,
461 this.connectionData.walletPubkey,
462 event.content
463 );
464 } else {
465 this.log('info', 'Decrypting response (NIP-44 format)');
466 try {
467 decrypted = nip44.v2.decrypt(event.content, this.conversationKey);
468 } catch (nip44Error) {
469 // NIP-44 decryption failed, maybe it's NIP-04 without standard format?
470 // Try NIP-04 as fallback
471 this.log(
472 'warn',
473 `NIP-44 decryption failed: ${(nip44Error as Error).message}, trying NIP-04...`
474 );
475 try {
476 decrypted = await nip04.decrypt(
477 this.connectionData.secret,
478 this.connectionData.walletPubkey,
479 event.content
480 );
481 } catch {
482 // Both failed, throw original error
483 throw nip44Error;
484 }
485 }
486 }
487
488 const response = JSON.parse(decrypted) as NwcResponse;
489
490 // Check if the decrypted response contains an encryption error
491 if (response.error) {
492 const errorMsg = response.error.message || '';
493 if (
494 !pending.isRetry &&
495 this.encryptionMode === 'nip44' &&
496 this.isEncryptionError(errorMsg)
497 ) {
498 this.log(
499 'warn',
500 `Wallet returned encryption error: ${errorMsg}, retrying with NIP-04`
501 );
502 try {
503 const retryResponse = await this.retryWithNip04(pending.request);
504 pending.resolve(retryResponse);
505 return;
506 } catch (retryError) {
507 pending.reject(retryError as Error);
508 return;
509 }
510 }
511 this.log('error', `Wallet error: ${errorMsg}`);
512 } else {
513 this.log('info', 'Request successful');
514 }
515
516 pending.resolve(response);
517 } catch (error) {
518 const errorMsg = (error as Error).message;
519 this.log('error', `Failed to decrypt response: ${errorMsg}`);
520
521 // If this is an encryption error and we haven't retried, try NIP-04
522 if (
523 !pending.isRetry &&
524 this.encryptionMode === 'nip44' &&
525 this.isEncryptionError(errorMsg)
526 ) {
527 this.log('warn', 'Decryption failed, retrying with NIP-04 encryption');
528 try {
529 const retryResponse = await this.retryWithNip04(pending.request);
530 pending.resolve(retryResponse);
531 return;
532 } catch (retryError) {
533 pending.reject(retryError as Error);
534 return;
535 }
536 }
537
538 pending.reject(new Error(`Failed to decrypt response: ${errorMsg}`));
539 }
540 }
541 }
542