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