nwc.ts raw
1 import {
2 CryptoHelper,
3 NwcConnection_DECRYPTED,
4 NwcConnection_ENCRYPTED,
5 StorageService,
6 } from '@common';
7 import { LockedVaultContext } from './identity';
8
9 /**
10 * Parse a nostr+walletconnect:// URL into its components
11 */
12 export function parseNwcUrl(url: string): {
13 walletPubkey: string;
14 relayUrl: string;
15 secret: string;
16 lud16?: string;
17 } | null {
18 try {
19 // Format: nostr+walletconnect://<pubkey>?relay=<url>&secret=<hex>&lud16=<optional>
20 const match = url.match(/^nostr\+walletconnect:\/\/([a-f0-9]{64})\?(.+)$/i);
21 if (!match) {
22 return null;
23 }
24
25 const walletPubkey = match[1].toLowerCase();
26 const params = new URLSearchParams(match[2]);
27
28 const relayUrl = params.get('relay');
29 const secret = params.get('secret');
30 const lud16 = params.get('lud16') || undefined;
31
32 if (!relayUrl || !secret) {
33 return null;
34 }
35
36 // Validate secret is 64-char hex
37 if (!/^[a-f0-9]{64}$/i.test(secret)) {
38 return null;
39 }
40
41 return {
42 walletPubkey,
43 relayUrl: decodeURIComponent(relayUrl),
44 secret: secret.toLowerCase(),
45 lud16,
46 };
47 } catch {
48 return null;
49 }
50 }
51
52 export const addNwcConnection = async function (
53 this: StorageService,
54 data: {
55 name: string;
56 connectionUrl: string;
57 }
58 ): Promise<void> {
59 this.assureIsInitialized();
60
61 // Parse the NWC URL
62 const parsed = parseNwcUrl(data.connectionUrl);
63 if (!parsed) {
64 throw new Error('Invalid NWC URL format');
65 }
66
67 // Check if a connection with the same wallet pubkey already exists
68 const existingConnection = (
69 this.getBrowserSessionHandler().browserSessionData?.nwcConnections ?? []
70 ).find((x) => x.walletPubkey === parsed.walletPubkey);
71 if (existingConnection) {
72 throw new Error(
73 `A connection to this wallet already exists: ${existingConnection.name}`
74 );
75 }
76
77 const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
78 if (!browserSessionData) {
79 throw new Error('Browser session data is undefined.');
80 }
81
82 const decryptedConnection: NwcConnection_DECRYPTED = {
83 id: CryptoHelper.v4(),
84 name: data.name,
85 connectionUrl: data.connectionUrl,
86 walletPubkey: parsed.walletPubkey,
87 relayUrl: parsed.relayUrl,
88 secret: parsed.secret,
89 lud16: parsed.lud16,
90 createdAt: new Date().toISOString(),
91 };
92
93 // Initialize array if needed
94 if (!browserSessionData.nwcConnections) {
95 browserSessionData.nwcConnections = [];
96 }
97
98 // Add the new connection to the session data
99 browserSessionData.nwcConnections.push(decryptedConnection);
100 this.getBrowserSessionHandler().saveFullData(browserSessionData);
101
102 // Encrypt the new connection and add it to the sync data
103 const encryptedConnection = await encryptNwcConnection.call(
104 this,
105 decryptedConnection
106 );
107 const encryptedConnections = [
108 ...(this.getBrowserSyncHandler().browserSyncData?.nwcConnections ?? []),
109 encryptedConnection,
110 ];
111
112 await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
113 nwcConnections: encryptedConnections,
114 });
115 };
116
117 export const deleteNwcConnection = async function (
118 this: StorageService,
119 connectionId: string
120 ): Promise<void> {
121 this.assureIsInitialized();
122
123 if (!connectionId) {
124 return;
125 }
126
127 const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
128 const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
129 if (!browserSessionData || !browserSyncData) {
130 throw new Error('Browser session or sync data is undefined.');
131 }
132
133 // Remove from session data
134 browserSessionData.nwcConnections = (
135 browserSessionData.nwcConnections ?? []
136 ).filter((x) => x.id !== connectionId);
137 await this.getBrowserSessionHandler().saveFullData(browserSessionData);
138
139 // Handle Sync data
140 const encryptedConnectionId = await this.encrypt(connectionId);
141 await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
142 nwcConnections: (browserSyncData.nwcConnections ?? []).filter(
143 (x) => x.id !== encryptedConnectionId
144 ),
145 });
146 };
147
148 export const updateNwcConnectionBalance = async function (
149 this: StorageService,
150 connectionId: string,
151 balanceMillisats: number
152 ): Promise<void> {
153 this.assureIsInitialized();
154
155 const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
156 const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
157 if (!browserSessionData || !browserSyncData) {
158 throw new Error('Browser session or sync data is undefined.');
159 }
160
161 const sessionConnection = (browserSessionData.nwcConnections ?? []).find(
162 (x) => x.id === connectionId
163 );
164 const encryptedConnectionId = await this.encrypt(connectionId);
165 const syncConnection = (browserSyncData.nwcConnections ?? []).find(
166 (x) => x.id === encryptedConnectionId
167 );
168
169 if (!sessionConnection || !syncConnection) {
170 throw new Error('NWC connection not found for balance update.');
171 }
172
173 const now = new Date().toISOString();
174
175 // Update session data
176 sessionConnection.cachedBalance = balanceMillisats;
177 sessionConnection.cachedBalanceAt = now;
178 await this.getBrowserSessionHandler().saveFullData(browserSessionData);
179
180 // Update sync data
181 syncConnection.cachedBalance = await this.encrypt(balanceMillisats.toString());
182 syncConnection.cachedBalanceAt = await this.encrypt(now);
183 await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
184 nwcConnections: browserSyncData.nwcConnections ?? [],
185 });
186 };
187
188 export const encryptNwcConnection = async function (
189 this: StorageService,
190 connection: NwcConnection_DECRYPTED
191 ): Promise<NwcConnection_ENCRYPTED> {
192 const encrypted: NwcConnection_ENCRYPTED = {
193 id: await this.encrypt(connection.id),
194 name: await this.encrypt(connection.name),
195 connectionUrl: await this.encrypt(connection.connectionUrl),
196 walletPubkey: await this.encrypt(connection.walletPubkey),
197 relayUrl: await this.encrypt(connection.relayUrl),
198 secret: await this.encrypt(connection.secret),
199 createdAt: await this.encrypt(connection.createdAt),
200 };
201
202 if (connection.lud16) {
203 encrypted.lud16 = await this.encrypt(connection.lud16);
204 }
205 if (connection.cachedBalance !== undefined) {
206 encrypted.cachedBalance = await this.encrypt(
207 connection.cachedBalance.toString()
208 );
209 }
210 if (connection.cachedBalanceAt) {
211 encrypted.cachedBalanceAt = await this.encrypt(connection.cachedBalanceAt);
212 }
213
214 return encrypted;
215 };
216
217 export const decryptNwcConnection = async function (
218 this: StorageService,
219 connection: NwcConnection_ENCRYPTED,
220 withLockedVault: LockedVaultContext | undefined = undefined
221 ): Promise<NwcConnection_DECRYPTED> {
222 if (typeof withLockedVault === 'undefined') {
223 // Normal decryption with unlocked vault
224 const decrypted: NwcConnection_DECRYPTED = {
225 id: await this.decrypt(connection.id, 'string'),
226 name: await this.decrypt(connection.name, 'string'),
227 connectionUrl: await this.decrypt(connection.connectionUrl, 'string'),
228 walletPubkey: await this.decrypt(connection.walletPubkey, 'string'),
229 relayUrl: await this.decrypt(connection.relayUrl, 'string'),
230 secret: await this.decrypt(connection.secret, 'string'),
231 createdAt: await this.decrypt(connection.createdAt, 'string'),
232 };
233
234 if (connection.lud16) {
235 decrypted.lud16 = await this.decrypt(connection.lud16, 'string');
236 }
237 if (connection.cachedBalance) {
238 decrypted.cachedBalance = await this.decrypt(
239 connection.cachedBalance,
240 'number'
241 );
242 }
243 if (connection.cachedBalanceAt) {
244 decrypted.cachedBalanceAt = await this.decrypt(
245 connection.cachedBalanceAt,
246 'string'
247 );
248 }
249
250 return decrypted;
251 }
252
253 // v2: Use pre-derived key
254 if (withLockedVault.keyBase64) {
255 const decrypted: NwcConnection_DECRYPTED = {
256 id: await this.decryptWithLockedVaultV2(
257 connection.id,
258 'string',
259 withLockedVault.iv,
260 withLockedVault.keyBase64
261 ),
262 name: await this.decryptWithLockedVaultV2(
263 connection.name,
264 'string',
265 withLockedVault.iv,
266 withLockedVault.keyBase64
267 ),
268 connectionUrl: await this.decryptWithLockedVaultV2(
269 connection.connectionUrl,
270 'string',
271 withLockedVault.iv,
272 withLockedVault.keyBase64
273 ),
274 walletPubkey: await this.decryptWithLockedVaultV2(
275 connection.walletPubkey,
276 'string',
277 withLockedVault.iv,
278 withLockedVault.keyBase64
279 ),
280 relayUrl: await this.decryptWithLockedVaultV2(
281 connection.relayUrl,
282 'string',
283 withLockedVault.iv,
284 withLockedVault.keyBase64
285 ),
286 secret: await this.decryptWithLockedVaultV2(
287 connection.secret,
288 'string',
289 withLockedVault.iv,
290 withLockedVault.keyBase64
291 ),
292 createdAt: await this.decryptWithLockedVaultV2(
293 connection.createdAt,
294 'string',
295 withLockedVault.iv,
296 withLockedVault.keyBase64
297 ),
298 };
299
300 if (connection.lud16) {
301 decrypted.lud16 = await this.decryptWithLockedVaultV2(
302 connection.lud16,
303 'string',
304 withLockedVault.iv,
305 withLockedVault.keyBase64
306 );
307 }
308 if (connection.cachedBalance) {
309 decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
310 connection.cachedBalance,
311 'number',
312 withLockedVault.iv,
313 withLockedVault.keyBase64
314 );
315 }
316 if (connection.cachedBalanceAt) {
317 decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
318 connection.cachedBalanceAt,
319 'string',
320 withLockedVault.iv,
321 withLockedVault.keyBase64
322 );
323 }
324
325 return decrypted;
326 }
327
328 // v1: Use password (PBKDF2)
329 const decrypted: NwcConnection_DECRYPTED = {
330 id: await this.decryptWithLockedVault(
331 connection.id,
332 'string',
333 withLockedVault.iv,
334 withLockedVault.password!
335 ),
336 name: await this.decryptWithLockedVault(
337 connection.name,
338 'string',
339 withLockedVault.iv,
340 withLockedVault.password!
341 ),
342 connectionUrl: await this.decryptWithLockedVault(
343 connection.connectionUrl,
344 'string',
345 withLockedVault.iv,
346 withLockedVault.password!
347 ),
348 walletPubkey: await this.decryptWithLockedVault(
349 connection.walletPubkey,
350 'string',
351 withLockedVault.iv,
352 withLockedVault.password!
353 ),
354 relayUrl: await this.decryptWithLockedVault(
355 connection.relayUrl,
356 'string',
357 withLockedVault.iv,
358 withLockedVault.password!
359 ),
360 secret: await this.decryptWithLockedVault(
361 connection.secret,
362 'string',
363 withLockedVault.iv,
364 withLockedVault.password!
365 ),
366 createdAt: await this.decryptWithLockedVault(
367 connection.createdAt,
368 'string',
369 withLockedVault.iv,
370 withLockedVault.password!
371 ),
372 };
373
374 if (connection.lud16) {
375 decrypted.lud16 = await this.decryptWithLockedVault(
376 connection.lud16,
377 'string',
378 withLockedVault.iv,
379 withLockedVault.password!
380 );
381 }
382 if (connection.cachedBalance) {
383 decrypted.cachedBalance = await this.decryptWithLockedVault(
384 connection.cachedBalance,
385 'number',
386 withLockedVault.iv,
387 withLockedVault.password!
388 );
389 }
390 if (connection.cachedBalanceAt) {
391 decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
392 connection.cachedBalanceAt,
393 'string',
394 withLockedVault.iv,
395 withLockedVault.password!
396 );
397 }
398
399 return decrypted;
400 };
401
402 export const decryptNwcConnections = async function (
403 this: StorageService,
404 connections: NwcConnection_ENCRYPTED[],
405 withLockedVault: LockedVaultContext | undefined = undefined
406 ): Promise<NwcConnection_DECRYPTED[]> {
407 const decryptedConnections: NwcConnection_DECRYPTED[] = [];
408
409 for (const connection of connections) {
410 const decryptedConnection = await decryptNwcConnection.call(
411 this,
412 connection,
413 withLockedVault
414 );
415 decryptedConnections.push(decryptedConnection);
416 }
417
418 return decryptedConnections;
419 };
420