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