@dynamic-labs-wallet/browser 0.0.339 → 0.0.341

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
@@ -354,6 +354,17 @@ class InvalidPasswordError extends Error {
354
354
  return metadata;
355
355
  };
356
356
 
357
+ class StaleLocalSharesError extends Error {
358
+ constructor(args){
359
+ super(args.localSharesRefreshed ? 'Local key shares were stale and have been refreshed from the server. Please retry the operation.' : 'Local key shares are stale and could not be refreshed automatically. Please recover or re-authenticate before re-attempting.');
360
+ this.name = 'StaleLocalSharesError';
361
+ this.accountAddress = args.accountAddress;
362
+ this.localKeygenIds = args.localKeygenIds;
363
+ this.recordedKeygenIds = args.recordedKeygenIds;
364
+ this.localSharesRefreshed = args.localSharesRefreshed;
365
+ }
366
+ }
367
+
357
368
  const GOOGLE_DRIVE_UPLOAD_API = 'https://www.googleapis.com';
358
369
  const USER_ACTIONABLE_REASONS = new Set([
359
370
  'auth_denied',
@@ -721,36 +732,6 @@ const getClientKeyShareBackupInfo = (params)=>{
721
732
  passwordEncrypted
722
733
  };
723
734
  };
724
- /**
725
- * Helper function to merge keyshares and remove duplicates based on pubkey and secretShare
726
- * @param existingKeyShares - Array of existing keyshares
727
- * @param newKeyShares - Array of new keyshares to merge
728
- * @returns Array of merged unique keyshares
729
- */ const mergeUniqueKeyShares = (existingKeyShares, newKeyShares)=>{
730
- const hasDegeneratePubkeyToString = [
731
- ...existingKeyShares,
732
- ...newKeyShares
733
- ].some((share)=>{
734
- var _share_pubkey;
735
- return 'pubkey' in share && ((_share_pubkey = share.pubkey) == null ? void 0 : _share_pubkey.toString()) === '[object Object]' // NOSONAR
736
- ;
737
- });
738
- if (hasDegeneratePubkeyToString) {
739
- core.Logger.warn('[mergeUniqueKeyShares] pubkey.toString() returned "[object Object]" — dedup falls back to secretShare-only comparison', {
740
- existingCount: existingKeyShares.length,
741
- newCount: newKeyShares.length
742
- });
743
- }
744
- const uniqueKeyShares = newKeyShares.filter((newShare)=>!existingKeyShares.some((existingShare)=>{
745
- if (!('pubkey' in newShare) || !('pubkey' in existingShare)) return false;
746
- if (!newShare.pubkey || !existingShare.pubkey) return false;
747
- return newShare.pubkey.toString() === existingShare.pubkey.toString() && newShare.secretShare === existingShare.secretShare;
748
- }));
749
- return [
750
- ...existingKeyShares,
751
- ...uniqueKeyShares
752
- ];
753
- };
754
735
  const timeoutPromise = ({ timeInMs, activity = 'Ceremony' })=>{
755
736
  return new Promise((_, reject)=>setTimeout(()=>reject(new Error(`${activity} did not complete in ${timeInMs}ms`)), timeInMs));
756
737
  };
@@ -2753,23 +2734,15 @@ class DynamicWalletClient {
2753
2734
  walletOperation,
2754
2735
  dynamicRequestId
2755
2736
  }, this.getTraceContext(traceContext)));
2756
- // Refresh walletMap from API before recovery to ensure backup info is up-to-date
2737
+ // Refresh walletMap from API before recovery to ensure backup info is up-to-date.
2757
2738
  await this.getWallets();
2758
- // Recover key shares from backup (don't auto-store, we'll overwrite below)
2759
- // Use internal method directly to bypass queueing - we're already inside a queued operation
2760
2739
  const recoveredKeyShares = await this.internalRecoverEncryptedBackupByWallet({
2761
2740
  accountAddress,
2762
- password: password != null ? password : this.environmentId,
2763
- walletOperation,
2741
+ password,
2764
2742
  signedSessionId,
2743
+ walletOperation,
2765
2744
  mfaToken,
2766
- storeRecoveredShares: false
2767
- });
2768
- // Overwrite local key shares with recovered ones (not merge)
2769
- await this.setClientKeySharesToStorage({
2770
- accountAddress,
2771
- clientKeyShares: recoveredKeyShares,
2772
- overwriteOrMerge: 'overwrite'
2745
+ storeRecoveredShares: true
2773
2746
  });
