@dynamic-labs-wallet/browser 1.0.42 → 1.0.44

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
@@ -1266,6 +1266,22 @@ const downloadStringAsFile = ({ filename, content, mimeType = 'application/json'
1266
1266
  }
1267
1267
  return false;
1268
1268
  };
1269
+ /**
1270
+ * Checks for the server's stale-share preflight rejection (HTTP 409 sent as
1271
+ * an SSE error event with `code: 'STALE_CLIENT_SHARES'`). The server compares
1272
+ * the `shareSetId` the SDK sends on sign requests against the wallet's active
1273
+ * share set and rejects BEFORE any room/ceremony work when they diverge
1274
+ * (e.g. the shares were rotated by a reshare/refresh on another device).
1275
+ * The SDK heals (re-syncs local shares from backup) and retries internally.
1276
+ *
1277
+ * The SSE event-stream layer copies the event's data fields onto the Error
1278
+ * object (see createErrorFromEventData), so `code` is available here.
1279
+ */ const isStaleClientSharesError = (error)=>{
1280
+ if (!error || typeof error !== 'object') {
1281
+ return false;
1282
+ }
1283
+ return error.code === 'STALE_CLIENT_SHARES';
1284
+ };
1269
1285
  /**
1270
1286
  * Determines whether a reshare operation should reuse the wallet's existing backup locations.
1271
1287
  *
@@ -1786,12 +1802,40 @@ const initializeCloudKit = async (config, signInButtonId, onSignInRequired, onSi
1786
1802
  * `WalletApiError` and caught again (e.g. by queue error handlers).
1787
1803
  * Logging these at `warn` prevents double-counting them as real errors.
1788
1804
  */ const isTransientApiError = (error)=>error instanceof WalletApiError && error.status === 429;
