nwc.service.ts raw

   1  import { Injectable } from '@angular/core';
   2  import { BehaviorSubject } from 'rxjs';
   3  import { StorageService, NwcConnection_DECRYPTED } from '@common';
   4  import { NwcClient, NwcConnectionData, NwcLogLevel, NwcLogCallback } from './nwc-client';
   5  import {
   6    NwcGetInfoResult,
   7    NwcPayInvoiceResult,
   8    NwcMakeInvoiceResult,
   9    NwcListTransactionsParams,
  10    NwcLookupInvoiceResult,
  11  } from './types';
  12  import { parseNwcUrl } from '../storage/related/nwc';
  13  
  14  export interface NwcLogEntry {
  15    timestamp: Date;
  16    level: NwcLogLevel;
  17    message: string;
  18  }
  19  
  20  interface CachedClient {
  21    client: NwcClient;
  22    connectionId: string;
  23  }
  24  
  25  /**
  26   * Angular service for managing NWC wallet connections
  27   */
  28  @Injectable({
  29    providedIn: 'root',
  30  })
  31  export class NwcService {
  32    private clients = new Map<string, CachedClient>();
  33    private _logs$ = new BehaviorSubject<NwcLogEntry[]>([]);
  34    private maxLogs = 100;
  35  
  36    /** Observable stream of NWC log entries */
  37    readonly logs$ = this._logs$.asObservable();
  38  
  39    constructor(private storageService: StorageService) {}
  40  
  41    /** Get current logs */
  42    get logs(): NwcLogEntry[] {
  43      return this._logs$.value;
  44    }
  45  
  46    /** Clear all logs */
  47    clearLogs(): void {
  48      this._logs$.next([]);
  49    }
  50  
  51    /** Add a log entry */
  52    private addLog(level: NwcLogLevel, message: string): void {
  53      const entry: NwcLogEntry = {
  54        timestamp: new Date(),
  55        level,
  56        message,
  57      };
  58      const logs = [entry, ...this._logs$.value].slice(0, this.maxLogs);
  59      this._logs$.next(logs);
  60    }
  61  
  62    /** Create a log callback for the NWC client */
  63    private createLogCallback(): NwcLogCallback {
  64      return (level: NwcLogLevel, message: string) => {
  65        this.addLog(level, message);
  66      };
  67    }
  68  
  69    /**
  70     * Parse and validate an NWC URL
  71     */
  72    parseNwcUrl(url: string): {
  73      walletPubkey: string;
  74      relayUrl: string;
  75      secret: string;
  76      lud16?: string;
  77    } | null {
  78      return parseNwcUrl(url);
  79    }
  80  
  81    /**
  82     * Get all NWC connections from storage
  83     */
  84    getConnections(): NwcConnection_DECRYPTED[] {
  85      const sessionData =
  86        this.storageService.getBrowserSessionHandler().browserSessionData;
  87      return sessionData?.nwcConnections ?? [];
  88    }
  89  
  90    /**
  91     * Get a single NWC connection by ID
  92     */
  93    getConnection(connectionId: string): NwcConnection_DECRYPTED | undefined {
  94      return this.getConnections().find((c) => c.id === connectionId);
  95    }
  96  
  97    /**
  98     * Add a new NWC connection
  99     */
 100    async addConnection(name: string, connectionUrl: string): Promise<void> {
 101      await this.storageService.addNwcConnection({ name, connectionUrl });
 102    }
 103  
 104    /**
 105     * Delete an NWC connection
 106     */
 107    async deleteConnection(connectionId: string): Promise<void> {
 108      // Disconnect and remove the client if it exists
 109      this.disconnectClient(connectionId);
 110      await this.storageService.deleteNwcConnection(connectionId);
 111    }
 112  
 113    /**
 114     * Get a connected client for a connection, creating it if necessary
 115     */
 116    private async getClient(connectionId: string): Promise<NwcClient> {
 117      // Check if we have a cached client
 118      const cached = this.clients.get(connectionId);
 119      if (cached && cached.client.isConnected()) {
 120        return cached.client;
 121      }
 122  
 123      // Get the connection data
 124      const connection = this.getConnection(connectionId);
 125      if (!connection) {
 126        throw new Error('Connection not found');
 127      }
 128  
 129      // Create a new client
 130      const connectionData: NwcConnectionData = {
 131        walletPubkey: connection.walletPubkey,
 132        relayUrl: connection.relayUrl,
 133        secret: connection.secret,
 134      };
 135  
 136      const client = new NwcClient(connectionData, this.createLogCallback());
 137      await client.connect();
 138  
 139      // Cache the client
 140      this.clients.set(connectionId, {
 141        client,
 142        connectionId,
 143      });
 144  
 145      return client;
 146    }
 147  
 148    /**
 149     * Disconnect a client
 150     */
 151    private disconnectClient(connectionId: string): void {
 152      const cached = this.clients.get(connectionId);
 153      if (cached) {
 154        cached.client.disconnect();
 155        this.clients.delete(connectionId);
 156      }
 157    }
 158  
 159    /**
 160     * Disconnect all clients
 161     */
 162    disconnectAll(): void {
 163      for (const cached of this.clients.values()) {
 164        cached.client.disconnect();
 165      }
 166      this.clients.clear();
 167    }
 168  
 169    /**
 170     * Get wallet info for a connection
 171     */
 172    async getInfo(connectionId: string): Promise<NwcGetInfoResult> {
 173      const client = await this.getClient(connectionId);
 174      return client.getInfo();
 175    }
 176  
 177    /**
 178     * Get balance for a connection (in millisatoshis)
 179     */
 180    async getBalance(connectionId: string): Promise<number> {
 181      const client = await this.getClient(connectionId);
 182      const result = await client.getBalance();
 183  
 184      // Update the cached balance in storage
 185      await this.storageService.updateNwcConnectionBalance(
 186        connectionId,
 187        result.balance
 188      );
 189  
 190      return result.balance;
 191    }
 192  
 193    /**
 194     * Get balances for all connections
 195     * Returns a map of connectionId -> balance in millisatoshis
 196     */
 197    async getAllBalances(): Promise<Map<string, number>> {
 198      const balances = new Map<string, number>();
 199      const connections = this.getConnections();
 200  
 201      const results = await Promise.allSettled(
 202        connections.map(async (conn) => {
 203          try {
 204            const balance = await this.getBalance(conn.id);
 205            return { id: conn.id, balance };
 206          } catch (error) {
 207            // Return cached balance if available
 208            if (conn.cachedBalance !== undefined) {
 209              return { id: conn.id, balance: conn.cachedBalance };
 210            }
 211            throw error;
 212          }
 213        })
 214      );
 215  
 216      for (const result of results) {
 217        if (result.status === 'fulfilled') {
 218          balances.set(result.value.id, result.value.balance);
 219        }
 220      }
 221  
 222      return balances;
 223    }
 224  
 225    /**
 226     * Get total balance across all connections (in millisatoshis)
 227     */
 228    async getTotalBalance(): Promise<number> {
 229      const balances = await this.getAllBalances();
 230      let total = 0;
 231      for (const balance of balances.values()) {
 232        total += balance;
 233      }
 234      return total;
 235    }
 236  
 237    /**
 238     * Get cached total balance (without making network requests)
 239     */
 240    getCachedTotalBalance(): number {
 241      const connections = this.getConnections();
 242      let total = 0;
 243      for (const conn of connections) {
 244        if (conn.cachedBalance !== undefined) {
 245          total += conn.cachedBalance;
 246        }
 247      }
 248      return total;
 249    }
 250  
 251    /**
 252     * Pay a Lightning invoice
 253     */
 254    async payInvoice(
 255      connectionId: string,
 256      invoice: string,
 257      amountMsat?: number
 258    ): Promise<NwcPayInvoiceResult> {
 259      const client = await this.getClient(connectionId);
 260      const result = await client.payInvoice({
 261        invoice,
 262        amount: amountMsat,
 263      });
 264  
 265      // Refresh balance after payment
 266      try {
 267        await this.getBalance(connectionId);
 268      } catch {
 269        // Ignore balance refresh errors
 270      }
 271  
 272      return result;
 273    }
 274  
 275    /**
 276     * Create a Lightning invoice
 277     */
 278    async makeInvoice(
 279      connectionId: string,
 280      amountMsat: number,
 281      description?: string
 282    ): Promise<NwcMakeInvoiceResult> {
 283      const client = await this.getClient(connectionId);
 284      return client.makeInvoice({
 285        amount: amountMsat,
 286        description,
 287      });
 288    }
 289  
 290    /**
 291     * List transaction history
 292     */
 293    async listTransactions(
 294      connectionId: string,
 295      params?: NwcListTransactionsParams
 296    ): Promise<NwcLookupInvoiceResult[]> {
 297      const client = await this.getClient(connectionId);
 298      const result = await client.listTransactions(params);
 299      return result.transactions;
 300    }
 301  
 302    /**
 303     * Resolve a Lightning Address (user@domain.com) to a bolt11 invoice
 304     * Uses LNURL-pay protocol
 305     */
 306    async resolveLightningAddress(
 307      address: string,
 308      amountMsat: number
 309    ): Promise<string> {
 310      // Parse lightning address
 311      const match = address.match(/^([^@]+)@([^@]+)$/);
 312      if (!match) {
 313        throw new Error('Invalid lightning address format');
 314      }
 315  
 316      const [, name, domain] = match;
 317  
 318      // Fetch LNURL-pay endpoint
 319      const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${name}`;
 320      this.addLog('info', `Fetching LNURL-pay from ${domain}...`);
 321  
 322      const response = await fetch(lnurlpUrl);
 323      if (!response.ok) {
 324        throw new Error(`Failed to fetch LNURL-pay: ${response.status}`);
 325      }
 326  
 327      const lnurlpData = await response.json();
 328  
 329      // Validate response
 330      if (lnurlpData.status === 'ERROR') {
 331        throw new Error(lnurlpData.reason || 'LNURL-pay error');
 332      }
 333  
 334      if (!lnurlpData.callback) {
 335        throw new Error('Invalid LNURL-pay response: missing callback');
 336      }
 337  
 338      // Check amount bounds
 339      const minSendable = lnurlpData.minSendable || 1000;
 340      const maxSendable = lnurlpData.maxSendable || 100000000000;
 341  
 342      if (amountMsat < minSendable) {
 343        throw new Error(
 344          `Amount too small. Minimum: ${Math.ceil(minSendable / 1000)} sats`
 345        );
 346      }
 347  
 348      if (amountMsat > maxSendable) {
 349        throw new Error(
 350          `Amount too large. Maximum: ${Math.floor(maxSendable / 1000)} sats`
 351        );
 352      }
 353  
 354      // Request invoice from callback
 355      const callbackUrl = new URL(lnurlpData.callback);
 356      callbackUrl.searchParams.set('amount', amountMsat.toString());
 357  
 358      this.addLog('info', 'Requesting invoice...');
 359      const invoiceResponse = await fetch(callbackUrl.toString());
 360      if (!invoiceResponse.ok) {
 361        throw new Error(`Failed to get invoice: ${invoiceResponse.status}`);
 362      }
 363  
 364      const invoiceData = await invoiceResponse.json();
 365  
 366      if (invoiceData.status === 'ERROR') {
 367        throw new Error(invoiceData.reason || 'Failed to get invoice');
 368      }
 369  
 370      if (!invoiceData.pr) {
 371        throw new Error('Invalid invoice response: missing payment request');
 372      }
 373  
 374      this.addLog('info', 'Invoice received');
 375      return invoiceData.pr;
 376    }
 377  
 378    /**
 379     * Check if a string is a lightning address (user@domain)
 380     */
 381    isLightningAddress(input: string): boolean {
 382      return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input);
 383    }
 384  
 385    /**
 386     * Check if a string is a bolt11 invoice
 387     */
 388    isBolt11Invoice(input: string): boolean {
 389      return /^ln(bc|tb|tbs)[0-9a-z]+$/i.test(input.toLowerCase());
 390    }
 391  
 392    /**
 393     * Test a connection by getting wallet info
 394     */
 395    async testConnection(connectionUrl: string): Promise<NwcGetInfoResult> {
 396      this.addLog('info', 'Testing NWC connection...');
 397      const parsed = this.parseNwcUrl(connectionUrl);
 398      if (!parsed) {
 399        this.addLog('error', 'Invalid NWC URL');
 400        throw new Error('Invalid NWC URL');
 401      }
 402  
 403      const client = new NwcClient(parsed, this.createLogCallback());
 404      try {
 405        await client.connect();
 406        const info = await client.getInfo();
 407        this.addLog('info', `Connection test successful: ${info.alias || 'wallet'}`);
 408        return info;
 409      } catch (error) {
 410        this.addLog('error', `Connection test failed: ${(error as Error).message}`);
 411        throw error;
 412      } finally {
 413        client.disconnect();
 414      }
 415    }
 416  }
 417