2774
2747
  this.logger.info('[DynamicWaasWalletClient] Key shares recovered, retrying operation', _extends({
2775
2748
  accountAddress,
@@ -3103,8 +3076,7 @@ class DynamicWalletClient {
3103
3076
  });
3104
3077
  await this.setClientKeySharesToStorage({
3105
3078
  accountAddress,
3106
- clientKeyShares: refreshResults,
3107
- overwriteOrMerge: 'overwrite'
3079
+ clientKeyShares: refreshResults
3108
3080
  });
3109
3081
  } catch (error) {
3110
3082
  // Expected condition — not a server error, no need to page on this.
@@ -3126,6 +3098,13 @@ class DynamicWalletClient {
3126
3098
  throw error;
3127
3099
  }
3128
3100
  }
3101
+ async getKeygenIdForShare(mpcSigner, share) {
3102
+ // BIP340.exportID accepts the raw secretShare string; other signers accept the whole keygen result.
3103
+ if (mpcSigner instanceof web.BIP340) {
3104
+ return mpcSigner.exportID(share.secretShare);
3105
+ }
3106
+ return mpcSigner.exportID(share);
3107
+ }
3129
3108
  async getExportId({ chainName, clientKeyShare, bitcoinConfig }) {
3130
3109
  const mpcSigner = getMPCSigner({
3131
3110
  chainName,
@@ -3133,15 +3112,7 @@ class DynamicWalletClient {
3133
3112
  bitcoinConfig
3134
3113
  });
3135
3114
  try {
3136
- // For BIP340, try passing the secretShare as a string directly
3137
- // The exportID accepts either BIP340KeygenResult or string (secretShare)
3138
- let exportId;
3139
- if (mpcSigner instanceof web.BIP340) {
3140
- const secretShareString = clientKeyShare.secretShare;
3141
- exportId = await mpcSigner.exportID(secretShareString);
3142
- } else {
3143
- exportId = await mpcSigner.exportID(clientKeyShare);
3144
- }
3115
+ const exportId = await this.getKeygenIdForShare(mpcSigner, clientKeyShare);
3145
3116
  this.logger.debug('[DynamicWaasWalletClient] getExportId succeeded', {
3146
3117
  chainName,
3147
3118
  exportId
@@ -3459,8 +3430,7 @@ class DynamicWalletClient {
3459
3430
  // only after backup succeeds.
3460
3431
  await this.setClientKeySharesToStorage({
3461
3432
  accountAddress,
3462
- clientKeyShares: distribution.clientShares,
3463
- overwriteOrMerge: 'overwrite'
3433
+ clientKeyShares: distribution.clientShares
3464
3434
  });
3465
3435
  } catch (error) {
3466
3436
  logError({
@@ -3483,8 +3453,7 @@ class DynamicWalletClient {
3483
3453
  });
3484
3454
  await this.setClientKeySharesToStorage({
3485
3455
  accountAddress,
3486
- clientKeyShares: [],
3487
- overwriteOrMerge: 'overwrite'
3456
+ clientKeyShares: []
3488
3457
  });
3489
3458
  throw error;
3490
3459
  }
@@ -3830,63 +3799,44 @@ class DynamicWalletClient {
3830
3799
  * Helper function to store client key shares in storage.
3831
3800
  * Uses secureStorage when available (mobile), otherwise falls back to localStorage (browser).
3832
3801
  */ /**
3833
- * Resolves the final array of shares to persist by either replacing
3834
- * (overwrite) or merging with existing shares (merge), and emits a
3835
- * persistence-info log so we can audit storage operations from traces.
3836
- */ async resolveSharesToPersist({ accountAddress, clientKeyShares, overwriteOrMerge, source, readExisting }) {
3837
- let sharesToStore;
3838
- let existingCount = 0;
3839
- if (overwriteOrMerge === 'overwrite') {
3840
- sharesToStore = clientKeyShares;
3841
- } else {
3842
- const existing = await readExisting();
3843
- existingCount = existing.length;
3844
- sharesToStore = mergeUniqueKeyShares(existing, clientKeyShares);
3845
- }
3802
+ * Emits a persistence-info log so storage writes are auditable from traces.
3803
+ */ logSharePersistence({ accountAddress, clientKeyShares, source }) {
3846
3804
  this.logger.info('[DynamicWaasWalletClient] Persisting client key shares', {
3847
3805
  accountAddress,
3848
3806
  source,
3849
- mode: overwriteOrMerge,
3850
- inputCount: clientKeyShares.length,
3851
- existingCount,
3852
- finalCount: sharesToStore.length
3807
+ inputCount: clientKeyShares.length
3853
3808
  });
3854
- return sharesToStore;
3855
3809
  }
3856
- async setClientKeySharesToLocalStorage({ accountAddress, clientKeyShares, overwriteOrMerge = 'merge' }) {
3810
+ async setClientKeySharesToLocalStorage({ accountAddress, clientKeyShares }) {
3857
3811
  accountAddress = normalizeAddress(accountAddress);
3858
- const sharesToStore = await this.resolveSharesToPersist({
3812
+ this.logSharePersistence({
3859
3813
  accountAddress,
3860
3814
  clientKeyShares,
3861
- overwriteOrMerge,
3862
- source: 'localStorage',
3863
- readExisting: ()=>this.getClientKeySharesFromLocalStorage({
3864
- accountAddress
3865
- })
3815
+ source: 'localStorage'
3866
3816
  });
3867
3817
  const stringifiedClientKeyShares = JSON.stringify({
3868
- clientKeyShares: sharesToStore
3818
+ clientKeyShares
3869
3819
  });
3870
3820
  await this.storage.setItem(accountAddress, stringifiedClientKeyShares);
3871
3821
  }
3872
3822
  /**
3873
- * Helper function to store client key shares in storage.
3823
+ * Replaces the client key shares stored for `accountAddress` with `clientKeyShares`.
3874
3824
  * Uses secureStorage when available (mobile), otherwise falls back to localStorage (browser).
3875
- */ async setClientKeySharesToStorage({ accountAddress, clientKeyShares, overwriteOrMerge = 'merge' }) {
3825
+ *
3826
+ * A WaaS wallet holds exactly one client share per device (see share-distribution
3827
+ * factories in types.ts), so writes are always a full replacement — there is no
3828
+ * merge mode. Callers that need to clear local shares pass `clientKeyShares: []`.
3829
+ */ async setClientKeySharesToStorage({ accountAddress, clientKeyShares }) {
3876
3830
  accountAddress = normalizeAddress(accountAddress);
3877
3831
  // Use secure storage if available (mobile)
3878
3832
  if (this.secureStorage) {
3879
3833
  try {
3880
- const sharesToStore = await this.resolveSharesToPersist({
3834
+ this.logSharePersistence({
3881
3835
  accountAddress,
3882
3836
  clientKeyShares,
3883
- overwriteOrMerge,
3884
- source: 'secureStorage',
3885
- readExisting: ()=>this.getClientKeySharesFromStorage({
3886
- accountAddress
3887
- })
3837
+ source: 'secureStorage'
3888
3838
  });
3889
- await this.secureStorage.setClientKeyShare(accountAddress, sharesToStore);
3839
+ await this.secureStorage.setClientKeyShare(accountAddress, clientKeyShares);
3890
3840
  return;
3891
3841
  } catch (error) {
3892
3842
  logError({
@@ -3902,8 +3852,7 @@ class DynamicWalletClient {
3902
3852
  // Fallback to localStorage (browser)
3903
3853
  await this.setClientKeySharesToLocalStorage({
3904
3854
  accountAddress,
3905
- clientKeyShares,
3906
- overwriteOrMerge
3855
+ clientKeyShares
3907
3856
  });
3908
3857
  }
3909
3858
  /**
@@ -4262,6 +4211,93 @@ class DynamicWalletClient {
4262
4211
  throw error;
4263
4212
  }
4264
4213
  }
4214
+ // Defense-in-depth check: before uploading a backup, confirm that local client shares
4215
+ // still match the keygenIds recorded for backups[DYNAMIC] on the server. On mismatch,
4216
+ // refreshes local storage from the server's encrypted backup (so a subsequent retry
4217
+ // sees fresh state) and then throws StaleLocalSharesError. We deliberately don't
4218
+ // continue the upload silently after recovery — callers like updatePassword hold
4219
+ // operation-specific context (existing/new password) that must be re-applied to
4220
+ // freshly-recovered shares. Throwing surfaces the stale-state event so the host can
4221
+ // re-enter the outer operation with correct context. Skipped on internal post-rotation
4222
+ // flows that pass clientKeyShares explicitly (the refresh path).
4223
+ async ensureLocalSharesAreFresh({ accountAddress, walletData, localShares, password, signedSessionId }) {
4224
+ var _freshBackupInfo_backups;
4225
+ // Always round-trip to the server — walletMap is populated at wallet-load and is
4226
+ // not invalidated when another tab rotates shares, so trusting it would
4227
+ // false-positive on cross-tab refresh.
4228
+ const { chainName } = walletData;
4229
+ const mpcSigner = getMPCSigner({
4230
+ chainName,
4231
+ baseRelayUrl: this.baseMPCRelayApiUrl,
4232
+ bitcoinConfig: this.getBitcoinConfigForChain(chainName, accountAddress)
4233
+ });
4234
+ const [freshBackupInfo, localKeygenIds] = await Promise.all([
4235
+ this.fetchFreshWalletBackupInfo({
4236
+ accountAddress
4237
+ }),
4238
+ Promise.all(localShares.map((share)=>this.getKeygenIdForShare(mpcSigner, share)))
4239
+ ]);
4240
+ var _freshBackupInfo_backups_BackupLocation_DYNAMIC;
4241
+ const recordedKeygenIds = ((_freshBackupInfo_backups_BackupLocation_DYNAMIC = freshBackupInfo == null ? void 0 : (_freshBackupInfo_backups = freshBackupInfo.backups) == null ? void 0 : _freshBackupInfo_backups[core.BackupLocation.DYNAMIC]) != null ? _freshBackupInfo_backups_BackupLocation_DYNAMIC : []).map((entry)=>entry.keygenId).filter((id)=>typeof id === 'string' && id.length > 0);
4242
+ // First-time backup or legacy data (server didn't populate keygenId yet) — nothing to compare.
4243
+ if (recordedKeygenIds.length === 0) return;
4244
+ const recordedSet = new Set(recordedKeygenIds);
4245
+ const localMatchesRecorded = localKeygenIds.length === recordedSet.size && localKeygenIds.every((id)=>recordedSet.has(id));
4246
+ if (localMatchesRecorded) return;
4247
+ this.logger.warn('[storeEncryptedBackupByWallet] Stale local shares detected; refreshing local storage', {
4248
+ accountAddress,
4249
+ walletId: walletData.walletId,
4250
+ environmentId: this.environmentId,
4251
+ localKeygenIds,
4252
+ recordedKeygenIds
4253
+ });
4254
+ // Resolve a session, falling back to the host's reverse-channel callback when one
4255
+ // wasn't passed in. If neither source produces a session we skip the refresh and
4256
+ // still throw — the host can re-auth from the error.
4257
+ let resolvedSessionId;
4258
+ try {
4259
+ resolvedSessionId = await this.resolveSignedSessionId(signedSessionId);
4260
+ } catch (e) {
4261
+ resolvedSessionId = undefined;
4262
+ }
4263
+ let localSharesRefreshed = false;
4264
+ if (resolvedSessionId) {
4265
+ // The recovery decrypt key is whatever the server's blob was encrypted with — not the
4266
+ // caller's `password` arg, which is the key we'll re-encrypt the *next* upload with.
4267
+ // For setPassword the wallet is currently passwordless (blob encrypted with environmentId)
4268
+ // and the caller passed the new password; passing it through here would
4269
+ // InvalidPasswordError. Fall back to undefined so internalRecover picks environmentId.
4270
+ const recoveryPassword = freshBackupInfo.passwordEncrypted ? password : undefined;
4271
+ try {
4272
+ // walletMap was already refreshed by fetchFreshWalletBackupInfo above, so we skip the
4273
+ // getWallets() refresh that recoverKeySharesOnMismatch performs for its own callers.
4274
+ await this.internalRecoverEncryptedBackupByWallet({
4275
+ accountAddress,
4276
+ password: recoveryPassword,
4277
+ signedSessionId: resolvedSessionId,
4278
+ walletOperation: core.WalletOperation.RECOVER,
4279
+ storeRecoveredShares: true
4280
+ });
4281
+ localSharesRefreshed = true;
4282
+ this.logger.info('[storeEncryptedBackupByWallet] Local shares refreshed from server; caller should retry', {
4283
+ accountAddress,
4284
+ walletId: walletData.walletId
4285
+ });
4286
+ } catch (recoveryError) {
4287
+ this.logger.error('[storeEncryptedBackupByWallet] Failed to refresh local shares from server', {
4288
+ accountAddress,
4289
+ walletId: walletData.walletId,
4290
+ error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
4291
+ });
4292
+ }
4293
+ }
4294
+ throw new StaleLocalSharesError({
4295
+ accountAddress,
4296
+ localKeygenIds,
4297
+ recordedKeygenIds,
4298
+ localSharesRefreshed
4299
+ });
4300
+ }
4265
4301
  /**
4266
4302
  * Central backup orchestrator that encrypts and stores wallet key shares.
4267
4303
  *
@@ -4294,10 +4330,24 @@ class DynamicWalletClient {
4294
4330
  * @returns Promise with backup metadata including share locations and IDs
4295
4331
  */ async storeEncryptedBackupByWallet({ accountAddress, clientKeyShares = undefined, password = undefined, signedSessionId, cloudProviders = [], delegatedKeyshare = undefined, passwordUpdateBatchId, googleDriveAccessToken }) {
4296
4332
  var _this_getWalletFromMap, _backupData_locationsWithKeyShares, _backupData_locationsWithKeyShares1;
4333
+ const hasProvidedShares = clientKeyShares !== undefined;
4297
4334
  const keySharesToBackup = clientKeyShares != null ? clientKeyShares : await this.getClientKeySharesFromStorage({
4298
4335
  accountAddress
4299
4336
  });
4300
- // Check if we should backup to cloud providers (either requested or already exists)
4337
+ // Freshness check only when shares were loaded from local storage. Internal
4338
+ // post-rotation callers (refreshWalletAccountShares) pass clientKeyShares explicitly
4339
+ // and hold shares whose keygenIds are intentionally newer than the recorded backup.
4340
+ const walletDataForFreshness = this.getWalletFromMap(accountAddress);
4341
+ if (!hasProvidedShares && walletDataForFreshness) {
4342
+ await this.ensureLocalSharesAreFresh({
4343
+ accountAddress,
4344
+ walletData: walletDataForFreshness,
4345
+ localShares: keySharesToBackup,
4346
+ password,
4347
+ signedSessionId
4348
+ });
4349
+ }
4350
+ // Re-read after the freshness check: walletMap may have been updated with fresh server data.
4301
4351
  const walletBackupInfo = (_this_getWalletFromMap = this.getWalletFromMap(accountAddress)) == null ? void 0 : _this_getWalletFromMap.clientKeySharesBackupInfo;
4302
4352
  const activeCloudProviders = getActiveCloudProviders(walletBackupInfo);
4303
4353
  const shouldBackupToCloudProviders = cloudProviders.length > 0 || activeCloudProviders.length > 0;
@@ -4695,8 +4745,7 @@ class DynamicWalletClient {
4695
4745
  if (storeRecoveredShares) {
4696
4746
  await this.setClientKeySharesToStorage({
4697
4747
  accountAddress,
4698
- clientKeyShares: decryptedKeyShares,
4699
- overwriteOrMerge: 'merge'
4748
+ clientKeyShares: decryptedKeyShares
4700
4749
  });
4701
4750
  await this.storage.setItem(this.storageKey, JSON.stringify(this.walletMap));
4702
4751
  }
@@ -5182,24 +5231,27 @@ class DynamicWalletClient {
5182
5231
  }
5183
5232
  return true;
5184
5233
  }
5234
+ async fetchWalletBackupInfoFromServer(accountAddress) {
5235
+ var _user_verifiedCredentials;
5236
+ const dynamicRequestId = uuid.v4();
5237
+ const user = await this.apiClient.getUser(dynamicRequestId);
5238
+ const wallet = (_user_verifiedCredentials = user.verifiedCredentials) == null ? void 0 : _user_verifiedCredentials.find((vc)=>{
5239
+ var _vc_address;
5240
+ return ((_vc_address = vc.address) == null ? void 0 : _vc_address.toLowerCase()) === accountAddress.toLowerCase();
5241
+ });
5242
+ return getClientKeyShareBackupInfo({
5243
+ walletProperties: wallet == null ? void 0 : wallet.walletProperties
5244
+ });
5245
+ }
5185
5246
  async getWalletClientKeyShareBackupInfo({ accountAddress }) {
5186
5247
  const dynamicRequestId = uuid.v4();
5187
5248
  try {
5188
- var _this_getWalletFromMap, _walletBackupInfo_backups_BackupLocation_DYNAMIC, _walletBackupInfo_backups, _user_verifiedCredentials;
5189
- // Return existing backup info if it exists
5249
+ var _this_getWalletFromMap, _walletBackupInfo_backups_BackupLocation_DYNAMIC, _walletBackupInfo_backups;
5190
5250
  const walletBackupInfo = (_this_getWalletFromMap = this.getWalletFromMap(accountAddress)) == null ? void 0 : _this_getWalletFromMap.clientKeySharesBackupInfo;
5191
5251
  if (walletBackupInfo && ((_walletBackupInfo_backups = walletBackupInfo.backups) == null ? void 0 : (_walletBackupInfo_backups_BackupLocation_DYNAMIC = _walletBackupInfo_backups[core.BackupLocation.DYNAMIC]) == null ? void 0 : _walletBackupInfo_backups_BackupLocation_DYNAMIC.length) > 0) {
5192
5252
  return walletBackupInfo;
5193
5253
  }
5194
- // Get backup info from server
5195
- const user = await this.apiClient.getUser(dynamicRequestId);
5196
- const wallet = (_user_verifiedCredentials = user.verifiedCredentials) == null ? void 0 : _user_verifiedCredentials.find((vc)=>{
5197
- var _vc_address;
5198
- return ((_vc_address = vc.address) == null ? void 0 : _vc_address.toLowerCase()) === accountAddress.toLowerCase();
5199
- });
5200
- return getClientKeyShareBackupInfo({
5201
- walletProperties: wallet == null ? void 0 : wallet.walletProperties
5202
- });
5254
+ return this.fetchWalletBackupInfoFromServer(accountAddress);
5203
5255
  } catch (error) {
5204
5256
  logError({
5205
5257
  message: 'Error in getWalletClientKeyShareBackupInfo',
@@ -5212,6 +5264,18 @@ class DynamicWalletClient {
5212
5264
  throw error;
5213
5265
  }
5214
5266
  }
5267
+ // Bypasses the cache short-circuit in getWalletClientKeyShareBackupInfo. Required when
5268
+ // freshness matters across tabs/contexts — walletMap is populated at wallet-load time and
5269
+ // is not invalidated when another tab rotates shares.
5270
+ async fetchFreshWalletBackupInfo({ accountAddress }) {
5271
+ const freshBackupInfo = await this.fetchWalletBackupInfoFromServer(accountAddress);
5272
+ if (this.getWalletFromMap(accountAddress)) {
5273
+ this.updateWalletMap(accountAddress, {
5274
+ clientKeySharesBackupInfo: freshBackupInfo
5275
+ });
5276
+ }
5277
+ return freshBackupInfo;
5278
+ }
5215
5279
  async getWallet({ accountAddress, walletOperation = core.WalletOperation.NO_OPERATION, shareCount = undefined, password = undefined, signedSessionId }) {
5216
5280
  const dynamicRequestId = uuid.v4();
5217
5281
  try {
@@ -5332,13 +5396,8 @@ class DynamicWalletClient {
5332
5396
  signedSessionId,
5333
5397
  shareCount
5334
5398
  });
5335
- const existingKeyShares = await this.getClientKeySharesFromStorage({
5336
- accountAddress
5337
- });
5338
- await this.setClientKeySharesToStorage({
5339
- accountAddress,
5340
- clientKeyShares: mergeUniqueKeyShares(existingKeyShares, decryptedKeyShares)
5341
- });
5399
+ // recoverEncryptedBackupByWallet (storeRecoveredShares: true by default) has
5400
+ // already written decryptedKeyShares to storage. No further write needed.
5342
5401
  this.logger.debug('[DynamicWaasWalletClient] Recovered backup', {
5343
5402
  decryptedKeyShares
5344
5403
  });
@@ -5423,6 +5482,45 @@ class DynamicWalletClient {
5423
5482
  });
5424
5483
  }
5425
5484
  /**
5485
+ * Fetches the encrypted-shares blob from the server and caches it in this.storage.
5486
+ * Returns the serialized blob, or null if the wallet is unknown or the API returns
5487
+ * no data. Does not decrypt — callers (e.g. unlockWallet, getWallet eager-load)
5488
+ * own the decrypt step.
5489
+ */ async fetchAndCacheEncryptedShares({ accountAddress, signedSessionId, mfaToken }) {
5490
+ const walletData = this.getWalletFromMap(accountAddress);
5491
+ if (!walletData) {
5492
+ return null;
5493
+ }
5494
+ const { shares } = this.recoverStrategy({
5495
+ clientKeyShareBackupInfo: walletData.clientKeySharesBackupInfo,
5496
+ thresholdSignatureScheme: walletData.thresholdSignatureScheme,
5497
+ walletOperation: core.WalletOperation.RECOVER
5498
+ });
5499
+ const externalKeyShareIds = shares[core.BackupLocation.DYNAMIC] || [];
5500
+ this.logger.info('[unlockWallet] cache miss after getWallet, fetching encrypted shares from server', {
5501
+ context: {
5502
+ accountAddress,
5503
+ walletId: walletData.walletId,
5504
+ externalKeyShareIds
5505
+ }
5506
+ });
5507
+ const data = await this.apiClient.recoverEncryptedBackupByWallet({
5508
+ walletId: walletData.walletId,
5509
+ externalKeyShareIds,
5510
+ signedSessionId,
5511
+ mfaToken,
5512
+ requiresSignedSessionId: this.requiresSignedSessionId(),
5513
+ userId: this.userId
5514
+ });
5515
+ if (!data) {
5516
+ return null;
5517
+ }
5518
+ const serialized = JSON.stringify(data);
5519
+ const encryptedStorageKey = `${normalizeAddress(accountAddress)}${core.ENCRYPTED_SHARES_STORAGE_SUFFIX}`;
5520
+ await this.storage.setItem(encryptedStorageKey, serialized);
5521
+ return serialized;
5522
+ }
5523
+ /**
5426
5524
  * Unlocks a password-encrypted wallet by decrypting cached encrypted shares.
5427
5525
  * This method should be called after getWalletRecoveryState returns LOCKED state.
5428
5526
  *
@@ -5430,7 +5528,7 @@ class DynamicWalletClient {
5430
5528
  * @param password - The password to decrypt the shares
5431
5529
  * @param signedSessionId - The signed session ID for authentication
5432
5530
  * @returns The unlocked wallet properties
5433
- */ async unlockWallet({ accountAddress, password, signedSessionId }) {
5531
+ */ async unlockWallet({ accountAddress, password, signedSessionId, mfaToken }) {
5434
5532
  const dynamicRequestId = uuid.v4();
5435
5533
  try {
5436
5534
  await this.requireWalletFromMap(accountAddress, signedSessionId);
@@ -5450,6 +5548,18 @@ class DynamicWalletClient {
5450
5548
  });
5451
5549
  encryptedData = await this.storage.getItem(encryptedStorageKey);
5452
5550
  }
5551
+ // getWallet short-circuits via checkWalletFields when client key shares are
5552
+ // durable but the encrypted-shares cache in this.storage was evicted (e.g. RN
5553
+ // secureStorage survives a WebKit content-process recycle while localStorage
5554
+ // does not). Populate the cache directly so the single decrypt path below
5555
+ // can proceed.
5556
+ if (!encryptedData) {
5557
+ encryptedData = await this.fetchAndCacheEncryptedShares({
5558
+ accountAddress,
5559
+ signedSessionId,
5560
+ mfaToken
5561
+ });
5562
+ }
5453
5563
  if (!encryptedData) {
5454
5564
  throw new Error('No encrypted shares found for wallet');
5455
5565
  }
@@ -5463,7 +5573,17 @@ class DynamicWalletClient {
5463
5573
  const otherEncryptedWallets = Object.entries(this.walletMap).filter(([addr, w])=>addr !== normalizedAccountAddress && w.walletReadyState !== core.WalletReadyState.READY && isWalletPasswordEncrypted(w));
5464
5574
  const results = await Promise.allSettled(otherEncryptedWallets.map(async ([otherAddr])=>{
5465
5575
  const otherEncryptedStorageKey = `${otherAddr}${core.ENCRYPTED_SHARES_STORAGE_SUFFIX}`;
5466
- const otherEncryptedData = await this.storage.getItem(otherEncryptedStorageKey);
5576
+ let otherEncryptedData = await this.storage.getItem(otherEncryptedStorageKey);
5577
+ // Same cache-eviction scenario as the main wallet above: on RN, the
5578
+ // encrypted-shares blob can be gone while client shares survive in
5579
+ // secureStorage. Re-fetch from the server before giving up.
5580
+ if (!otherEncryptedData) {
5581
+ otherEncryptedData = await this.fetchAndCacheEncryptedShares({
5582
+ accountAddress: otherAddr,
5583
+ signedSessionId,
5584
+ mfaToken
5585
+ });
5586
+ }
5467
5587
  if (!otherEncryptedData) {
5468
5588
  return;
5469
5589
  }
@@ -5938,6 +6058,7 @@ exports.SIGNED_SESSION_ID_MIN_VERSION = SIGNED_SESSION_ID_MIN_VERSION;
5938
6058
  exports.SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE = SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE;
5939
6059
  exports.SIGN_QUEUE_OPERATIONS = SIGN_QUEUE_OPERATIONS;
5940
6060
  exports.STORAGE_KEY = STORAGE_KEY;
6061
+ exports.StaleLocalSharesError = StaleLocalSharesError;
5941
6062
  exports.WalletBusyError = WalletBusyError;
5942
6063
  exports.WalletNotReadyError = WalletNotReadyError;
5943
6064
  exports.cancelICloudAuth = cancelICloudAuth;
@@ -5974,7 +6095,6 @@ exports.isRecoverQueueOperation = isRecoverQueueOperation;
5974
6095
  exports.isSignQueueOperation = isSignQueueOperation;
5975
6096
  exports.listICloudBackups = listICloudBackups;
5976
6097
  exports.markCeremonyErrorNonRetryable = markCeremonyErrorNonRetryable;
5977
- exports.mergeUniqueKeyShares = mergeUniqueKeyShares;
5978
6098
  exports.readEnvironmentSettings = readEnvironmentSettings;
5979
6099
  exports.retryPromise = retryPromise;
5980
6100
  exports.shouldReshareToSameBackups = shouldReshareToSameBackups;