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