1805
+ /**
1806
+ * Returns true for p-queue TimeoutError. These are transient and
1807
+ * retryable client-side, so logging at `warn` prevents them from
1808
+ * firing error-level monitors (e.g. the WaaS task timeout alert).
1809
+ */ const isQueueTimeoutError = (error)=>error instanceof Error && error.name === 'TimeoutError';
1810
+ const CLIENT_NETWORK_ERROR_MESSAGES = new Set([
1811
+ 'network error',
1812
+ 'failed to fetch',
1813
+ 'load failed'
1814
+ ]);
1815
+ /**
1816
+ * Returns true for errors caused by transient client-side network failures
1817
+ * (e.g. user lost connectivity mid-operation). These are not actionable as
1818
+ * errors — the client will retry or the user will reload.
1819
+ */ const isClientNetworkError = (error)=>{
1820
+ if (!(error instanceof Error)) return false;
1821
+ const msg = error.message.toLowerCase();
1822
+ return CLIENT_NETWORK_ERROR_MESSAGES.has(msg) || msg.startsWith('webassembly compilation aborted: network error');
1823
+ };
1824
+ const resolveLogLevel = (error, fallback)=>{
1825
+ if (isExpectedUserError(error)) {
1826
+ return 'info';
1827
+ }
1828
+ if (isTransientApiError(error) || isQueueTimeoutError(error) || isClientNetworkError(error)) {
1829
+ return 'warn';
1830
+ }
1831
+ return fallback;
1832
+ };
1789
1833
  const logError = ({ message, error, context, level = 'error' })=>{
1790
1834
  if (error instanceof AxiosError) {
1791
1835
  handleAxiosError(error, message, context);
1792
1836
  return;
1793
1837
  }
1794
- const resolvedLevel = isExpectedUserError(error) ? 'info' : isTransientApiError(error) ? 'warn' : level;
1838
+ const resolvedLevel = resolveLogLevel(error, level);
1795
1839
  // Surface the unwrapped `cause` (name only — never its raw fields, which may
1796
1840
  // carry key material). Errors like EncryptionSelfTestFailedError wrap the real
1797
1841
  // failure (e.g. KeyShareDecryptionError) as `cause`; without this the original
@@ -2538,6 +2582,15 @@ const KNOWN_SHARE_SET_TYPES = new Set([
2538
2582
  'delegated',
2539
2583
  'server'
2540
2584
  ]);
2585
+ /**
2586
+ * keygenIds the server records for the wallet's Dynamic-backed-up client
2587
+ * shares — the reference generation that local shares are compared against.
2588
+ * Empty for first-time backups or legacy data without keygenIds.
2589
+ */ const recordedDynamicKeygenIds = (backupInfo)=>{
2590
+ var _backupInfo_backups;
2591
+ var _backupInfo_backups_BackupLocation_DYNAMIC;
2592
+ return ((_backupInfo_backups_BackupLocation_DYNAMIC = backupInfo == null ? void 0 : (_backupInfo_backups = backupInfo.backups) == null ? void 0 : _backupInfo_backups[BackupLocation.DYNAMIC]) != null ? _backupInfo_backups_BackupLocation_DYNAMIC : []).map((backup)=>backup.keygenId).filter((keygenId)=>typeof keygenId === 'string' && keygenId.length > 0);
2593
+ };
2541
2594
  class DynamicWalletClient {
2542
2595
  /**
2543
2596
  * Check if wallet has heavy operations in progress
@@ -2823,7 +2876,7 @@ class DynamicWalletClient {
2823
2876
  operationSource: OperationSource.FORWARD_MPC_SERVICE
2824
2877
  };
2825
2878
  } catch (error) {
2826
- this.logger.error('Error during forward MPC client keygen', {
2879
+ this.logger.warn('Error during forward MPC client keygen', {
2827
2880
  error,
2828
2881
  chainName,
2829
2882
  environmentId: this.environmentId,
@@ -3263,7 +3316,7 @@ class DynamicWalletClient {
3263
3316
  }
3264
3317
  return this.processForwardMPCSignature(signatureBytes, signingAlgo);
3265
3318
  } catch (error) {
3266
- this.logger.error('Error signing message with forward MPC client', {
3319
+ this.logger.warn('Error signing message with forward MPC client', {
3267
3320
  error,
3268
3321
  environmentId: this.environmentId,
3269
3322
  userId: this.userId,
@@ -3372,22 +3425,114 @@ class DynamicWalletClient {
3372
3425
  }
3373
3426
  }
3374
3427
  /**
3375
- * On public-key mismatch, restore local shares from backup and rethrow so
3376
- * the caller's next call (with refreshed shares) succeeds. Auto-retry was
3377
- * removed because in the iframe transport the error response is already
3378
- * posted to the host before any retry can resolve.
3379
- * Passthrough for non-mismatch errors.
3380
- */ async recoverKeySharesOnMismatch({ error, accountAddress, password, signedSessionId, walletOperation, mfaToken, traceContext, dynamicRequestId }) {
3381
- if (!isPublicKeyMismatchError(error)) {
3382
- throw error;
3428
+ * Best-effort heal for stale local shares. Runs verifyAndRecoverStaleShare
3429
+ * through the shared recovery queue: concurrent triggers join the in-flight
3430
+ * recovery (WalletQueueManager dedup), and execution is deadlock-safe when
3431
+ * called from inside a queued sign/heavy operation. Never throws — callers
3432
+ * are already on a failing path and rethrow their original error.
3433
+ */ async healStaleShares(params) {
3434
+ const runCheck = ()=>WalletQueueManager.queueRecoverOperation(params.accountAddress, WalletOperation.RECOVER, ()=>this.verifyAndRecoverStaleShare(params));
3435
+ try {
3436
+ const result = await runCheck();
3437
+ // The queue dedup returns the in-flight recovery's promise, dropping
3438
+ // this caller's params. If that run skipped recovery but we carry a
3439
+ // confirmed mismatch, re-run once with our params. (A run we started
3440
+ // ourselves with confirmedMismatch=true always recovers, so this only
3441
+ // fires after joining a weaker concurrent check.)
3442
+ if (params.confirmedMismatch && (result == null ? void 0 : result.recovered) === false) {
3443
+ await runCheck();
3444
+ }
3445
+ } catch (error) {
3446
+ this.logger.error('[StaleShareCheck] share recovery failed', {
3447
+ accountAddress: params.accountAddress,
3448
+ dynamicRequestId: params.dynamicRequestId,
3449
+ trigger: params.trigger,
3450
+ error: error instanceof Error ? error.message : String(error)
3451
+ });
3383
3452
  }
3384
- this.logger.info('[DynamicWaasWalletClient] Public key mismatch detected, recovering key shares from backup', _extends({
3453
+ }
3454
+ /**
3455
+ * Ground-truth staleness check: refreshes the wallet from the API, compares
3456
+ * local share keygenIds against the server's recorded Dynamic-backup
3457
+ * keygenIds, and restores from backup when the local generation is stale
3458
+ * (a reshare/refresh on another device/browser rotates the shares without
3459
+ * this client noticing). No-ops when shares are fresh, so callers may run
3460
+ * it on any ceremony failure regardless of the error message.
3461
+ */ async verifyAndRecoverStaleShare({ accountAddress, chainName, password, signedSessionId, walletOperation, mfaToken, dynamicRequestId, traceContext, bitcoinConfig, trigger, confirmedMismatch }) {
3462
+ this.logger.info('[StaleShareCheck] verifying local share generation against server', _extends({
3385
3463
  accountAddress,
3386
- walletOperation,
3464
+ chainName,
3465
+ trigger,
3466
+ confirmedMismatch,
3387
3467
  dynamicRequestId
3388
3468
  }, this.getTraceContext(traceContext)));
3389
- // Refresh walletMap from API before recovery to ensure backup info is up-to-date.
3469
+ // Refresh walletMap from the API: the cached entry may predate a rotation
3470
+ // performed on another device, which would make this comparison a no-op.
3471
+ // Full getWallets (not just backup info) so shareSetId etc. are also
3472
+ // current for the caller's retry.
3390
3473
  await this.getWallets();
3474
+ const wallet = this.getWalletFromMap(accountAddress);
3475
+ const recordedKeygenIds = recordedDynamicKeygenIds(wallet == null ? void 0 : wallet.clientKeySharesBackupInfo);
3476
+ let staleness;
3477
+ let localKeygenIds = [];
3478
+ if (recordedKeygenIds.length === 0) {
3479
+ // First-time backup or legacy data (server didn't populate keygenId) —
3480
+ // generation can't be compared. A confirmed mismatch still recovers
3481
+ // below (legacy recovery behavior); an unclassified
3482
+ // failure does not.
3483
+ staleness = 'unverifiable';
3484
+ } else {
3485
+ try {
3486
+ const localShares = await this.getClientKeySharesFromStorage({
3487
+ accountAddress
3488
+ });
3489
+ const comparison = await this.compareShareGenerations({
3490
+ accountAddress,
3491
+ chainName,
3492
+ localShares,
3493
+ recordedKeygenIds,
3494
+ bitcoinConfig
3495
+ });
3496
+ localKeygenIds = comparison.localKeygenIds;
3497
+ staleness = comparison.fresh ? 'fresh' : 'stale';
3498
+ } catch (error) {
3499
+ // Unreadable/corrupted local share — treat as stale and recover.
3500
+ this.logger.warn('[StaleShareCheck] could not derive keygenId from local shares — treating as stale', {
3501
+ accountAddress,
3502
+ trigger,
3503
+ dynamicRequestId,
3504
+ error: error instanceof Error ? error.message : String(error)
3505
+ });
3506
+ staleness = 'stale';
3507
+ }
3508
+ }
3509
+ // Stale generation always recovers. A confirmed mismatch also recovers
3510
+ // when the comparison is inconclusive or even reports fresh (possible
3511
+ // server-side inconsistency) — matching the legacy recovery behavior.
3512
+ const shouldRecover = staleness === 'stale' || confirmedMismatch;
3513
+ if (!shouldRecover) {
3514
+ this.logger.info('[StaleShareCheck] no recovery needed', {
3515
+ accountAddress,
3516
+ trigger,
3517
+ staleness,
3518
+ localKeygenIds,
3519
+ dynamicRequestId
3520
+ });
3521
+ return {
3522
+ recovered: false,
3523
+ localKeygenIds,
3524
+ recordedKeygenIds
3525
+ };
3526
+ }
3527
+ this.logger.warn('[StaleShareCheck] recovering current shares from backup', _extends({
3528
+ accountAddress,
3529
+ trigger,
3530
+ confirmedMismatch,
3531
+ staleness,
3532
+ localKeygenIds,
3533
+ recordedKeygenIds,
3534
+ dynamicRequestId
3535
+ }, this.getTraceContext(traceContext)));
3391
3536
  await this.internalRecoverEncryptedBackupByWallet({
3392
3537
  accountAddress,
3393
3538
  password,
@@ -3396,12 +3541,35 @@ class DynamicWalletClient {
3396
3541
  mfaToken,
3397
3542
  storeRecoveredShares: true
3398
3543
  });
3399
- this.logger.info('[DynamicWaasWalletClient] Key shares recovered; rethrowing original error for caller to retry', _extends({
3544
+ this.logger.info('[StaleShareCheck] recovery completed next operation will use the recovered shares', {
3400
3545
  accountAddress,
3401
- walletOperation,
3546
+ trigger,
3402
3547
  dynamicRequestId
3403
- }, this.getTraceContext(traceContext)));
3404
- throw error;
3548
+ });
3549
+ return {
3550
+ recovered: true,
3551
+ localKeygenIds,
3552
+ recordedKeygenIds
3553
+ };
3554
+ }
3555
+ /**
3556
+ * Set-compare local share keygenIds (MPC exportID) against the keygenIds
3557
+ * the server recorded for the wallet's Dynamic backups. Shared by the
3558
+ * stale-share heal (verifyAndRecoverStaleShare) and the backup preflight
3559
+ * (ensureLocalSharesAreFresh).
3560
+ */ async compareShareGenerations({ accountAddress, chainName, localShares, recordedKeygenIds, bitcoinConfig }) {
3561
+ const mpcSigner = getMPCSigner({
3562
+ chainName,
3563
+ baseRelayUrl: this.baseMPCRelayApiUrl,
3564
+ bitcoinConfig: bitcoinConfig != null ? bitcoinConfig : this.getBitcoinConfigForChain(chainName, accountAddress)
3565
+ });
3566
+ const localKeygenIds = await Promise.all(localShares.map((share)=>this.getKeygenIdForShare(mpcSigner, share)));
3567
+ const recordedSet = new Set(recordedKeygenIds);
3568
+ const fresh = localKeygenIds.length === recordedSet.size && localKeygenIds.every((id)=>recordedSet.has(id));
3569
+ return {
3570
+ fresh,
3571
+ localKeygenIds
3572
+ };
3405
3573
  }
3406
3574
  //todo: need to modify with imported flag
3407
3575
  async sign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, onError, traceContext, bitcoinConfig }) {
@@ -3420,7 +3588,7 @@ class DynamicWalletClient {
3420
3588
  bitcoinConfig
3421
3589
  }));
3422
3590
  }
3423
- async internalSign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, onError, traceContext, bitcoinConfig }) {
3591
+ async internalSign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, onError, traceContext, bitcoinConfig, staleShareRetry = false }) {
3424
3592
  const dynamicRequestId = v4();
3425
3593
  try {
3426
3594
  await this.verifyPassword({
@@ -3463,6 +3631,17 @@ class DynamicWalletClient {
3463
3631
  // (sseErrorPromise would never reach Promise.race in that path)
3464
3632
  // eslint-disable-next-line @typescript-eslint/no-empty-function
3465
3633
  sseErrorPromise.catch(()=>{});
3634
+ // Dedicated channel for the server's 409 stale-shares preflight: it must
3635
+ // abort the ceremony into the heal-and-retry path WITHOUT settling the
3636
+ // host request (so the transparent retry's outcome is what the host
3637
+ // sees). Needed as a race arm because in the cached-room flow
3638
+ // serverSignPromise is never awaited, so its rejection alone would float.
3639
+ let rejectOnStaleShares;
3640
+ const staleSharesPromise = new Promise((_, reject)=>{
3641
+ rejectOnStaleShares = reject;
3642
+ });
3643
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
3644
+ staleSharesPromise.catch(()=>{});
3466
3645
  // Perform the server sign
3467
3646
  const serverSignPromise = this.serverSign({
3468
3647
  walletId: wallet.walletId,
@@ -3474,6 +3653,13 @@ class DynamicWalletClient {
3474
3653
  roomId,
3475
3654
  context,
3476
3655
  onError: (error)=>{
3656
+ // The server's 409 stale-shares preflight rejection is healed and
3657
+ // retried internally (see the catch below) — don't settle the host
3658
+ // request or poison the SSE race with it.
3659
+ if (isStaleClientSharesError(error)) {
3660
+ rejectOnStaleShares(error);
3661
+ return;
3662
+ }
3477
3663
  onError == null ? void 0 : onError(error);
3478
3664
  rejectOnSSEError(error);
3479
3665
  },
@@ -3481,6 +3667,11 @@ class DynamicWalletClient {
3481
3667
  traceContext,
3482
3668
  bitcoinConfig
3483
3669
  });
3670
+ // In the cached-room flow the promise below is never awaited — attach a
3671
+ // handler so a rejection (e.g. the 409 preflight, which also arrives via
3672
+ // staleSharesPromise above) doesn't surface as an unhandled rejection.
3673
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
3674
+ serverSignPromise.catch(()=>{});
3484
3675
  // Only await if roomId is not provided
3485
3676
  roomId = roomId != null ? roomId : (await serverSignPromise).roomId;
3486
3677
  this.logger.info('[DynamicWaasWalletClient] Server sign completed', _extends({
@@ -3493,6 +3684,9 @@ class DynamicWalletClient {
3493
3684
  operation: 'serverSign'
3494
3685
  }, this.getTraceContext(traceContext)));
3495
3686
  let signature;
3687
+ // Set by the clientSign rejection handler below; read in the catch to
3688
+ // detect a client-side mismatch masked by a generic SSE race winner.
3689
+ let clientSignMismatchError;
3496
3690
  try {
3497
3691
  // Race clientSign against SSE errors so that server-side failures
3498
3692
  // (e.g. policy violations) immediately reject instead of hanging
@@ -3520,6 +3714,12 @@ class DynamicWalletClient {
3520
3714
  roomId: undefined,
3521
3715
  context,
3522
3716
  onError: (error)=>{
3717
+ // See the initial serverSign onError: 409 stale-shares is
3718
+ // healed + retried internally, never surfaced to the host.
3719
+ if (isStaleClientSharesError(error)) {
3720
+ rejectOnStaleShares(error);
3721
+ return;
3722
+ }
3523
3723
  onError == null ? void 0 : onError(error);
3524
3724
  rejectOnSSEError(error);
3525
3725
  },
@@ -3530,26 +3730,63 @@ class DynamicWalletClient {
3530
3730
  return freshRoomId;
3531
3731
  }
3532
3732
  });
3533
- // Prevent unhandled rejection from the losing promise in the race:
3534
- // if sseErrorPromise wins, clientSign keeps running and will eventually
3535
- // reject (MPC timeout); if clientSign wins, sseErrorPromise may never settle.
3536
- // eslint-disable-next-line @typescript-eslint/no-empty-function
3537
- clientSignPromise.catch(()=>{});
3733
+ // Handle the losing race promise (prevents an unhandled rejection) and
3734
+ // capture a client-side ERROR_PUBLIC_KEY_MISMATCH here the only place
3735
+ // a stale-local-share failure surfaces, since the server's generic SSE
3736
+ // error wins the race and masks it. Schedule recovery regardless of
3737
+ // which promise wins or when this rejection settles.
3738
+ clientSignPromise.catch((error)=>{
3739
+ if (isPublicKeyMismatchError(error)) {
3740
+ clientSignMismatchError = error;
3741
+ this.logger.warn('[StaleShareCheck] clientSign rejected with public-key mismatch — scheduling share recovery', _extends({
3742
+ accountAddress,
3743
+ chainName,
3744
+ dynamicRequestId
3745
+ }, this.getTraceContext(traceContext)));
3746
+ void this.healStaleShares({
3747
+ accountAddress,
3748
+ chainName,
3749
+ password,
3750
+ signedSessionId,
3751
+ walletOperation: WalletOperation.SIGN_MESSAGE,
3752
+ mfaToken,
3753
+ dynamicRequestId,
3754
+ traceContext,
3755
+ bitcoinConfig,
3756
+ trigger: 'clientSign rejection',
3757
+ confirmedMismatch: true
3758
+ });
3759
+ }
3760
+ });
3538
3761
  signature = await Promise.race([
3539
3762
  clientSignPromise,
3540
- sseErrorPromise
3763
+ sseErrorPromise,
3764
+ staleSharesPromise
3541
3765
  ]);
3542
3766
  } catch (signError) {
3543
- // Restore shares from backup on mismatch; passthrough otherwise.
3544
- await this.recoverKeySharesOnMismatch({
3545
- error: signError,
3767
+ // The 409 stale-shares preflight is handled exclusively by the outer
3768
+ // catch (heal + transparent retry) — skip the generic heal here so the
3769
+ // ground-truth check runs once, right before the retry decision.
3770
+ if (isStaleClientSharesError(signError)) {
3771
+ throw signError;
3772
+ }
3773
+ // Heal before rethrowing so the caller's retry succeeds. Run the
3774
+ // ground-truth check whatever the raced error was: a generic SSE
3775
+ // error frequently wins the race while the client-side mismatch
3776
+ // settles later, so error-string inspection alone is timing-dependent.
3777
+ // The check no-ops when shares are fresh.
3778
+ await this.healStaleShares({
3546
3779
  accountAddress,
3780
+ chainName,
3547
3781
  password,
3548
3782
  signedSessionId,
3549
3783
  walletOperation: WalletOperation.SIGN_MESSAGE,
3550
3784
  mfaToken,
3785
+ dynamicRequestId,
3551
3786
  traceContext,
3552
- dynamicRequestId
3787
+ bitcoinConfig,
3788
+ trigger: 'sign failure',
3789
+ confirmedMismatch: isPublicKeyMismatchError(signError) || clientSignMismatchError !== undefined
3553
3790
  });
3554
3791
  throw signError;
3555
3792
  }
@@ -3565,6 +3802,51 @@ class DynamicWalletClient {
3565
3802
  }, this.getTraceContext(traceContext)));
3566
3803
  return signature;
3567
3804
  } catch (error) {
3805
+ // Server-side stale-share preflight (409 STALE_CLIENT_SHARES): the
3806
+ // request was rejected BEFORE any room/ceremony work, and the onError
3807
+ // wrappers above kept it away from the host — so the host request is
3808
+ // still pending. Heal the local shares from backup and transparently
3809
+ // retry once; the host only ever sees the (successful) outcome.
3810
+ if (!staleShareRetry && isStaleClientSharesError(error)) {
3811
+ // Heal first (re-sync local shares + walletMap from the server), then
3812
+ // retry regardless of whether shares were replaced: even a "fresh"
3813
+ // verdict means the heal's getWallets() refreshed the stale
3814
+ // walletMap.shareSetId the server rejected. The retry is guarded to
3815
+ // run at most once; if it 409s again the error surfaces normally.
3816
+ await this.healStaleShares({
3817
+ accountAddress,
3818
+ chainName,
3819
+ password,
3820
+ signedSessionId,
3821
+ walletOperation: WalletOperation.SIGN_MESSAGE,
3822
+ mfaToken,
3823
+ dynamicRequestId,
3824
+ traceContext,
3825
+ bitcoinConfig,
3826
+ trigger: 'server 409 stale-shares preflight',
3827
+ confirmedMismatch: true
3828
+ });
3829
+ this.logger.info('[StaleShareCheck] retrying sign transparently after 409 stale-shares preflight', _extends({
3830
+ accountAddress,
3831
+ chainName,
3832
+ dynamicRequestId
3833
+ }, this.getTraceContext(traceContext)));
3834
+ return await this.internalSign({
3835
+ accountAddress,
3836
+ message,
3837
+ chainName,
3838
+ password,
3839
+ isFormatted,
3840
+ signedSessionId,
3841
+ mfaToken,
3842
+ elevatedAccessToken,
3843
+ context,
3844
+ onError,
3845
+ traceContext,
3846
+ bitcoinConfig,
3847
+ staleShareRetry: true
3848
+ });
3849
+ }
3568
3850
  logError({
3569
3851
  message: 'Error in sign',
3570
3852
  error: error,
@@ -3578,12 +3860,13 @@ class DynamicWalletClient {
3578
3860
  throw error;
3579
3861
  }
3580
3862
  }
3581
- async refreshWalletAccountShares({ accountAddress, chainName, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, traceContext }) {
3863
+ async refreshWalletAccountShares({ accountAddress, chainName, password = undefined, signedSessionId, sessionPublicKey, mfaToken, elevatedAccessToken, traceContext }) {
3582
3864
  return WalletQueueManager.queueHeavyOperation(accountAddress, WalletOperation.REFRESH, ()=>this.internalRefreshWalletAccountShares({
3583
3865
  accountAddress,
3584
3866
  chainName,
3585
3867
  password,
3586
3868
  signedSessionId,
3869
+ sessionPublicKey,
3587
3870
  mfaToken,
3588
3871
  elevatedAccessToken,
3589
3872
  traceContext
@@ -3650,7 +3933,7 @@ class DynamicWalletClient {
3650
3933
  addressType: addressType
3651
3934
  } : undefined;
3652
3935
  }
3653
- async internalRefreshWalletAccountShares({ accountAddress, chainName, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, traceContext }) {
3936
+ async internalRefreshWalletAccountShares({ accountAddress, chainName, password = undefined, signedSessionId, sessionPublicKey, mfaToken, elevatedAccessToken, traceContext, staleShareRetry = false }) {
3654
3937
  const dynamicRequestId = v4();
3655
3938
  try {
3656
3939
  this.assertPasswordRequired(password);
@@ -3742,16 +4025,19 @@ class DynamicWalletClient {
3742
4025
  try {
3743
4026
  refreshResults = await Promise.all(clientKeyShares.map((clientKeyShare)=>mpcSigner.refresh(roomId, clientKeyShare)));
3744
4027
  } catch (refreshError) {
3745
- // Restore shares from backup on mismatch; passthrough otherwise.
3746
- await this.recoverKeySharesOnMismatch({
3747
- error: refreshError,
4028
+ // Heal stale local shares before rethrowing so a retry succeeds.
4029
+ await this.healStaleShares({
3748
4030
  accountAddress,
4031
+ chainName,
3749
4032
  password,
3750
4033
  signedSessionId,
3751
4034
  walletOperation: WalletOperation.REFRESH,
3752
4035
  mfaToken,
4036
+ dynamicRequestId,
3753
4037
  traceContext,
3754
- dynamicRequestId
4038
+ bitcoinConfig,
4039
+ trigger: 'refresh failure',
4040
+ confirmedMismatch: isPublicKeyMismatchError(refreshError)
3755
4041
  });
3756
4042
  throw refreshError;
3757
4043
  }
@@ -3777,7 +4063,8 @@ class DynamicWalletClient {
3777
4063
  accountAddress,
3778
4064
  clientKeyShares: refreshResults,
3779
4065
  password: password != null ? password : this.environmentId,
3780
- signedSessionId
4066
+ signedSessionId,
4067
+ sessionPublicKey
3781
4068
  })
3782
4069
  });
3783
4070
  await this.setClientKeySharesToStorage({
@@ -3803,6 +4090,42 @@ class DynamicWalletClient {
3803
4090
  });
3804
4091
  throw error;
3805
4092
  }
4093
+ // Server-side stale-share preflight (409 STALE_CLIENT_SHARES): rejected
4094
+ // BEFORE any room/ceremony work, so nothing was rotated. Heal the local
4095
+ // shares from backup and transparently retry once — even a "fresh" heal
4096
+ // verdict matters, because its getWallets() refreshed the stale
4097
+ // walletMap.shareSetId the server rejected.
4098
+ if (!staleShareRetry && isStaleClientSharesError(error)) {
4099
+ await this.healStaleShares({
4100
+ accountAddress,
4101
+ chainName,
4102
+ password,
4103
+ signedSessionId,
4104
+ walletOperation: WalletOperation.REFRESH,
4105
+ mfaToken,
4106
+ dynamicRequestId,
4107
+ traceContext,
4108
+ bitcoinConfig: this.getBitcoinConfigForChain(chainName, accountAddress),
4109
+ trigger: 'server 409 stale-shares preflight (refresh)',
4110
+ confirmedMismatch: true
4111
+ });
4112
+ this.logger.info('[StaleShareCheck] retrying refresh transparently after 409 stale-shares preflight', _extends({
4113
+ accountAddress,
4114
+ chainName,
4115
+ dynamicRequestId
4116
+ }, this.getTraceContext(traceContext)));
4117
+ return await this.internalRefreshWalletAccountShares({
4118
+ accountAddress,
4119
+ chainName,
4120
+ password,
4121
+ signedSessionId,
4122
+ sessionPublicKey,
4123
+ mfaToken,
4124
+ elevatedAccessToken,
4125
+ traceContext,
4126
+ staleShareRetry: true
4127
+ });
4128
+ }
3806
4129
  logError({
3807
4130
  message: 'Error in refreshWalletAccountShares',
3808
4131
  error: error,
@@ -3894,7 +4217,7 @@ class DynamicWalletClient {
3894
4217
  existingClientKeyShares
3895
4218
  };
3896
4219
  }
3897
- async reshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, cloudProviders = [], delegateToProjectEnvironment = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource }) {
4220
+ async reshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegateToProjectEnvironment = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource }) {
3898
4221
  return WalletQueueManager.queueHeavyOperation(accountAddress, WalletOperation.RESHARE, ()=>this.internalReshare({
3899
4222
  chainName,
3900
4223
  accountAddress,
@@ -3902,6 +4225,7 @@ class DynamicWalletClient {
3902
4225
  newThresholdSignatureScheme,
3903
4226
  password,
3904
4227
  signedSessionId,
4228
+ sessionPublicKey,
3905
4229
  cloudProviders,
3906
4230
  delegateToProjectEnvironment,
3907
4231
  mfaToken,
@@ -4386,7 +4710,7 @@ class DynamicWalletClient {
4386
4710
  clientKeySharesBackupInfo: updatedBackupInfo
4387
4711
  } : {}));
4388
4712
  }
4389
- async internalReshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, cloudProviders = [], delegateToProjectEnvironment = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource }) {
4713
+ async internalReshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegateToProjectEnvironment = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource, staleShareRetry = false }) {
4390
4714
  const dynamicRequestId = v4();
4391
4715
  // Password validation - wrapped in try-catch for consistent error handling
4392
4716
  // This path should NOT wipe key shares on failure (shares remain valid)
@@ -4598,15 +4922,18 @@ class DynamicWalletClient {
4598
4922
  existingReshareResults = existingSettled.map((r)=>r.value);
4599
4923
  newReshareResults = newSettled.map((r)=>r.value);
4600
4924
  } catch (reshareError) {
4601
- // Restore shares from backup on mismatch; passthrough otherwise.
4602
- await this.recoverKeySharesOnMismatch({
4603
- error: reshareError,
4925
+ // Heal stale local shares before rethrowing so a retry succeeds.
4926
+ await this.healStaleShares({
4604
4927
  accountAddress,
4928
+ chainName,
4605
4929
  password,
4606
4930
  signedSessionId,
4607
4931
  walletOperation: WalletOperation.RESHARE,
4608
4932
  mfaToken,
4609
- dynamicRequestId
4933
+ dynamicRequestId,
4934
+ bitcoinConfig,
4935
+ trigger: 'reshare failure',
4936
+ confirmedMismatch: isPublicKeyMismatchError(reshareError)
4610
4937
  });
4611
4938
  throw reshareError;
4612
4939
  }
@@ -4641,6 +4968,7 @@ class DynamicWalletClient {
4641
4968
  accountAddress,
4642
4969
  password,
4643
4970
  signedSessionId,
4971
+ sessionPublicKey,
4644
4972
  distribution,
4645
4973
  googleDriveAccessToken,
4646
4974
  googleDriveTokenSource
@@ -4678,6 +5006,47 @@ class DynamicWalletClient {
4678
5006
  }
4679
5007
  });
4680
5008
  } catch (error) {
5009
+ // Server-side stale-share preflight (409 STALE_CLIENT_SHARES): rejected
5010
+ // BEFORE any room/ceremony work, so local shares are intact (nothing to
5011
+ // wipe). Heal from backup and transparently retry once — even a "fresh"
5012
+ // heal verdict matters, because its getWallets() refreshed the stale
5013
+ // walletMap.shareSetId the server rejected.
5014
+ if (!staleShareRetry && isStaleClientSharesError(error)) {
5015
+ await this.healStaleShares({
5016
+ accountAddress,
5017
+ chainName,
5018
+ password,
5019
+ signedSessionId,
5020
+ walletOperation: WalletOperation.RESHARE,
5021
+ mfaToken,
5022
+ dynamicRequestId,
5023
+ bitcoinConfig: this.getBitcoinConfigForChain(chainName, accountAddress),
5024
+ trigger: 'server 409 stale-shares preflight (reshare)',
5025
+ confirmedMismatch: true
5026
+ });
5027
+ this.logger.info('[StaleShareCheck] retrying reshare transparently after 409 stale-shares preflight', {
5028
+ accountAddress,
5029
+ chainName,
5030
+ dynamicRequestId
5031
+ });
5032
+ return await this.internalReshare({
5033
+ chainName,
5034
+ accountAddress,
5035
+ oldThresholdSignatureScheme,
5036
+ newThresholdSignatureScheme,
5037
+ password,
5038
+ signedSessionId,
5039
+ sessionPublicKey,
5040
+ cloudProviders,
5041
+ delegateToProjectEnvironment,
5042
+ mfaToken,
5043
+ elevatedAccessToken,
5044
+ revokeDelegation,
5045
+ googleDriveAccessToken,
5046
+ googleDriveTokenSource,
5047
+ staleShareRetry: true
5048
+ });
5049
+ }
4681
5050
  logError({
4682
5051
  message: 'Error in reshare, resetting wallet to previous state',
4683
5052
  error: error,
@@ -4692,9 +5061,12 @@ class DynamicWalletClient {
4692
5061
  dynamicRequestId
4693
5062
  }
4694
5063
  });
4695
- // Skip the wipe on mismatch — recoverKeySharesOnMismatch just restored
4696
- // these shares from backup, undoing that would break the caller's retry.
4697
- if (!isPublicKeyMismatchError(error)) {
5064
+ // Skip the wipe on mismatch — healStaleShares just restored these
5065
+ // shares from backup, undoing that would break the caller's retry.
5066
+ // Same for a 409 stale-shares preflight (only reachable here on the
5067
+ // retried attempt): the request was rejected before any ceremony, so
5068
+ // local shares are consistent — wiping would strand the user.
5069
+ if (!isPublicKeyMismatchError(error) && !isStaleClientSharesError(error)) {
4698
5070
  this.updateWalletMap(accountAddress, {
4699
5071
  thresholdSignatureScheme: oldThresholdSignatureScheme
4700
5072
  });
@@ -4706,7 +5078,7 @@ class DynamicWalletClient {
4706
5078
  throw error;
4707
5079
  }
4708
5080
  }
4709
- async performDelegationOperation({ accountAddress, password, signedSessionId, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, useShareSetReshare = false, operationName }) {
5081
+ async performDelegationOperation({ accountAddress, password, signedSessionId, sessionPublicKey, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, useShareSetReshare = false, operationName }) {
4710
5082
  try {
4711
5083
  const delegateToProjectEnvironment = this.featureFlags && this.featureFlags[FEATURE_FLAGS.ENABLE_DELEGATED_KEY_SHARES_FLAG] === true;
4712
5084
  if (!delegateToProjectEnvironment) {
@@ -4735,6 +5107,7 @@ class DynamicWalletClient {
4735
5107
  newThresholdSignatureScheme,
4736
5108
  password,
4737
5109
  signedSessionId,
5110
+ sessionPublicKey,
4738
5111
  cloudProviders: activeProviders,
4739
5112
  // Server soft-deletes the wallet API key on revoke; a delegated
4740
5113
  // distribution would 400 on the follow-up delivery POST.
@@ -4753,7 +5126,7 @@ class DynamicWalletClient {
4753
5126
  throw error;
4754
5127
  }
4755
5128
  }
4756
- async delegateKeyShares({ accountAddress, password = undefined, signedSessionId, mfaToken }) {
5129
+ async delegateKeyShares({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken }) {
4757
5130
  var _this_featureFlags;
4758
5131
  const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
4759
5132
  if (useShareSetReshare) {
@@ -4778,6 +5151,7 @@ class DynamicWalletClient {
4778
5151
  accountAddress,
4779
5152
  password,
4780
5153
  signedSessionId,
5154
+ sessionPublicKey,
4781
5155
  mfaToken,
4782
5156
  // FF on: reshare to 2/2 — the new delegated share set is always 2/2
4783
5157
  // (server + delegated webhook). getReshareConfig now has a 2/2 → 2/2
@@ -4792,7 +5166,7 @@ class DynamicWalletClient {
4792
5166
  const delegatedKeyShares = backupInfo.backups[BackupLocation.DELEGATED] || [];
4793
5167
  return delegatedKeyShares;
4794
5168
  }
4795
- async revokeDelegation({ accountAddress, password = undefined, signedSessionId, mfaToken }) {
5169
+ async revokeDelegation({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken }) {
4796
5170
  var _this_featureFlags;
4797
5171
  const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
4798
5172
  if (useShareSetReshare) {
@@ -4838,6 +5212,7 @@ class DynamicWalletClient {
4838
5212
  accountAddress,
4839
5213
  password,
4840
5214
  signedSessionId,
5215
+ sessionPublicKey,
4841
5216
  mfaToken,
4842
5217
  newThresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO,
4843
5218
  revokeDelegation: true,
@@ -4854,6 +5229,22 @@ class DynamicWalletClient {
4854
5229
  return new BIP340KeygenResult(extractedPubkey, secretShare);
4855
5230
  }
4856
5231
  async exportKey({ accountAddress, chainName, bitcoinConfig, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, traceContext }) {
5232
+ // Public entry point delegates to the private implementation. Keeping the
5233
+ // `staleShareRetry` flag off the public signature mirrors sign/refresh/
5234
+ // reshare and ensures the transparent 409 retry can't be suppressed by
5235
+ // external callers or intercepted by subclass overrides.
5236
+ return this.internalExportKey({
5237
+ accountAddress,
5238
+ chainName,
5239
+ bitcoinConfig,
5240
+ password,
5241
+ signedSessionId,
5242
+ mfaToken,
5243
+ elevatedAccessToken,
5244
+ traceContext
5245
+ });
5246
+ }
5247
+ async internalExportKey({ accountAddress, chainName, bitcoinConfig, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, traceContext, staleShareRetry = false }) {
4857
5248
  const dynamicRequestId = v4();
4858
5249
  try {
4859
5250
  const wallet = await this.getWallet({
@@ -4898,10 +5289,51 @@ class DynamicWalletClient {
4898
5289
  roomId: data.roomId
4899
5290
  }, this.getTraceContext(traceContext)));
4900
5291
  const derivedPrivateKey = await this.derivePrivateKeyFromExport(mpcSigner, keyExportRaw, wallet);
5292
+ // derivePrivateKeyFromExport returns undefined for unsupported signer
5293
+ // types; fail loudly rather than returning undefined as a string.
5294
+ if (derivedPrivateKey === undefined) {
5295
+ throw new Error(`Private key export is not supported for chain ${chainName}`);
5296
+ }
4901
5297
  return {
4902
5298
  derivedPrivateKey
4903
5299
  };
4904
5300
  } catch (error) {
5301
+ // Server-side stale-share preflight (409 STALE_CLIENT_SHARES): rejected
5302
+ // BEFORE any room/ceremony work. Heal the local shares from backup and
5303
+ // transparently retry once — even a "fresh" heal verdict matters,
5304
+ // because its getWallets() refreshed the stale walletMap.shareSetId the
5305
+ // server rejected.
5306
+ if (!staleShareRetry && isStaleClientSharesError(error)) {
5307
+ await this.healStaleShares({
5308
+ accountAddress,
5309
+ chainName,
5310
+ password,
5311
+ signedSessionId,
5312
+ walletOperation: WalletOperation.EXPORT_PRIVATE_KEY,
5313
+ mfaToken,
5314
+ dynamicRequestId,
5315
+ traceContext,
5316
+ bitcoinConfig,
5317
+ trigger: 'server 409 stale-shares preflight (export)',
5318
+ confirmedMismatch: true
5319
+ });
5320
+ this.logger.info('[StaleShareCheck] retrying export transparently after 409 stale-shares preflight', _extends({
5321
+ accountAddress,
5322
+ chainName,
5323
+ dynamicRequestId
5324
+ }, this.getTraceContext(traceContext)));
5325
+ return await this.internalExportKey({
5326
+ accountAddress,
5327
+ chainName,
5328
+ bitcoinConfig,
5329
+ password,
5330
+ signedSessionId,
5331
+ mfaToken,
5332
+ elevatedAccessToken,
5333
+ traceContext,
5334
+ staleShareRetry: true
5335
+ });
5336
+ }
4905
5337
  logError({
4906
5338
  message: 'Error in exportKey',
4907
5339
  error: error,
@@ -4911,6 +5343,22 @@ class DynamicWalletClient {
4911
5343
  dynamicRequestId
4912
5344
  }, this.getTraceContext(traceContext))
4913
5345
  });
5346
+ // Heal stale local shares before rethrowing so a retry succeeds —
5347
+ // export reconstructs from the local share, so a generation rotated on
5348
+ // another device fails here too.
5349
+ await this.healStaleShares({
5350
+ accountAddress,
5351
+ chainName,
5352
+ password,
5353
+ signedSessionId,
5354
+ walletOperation: WalletOperation.EXPORT_PRIVATE_KEY,
5355
+ mfaToken,
5356
+ dynamicRequestId,
5357
+ traceContext,
5358
+ bitcoinConfig,
5359
+ trigger: 'export failure',
5360
+ confirmedMismatch: isPublicKeyMismatchError(error)
5361
+ });
4914
5362
  throw error;
4915
5363
  }
4916
5364
  }
@@ -5280,6 +5728,14 @@ class DynamicWalletClient {
5280
5728
  * Auto-recovery logic has been removed in favor of the queue pattern.
5281
5729
  * Callers should handle recovery explicitly if needed.
5282
5730
  */ async ensureClientShare(accountAddress) {
5731
+ var _WalletQueueManager_getPendingRecoveryPromise;
5732
+ // A recovery may be in flight (e.g. a stale-share heal scheduled when a
5733
+ // previous sign hit a public-key mismatch). Wait for it so this operation
5734
+ // reads the recovered shares instead of the superseded ones. Recovery
5735
+ // failures are logged where they happen; this operation should proceed
5736
+ // with whatever shares exist rather than fail on the recovery.
5737
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
5738
+ await ((_WalletQueueManager_getPendingRecoveryPromise = WalletQueueManager.getPendingRecoveryPromise(accountAddress)) == null ? void 0 : _WalletQueueManager_getPendingRecoveryPromise.catch(()=>{}));
5283
5739
  const shares = await this.getClientKeySharesFromStorage({
5284
5740
  accountAddress
5285
5741
  });
@@ -5288,13 +5744,14 @@ class DynamicWalletClient {
5288
5744
  }
5289
5745
  return shares;
5290
5746
  }
5291
- async backupToDynamicBackend({ walletId, clientShares, password, signedSessionId, dynamicRequestId, chainName, bitcoinConfig, isPasswordEncrypted, accountAddress, preEncryptedShares, passwordUpdateBatchId, encryptionVersion = DEFAULT_ENCRYPTION_VERSION }) {
5747
+ async backupToDynamicBackend({ walletId, clientShares, password, signedSessionId, sessionPublicKey, dynamicRequestId, chainName, bitcoinConfig, isPasswordEncrypted, accountAddress, preEncryptedShares, passwordUpdateBatchId, encryptionVersion = DEFAULT_ENCRYPTION_VERSION }) {
5292
5748
  var _this_getWalletFromMap;
5293
5749
  const encryptedDynamicShares = preEncryptedShares != null ? preEncryptedShares : await Promise.all(clientShares.map((keyShare)=>this.encryptKeyShare({
5294
5750
  keyShare,
5295
5751
  password,
5296
5752
  version: encryptionVersion
5297
5753
  })));
5754
+ const resolvedSessionPublicKey = sessionPublicKey != null ? sessionPublicKey : await (this.getSessionPublicKeyCallback == null ? void 0 : this.getSessionPublicKeyCallback.call(this));
5298
5755
  const data = await this.apiClient.storeEncryptedBackupByWallet({
5299
5756
  walletId,
5300
5757
  shareSetId: (_this_getWalletFromMap = this.getWalletFromMap(accountAddress)) == null ? void 0 : _this_getWalletFromMap.shareSetId,
@@ -5302,6 +5759,7 @@ class DynamicWalletClient {
5302
5759
  passwordEncrypted: isPasswordEncrypted,
5303
5760
  encryptionVersion,
5304
5761
  signedSessionId,
5762
+ sessionPublicKey: resolvedSessionPublicKey,
5305
5763
  requiresSignedSessionId: this.requiresSignedSessionId(),
5306
5764
  dynamicRequestId
5307
5765
  });
@@ -5450,7 +5908,7 @@ class DynamicWalletClient {
5450
5908
  }
5451
5909
  return location;
5452
5910
  }
5453
- async backupSharesWithDistribution({ accountAddress, password, signedSessionId, distribution, preserveDelegatedLocation = false, passwordUpdateBatchId, googleDriveAccessToken, googleDriveTokenSource, businessAccountId, encryptionVersion }) {
5911
+ async backupSharesWithDistribution({ accountAddress, password, signedSessionId, sessionPublicKey, distribution, preserveDelegatedLocation = false, passwordUpdateBatchId, googleDriveAccessToken, googleDriveTokenSource, businessAccountId, encryptionVersion }) {
5454
5912
  const dynamicRequestId = v4();
5455
5913
  // Get wallet data early for logging context (also used in catch block)
5456
5914
  const walletData = this.getWalletFromMap(accountAddress);
@@ -5529,6 +5987,7 @@ class DynamicWalletClient {
5529
5987
  clientShares: distribution.clientShares,
5530
5988
  password,
5531
5989
  signedSessionId: resolvedSignedSessionId,
5990
+ sessionPublicKey,
5532
5991
  dynamicRequestId,
5533
5992
  chainName: walletData.chainName,
5534
5993
  bitcoinConfig,
@@ -5725,29 +6184,23 @@ class DynamicWalletClient {
5725
6184
  // re-enter the outer operation with correct context. Skipped on internal post-rotation
5726
6185
  // flows that pass clientKeyShares explicitly (the refresh path).
5727
6186
  async ensureLocalSharesAreFresh({ accountAddress, walletData, localShares, password, signedSessionId }) {
5728
- var _freshBackupInfo_backups;
5729
6187
  // Always round-trip to the server — walletMap is populated at wallet-load and is
5730
6188
  // not invalidated when another tab rotates shares, so trusting it would
5731
6189
  // false-positive on cross-tab refresh.
5732
6190
  const { chainName } = walletData;
5733
- const mpcSigner = getMPCSigner({
5734
- chainName,
5735
- baseRelayUrl: this.baseMPCRelayApiUrl,
5736
- bitcoinConfig: this.getBitcoinConfigForChain(chainName, accountAddress)
6191
+ const freshBackupInfo = await this.fetchFreshWalletBackupInfo({
6192
+ accountAddress
5737
6193
  });
5738
- const [freshBackupInfo, localKeygenIds] = await Promise.all([
5739
- this.fetchFreshWalletBackupInfo({
5740
- accountAddress
5741
- }),
5742
- Promise.all(localShares.map((share)=>this.getKeygenIdForShare(mpcSigner, share)))
5743
- ]);
5744
- var _freshBackupInfo_backups_BackupLocation_DYNAMIC;
5745
- 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);
6194
+ const recordedKeygenIds = recordedDynamicKeygenIds(freshBackupInfo);
5746
6195
  // First-time backup or legacy data (server didn't populate keygenId yet) — nothing to compare.
5747
6196
  if (recordedKeygenIds.length === 0) return;
5748
- const recordedSet = new Set(recordedKeygenIds);
5749
- const localMatchesRecorded = localKeygenIds.length === recordedSet.size && localKeygenIds.every((id)=>recordedSet.has(id));
5750
- if (localMatchesRecorded) return;
6197
+ const { fresh, localKeygenIds } = await this.compareShareGenerations({
6198
+ accountAddress,
6199
+ chainName,
6200
+ localShares,
6201
+ recordedKeygenIds
6202
+ });
6203
+ if (fresh) return;
5751
6204
  this.logger.warn('[storeEncryptedBackupByWallet] Stale local shares detected; refreshing local storage', {
5752
6205
  accountAddress,
5753
6206
  walletId: walletData.walletId,
@@ -5774,7 +6227,7 @@ class DynamicWalletClient {
5774
6227
  const recoveryPassword = freshBackupInfo.passwordEncrypted ? password : undefined;
5775
6228
  try {
5776
6229
  // walletMap was already refreshed by fetchFreshWalletBackupInfo above, so we skip the
5777
- // getWallets() refresh that recoverKeySharesOnMismatch performs for its own callers.
6230
+ // getWallets() refresh that verifyAndRecoverStaleShare performs for its own callers.
5778
6231
  await this.internalRecoverEncryptedBackupByWallet({
5779
6232
  accountAddress,
5780
6233
  password: recoveryPassword,
@@ -5832,7 +6285,7 @@ class DynamicWalletClient {
5832
6285
  * @param params.signedSessionId - Optional signed session ID for authentication
5833
6286
  * @param params.backupToGoogleDrive - Whether to backup to Google Drive (defaults to false)
5834
6287
  * @returns Promise with backup metadata including share locations and IDs
5835
- */ async storeEncryptedBackupByWallet({ accountAddress, clientKeyShares = undefined, password = undefined, signedSessionId, cloudProviders = [], delegatedKeyshare = undefined, passwordUpdateBatchId, googleDriveAccessToken, googleDriveTokenSource, businessAccountId, encryptionVersion }) {
6288
+ */ async storeEncryptedBackupByWallet({ accountAddress, clientKeyShares = undefined, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegatedKeyshare = undefined, passwordUpdateBatchId, googleDriveAccessToken, googleDriveTokenSource, businessAccountId, encryptionVersion }) {
5836
6289
  var _backupData_locationsWithKeyShares, _backupData_locationsWithKeyShares1;
5837
6290
  const hasProvidedShares = clientKeyShares !== undefined;
5838
6291
  const keySharesToBackup = clientKeyShares != null ? clientKeyShares : await this.getClientKeySharesFromStorage({
@@ -5902,6 +6355,7 @@ class DynamicWalletClient {
5902
6355
  accountAddress,
5903
6356
  password,
5904
6357
  signedSessionId,
6358
+ sessionPublicKey,
5905
6359
  distribution,
5906
6360
  preserveDelegatedLocation,
5907
6361
  passwordUpdateBatchId,
@@ -5974,7 +6428,7 @@ class DynamicWalletClient {
5974
6428
  errorContext
5975
6429
  } : {}));
5976
6430
  }
5977
- async updatePassword({ accountAddress, existingPassword, newPassword, signedSessionId, passwordUpdateBatchId }) {
6431
+ async updatePassword({ accountAddress, existingPassword, newPassword, signedSessionId, sessionPublicKey, passwordUpdateBatchId }) {
5978
6432
  const dynamicRequestId = v4();
5979
6433
  try {
5980
6434
  const wallet = this.getWalletFromMap(accountAddress);
@@ -5994,6 +6448,7 @@ class DynamicWalletClient {
5994
6448
  accountAddress,
5995
6449
  password: newPassword,
5996
6450
  signedSessionId,
6451
+ sessionPublicKey,
5997
6452
  passwordUpdateBatchId
5998
6453
  });
5999
6454
  this.logPasswordOperationSuccess('updatePassword', {
@@ -6023,7 +6478,7 @@ class DynamicWalletClient {
6023
6478
  throw error;
6024
6479
  }
6025
6480
  }
6026
- async setPassword({ accountAddress, newPassword, signedSessionId, passwordUpdateBatchId }) {
6481
+ async setPassword({ accountAddress, newPassword, signedSessionId, sessionPublicKey, passwordUpdateBatchId }) {
6027
6482
  const dynamicRequestId = v4();
6028
6483
  try {
6029
6484
  const wallet = this.getWalletFromMap(accountAddress);
@@ -6034,6 +6489,7 @@ class DynamicWalletClient {
6034
6489
  accountAddress,
6035
6490
  password: newPassword,
6036
6491
  signedSessionId,
6492
+ sessionPublicKey,
6037
6493
  passwordUpdateBatchId
6038
6494
  });
6039
6495
  this.logPasswordOperationSuccess('setPassword', {
@@ -6454,7 +6910,7 @@ class DynamicWalletClient {
6454
6910
  * Internal helper method that handles the complete flow for ensuring wallet key shares are backed up to a cloud provider.
6455
6911
  * - For 2-of-2 wallets: Automatically reshares to 2-of-3 threshold, then distributes shares (1 to backend, 1 to cloud)
6456
6912
  * - For 2-of-3 wallets: Call storeEncryptedBackupByWallet to backup for backend and cloud
6457
- */ async backupKeySharesToCloudProvider({ accountAddress, password, signedSessionId, backupLocation, googleDriveAccessToken, googleDriveTokenSource }) {
6913
+ */ async backupKeySharesToCloudProvider({ accountAddress, password, signedSessionId, sessionPublicKey, backupLocation, googleDriveAccessToken, googleDriveTokenSource }) {
6458
6914
  try {
6459
6915
  await this.getWallet({
6460
6916
  accountAddress,
@@ -6473,6 +6929,7 @@ class DynamicWalletClient {
6473
6929
  newThresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_THREE,
6474
6930
  password,
6475
6931
  signedSessionId,
6932
+ sessionPublicKey,
6476
6933
  cloudProviders: [
6477
6934
  backupLocation
6478
6935
  ],
@@ -6484,6 +6941,7 @@ class DynamicWalletClient {
6484
6941
  accountAddress,
6485
6942
  password,
6486
6943
  signedSessionId,
6944
+ sessionPublicKey,
6487
6945
  cloudProviders: [
6488
6946
  backupLocation
6489
6947
  ],
@@ -6514,7 +6972,7 @@ class DynamicWalletClient {
6514
6972
  * @param params.signedSessionId - Optional signed session ID for authentication
6515
6973
  * @param params.googleDriveAccessToken - Optional Google OAuth access token. When provided, the SDK uses it directly
6516
6974
  * instead of looking up the user's linked Google account and exchanging it for a token via the Dynamic backend.
6517
- */ async backupKeySharesToGoogleDrive({ accountAddress, password, signedSessionId, googleDriveAccessToken }) {
6975
+ */ async backupKeySharesToGoogleDrive({ accountAddress, password, signedSessionId, sessionPublicKey, googleDriveAccessToken }) {
6518
6976
  // Track whether the Drive token was supplied by the caller (external) or
6519
6977
  // will be resolved from the user's stored OAuth account (stored_oauth), so
6520
6978
  // backups using a token we don't control are auditable. Enum only — the
@@ -6534,6 +6992,7 @@ class DynamicWalletClient {
6534
6992
  accountAddress,
6535
6993
  password,
6536
6994
  signedSessionId,
6995
+ sessionPublicKey,
6537
6996
  backupLocation: BackupLocation.GOOGLE_DRIVE,
6538
6997
  // Forward the resolved token so the downstream upload path doesn't
6539
6998
  // re-fetch it via fetchGoogleDriveAccessToken.
@@ -6605,11 +7064,12 @@ class DynamicWalletClient {
6605
7064
  * @param params.accountAddress - The wallet account address to backup
6606
7065
  * @param params.password - Optional password for encryption (uses environment ID if not provided)
6607
7066
  * @param params.signedSessionId - Optional signed session ID for authentication
6608
- */ async backupKeySharesToICloud({ accountAddress, password, signedSessionId }) {
7067
+ */ async backupKeySharesToICloud({ accountAddress, password, signedSessionId, sessionPublicKey }) {
6609
7068
  return this.backupKeySharesToCloudProvider({
6610
7069
  accountAddress,
6611
7070
  password,
6612
7071
  signedSessionId,
7072
+ sessionPublicKey,
6613
7073
  backupLocation: BackupLocation.ICLOUD
6614
7074
  });
6615
7075
  }
@@ -7764,13 +8224,16 @@ class DynamicWalletClient {
7764
8224
  webLocksAvailable
7765
8225
  });
7766
8226
  // If less than or only 1 room left after removing one, trigger async creation for more rooms.
8227
+ // The catch prevents an unhandled promise rejection when the API
8228
+ // rate-limits this best-effort background call — createRooms already
8229
+ // logs the error at the appropriate level (warn for 429).
7767
8230
  const remainingRooms = DynamicWalletClient.rooms[numberOfParties] || [];
7768
8231
  if (remainingRooms.length <= 1) {
7769
8232
  this.createRooms({
7770
8233
  roomType,
7771
8234
  thresholdSignatureScheme,
7772
8235
  roomCount: ROOM_CACHE_COUNT
7773
- });
8236
+ }).catch(()=>{});
7774
8237
  }
7775
8238
  return room;
7776
8239
  }
@@ -7819,6 +8282,10 @@ class DynamicWalletClient {
7819
8282
  if (internalOptions == null ? void 0 : internalOptions.getSignedSessionId) {
7820
8283
  this.getSignedSessionIdCallback = internalOptions.getSignedSessionId;
7821
8284
  }
8285
+ // Set session public key callback if provided (internal use only)
8286
+ if (internalOptions == null ? void 0 : internalOptions.getSessionPublicKey) {
8287
+ this.getSessionPublicKeyCallback = internalOptions.getSessionPublicKey;
8288
+ }
7822
8289
  // Read the callback lazily so it tracks `getSignedSessionIdCallback` even
7823
8290
  // when tests reassign it after construction.
7824
8291
  this.signedSession = new SignedSessionManager({
@@ -7892,4 +8359,4 @@ DynamicWalletClient.roomsPersistChain = Promise.resolve();
7892
8359
  // rooms back into the new user's storage (TOCTOU on the create→merge gap).
7893
8360
  DynamicWalletClient.roomsGeneration = 0;
7894
8361
 
7895
- 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_INCORRECT_PASSWORD, ERROR_KEYGEN_FAILED, ERROR_MULTIPLE_WALLETS_PER_CHAIN, ERROR_NO_KEY_SHARES_BACKED_UP, ERROR_PASSWORD_MISMATCH, ERROR_PASSWORD_REQUIRED, 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, OperationSource, 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, formatTronMessage, getActiveCloudProviders, getBitcoinAddressTypeFromDerivationPath, getClientKeyShareBackupInfo, getClientKeyShareExportFileName, getDelegatedShareSet, getGoogleOAuthAccountId, getHttpStatus, getICloudBackup, getMPCSignatureScheme, getMPCSigner, hasCloudProviderBackup, hasDelegatedBackup, hasEncryptedSharesCached, initializeCloudKit, isBrowser, isHeavyQueueOperation, isHexString, isICloudAuthenticated, isNonRetryableCeremonyError, isPublicKeyMismatchError, isRecoverQueueOperation, isSignQueueOperation, listICloudBackups, markCeremonyErrorNonRetryable, readEnvironmentSettings, retryPromise, shouldReshareToSameBackups, timeoutPromise };
8362
+ 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_INCORRECT_PASSWORD, ERROR_KEYGEN_FAILED, ERROR_MULTIPLE_WALLETS_PER_CHAIN, ERROR_NO_KEY_SHARES_BACKED_UP, ERROR_PASSWORD_MISMATCH, ERROR_PASSWORD_REQUIRED, 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, OperationSource, 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, formatTronMessage, getActiveCloudProviders, getBitcoinAddressTypeFromDerivationPath, getClientKeyShareBackupInfo, getClientKeyShareExportFileName, getDelegatedShareSet, getGoogleOAuthAccountId, getHttpStatus, getICloudBackup, getMPCSignatureScheme, getMPCSigner, hasCloudProviderBackup, hasDelegatedBackup, hasEncryptedSharesCached, initializeCloudKit, isBrowser, isHeavyQueueOperation, isHexString, isICloudAuthenticated, isNonRetryableCeremonyError, isPublicKeyMismatchError, isRecoverQueueOperation, isSignQueueOperation, isStaleClientSharesError, listICloudBackups, markCeremonyErrorNonRetryable, readEnvironmentSettings, retryPromise, shouldReshareToSameBackups, timeoutPromise };