@dynamic-labs-wallet/browser 0.0.338 → 0.0.340

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -356,6 +356,21 @@ class InvalidPasswordError extends Error {
356
356
  };
357
357
 
358
358
  const GOOGLE_DRIVE_UPLOAD_API = 'https://www.googleapis.com';
359
+ const USER_ACTIONABLE_REASONS = new Set([
360
+ 'auth_denied',
361
+ 'storage_full',
362
+ // Google Drive credentials are provided per-customer (each project's own
363
+ // Google Cloud OAuth app), so 429s hit the customer's project quota, not
364
+ // Dynamic's. From an oncall perspective there is nothing to act on -- the
365
+ // end user just needs to wait and retry.
366
+ 'rate_limited'
367
+ ]);
368
+ /**
369
+ * Returns true when the failure reason represents a problem the end user
370
+ * has to wait out or resolve themselves (e.g. grant Drive permission, free
371
+ * up storage, wait for a per-user/per-project rate limit to clear) and not
372
+ * a system-side issue oncall should investigate.
373
+ */ const isUserActionableGoogleDriveBackupErrorReason = (reason)=>USER_ACTIONABLE_REASONS.has(reason);
359
374
  const NON_RETRYABLE_AUTH_REASONS = new Set([
360
375
  'insufficientPermissions',
361
376
  'unauthorized',
@@ -373,7 +388,8 @@ const RATE_LIMIT_REASONS = new Set([
373
388
  ]);
374
389
  const NETWORK_ERROR = {
375
390
  message: 'Could not reach Google Drive. Please check your internet connection and try again.',
376
- isRetryable: true
391
+ isRetryable: true,
392
+ errorReason: 'network'
377
393
  };
378
394
  /**
379
395
  * Maps a Google Drive API error to an actionable user-facing message and a retry hint.
@@ -386,31 +402,36 @@ const NETWORK_ERROR = {
386
402
  if (httpStatus === 401 || reason && NON_RETRYABLE_AUTH_REASONS.has(reason) || detailReason && NON_RETRYABLE_AUTH_DETAIL_REASONS.has(detailReason)) {
387
403
  return {
388
404
  message: 'Google Drive access denied: missing or insufficient permissions. Make sure the app is requesting Drive permission and that you allowed it during sign-in, then try again.',
389
- isRetryable: false
405
+ isRetryable: false,
406
+ errorReason: 'auth_denied'
390
407
  };
391
408
  }
392
409
  if (reason === 'storageQuotaExceeded') {
393
410
  return {
394
411
  message: 'Google Drive storage is full. Free up space in your Google Drive and try again.',
395
- isRetryable: false
412
+ isRetryable: false,
413
+ errorReason: 'storage_full'
396
414
  };
397
415
  }
398
416
  if (httpStatus === 429 || reason && RATE_LIMIT_REASONS.has(reason)) {
399
417
  return {
400
418
  message: 'Google Drive is temporarily rate-limited. Please try again in a few moments.',
401
- isRetryable: true
419
+ isRetryable: true,
420
+ errorReason: 'rate_limited'
402
421
  };
403
422
  }
404
423
  if (httpStatus >= 500 && httpStatus < 600) {
405
424
  return {
406
425
  message: 'Google Drive is temporarily unavailable. Please try again shortly.',
407
- isRetryable: true
426
+ isRetryable: true,
427
+ errorReason: 'service_unavailable'
408
428
  };
409
429
  }
410
430
  const detail = reason != null ? reason : error == null ? void 0 : error.status;
411
431
  return {
412
432
  message: detail ? `Google Drive upload failed (HTTP ${httpStatus}: ${detail}).` : `Google Drive upload failed (HTTP ${httpStatus}).`,
413
- isRetryable: false
433
+ isRetryable: false,
434
+ errorReason: 'unknown'
414
435
  };
415
436
  };
416
437
  const parseGoogleDriveErrorBody = async (response)=>{
@@ -423,6 +444,7 @@ const parseGoogleDriveErrorBody = async (response)=>{
423
444
  const createGoogleDriveError = (mapped)=>{
424
445
  const error = new Error(mapped.message);
425
446
  error.isRetryable = mapped.isRetryable;
447
+ error.errorReason = mapped.errorReason;
426
448
  return error;
427
449
  };
428
450
  const uploadFileToGoogleDriveAppStorage = async ({ accessToken, fileName, jsonData })=>{
@@ -587,6 +609,62 @@ const ERROR_REFRESH_NOT_SUPPORTED_FOR_TWO_OF_THREE = '[DynamicWaasWalletClient]:
587
609
  * resolve to the same wallet entry.
588
610
  */ const normalizeAddress = (address)=>address.toLowerCase();
589
611
 
612
+ const ERROR_PASSWORD_MISMATCH = '[DynamicWaasWalletClient]: Password does not match the password used for existing wallets. All wallets must use the same password for encrypted backups.';
613
+ const ERROR_PASSWORD_REQUIRED_FOR_ENCRYPTED_WALLET = '[DynamicWaasWalletClient]: Password is required for refresh/reshare of a password-encrypted wallet.';
614
+ const ERROR_EXISTING_PASSWORD_REQUIRED = '[DynamicWaasWalletClient]: Existing password is required to update the password for encrypted wallets.';
615
+ const ERROR_WALLETS_ALREADY_ENCRYPTED = '[DynamicWaasWalletClient]: Cannot set password: wallets are already password-encrypted. Use updatePassword instead.';
616
+ /**
617
+ * Checks if password consistency validation is needed.
618
+ * Returns false if no password provided or password is the environmentId default.
619
+ */ function shouldValidatePassword(password, environmentId) {
620
+ if (!password || password === environmentId) {
621
+ return false;
622
+ }
623
+ return true;
624
+ }
625
+ /**
626
+ * Finds the first password-encrypted wallet in the walletMap.
627
+ * Returns [normalizedAddress, walletProperties] or undefined.
628
+ */ function findPasswordEncryptedWallet(walletMap) {
629
+ for (const [address, wallet] of Object.entries(walletMap)){
630
+ var _wallet_clientKeySharesBackupInfo;
631
+ if ((_wallet_clientKeySharesBackupInfo = wallet.clientKeySharesBackupInfo) == null ? void 0 : _wallet_clientKeySharesBackupInfo.passwordEncrypted) {
632
+ return [
633
+ address,
634
+ wallet
635
+ ];
636
+ }
637
+ }
638
+ return undefined;
639
+ }
640
+ /**
641
+ * Extracts the first encrypted key share credential from cached encrypted data.
642
+ * Returns the base64-encoded encrypted credential string, or undefined if none found.
643
+ */ function extractFirstEncryptedCredential(encryptedData) {
644
+ try {
645
+ const parsed = JSON.parse(encryptedData);
646
+ const keyShares = parsed == null ? void 0 : parsed.keyShares;
647
+ if (!Array.isArray(keyShares) || keyShares.length === 0) {
648
+ return undefined;
649
+ }
650
+ return keyShares[0].encryptedAccountCredential;
651
+ } catch (e) {
652
+ return undefined;
653
+ }
654
+ }
655
+ /**
656
+ * Checks if a wallet uses password-based encryption for its key share backups.
657
+ */ function isWalletPasswordEncrypted(wallet) {
658
+ var _wallet_clientKeySharesBackupInfo;
659
+ var _wallet_clientKeySharesBackupInfo_passwordEncrypted;
660
+ return (_wallet_clientKeySharesBackupInfo_passwordEncrypted = wallet == null ? void 0 : (_wallet_clientKeySharesBackupInfo = wallet.clientKeySharesBackupInfo) == null ? void 0 : _wallet_clientKeySharesBackupInfo.passwordEncrypted) != null ? _wallet_clientKeySharesBackupInfo_passwordEncrypted : false;
661
+ }
662
+ /**
663
+ * Checks if an error indicates a password mismatch (wrong password during decryption).
664
+ */ function isPasswordMismatchError(error) {
665
+ return error instanceof InvalidPasswordError;
666
+ }
667
+
590
668
  const isBrowser = ()=>typeof window !== 'undefined';
591
669
  /**
592
670
  * Helper function to extract pubkey from potentially nested structure
@@ -644,36 +722,6 @@ const getClientKeyShareBackupInfo = (params)=>{
644
722
  passwordEncrypted
645
723
  };
646
724
  };
647
- /**
648
- * Helper function to merge keyshares and remove duplicates based on pubkey and secretShare
649
- * @param existingKeyShares - Array of existing keyshares
650
- * @param newKeyShares - Array of new keyshares to merge
651
- * @returns Array of merged unique keyshares
652
- */ const mergeUniqueKeyShares = (existingKeyShares, newKeyShares)=>{
653
- const hasDegeneratePubkeyToString = [
654
- ...existingKeyShares,
655
- ...newKeyShares
656
- ].some((share)=>{
657
- var _share_pubkey;
658
- return 'pubkey' in share && ((_share_pubkey = share.pubkey) == null ? void 0 : _share_pubkey.toString()) === '[object Object]' // NOSONAR
659
- ;
660
- });
661
- if (hasDegeneratePubkeyToString) {
662
- Logger.warn('[mergeUniqueKeyShares] pubkey.toString() returned "[object Object]" — dedup falls back to secretShare-only comparison', {
663
- existingCount: existingKeyShares.length,
664
- newCount: newKeyShares.length
665
- });
666
- }
667
- const uniqueKeyShares = newKeyShares.filter((newShare)=>!existingKeyShares.some((existingShare)=>{
668
- if (!('pubkey' in newShare) || !('pubkey' in existingShare)) return false;
669
- if (!newShare.pubkey || !existingShare.pubkey) return false;
670
- return newShare.pubkey.toString() === existingShare.pubkey.toString() && newShare.secretShare === existingShare.secretShare;
671
- }));
672
- return [
673
- ...existingKeyShares,
674
- ...uniqueKeyShares
675
- ];
676
- };
677
725
  const timeoutPromise = ({ timeInMs, activity = 'Ceremony' })=>{
678
726
  return new Promise((_, reject)=>setTimeout(()=>reject(new Error(`${activity} did not complete in ${timeInMs}ms`)), timeInMs));
679
727
  };
@@ -743,6 +791,31 @@ const logRetryExhausted = (operationName, maxAttempts, errorContext, logContext)
743
791
  // TypeScript needs this even though it's unreachable
744
792
  throw new Error('Unreachable code');
745
793
  }
794
+ /**
795
+ * Classifies whether a wallet-creation / wallet-import ceremony error is
796
+ * worth retrying. Returns true for terminal errors that won't get better on
797
+ * retry (wrong password, missing passcode, public-key mismatch, etc.) so the
798
+ * caller can mark them with `isRetryable: false` and short-circuit
799
+ * `retryPromise`.
800
+ */ const isNonRetryableCeremonyError = (error)=>{
801
+ if (!error) return false;
802
+ if ((error == null ? void 0 : error.isRetryable) === false) return true;
803
+ if (isPasswordMismatchError(error)) return true;
804
+ if (error instanceof InvalidPasswordError) return true;
805
+ const message = error instanceof Error ? error.message : '';
806
+ if (!message) return false;
807
+ if (message.includes(ERROR_PASSCODE_REQUIRED)) return true;
808
+ if (message.includes(ERROR_PUBLIC_KEY_MISMATCH)) return true;
809
+ return false;
810
+ };
811
+ /**
812
+ * Marks an error as non-retryable in place so `retryPromise` will skip
813
+ * remaining attempts. Used at ceremony retry boundaries.
814
+ */ const markCeremonyErrorNonRetryable = (error)=>{
815
+ if (error && typeof error === 'object' && isNonRetryableCeremonyError(error)) {
816
+ error.isRetryable = false;
817
+ }
818
+ };
746
819
  const formatEvmMessage = (message)=>{
747
820
  if (typeof message === 'string' && message.startsWith('0x')) {
748
821
  const serializedTxBytes = Uint8Array.from(Buffer.from(message.slice(2), 'hex'));
@@ -1251,21 +1324,25 @@ const initializeCloudKit = async (config, signInButtonId, onSignInRequired, onSi
1251
1324
  * Processes a single upload result and logs appropriately
1252
1325
  * @returns Failure details if rejected, undefined if successful
1253
1326
  */ const processUploadResult = (result, locationName, logContext, logger)=>{
1254
- var _result_reason;
1255
1327
  if (result.status === 'fulfilled') {
1256
1328
  logger.info(`[DynamicWaasWalletClient] Successfully uploaded keyshares to ${locationName}`, logContext);
1257
1329
  return undefined;
1258
1330
  }
1259
1331
  const { message, stack } = getErrorDetails(result.reason);
1260
- const isRetryable = ((_result_reason = result.reason) == null ? void 0 : _result_reason.isRetryable) !== false;
1332
+ const reasonProps = result.reason;
1333
+ const isRetryable = (reasonProps == null ? void 0 : reasonProps.isRetryable) !== false;
1334
+ var _reasonProps_errorReason;
1335
+ const errorReason = (_reasonProps_errorReason = reasonProps == null ? void 0 : reasonProps.errorReason) != null ? _reasonProps_errorReason : 'unknown';
1261
1336
  logger.error(`[DynamicWaasWalletClient] Failed to upload keyshares to ${locationName}`, _extends({}, logContext, {
1262
1337
  error: message,
1263
- errorStack: stack
1338
+ errorStack: stack,
1339
+ errorReason
1264
1340
  }));
1265
1341
  return {
1266
1342
  locationName,
1267
1343
  message,
1268
- isRetryable
1344
+ isRetryable,
1345
+ errorReason
1269
1346
  };
1270
1347
  };
1271
1348
  /**
@@ -1311,8 +1388,24 @@ const initializeCloudKit = async (config, signInButtonId, onSignInRequired, onSi
1311
1388
  processUploadResult(results[1], 'Google Drive Personal', logContext, logger)
1312
1389
  ].filter((failure)=>failure !== undefined);
1313
1390
  if (failures.length > 0) {
1314
- logger.error('[DynamicWaasWalletClient] Google Drive backup failed', _extends({}, logContext, {
1391
+ const uniqueReasons = [
1392
+ ...new Set(failures.map((f)=>f.errorReason))
1393
+ ];
1394
+ const isUserActionable = failures.every((f)=>isUserActionableGoogleDriveBackupErrorReason(f.errorReason));
1395
+ // OAuth scope and storage-quota failures are end-user issues (grant
1396
+ // Drive permission, free up storage) — not anything oncall can fix.
1397
+ // Downgrade them to warn so monitors can filter on status:error and
1398
+ // still catch real system-side failures (5xx/network/unknown) without
1399
+ // the user-side noise drowning out the signal.
1400
+ const logFn = isUserActionable ? logger.warn : logger.error;
1401
+ // When every location failed for the same reason (the dominant case)
1402
+ // surface a single string; otherwise emit the array so consumers can
1403
+ // see the mix.
1404
+ const logErrorReason = uniqueReasons.length === 1 ? uniqueReasons[0] : uniqueReasons;
1405
+ logFn.call(logger, '[DynamicWaasWalletClient] Google Drive backup failed', _extends({}, logContext, {
1315
1406
  errorCount: failures.length,
1407
+ errorReason: logErrorReason,
1408
+ isUserActionable,
1316
1409
  errors: failures.map((f)=>`Failed to backup keyshares to ${f.locationName}: ${f.message}`)
1317
1410
  }));
1318
1411
  // Both upload destinations (appDataFolder + personal) commonly fail with
@@ -1326,67 +1419,12 @@ const initializeCloudKit = async (config, signInButtonId, onSignInRequired, onSi
1326
1419
  const finalMessage = uniqueMessages.length === 1 ? uniqueMessages[0] : failures.map((f)=>`${f.locationName}: ${f.message}`).join(' | ');
1327
1420
  const aggregatedError = new Error(finalMessage);
1328
1421
  aggregatedError.isRetryable = failures.every((f)=>f.isRetryable);
1422
+ aggregatedError.errorReason = uniqueReasons.length === 1 ? uniqueReasons[0] : 'unknown';
1329
1423
  throw aggregatedError;
1330
1424
  }
1331
1425
  logger.info('[DynamicWaasWalletClient] Google Drive backup completed successfully', logContext);
1332
1426
  };
1333
1427
 
1334
- const ERROR_PASSWORD_MISMATCH = '[DynamicWaasWalletClient]: Password does not match the password used for existing wallets. All wallets must use the same password for encrypted backups.';
1335
- const ERROR_PASSWORD_REQUIRED_FOR_ENCRYPTED_WALLET = '[DynamicWaasWalletClient]: Password is required for refresh/reshare of a password-encrypted wallet.';
1336
- const ERROR_EXISTING_PASSWORD_REQUIRED = '[DynamicWaasWalletClient]: Existing password is required to update the password for encrypted wallets.';
1337
- const ERROR_WALLETS_ALREADY_ENCRYPTED = '[DynamicWaasWalletClient]: Cannot set password: wallets are already password-encrypted. Use updatePassword instead.';
1338
- /**
1339
- * Checks if password consistency validation is needed.
1340
- * Returns false if no password provided or password is the environmentId default.
1341
- */ function shouldValidatePassword(password, environmentId) {
1342
- if (!password || password === environmentId) {
1343
- return false;
1344
- }
1345
- return true;
1346
- }
1347
- /**
1348
- * Finds the first password-encrypted wallet in the walletMap.
1349
- * Returns [normalizedAddress, walletProperties] or undefined.
1350
- */ function findPasswordEncryptedWallet(walletMap) {
1351
- for (const [address, wallet] of Object.entries(walletMap)){
1352
- var _wallet_clientKeySharesBackupInfo;
1353
- if ((_wallet_clientKeySharesBackupInfo = wallet.clientKeySharesBackupInfo) == null ? void 0 : _wallet_clientKeySharesBackupInfo.passwordEncrypted) {
1354
- return [
1355
- address,
1356
- wallet
1357
- ];
1358
- }
1359
- }
1360
- return undefined;
1361
- }
1362
- /**
1363
- * Extracts the first encrypted key share credential from cached encrypted data.
1364
- * Returns the base64-encoded encrypted credential string, or undefined if none found.
1365
- */ function extractFirstEncryptedCredential(encryptedData) {
1366
- try {
1367
- const parsed = JSON.parse(encryptedData);
1368
- const keyShares = parsed == null ? void 0 : parsed.keyShares;
1369
- if (!Array.isArray(keyShares) || keyShares.length === 0) {
1370
- return undefined;
1371
- }
1372
- return keyShares[0].encryptedAccountCredential;
1373
- } catch (e) {
1374
- return undefined;
1375
- }
1376
- }
1377
- /**
1378
- * Checks if a wallet uses password-based encryption for its key share backups.
1379
- */ function isWalletPasswordEncrypted(wallet) {
1380
- var _wallet_clientKeySharesBackupInfo;
1381
- var _wallet_clientKeySharesBackupInfo_passwordEncrypted;
1382
- return (_wallet_clientKeySharesBackupInfo_passwordEncrypted = wallet == null ? void 0 : (_wallet_clientKeySharesBackupInfo = wallet.clientKeySharesBackupInfo) == null ? void 0 : _wallet_clientKeySharesBackupInfo.passwordEncrypted) != null ? _wallet_clientKeySharesBackupInfo_passwordEncrypted : false;
1383
- }
1384
- /**
1385
- * Checks if an error indicates a password mismatch (wrong password during decryption).
1386
- */ function isPasswordMismatchError(error) {
1387
- return error instanceof InvalidPasswordError;
1388
- }
1389
-
1390
1428
  /**
1391
1429
  * Returns true for errors that represent expected user-facing conditions
1392
1430
  * (e.g. wrong password). These are logged at `info` instead of `error`
@@ -2314,7 +2352,18 @@ class DynamicWalletClient {
2314
2352
  clientKeygenResults
2315
2353
  };
2316
2354
  }
2317
- async keyGen({ chainName, thresholdSignatureScheme, bitcoinConfig, onError, onCeremonyComplete, traceContext, password, signedSessionId }) {
2355
+ async keyGen(args) {
2356
+ return retryPromise(()=>this.runKeyGenAttempt(args), {
2357
+ maxAttempts: 5,
2358
+ retryInterval: 500,
2359
+ operationName: 'keyGen',
2360
+ logContext: _extends({
2361
+ chainName: args.chainName,
2362
+ thresholdSignatureScheme: args.thresholdSignatureScheme
2363
+ }, this.getTraceContext(args.traceContext))
2364
+ });
2365
+ }
2366
+ async runKeyGenAttempt({ chainName, thresholdSignatureScheme, bitcoinConfig, onCeremonyComplete, traceContext, password, signedSessionId }) {
2318
2367
  const dynamicRequestId = v4();
2319
2368
  try {
2320
2369
  this.assertPasswordRequired(password);
@@ -2376,6 +2425,7 @@ class DynamicWalletClient {
2376
2425
  clientKeyShares
2377
2426
  };
2378
2427
  } catch (error) {
2428
+ markCeremonyErrorNonRetryable(error);
2379
2429
  logError({
2380
2430
  message: 'Error in keyGen',
2381
2431
  error: error,
@@ -2388,7 +2438,18 @@ class DynamicWalletClient {
2388
2438
  throw error;
2389
2439
  }
2390
2440
  }
2391
- async importRawPrivateKey({ chainName, privateKey, thresholdSignatureScheme, bitcoinConfig, onError, onCeremonyComplete, traceContext, legacyWalletId, password, signedSessionId }) {
2441
+ async importRawPrivateKey(args) {
2442
+ return retryPromise(()=>this.runImportRawPrivateKeyAttempt(args), {
2443
+ maxAttempts: 5,
2444
+ retryInterval: 500,
2445
+ operationName: 'importRawPrivateKey',
2446
+ logContext: _extends({
2447
+ chainName: args.chainName,
2448
+ thresholdSignatureScheme: args.thresholdSignatureScheme
2449
+ }, this.getTraceContext(args.traceContext))
2450
+ });
2451
+ }
2452
+ async runImportRawPrivateKeyAttempt({ chainName, privateKey, thresholdSignatureScheme, bitcoinConfig, onError, onCeremonyComplete, traceContext, legacyWalletId, password, signedSessionId }) {
2392
2453
  const dynamicRequestId = v4();
2393
2454
  try {
2394
2455
  this.assertPasswordRequired(password);
@@ -2474,6 +2535,7 @@ class DynamicWalletClient {
2474
2535
  clientKeyShares: clientKeygenResults
2475
2536
  };
2476
2537
  } catch (error) {
2538
+ markCeremonyErrorNonRetryable(error);
2477
2539
  logError({
2478
2540
  message: 'Error in importRawPrivateKey',
2479
2541
  error: error,
@@ -2674,11 +2736,9 @@ class DynamicWalletClient {
2674
2736
  mfaToken,
2675
2737
  storeRecoveredShares: false
2676
2738
  });
2677
- // Overwrite local key shares with recovered ones (not merge)
2678
2739
  await this.setClientKeySharesToStorage({
2679
2740
  accountAddress,
2680
- clientKeyShares: recoveredKeyShares,
2681
- overwriteOrMerge: 'overwrite'
2741
+ clientKeyShares: recoveredKeyShares
2682
2742
  });
2683
2743
  this.logger.info('[DynamicWaasWalletClient] Key shares recovered, retrying operation', _extends({
2684
2744
  accountAddress,
@@ -3012,8 +3072,7 @@ class DynamicWalletClient {
3012
3072
  });
3013
3073
  await this.setClientKeySharesToStorage({
3014
3074
  accountAddress,
3015
- clientKeyShares: refreshResults,
3016
- overwriteOrMerge: 'overwrite'
3075
+ clientKeyShares: refreshResults
3017
3076
  });
3018
3077
  } catch (error) {
3019
3078
  // Expected condition — not a server error, no need to page on this.
@@ -3368,8 +3427,7 @@ class DynamicWalletClient {
3368
3427
  // only after backup succeeds.
3369
3428
  await this.setClientKeySharesToStorage({
3370
3429
  accountAddress,
3371
- clientKeyShares: distribution.clientShares,
3372
- overwriteOrMerge: 'overwrite'
3430
+ clientKeyShares: distribution.clientShares
3373
3431
  });
3374
3432
  } catch (error) {
3375
3433
  logError({
@@ -3392,8 +3450,7 @@ class DynamicWalletClient {
3392
3450
  });
3393
3451
  await this.setClientKeySharesToStorage({
3394
3452
  accountAddress,
3395
- clientKeyShares: [],
3396
- overwriteOrMerge: 'overwrite'
3453
+ clientKeyShares: []
3397
3454
  });
3398
3455
  throw error;
3399
3456
  }
@@ -3739,63 +3796,44 @@ class DynamicWalletClient {
3739
3796
  * Helper function to store client key shares in storage.
3740
3797
  * Uses secureStorage when available (mobile), otherwise falls back to localStorage (browser).
3741
3798
  */ /**
3742
- * Resolves the final array of shares to persist by either replacing
3743
- * (overwrite) or merging with existing shares (merge), and emits a
3744
- * persistence-info log so we can audit storage operations from traces.
3745
- */ async resolveSharesToPersist({ accountAddress, clientKeyShares, overwriteOrMerge, source, readExisting }) {
3746
- let sharesToStore;
3747
- let existingCount = 0;
3748
- if (overwriteOrMerge === 'overwrite') {
3749
- sharesToStore = clientKeyShares;
3750
- } else {
3751
- const existing = await readExisting();
3752
- existingCount = existing.length;
3753
- sharesToStore = mergeUniqueKeyShares(existing, clientKeyShares);
3754
- }
3799
+ * Emits a persistence-info log so storage writes are auditable from traces.
3800
+ */ logSharePersistence({ accountAddress, clientKeyShares, source }) {
3755
3801
  this.logger.info('[DynamicWaasWalletClient] Persisting client key shares', {
3756
3802
  accountAddress,
3757
3803
  source,
3758
- mode: overwriteOrMerge,
3759
- inputCount: clientKeyShares.length,
3760
- existingCount,
3761
- finalCount: sharesToStore.length
3804
+ inputCount: clientKeyShares.length
3762
3805
  });
3763
- return sharesToStore;
3764
3806
  }
3765
- async setClientKeySharesToLocalStorage({ accountAddress, clientKeyShares, overwriteOrMerge = 'merge' }) {
3807
+ async setClientKeySharesToLocalStorage({ accountAddress, clientKeyShares }) {
3766
3808
  accountAddress = normalizeAddress(accountAddress);
3767
- const sharesToStore = await this.resolveSharesToPersist({
3809
+ this.logSharePersistence({
3768
3810
  accountAddress,
3769
3811
  clientKeyShares,
3770
- overwriteOrMerge,
3771
- source: 'localStorage',
3772
- readExisting: ()=>this.getClientKeySharesFromLocalStorage({
3773
- accountAddress
3774
- })
3812
+ source: 'localStorage'
3775
3813
  });
3776
3814
  const stringifiedClientKeyShares = JSON.stringify({
3777
- clientKeyShares: sharesToStore
3815
+ clientKeyShares
3778
3816
  });
3779
3817
  await this.storage.setItem(accountAddress, stringifiedClientKeyShares);
3780
3818
  }
3781
3819
  /**
3782
- * Helper function to store client key shares in storage.
3820
+ * Replaces the client key shares stored for `accountAddress` with `clientKeyShares`.
3783
3821
  * Uses secureStorage when available (mobile), otherwise falls back to localStorage (browser).
3784
- */ async setClientKeySharesToStorage({ accountAddress, clientKeyShares, overwriteOrMerge = 'merge' }) {
3822
+ *
3823
+ * A WaaS wallet holds exactly one client share per device (see share-distribution
3824
+ * factories in types.ts), so writes are always a full replacement — there is no
3825
+ * merge mode. Callers that need to clear local shares pass `clientKeyShares: []`.
3826
+ */ async setClientKeySharesToStorage({ accountAddress, clientKeyShares }) {
3785
3827
  accountAddress = normalizeAddress(accountAddress);
3786
3828
  // Use secure storage if available (mobile)
3787
3829
  if (this.secureStorage) {
3788
3830
  try {
3789
- const sharesToStore = await this.resolveSharesToPersist({
3831
+ this.logSharePersistence({
3790
3832
  accountAddress,
3791
3833
  clientKeyShares,
3792
- overwriteOrMerge,
3793
- source: 'secureStorage',
3794
- readExisting: ()=>this.getClientKeySharesFromStorage({
3795
- accountAddress
3796
- })
3834
+ source: 'secureStorage'
3797
3835
  });
3798
- await this.secureStorage.setClientKeyShare(accountAddress, sharesToStore);
3836
+ await this.secureStorage.setClientKeyShare(accountAddress, clientKeyShares);
3799
3837
  return;
3800
3838
  } catch (error) {
3801
3839
  logError({
@@ -3811,8 +3849,7 @@ class DynamicWalletClient {
3811
3849
  // Fallback to localStorage (browser)
3812
3850
  await this.setClientKeySharesToLocalStorage({
3813
3851
  accountAddress,
3814
- clientKeyShares,
3815
- overwriteOrMerge
3852
+ clientKeyShares
3816
3853
  });
3817
3854
  }
3818
3855
  /**
@@ -4604,8 +4641,7 @@ class DynamicWalletClient {
4604
4641
  if (storeRecoveredShares) {
4605
4642
  await this.setClientKeySharesToStorage({
4606
4643
  accountAddress,
4607
- clientKeyShares: decryptedKeyShares,
4608
- overwriteOrMerge: 'merge'
4644
+ clientKeyShares: decryptedKeyShares
4609
4645
  });
4610
4646
  await this.storage.setItem(this.storageKey, JSON.stringify(this.walletMap));
4611
4647
  }
@@ -5241,13 +5277,8 @@ class DynamicWalletClient {
5241
5277
  signedSessionId,
5242
5278
  shareCount
5243
5279
  });
5244
- const existingKeyShares = await this.getClientKeySharesFromStorage({
5245
- accountAddress
5246
- });
5247
- await this.setClientKeySharesToStorage({
5248
- accountAddress,
5249
- clientKeyShares: mergeUniqueKeyShares(existingKeyShares, decryptedKeyShares)
5250
- });
5280
+ // recoverEncryptedBackupByWallet (storeRecoveredShares: true by default) has
5281
+ // already written decryptedKeyShares to storage. No further write needed.
5251
5282
  this.logger.debug('[DynamicWaasWalletClient] Recovered backup', {
5252
5283
  decryptedKeyShares
5253
5284
  });
@@ -5332,6 +5363,45 @@ class DynamicWalletClient {
5332
5363
  });
5333
5364
  }
5334
5365
  /**
5366
+ * Fetches the encrypted-shares blob from the server and caches it in this.storage.
5367
+ * Returns the serialized blob, or null if the wallet is unknown or the API returns
5368
+ * no data. Does not decrypt — callers (e.g. unlockWallet, getWallet eager-load)
5369
+ * own the decrypt step.
5370
+ */ async fetchAndCacheEncryptedShares({ accountAddress, signedSessionId, mfaToken }) {
5371
+ const walletData = this.getWalletFromMap(accountAddress);
5372
+ if (!walletData) {
5373
+ return null;
5374
+ }
5375
+ const { shares } = this.recoverStrategy({
5376
+ clientKeyShareBackupInfo: walletData.clientKeySharesBackupInfo,
5377
+ thresholdSignatureScheme: walletData.thresholdSignatureScheme,
5378
+ walletOperation: WalletOperation.RECOVER
5379
+ });
5380
+ const externalKeyShareIds = shares[BackupLocation.DYNAMIC] || [];
5381
+ this.logger.info('[unlockWallet] cache miss after getWallet, fetching encrypted shares from server', {
5382
+ context: {
5383
+ accountAddress,
5384
+ walletId: walletData.walletId,
5385
+ externalKeyShareIds
5386
+ }
5387
+ });
5388
+ const data = await this.apiClient.recoverEncryptedBackupByWallet({
5389
+ walletId: walletData.walletId,
5390
+ externalKeyShareIds,
5391
+ signedSessionId,
5392
+ mfaToken,
5393
+ requiresSignedSessionId: this.requiresSignedSessionId(),
5394
+ userId: this.userId
5395
+ });
5396
+ if (!data) {
5397
+ return null;
5398
+ }
5399
+ const serialized = JSON.stringify(data);
5400
+ const encryptedStorageKey = `${normalizeAddress(accountAddress)}${ENCRYPTED_SHARES_STORAGE_SUFFIX}`;
5401
+ await this.storage.setItem(encryptedStorageKey, serialized);
5402
+ return serialized;
5403
+ }
5404
+ /**
5335
5405
  * Unlocks a password-encrypted wallet by decrypting cached encrypted shares.
5336
5406
  * This method should be called after getWalletRecoveryState returns LOCKED state.
5337
5407
  *
@@ -5339,7 +5409,7 @@ class DynamicWalletClient {
5339
5409
  * @param password - The password to decrypt the shares
5340
5410
  * @param signedSessionId - The signed session ID for authentication
5341
5411
  * @returns The unlocked wallet properties
5342
- */ async unlockWallet({ accountAddress, password, signedSessionId }) {
5412
+ */ async unlockWallet({ accountAddress, password, signedSessionId, mfaToken }) {
5343
5413
  const dynamicRequestId = v4();
5344
5414
  try {
5345
5415
  await this.requireWalletFromMap(accountAddress, signedSessionId);
@@ -5359,6 +5429,18 @@ class DynamicWalletClient {
5359
5429
  });
5360
5430
  encryptedData = await this.storage.getItem(encryptedStorageKey);
5361
5431
  }
5432
+ // getWallet short-circuits via checkWalletFields when client key shares are
5433
+ // durable but the encrypted-shares cache in this.storage was evicted (e.g. RN
5434
+ // secureStorage survives a WebKit content-process recycle while localStorage
5435
+ // does not). Populate the cache directly so the single decrypt path below
5436
+ // can proceed.
5437
+ if (!encryptedData) {
5438
+ encryptedData = await this.fetchAndCacheEncryptedShares({
5439
+ accountAddress,
5440
+ signedSessionId,
5441
+ mfaToken
5442
+ });
5443
+ }
5362
5444
  if (!encryptedData) {
5363
5445
  throw new Error('No encrypted shares found for wallet');
5364
5446
  }
@@ -5372,7 +5454,17 @@ class DynamicWalletClient {
5372
5454
  const otherEncryptedWallets = Object.entries(this.walletMap).filter(([addr, w])=>addr !== normalizedAccountAddress && w.walletReadyState !== WalletReadyState.READY && isWalletPasswordEncrypted(w));
5373
5455
  const results = await Promise.allSettled(otherEncryptedWallets.map(async ([otherAddr])=>{
5374
5456
  const otherEncryptedStorageKey = `${otherAddr}${ENCRYPTED_SHARES_STORAGE_SUFFIX}`;
5375
- const otherEncryptedData = await this.storage.getItem(otherEncryptedStorageKey);
5457
+ let otherEncryptedData = await this.storage.getItem(otherEncryptedStorageKey);
5458
+ // Same cache-eviction scenario as the main wallet above: on RN, the
5459
+ // encrypted-shares blob can be gone while client shares survive in
5460
+ // secureStorage. Re-fetch from the server before giving up.
5461
+ if (!otherEncryptedData) {
5462
+ otherEncryptedData = await this.fetchAndCacheEncryptedShares({
5463
+ accountAddress: otherAddr,
5464
+ signedSessionId,
5465
+ mfaToken
5466
+ });
5467
+ }
5376
5468
  if (!otherEncryptedData) {
5377
5469
  return;
5378
5470
  }
@@ -5761,4 +5853,4 @@ DynamicWalletClient.rooms = {};
5761
5853
  DynamicWalletClient.roomsInitializing = {};
5762
5854
  DynamicWalletClient.roomsPersistChain = Promise.resolve();
5763
5855
 
5764
- export { BACKUP_FILENAME, CLIENT_KEYSHARE_EXPORT_FILENAME_PREFIX, DynamicWalletClient, ENVIRONMENT_SETTINGS_STORAGE_KEY, ERROR_ACCOUNT_ADDRESS_REQUIRED, ERROR_CREATE_WALLET_ACCOUNT, ERROR_EXPORT_PRIVATE_KEY, ERROR_IMPORT_PRIVATE_KEY, ERROR_KEYGEN_FAILED, ERROR_PASSCODE_REQUIRED, ERROR_PASSWORD_MISMATCH, ERROR_PASSWORD_REQUIRED_FOR_ENCRYPTED_WALLET, ERROR_PUBLIC_KEY_MISMATCH, ERROR_REFRESH_NOT_SUPPORTED_FOR_TWO_OF_THREE, ERROR_SIGN_MESSAGE, ERROR_SIGN_TYPED_DATA, ERROR_VERIFY_MESSAGE_SIGNATURE, ERROR_VERIFY_TRANSACTION_SIGNATURE, HEAVY_QUEUE_OPERATIONS, RECOVER_QUEUE_OPERATIONS, ROOM_CACHE_COUNT, ROOM_EXPIRATION_TIME, SIGNED_SESSION_ID_MIN_VERSION, SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE, SIGN_QUEUE_OPERATIONS, STORAGE_KEY, WalletBusyError, WalletNotReadyError, cancelICloudAuth, createAddCloudProviderToExistingDelegationDistribution, createBackupData, createCloudProviderDistribution, createDelegationOnlyDistribution, createDelegationWithCloudProviderDistribution, createDynamicOnlyDistribution, deleteICloudBackup, downloadStringAsFile, extractPubkey, formatEvmMessage, formatMessage, getActiveCloudProviders, getBitcoinAddressTypeFromDerivationPath, getClientKeyShareBackupInfo, getClientKeyShareExportFileName, getGoogleOAuthAccountId, getICloudBackup, getMPCSignatureScheme, getMPCSigner, hasCloudProviderBackup, hasDelegatedBackup, hasEncryptedSharesCached, initializeCloudKit, isBrowser, isHeavyQueueOperation, isHexString, isICloudAuthenticated, isPublicKeyMismatchError, isRecoverQueueOperation, isSignQueueOperation, listICloudBackups, mergeUniqueKeyShares, readEnvironmentSettings, retryPromise, shouldReshareToSameBackups, timeoutPromise };
5856
+ export { BACKUP_FILENAME, CLIENT_KEYSHARE_EXPORT_FILENAME_PREFIX, DynamicWalletClient, ENVIRONMENT_SETTINGS_STORAGE_KEY, ERROR_ACCOUNT_ADDRESS_REQUIRED, ERROR_CREATE_WALLET_ACCOUNT, ERROR_EXPORT_PRIVATE_KEY, ERROR_IMPORT_PRIVATE_KEY, ERROR_KEYGEN_FAILED, ERROR_PASSCODE_REQUIRED, ERROR_PASSWORD_MISMATCH, ERROR_PASSWORD_REQUIRED_FOR_ENCRYPTED_WALLET, ERROR_PUBLIC_KEY_MISMATCH, ERROR_REFRESH_NOT_SUPPORTED_FOR_TWO_OF_THREE, ERROR_SIGN_MESSAGE, ERROR_SIGN_TYPED_DATA, ERROR_VERIFY_MESSAGE_SIGNATURE, ERROR_VERIFY_TRANSACTION_SIGNATURE, HEAVY_QUEUE_OPERATIONS, RECOVER_QUEUE_OPERATIONS, ROOM_CACHE_COUNT, ROOM_EXPIRATION_TIME, SIGNED_SESSION_ID_MIN_VERSION, SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE, SIGN_QUEUE_OPERATIONS, STORAGE_KEY, WalletBusyError, WalletNotReadyError, cancelICloudAuth, createAddCloudProviderToExistingDelegationDistribution, createBackupData, createCloudProviderDistribution, createDelegationOnlyDistribution, createDelegationWithCloudProviderDistribution, createDynamicOnlyDistribution, deleteICloudBackup, downloadStringAsFile, extractPubkey, formatEvmMessage, formatMessage, getActiveCloudProviders, getBitcoinAddressTypeFromDerivationPath, getClientKeyShareBackupInfo, getClientKeyShareExportFileName, getGoogleOAuthAccountId, getICloudBackup, getMPCSignatureScheme, getMPCSigner, hasCloudProviderBackup, hasDelegatedBackup, hasEncryptedSharesCached, initializeCloudKit, isBrowser, isHeavyQueueOperation, isHexString, isICloudAuthenticated, isNonRetryableCeremonyError, isPublicKeyMismatchError, isRecoverQueueOperation, isSignQueueOperation, listICloudBackups, markCeremonyErrorNonRetryable, readEnvironmentSettings, retryPromise, shouldReshareToSameBackups, timeoutPromise };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@dynamic-labs-wallet/browser",
3
- "version": "0.0.338",
3
+ "version": "0.0.340",
4
4
  "license": "Licensed under the Dynamic Labs, Inc. Terms Of Service (https://www.dynamic.xyz/terms-conditions)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "@dynamic-labs-wallet/core": "0.0.338",
7
+ "@dynamic-labs-wallet/core": "0.0.340",
8
8
  "@dynamic-labs-wallet/forward-mpc-client": "0.9.0",
9
- "@dynamic-labs-wallet/primitives": "0.0.338",
9
+ "@dynamic-labs-wallet/primitives": "0.0.340",
10
10
  "@dynamic-labs/sdk-api-core": "^0.0.964",
11
11
  "argon2id": "1.0.1",
12
12
  "axios": "1.15.2",