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