@dynamic-labs-wallet/browser 1.0.19 → 1.0.21

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
@@ -2,13 +2,13 @@
2
2
 
3
3
  var core = require('@dynamic-labs-wallet/core');
4
4
  var web = require('#internal/web');
5
- var semver = require('semver');
6
5
  var uuid = require('uuid');
7
6
  var primitives = require('@dynamic-labs-wallet/primitives');
8
7
  var sdkApiCore = require('@dynamic-labs/sdk-api-core');
9
8
  var loadArgon2idWasm = require('argon2id');
10
9
  var axios = require('axios');
11
10
  var PQueue = require('p-queue');
11
+ var semver = require('semver');
12
12
 
13
13
  function _extends() {
14
14
  _extends = Object.assign || function assign(target) {
@@ -997,6 +997,16 @@ const downloadFileFromGoogleDrive = async ({ accessToken, fileName })=>{
997
997
  }
998
998
  };
999
999
 
1000
+ /**
1001
+ * Extracts the HTTP status from an `AxiosError`, or `undefined` for non-HTTP
1002
+ * errors. Lives in its own leaf module (no `#internal/web` / WASM imports) so
1003
+ * lightweight consumers like `services/signedSession.ts` can use it without
1004
+ * pulling the MPC WASM bundle that `utils.ts` loads.
1005
+ */ const getHttpStatus = (error)=>{
1006
+ var _error_response;
1007
+ return error instanceof axios.AxiosError ? (_error_response = error.response) == null ? void 0 : _error_response.status : undefined;
1008
+ };
1009
+
1000
1010
  const STORAGE_KEY = 'dynamic-waas-wallet-client';
1001
1011
  const BACKUP_FILENAME = 'dynamicWalletKeyShareBackup.json';
1002
1012
  const CLIENT_KEYSHARE_EXPORT_FILENAME_PREFIX = 'dynamicWalletKeyShareBackup';
@@ -1076,14 +1086,6 @@ const getClientKeyShareBackupInfo = (params)=>{
1076
1086
  const timeoutPromise = ({ timeInMs, activity = 'Ceremony' })=>{
1077
1087
  return new Promise((_, reject)=>setTimeout(()=>reject(new Error(`${activity} did not complete in ${timeInMs}ms`)), timeInMs));
1078
1088
  };
1079
- /**
1080
- * Extracts the HTTP status code from an error, returning undefined for non-HTTP
1081
- * failures (network errors, DNS, aborts) which retry logic typically treats
1082
- * the same as 5xx.
1083
- */ const getHttpStatus = (error)=>{
1084
- var _error_response;
1085
- return error instanceof axios.AxiosError ? (_error_response = error.response) == null ? void 0 : _error_response.status : undefined;
1086
- };
1087
1089
  const buildErrorContext = (error)=>{
1088
1090
  var _error_response, _error_response1;
1089
1091
  return {
@@ -2463,6 +2465,84 @@ const readEnvironmentSettings = ()=>{
2463
2465
  }
2464
2466
  });
2465
2467
 
2468
+ /**
2469
+ * Owns everything about signed sessions and their single-use replay nonces:
2470
+ * resolving a session from an explicit value or the host reverse channel,
2471
+ * deciding whether the SDK version requires one, and building the
2472
+ * refresh-on-replay-400 retry predicate shared by the backup and recovery
2473
+ * flows.
2474
+ *
2475
+ * Extracted from `DynamicWalletClient` so this logic can be exercised in
2476
+ * isolation. The host callback is read lazily (`getCallback`) so it stays in
2477
+ * sync with the owning client even if reassigned after construction.
2478
+ */ class SignedSessionManager {
2479
+ /** True when a reverse channel is configured to mint fresh signed sessions. */ get canRefresh() {
2480
+ return this.getCallback() !== undefined;
2481
+ }
2482
+ /**
2483
+ * Resolves the signed session ID from an explicit value or falls back to the
2484
+ * host reverse channel. Throws if neither source provides a value.
2485
+ */ async resolve(signedSessionId) {
2486
+ const callback = this.getCallback();
2487
+ const resolved = signedSessionId != null ? signedSessionId : await (callback == null ? void 0 : callback());
2488
+ if (!resolved) {
2489
+ throw new Error(signedSessionId === undefined && callback ? 'signedSessionId callback returned an invalid value' : 'signedSessionId is required for backup but was not provided and no callback is configured');
2490
+ }
2491
+ return resolved;
2492
+ }
2493
+ /** Whether the configured SDK version requires a signed session ID. */ isRequired() {
2494
+ return this.required;
2495
+ }
2496
+ computeIsRequired(sdkVersion) {
2497
+ if (!sdkVersion) {
2498
+ return false;
2499
+ }
2500
+ try {
2501
+ const parsedVersion = core.parseNamespacedVersion(sdkVersion);
2502
+ if (!parsedVersion) {
2503
+ return false;
2504
+ }
2505
+ const { namespace, version } = parsedVersion;
2506
+ const namespaceMinVersion = SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE[namespace];
2507
+ if (namespaceMinVersion) {
2508
+ return semver.gte(version, namespaceMinVersion);
2509
+ }
2510
+ return semver.gte(version, SIGNED_SESSION_ID_MIN_VERSION);
2511
+ } catch (error) {
2512
+ this.logger.warn(`[DynamicWaasWalletClient] Error checking if requiresSignedSessionId should be set to true for version ${sdkVersion}`, {
2513
+ error: error instanceof Error ? error.message : String(error)
2514
+ });
2515
+ return false;
2516
+ }
2517
+ }
2518
+ /**
2519
+ * Builds a `retryPromise` `shouldRetry` predicate that refreshes a consumed
2520
+ * single-use nonce via the reverse channel on a replay 400 and retries once.
2521
+ * The signed session carries a single-use replay nonce: it is valid on its
2522
+ * first use, but once consumed (e.g. by a preceding operation) the server
2523
+ * rejects with a 400, at which point a fresh session is fetched and the call
2524
+ * retried. `alsoRetry` lets callers opt into additional retryable statuses
2525
+ * (e.g. 429 / 5xx) on top of the nonce refresh.
2526
+ */ refreshShouldRetry({ onRefreshed, operationName, logContext, alsoRetry }) {
2527
+ return async (error, attempt)=>{
2528
+ const status = getHttpStatus(error);
2529
+ if (status === 400 && attempt === 1 && this.canRefresh) {
2530
+ onRefreshed(await this.resolve());
2531
+ this.logger.info(`[${operationName}] refreshed signed session, retrying`, _extends({
2532
+ status
2533
+ }, logContext));
2534
+ return true;
2535
+ }
2536
+ return alsoRetry ? alsoRetry(status) : false;
2537
+ };
2538
+ }
2539
+ constructor({ logger, sdkVersion, getCallback }){
2540
+ this.logger = logger;
2541
+ this.getCallback = getCallback;
2542
+ this.required = this.computeIsRequired(sdkVersion);
2543
+ }
2544
+ }
2545
+
2466
2546
  /**
2467
2547
  * Determines the recovery state of a wallet based on backup info and local shares.
2468
2548
  *
@@ -2512,17 +2592,6 @@ const KNOWN_SHARE_SET_TYPES = new Set([
2512
2592
  ]);
2513
2593
  class DynamicWalletClient {
2514
2594
  /**
2515
- * Resolves the signed session ID from an explicit value or falls back to the callback.
2516
- * Throws if neither source provides a value.
2517
- */ async resolveSignedSessionId(signedSessionId) {
2518
- const resolved = signedSessionId != null ? signedSessionId : await (this.getSignedSessionIdCallback == null ? void 0 : this.getSignedSessionIdCallback.call(this));
2519
- if (!resolved) {
2520
- const errorMsg = signedSessionId === undefined && this.getSignedSessionIdCallback ? 'signedSessionId callback returned an invalid value' : 'signedSessionId is required for backup but was not provided and no callback is configured';
2521
- throw new Error(errorMsg);
2522
- }
2523
- return resolved;
2524
- }
2525
- /**
2526
2595
  * Check if wallet has heavy operations in progress
2527
2596
  */ static isHeavyOpInProgress(accountAddress) {
2528
2597
  return WalletQueueManager.isHeavyOpInProgress(accountAddress);
@@ -2608,29 +2677,8 @@ class DynamicWalletClient {
2608
2677
  * Check if the SDK version meets the requirement for signed session ID
2609
2678
  * Uses namespace-specific version requirements when available
2610
2679
  * @returns boolean indicating if requireSignedSessionId should be set to true
2611
- */ requiresSignedSessionId() {
2612
- if (!this.sdkVersion) {
2613
- return false;
2614
- }
2615
- try {
2616
- const parsedVersion = core.parseNamespacedVersion(this.sdkVersion);
2617
- if (!parsedVersion) {
2618
- return false;
2619
- }
2620
- const { namespace, version } = parsedVersion;
2621
- // Check if we have a namespace-specific version requirement
2622
- const namespaceMinVersion = SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE[namespace];
2623
- if (namespaceMinVersion) {
2624
- return semver.gte(version, namespaceMinVersion);
2625
- }
2626
- // Fall back to default version requirement
2627
- return semver.gte(version, SIGNED_SESSION_ID_MIN_VERSION);
2628
- } catch (error) {
2629
- this.logger.warn(`[DynamicWaasWalletClient] Error checking if requiresSignedSessionId should be set to true for version ${this.sdkVersion}`, {
2630
- error: error instanceof Error ? error.message : String(error)
2631
- });
2632
- return false;
2633
- }
2680
+ */ /** @see SignedSessionManager.isRequired */ requiresSignedSessionId() {
2681
+ return this.signedSession.isRequired();
2634
2682
  }
2635
2683
  async initLoggerContext(authToken) {
2636
2684
  // only decode jwt token in header auth mode
@@ -3665,24 +3713,35 @@ class DynamicWalletClient {
3665
3713
  });
3666
3714
  // Ensure client key shares exist before hitting the API
3667
3715
  const clientKeyShares = await this.ensureClientShare(accountAddress);
3668
- // Tracks whether the SSE ceremony_complete event fired during this
3669
- // operation. Logged at completion so we can correlate "callback didn't
3670
- // fire" with downstream sign failures or pending-rotation reports.
3671
- let ceremonyCallbackFired = false;
3672
- // Deferred resolved by onCeremonyComplete. The MPC ceremony itself
3673
- // requires client + server in the room concurrently, so we run
3674
- // mpcSigner.refresh BEFORE awaiting this the await's purpose is to
3675
- // ensure the walletMap.shareSetId rotation (which happens in
3676
- // onCeremonyComplete) lands before storeEncryptedBackupByWallet reads
3677
- // it. Without it, backup races ceremony_complete and the atomic swap
3678
- // targets the stale row.
3679
- // Mirrors the pattern used in keyGen, with the key difference that
3680
- // refresh's API call returns on room_created (not on the local MPC
3681
- // result), so the await sits between the local MPC step and the
3682
- // backup step, not immediately after the API call.
3683
- let ceremonyCompleteResolver;
3684
- const ceremonyCompletePromise = new Promise((resolve)=>{
3685
- ceremonyCompleteResolver = resolve;
3716
+ // The MPC ceremony needs client + server in the room concurrently, so we
3717
+ // run mpcSigner.refresh BEFORE awaiting the gate. The gate's purpose is to
3718
+ // ensure the walletMap.shareSetId rotation (done in onCeremonyComplete)
3719
+ // lands before storeEncryptedBackupByWallet reads it; without it, backup
3720
+ // would race ceremony_complete and the atomic swap could target the stale
3721
+ // row. refresh's API call returns on room_created (not the local MPC
3722
+ // result), so the gate await sits between the local MPC and the backup.
3723
+ const ceremonyGate = this.createCeremonyTerminalGate((_accountAddress, _walletId, shareSetId, shareSetType)=>{
3724
+ this.logger.info('[WaasRefresh] ceremony_complete received', {
3725
+ context: {
3726
+ walletId: wallet.walletId,
3727
+ newShareSetId: shareSetId,
3728
+ shareSetType,
3729
+ dynamicRequestId
3730
+ }
3731
+ });
3732
+ this.logOnMalformedCeremonyPayload({
3733
+ walletId: wallet.walletId,
3734
+ ceremony: 'refresh',
3735
+ shareSetId,
3736
+ shareSetType
3737
+ });
3738
+ this.rotateRootUserShareSetIdIfChanged({
3739
+ accountAddress,
3740
+ wallet,
3741
+ ceremony: 'refresh',
3742
+ shareSetId,
3743
+ shareSetType
3744
+ });
3686
3745
  });
3687
3746
  this.logger.info('[WaasRefresh] calling refreshWalletAccountShares', {
3688
3747
  context: {
@@ -3697,31 +3756,8 @@ class DynamicWalletClient {
3697
3756
  shareSetId: wallet.shareSetId,
3698
3757
  mfaToken,
3699
3758
  elevatedAccessToken,
3700
- onCeremonyComplete: (_accountAddress, _walletId, shareSetId, shareSetType)=>{
3701
- ceremonyCallbackFired = true;
3702
- this.logger.info('[WaasRefresh] ceremony_complete received', {
3703
- context: {
3704
- walletId: wallet.walletId,
3705
- newShareSetId: shareSetId,
3706
- shareSetType,
3707
- dynamicRequestId
3708
- }
3709
- });
3710
- this.logOnMalformedCeremonyPayload({
3711
- walletId: wallet.walletId,
3712
- ceremony: 'refresh',
3713
- shareSetId,
3714
- shareSetType
3715
- });
3716
- this.rotateRootUserShareSetIdIfChanged({
3717
- accountAddress,
3718
- wallet,
3719
- ceremony: 'refresh',
3720
- shareSetId,
3721
- shareSetType
3722
- });
3723
- ceremonyCompleteResolver(undefined);
3724
- }
3759
+ onError: ceremonyGate.onError,
3760
+ onCeremonyComplete: ceremonyGate.onCeremonyComplete
3725
3761
  });
3726
3762
  this.logger.info('[WaasRefresh] room_created received, starting local MPC', {
3727
3763
  context: {
@@ -3751,40 +3787,11 @@ class DynamicWalletClient {
3751
3787
  this.logger.info('[WaasRefresh] local MPC done, awaiting ceremony_complete before backup', {
3752
3788
  context: {
3753
3789
  walletId: wallet.walletId,
3754
- ceremonyCallbackFired,
3790
+ ceremonyCallbackFired: ceremonyGate.fired,
3755
3791
  dynamicRequestId
3756
3792
  }
3757
3793
  });
3758
- // Wait for ceremony_complete so the shareSetId rotation in
3759
- // onCeremonyComplete lands in walletMap before the backup call reads it.
3760
- // Bounded by a 5s timeout so a missing/late event never hangs the SDK —
3761
- // if it fires we proceed with the rotated id; if it doesn't we fall back
3762
- // to the (stale) walletMap.shareSetId, matching pre-fix behavior.
3763
- const REFRESH_CEREMONY_COMPLETE_TIMEOUT_MS = 5000;
3764
- let ceremonyCompleteTimedOut = false;
3765
- let refreshTimeoutId;
3766
- await Promise.race([
3767
- ceremonyCompletePromise,
3768
- new Promise((resolve)=>{
3769
- refreshTimeoutId = setTimeout(()=>{
3770
- ceremonyCompleteTimedOut = true;
3771
- resolve(undefined);
3772
- }, REFRESH_CEREMONY_COMPLETE_TIMEOUT_MS);
3773
- })
3774
- ]);
3775
- // Cancel the pending timer when ceremony_complete wins the race so it
3776
- // doesn't fire later and flip ceremonyCompleteTimedOut after the fact.
3777
- // Safe no-op when the timer has already fired.
3778
- clearTimeout(refreshTimeoutId);
3779
- if (ceremonyCompleteTimedOut) {
3780
- this.logger.warn(`[WaasRefresh] ceremony_complete didn’t arrive within ${REFRESH_CEREMONY_COMPLETE_TIMEOUT_MS}ms — proceeding with stale walletMap`, {
3781
- context: {
3782
- walletId: wallet.walletId,
3783
- ceremonyCallbackFired,
3784
- dynamicRequestId
3785
- }
3786
- });
3787
- }
3794
+ await ceremonyGate.awaitTerminal();
3788
3795
  // Backup & activate server shares BEFORE saving to local storage.
3789
3796
  // This prevents a mismatch where new client shares are stored locally
3790
3797
  // but old server shares remain active if backup/activation fails.
@@ -3807,15 +3814,13 @@ class DynamicWalletClient {
3807
3814
  clientKeyShares: refreshResults
3808
3815
  });
3809
3816
  // Operation summary — correlates against downstream sign failures /
3810
- // pending-rotation reports. `ceremonyCallbackFired: false` here means
3811
- // either the server is legacy (didn't emit ceremony_complete) or the
3812
- // SSE event was lost mid-flight.
3817
+ // pending-rotation reports. The gate always fired here (we throw before
3818
+ // backup otherwise); kept for log continuity.
3813
3819
  this.logger.info('[WaasRefresh] completed', {
3814
3820
  context: {
3815
3821
  walletId: wallet.walletId,
3816
3822
  oldShareSetId: wallet.shareSetId,
3817
- ceremonyCallbackFired,
3818
- ceremonyCompleteTimedOut,
3823
+ ceremonyCallbackFired: ceremonyGate.fired,
3819
3824
  dynamicRequestId
3820
3825
  }
3821
3826
  });
@@ -3971,6 +3976,50 @@ class DynamicWalletClient {
3971
3976
  });
3972
3977
  }
3973
3978
  }
3979
+ // Terminal gate shared by refresh and reshare. Both APIs resolve early on
3980
+ // room_created, so a failed ceremony surfaces *after* the API call returns —
3981
+ // as an SSE error event, a stream close without ceremony_complete, or a
3982
+ // transport drop (all delivered via onError). awaitTerminal() races the two
3983
+ // outcomes with no timeout:
3984
+ // - ceremony_complete → resolves → caller backs up against the rotated id.
3985
+ // - any onError → rejects → caller throws, never backs up (the wedge fix:
3986
+ // a failed ceremony must not promote a client share whose server
3987
+ // counterpart was never persisted).
3988
+ // redcoast always emits one or the other and the event-stream consumer turns
3989
+ // "closed without ceremony_complete" into an onError, so the race always
3990
+ // settles. Aborting fails closed — worst case is a clean retry.
3991
+ //
3992
+ // The reject handle is wired synchronously (Promise executors run inline)
3993
+ // before the API call starts, so an immediate onError can never be dropped.
3994
+ // The lost branch's rejection is swallowed so it never surfaces as unhandled.
3995
+ createCeremonyTerminalGate(onCeremonyComplete) {
3996
+ let resolveComplete;
3997
+ const completePromise = new Promise((resolve)=>{
3998
+ resolveComplete = resolve;
3999
+ });
4000
+ let rejectOnError;
4001
+ const errorPromise = new Promise((_, reject)=>{
4002
+ rejectOnError = reject;
4003
+ });
4004
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
4005
+ errorPromise.catch(()=>{});
4006
+ let fired = false;
4007
+ return {
4008
+ onError: rejectOnError,
4009
+ onCeremonyComplete: (accountAddress, walletId, shareSetId, shareSetType)=>{
4010
+ fired = true;
4011
+ onCeremonyComplete == null ? void 0 : onCeremonyComplete(accountAddress, walletId, shareSetId, shareSetType);
4012
+ resolveComplete();
4013
+ },
4014
+ awaitTerminal: ()=>Promise.race([
4015
+ completePromise,
4016
+ errorPromise
4017
+ ]),
4018
+ get fired () {
4019
+ return fired;
4020
+ }
4021
+ };
4022
+ }
3974
4023
  // Runs the post-ceremony backup-activation step (storeEncryptedBackupByWallet
3975
4024
  // for refresh, backupSharesWithDistribution for reshare). On failure, rolls
3976
4025
  // walletMap.shareSetId back to the source row.
@@ -4202,19 +4251,39 @@ class DynamicWalletClient {
4202
4251
  ...existingClientKeygenIds
4203
4252
  ];
4204
4253
  const bitcoinConfig = this.getBitcoinConfigForChain(chainName, accountAddress);
4205
- // Tracks whether the SSE ceremony_complete event fired during this
4206
- // operation. Logged at completion so we can correlate "callback didn't
4207
- // fire" with downstream sign failures or pending-rotation reports.
4208
- let ceremonyCallbackFired = false;
4209
- // Deferred resolved by onCeremonyComplete. The MPC reshare requires
4210
- // client + server in the room concurrently, so we run mpcSigner.reshare*
4211
- // BEFORE awaiting this — the await's purpose is to ensure the
4254
+ // The MPC reshare needs client + server in the room concurrently, so we
4255
+ // run mpcSigner.reshare* BEFORE awaiting the gate. The gate ensures the
4212
4256
  // walletMap.shareSetId rotation lands before storeEncryptedBackupByWallet
4213
- // reads it. Without it, backup races ceremony_complete and the atomic
4214
- // swap targets the stale row.
4215
- let ceremonyCompleteResolver;
4216
- const ceremonyCompletePromise = new Promise((resolve)=>{
4217
- ceremonyCompleteResolver = resolve;
4257
+ // reads it; without it, backup would race ceremony_complete and the atomic
4258
+ // swap could target the stale row.
4259
+ const ceremonyGate = this.createCeremonyTerminalGate((_accountAddress, _walletId, shareSetId, shareSetType)=>{
4260
+ this.logger.info('[WaasReshare] ceremony_complete received', {
4261
+ context: {
4262
+ walletId: wallet.walletId,
4263
+ newShareSetId: shareSetId,
4264
+ shareSetType,
4265
+ dynamicRequestId
4266
+ }
4267
+ });
4268
+ this.logOnMalformedCeremonyPayload({
4269
+ walletId: wallet.walletId,
4270
+ ceremony: 'reshare',
4271
+ shareSetId,
4272
+ shareSetType
4273
+ });
4274
+ this.rotateRootUserShareSetIdIfChanged({
4275
+ accountAddress,
4276
+ wallet,
4277
+ ceremony: 'reshare',
4278
+ shareSetId,
4279
+ shareSetType,
4280
+ newThresholdSignatureScheme,
4281
+ extraContext: {
4282
+ oldThresholdSignatureScheme,
4283
+ newThresholdSignatureScheme,
4284
+ resolvedDelegation
4285
+ }
4286
+ });
4218
4287
  });
4219
4288
  this.logger.info('[WaasReshare] calling reshare API', {
4220
4289
  context: {
@@ -4237,37 +4306,8 @@ class DynamicWalletClient {
4237
4306
  mfaToken,
4238
4307
  elevatedAccessToken,
4239
4308
  revokeDelegation,
4240
- onCeremonyComplete: (_accountAddress, _walletId, shareSetId, shareSetType)=>{
4241
- ceremonyCallbackFired = true;
4242
- this.logger.info('[WaasReshare] ceremony_complete received', {
4243
- context: {
4244
- walletId: wallet.walletId,
4245
- newShareSetId: shareSetId,
4246
- shareSetType,
4247
- dynamicRequestId
4248
- }
4249
- });
4250
- this.logOnMalformedCeremonyPayload({
4251
- walletId: wallet.walletId,
4252
- ceremony: 'reshare',
4253
- shareSetId,
4254
- shareSetType
4255
- });
4256
- this.rotateRootUserShareSetIdIfChanged({
4257
- accountAddress,
4258
- wallet,
4259
- ceremony: 'reshare',
4260
- shareSetId,
4261
- shareSetType,
4262
- newThresholdSignatureScheme,
4263
- extraContext: {
4264
- oldThresholdSignatureScheme,
4265
- newThresholdSignatureScheme,
4266
- resolvedDelegation
4267
- }
4268
- });
4269
- ceremonyCompleteResolver(undefined);
4270
- }
4309
+ onError: ceremonyGate.onError,
4310
+ onCeremonyComplete: ceremonyGate.onCeremonyComplete
4271
4311
  });
4272
4312
  this.logger.info('[WaasReshare] room_created received, starting local MPC', {
4273
4313
  context: {
@@ -4376,38 +4416,11 @@ class DynamicWalletClient {
4376
4416
  this.logger.info('[WaasReshare] local MPC done, awaiting ceremony_complete before backup', {
4377
4417
  context: {
4378
4418
  walletId: wallet.walletId,
4379
- ceremonyCallbackFired,
4419
+ ceremonyCallbackFired: ceremonyGate.fired,
4380
4420
  dynamicRequestId
4381
4421
  }
4382
4422
  });
4383
- // Wait for ceremony_complete so the shareSetId rotation in
4384
- // onCeremonyComplete lands in walletMap before the backup call reads it.
4385
- // Bounded by a 5s timeout so a missing/late event never hangs the SDK.
4386
- const RESHARE_CEREMONY_COMPLETE_TIMEOUT_MS = 5000;
4387
- let ceremonyCompleteTimedOut = false;
4388
- let reshareTimeoutId;
4389
- await Promise.race([
4390
- ceremonyCompletePromise,
4391
- new Promise((resolve)=>{
4392
- reshareTimeoutId = setTimeout(()=>{
4393
- ceremonyCompleteTimedOut = true;
4394
- resolve(undefined);
4395
- }, RESHARE_CEREMONY_COMPLETE_TIMEOUT_MS);
4396
- })
4397
- ]);
4398
- // Cancel the pending timer when ceremony_complete wins the race so it
4399
- // doesn't fire later and flip ceremonyCompleteTimedOut after the fact.
4400
- // Safe no-op when the timer has already fired.
4401
- clearTimeout(reshareTimeoutId);
4402
- if (ceremonyCompleteTimedOut) {
4403
- this.logger.warn(`[WaasReshare] ceremony_complete didn’t arrive within ${RESHARE_CEREMONY_COMPLETE_TIMEOUT_MS}ms — proceeding with stale walletMap`, {
4404
- context: {
4405
- walletId: wallet.walletId,
4406
- ceremonyCallbackFired,
4407
- dynamicRequestId
4408
- }
4409
- });
4410
- }
4423
+ await ceremonyGate.awaitTerminal();
4411
4424
  // Backup & activate server shares BEFORE saving to local storage.
4412
4425
  // This prevents a mismatch where new client shares are stored locally
4413
4426
  // but old server shares remain active if backup/activation fails.
@@ -4445,18 +4458,16 @@ class DynamicWalletClient {
4445
4458
  });
4446
4459
  }
4447
4460
  // Operation summary — correlates against downstream sign failures /
4448
- // pending-rotation reports. `ceremonyCallbackFired: false` here means
4449
- // either the server is legacy (didn't emit ceremony_complete) or the
4450
- // SSE event was lost mid-flight.
4461
+ // pending-rotation reports. The gate always fired here (we throw before
4462
+ // backup otherwise); kept for log continuity.
4451
4463
  this.logger.info('[WaasReshare] completed', {
4452
4464
  context: {
4453
- ceremonyCompleteTimedOut,
4454
4465
  walletId: wallet.walletId,
4455
4466
  oldShareSetId: wallet.shareSetId,
4456
4467
  oldThresholdSignatureScheme,
4457
4468
  newThresholdSignatureScheme,
4458
4469
  resolvedDelegation,
4459
- ceremonyCallbackFired
4470
+ ceremonyCallbackFired: ceremonyGate.fired
4460
4471
  }
4461
4472
  });
4462
4473
  } catch (error) {
@@ -5296,8 +5307,7 @@ class DynamicWalletClient {
5296
5307
  try {
5297
5308
  var _preEncryptedCloudShares_, _this_getWalletFromMap, _backupData_locationsWithKeyShares, _backupData_locationsWithKeyShares1;
5298
5309
  // `let` so the retry path can swap in a freshly-signed session via the reverse channel on 400.
5299
- let resolvedSignedSessionId = await this.resolveSignedSessionId(signedSessionId);
5300
- const canRefreshSignedSessionId = this.getSignedSessionIdCallback !== undefined;
5310
+ let resolvedSignedSessionId = await this.signedSession.resolve(signedSessionId);
5301
5311
  if (!(walletData == null ? void 0 : walletData.walletId)) {
5302
5312
  const error = new Error(`WalletId not found for accountAddress ${accountAddress}`);
5303
5313
  logError({
@@ -5390,19 +5400,18 @@ class DynamicWalletClient {
5390
5400
  passwordUpdateBatchId
5391
5401
  }), {
5392
5402
  operationName,
5393
- shouldRetry: async (error, attempt)=>{
5394
- const status = getHttpStatus(error);
5395
- if (status === 400 && attempt === 1 && canRefreshSignedSessionId) {
5396
- resolvedSignedSessionId = await this.resolveSignedSessionId();
5397
- this.logger.info(`[${operationName}] refreshed signed session, retrying`, {
5398
- dynamicRequestId,
5399
- status
5400
- });
5401
- return true;
5402
- }
5403
- // 429 (rate limit) is the textbook back-off-and-retry status.
5404
- return status === undefined || status === 429 || status >= 500;
5405
- }
5403
+ // Refresh a consumed nonce on a 400; otherwise back off and retry
5404
+ // transient statuses (undefined / 429 / 5xx).
5405
+ shouldRetry: this.signedSession.refreshShouldRetry({
5406
+ onRefreshed: (id)=>{
5407
+ resolvedSignedSessionId = id;
5408
+ },
5409
+ operationName,
5410
+ logContext: {
5411
+ dynamicRequestId
5412
+ },
5413
+ alsoRetry: (status)=>status === undefined || status === 429 || status !== undefined && status >= 500
5414
+ })
5406
5415
  }));
5407
5416
  }
5408
5417
  // Cloud providers already have internal retries (Google Drive and iCloud),
@@ -5592,7 +5601,7 @@ class DynamicWalletClient {
5592
5601
  // still throw — the host can re-auth from the error.
5593
5602
  let resolvedSessionId;
5594
5603
  try {
5595
- resolvedSessionId = await this.resolveSignedSessionId(signedSessionId);
5604
+ resolvedSessionId = await this.signedSession.resolve(signedSessionId);
5596
5605
  } catch (e) {
5597
5606
  resolvedSessionId = undefined;
5598
5607
  }
@@ -5950,13 +5959,14 @@ class DynamicWalletClient {
5950
5959
  externalKeyShareIds: shares[core.BackupLocation.DYNAMIC] || []
5951
5960
  }
5952
5961
  });
5953
- const result = await this.apiClient.recoverEncryptedBackupByWallet({
5962
+ const result = await this.recoverEncryptedSharesWithNonceRetry({
5954
5963
  walletId: wallet.walletId,
5955
5964
  shareSetId: wallet.shareSetId,
5956
5965
  externalKeyShareIds: shares[core.BackupLocation.DYNAMIC] || [],
5957
- signedSessionId,
5958
- requiresSignedSessionId: this.requiresSignedSessionId(),
5959
- userId: this.userId
5966
+ signedSessionId
5967
+ }, {
5968
+ walletId: wallet.walletId,
5969
+ context: 'validatePasswordAgainstEncryptedShares'
5960
5970
  });
5961
5971
  if (!result) {
5962
5972
  throw new Error('Failed to retrieve encrypted backup from API for password validation');
@@ -6147,6 +6157,32 @@ class DynamicWalletClient {
6147
6157
  mfaToken
6148
6158
  }));
6149
6159
  }
6160
+ /**
6161
+ * Fetches encrypted backup shares from the server, transparently refreshing
6162
+ * the signed session on a single-use-nonce rejection. Recovery is otherwise
6163
+ * non-retrying, so other errors surface immediately. When no reverse channel
6164
+ * is configured the provided session is used unchanged.
6165
+ */ async recoverEncryptedSharesWithNonceRetry(params, logContext) {
6166
+ let resolvedSignedSessionId = params.signedSessionId;
6167
+ return retryPromise(()=>this.apiClient.recoverEncryptedBackupByWallet(_extends({}, params, {
6168
+ signedSessionId: resolvedSignedSessionId,
6169
+ requiresSignedSessionId: this.requiresSignedSessionId(),
6170
+ userId: this.userId
6171
+ })), {
6172
+ operationName: 'recoverEncryptedBackupByWallet',
6173
+ // At most one retry — the nonce refresh — and no back-off before it,
6174
+ // since a consumed-nonce 400 is deterministic, not a transient error.
6175
+ maxAttempts: 2,
6176
+ retryInterval: 0,
6177
+ shouldRetry: this.signedSession.refreshShouldRetry({
6178
+ onRefreshed: (id)=>{
6179
+ resolvedSignedSessionId = id;
6180
+ },
6181
+ operationName: 'recoverEncryptedBackupByWallet',
6182
+ logContext
6183
+ })
6184
+ });
6185
+ }
6150
6186
  async internalRecoverEncryptedBackupByWallet({ accountAddress, password, walletOperation, signedSessionId, shareCount = undefined, storeRecoveredShares = true, mfaToken }) {
6151
6187
  try {
6152
6188
  const wallet = this.getWalletFromMap(accountAddress);
@@ -6167,14 +6203,15 @@ class DynamicWalletClient {
6167
6203
  thresholdSignatureScheme: wallet.thresholdSignatureScheme
6168
6204
  }
6169
6205
  });
6170
- const data = await this.apiClient.recoverEncryptedBackupByWallet({
6206
+ const data = await this.recoverEncryptedSharesWithNonceRetry({
6171
6207
  walletId: wallet.walletId,
6172
6208
  shareSetId: wallet.shareSetId,
6173
6209
  externalKeyShareIds: dynamicKeyShareIds,
6174
6210
  signedSessionId,
6175
- mfaToken,
6176
- requiresSignedSessionId: this.requiresSignedSessionId(),
6177
- userId: this.userId
6211
+ mfaToken
6212
+ }, {
6213
+ walletId: wallet.walletId,
6214
+ walletOperation
6178
6215
  });
6179
6216
  const dynamicKeyShares = data.keyShares.filter((keyShare)=>keyShare.encryptedAccountCredential !== null && keyShare.backupLocation === core.BackupLocation.DYNAMIC);
6180
6217
  const decryptedKeyShares = await Promise.all(dynamicKeyShares.map((keyShare)=>// `decryptKeyShare` owns the `environmentId` fallback; passing
@@ -6842,8 +6879,10 @@ class DynamicWalletClient {
6842
6879
  })) {
6843
6880
  const walletData = this.getWalletFromMap(accountAddress);
6844
6881
  const isPasswordEncrypted = isWalletPasswordEncrypted(walletData);
6845
- // Password-encrypted wallet without password - fetch and cache for eager loading
6846
- if (walletOperation === core.WalletOperation.RECOVER && isPasswordEncrypted && !password) {
6882
+ // Locked wallet (password-encrypted, no password supplied): its shares need the
6883
+ // user's password, not the environmentId. Return it ENCRYPTED for any operation
6884
+ // rather than fall through to an envId-decrypt that throws KeyShareDecryptionError.
6885
+ if (isPasswordEncrypted && !password) {
6847
6886
  // Skip fetch if encrypted shares are already cached in storage
6848
6887
  const alreadyCached = await hasEncryptedSharesCached({
6849
6888
  accountAddress,
@@ -6864,28 +6903,32 @@ class DynamicWalletClient {
6864
6903
  }
6865
6904
  return cachedWallet;
6866
6905
  }
6906
+ // RECOVER on purpose: cache the full set a later unlockWallet needs, not the triggering op's subset.
6867
6907
  const { shares } = this.recoverStrategy({
6868
6908
  clientKeyShareBackupInfo: walletData.clientKeySharesBackupInfo,
6869
6909
  thresholdSignatureScheme: walletData.thresholdSignatureScheme,
6870
6910
  walletOperation: core.WalletOperation.RECOVER
6871
6911
  });
6872
6912
  const { dynamic: dynamicKeyShareIds } = shares;
6873
- this.logger.info('[walletMap] getWallet: requesting key shares for eager loading', {
6913
+ this.logger.info('[walletMap] getWallet: caching encrypted shares for locked wallet', {
6874
6914
  context: {
6875
6915
  accountAddress,
6876
6916
  walletId: walletData.walletId,
6877
6917
  externalKeyShareIds: dynamicKeyShareIds,
6878
6918
  dynamicRequestId,
6879
- isPasswordEncrypted
6919
+ isPasswordEncrypted,
6920
+ walletOperation
6880
6921
  }
6881
6922
  });
6882
- const data = await this.apiClient.recoverEncryptedBackupByWallet({
6923
+ const data = await this.recoverEncryptedSharesWithNonceRetry({
6883
6924
  walletId: walletData.walletId,
6884
6925
  shareSetId: walletData.shareSetId,
6885
6926
  externalKeyShareIds: dynamicKeyShareIds,
6886
- signedSessionId,
6887
- requiresSignedSessionId: this.requiresSignedSessionId(),
6888
- userId: this.userId
6927
+ signedSessionId
6928
+ }, {
6929
+ walletId: walletData.walletId,
6930
+ context: 'getWallet:eagerLoad',
6931
+ dynamicRequestId
6889
6932
  });
6890
6933
  // Cache encrypted shares for later decryption by unlockWallet
6891
6934
  const encryptedStorageKey = `${normalizeAddress(accountAddress)}${core.ENCRYPTED_SHARES_STORAGE_SUFFIX}`;
@@ -7022,13 +7065,14 @@ class DynamicWalletClient {
7022
7065
  externalKeyShareIds
7023
7066
  }
7024
7067
  });
7025
- const data = await this.apiClient.recoverEncryptedBackupByWallet({
7068
+ const data = await this.recoverEncryptedSharesWithNonceRetry({
7026
7069
  walletId: walletData.walletId,
7027
7070
  externalKeyShareIds,
7028
7071
  signedSessionId,
7029
- mfaToken,
7030
- requiresSignedSessionId: this.requiresSignedSessionId(),
7031
- userId: this.userId
7072
+ mfaToken
7073
+ }, {
7074
+ walletId: walletData.walletId,
7075
+ context: 'fetchAndCacheEncryptedShares'
7032
7076
  });
7033
7077
  if (!data) {
7034
7078
  return null;
@@ -7476,6 +7520,13 @@ class DynamicWalletClient {
7476
7520
  if (internalOptions == null ? void 0 : internalOptions.getSignedSessionId) {
7477
7521
  this.getSignedSessionIdCallback = internalOptions.getSignedSessionId;
7478
7522
  }
7523
+ // Read the callback lazily so it tracks `getSignedSessionIdCallback` even
7524
+ // when tests reassign it after construction.
7525
+ this.signedSession = new SignedSessionManager({
7526
+ logger: this.logger,
7527
+ sdkVersion: this.sdkVersion,
7528
+ getCallback: ()=>this.getSignedSessionIdCallback
7529
+ });
7479
7530
  this.apiClient = new core.DynamicApiClient({
7480
7531
  environmentId,
7481
7532
  authToken,