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