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