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