@dynamic-labs-wallet/browser 1.1.42 → 1.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -11,6 +11,7 @@ import { AxiosError } from 'axios';
11
11
  import gte from 'semver/functions/gte';
12
12
  import { ProviderEnum } from '@dynamic-labs/sdk-api-core/models/ProviderEnum';
13
13
  import PQueue from 'p-queue';
14
+ import pTimeout from 'p-timeout';
14
15
  import { redactSecretsFromText, KEY_MATERIAL_TEXT_PATTERNS } from '@dynamic-labs/redaction';
15
16
  import { ed25519 } from '@noble/curves/ed25519';
16
17
  import { bytesToHex, numberToBytesLE, hexToBytes, bytesToNumberLE } from '@noble/curves/abstract/utils';
@@ -588,6 +589,10 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
588
589
  'invalid_nonce_signature',
589
590
  'invalid_session_signature'
590
591
  ]);
592
+ /** Reasons a fresh nonce fixes, so they may refresh on every attempt, not just once. */ const REFRESHABLE_NONCE_FAILURE_REASONS = new Set([
593
+ 'nonce_already_used',
594
+ 'nonce_not_found_or_expired'
595
+ ]);
591
596
  /** Best-effort extraction of the server error `code`/`message` from a 4xx body. */ const readErrorBody = (error)=>{
592
597
  var _error_response;
593
598
  if (!(error instanceof AxiosError)) {
@@ -618,6 +623,9 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
618
623
  };
619
624
  /** Maps the server error `code`/`message` onto a {@link NonceFailureReason}. */ const classifyNonceFailure = ({ code, message })=>{
620
625
  const haystack = `${code != null ? code : ''} ${message != null ? message : ''}`.toLowerCase();
626
+ if (haystack.includes('not_found_or_expired') || haystack.includes('not found or expired')) {
627
+ return 'nonce_not_found_or_expired';
628
+ }
621
629
  if (haystack.includes('already_used') || haystack.includes('already used') || haystack.includes('consumption failed')) {
622
630
  return 'nonce_already_used';
623
631
  }
@@ -662,7 +670,8 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
662
670
  }
663
671
  /**
664
672
  * Builds a `retryPromise` `shouldRetry` predicate that refreshes a consumed
665
- * single-use nonce via the reverse channel on a replay 400 and retries once.
673
+ * single-use nonce via the reverse channel on a replay 400 and retries once
674
+ * (on every attempt when the body says the nonce was consumed or expired).
666
675
  * The signed session carries a single-use replay nonce: it is valid on its
667
676
  * first use, but once consumed (e.g. by a preceding operation) the server
668
677
  * rejects with a 400, at which point a fresh session is fetched and the call
@@ -674,18 +683,24 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
674
683
  // triggered an `alsoRetry`), so the replay 400 can land on any attempt, not
675
684
  // just the first. Refresh on the first such 400 seen, whichever attempt
676
685
  // it's on, and never again — preserves the single-refresh-per-operation limit.
686
+ // A consumed or expired nonce is the exception: each fresh session may still
687
+ // pop a stale pooled nonce, so it refreshes on every attempt (bounded by the
688
+ // caller's maxAttempts).
677
689
  let hasRefreshed = false;
678
690
  return async (error, attempt)=>{
679
691
  const status = getHttpStatus(error);
692
+ const reason = status === 400 || status === 403 ? classifyNonceFailure(readErrorBody(error)) : undefined;
680
693
  // A 400 is a refreshable replay nonce; a 403 is a session-key
681
694
  // mismatch the server returns to break the refresh loop (DYNT-1282) — logged only
682
695
  // when the body classifies as a genuine session-signature failure so unrelated 403s
683
696
  // (e.g. an exhausted add-signer grant) don't pollute the DYNT-1599 metric.
684
697
  // Only the 400 can actually refresh.
685
- const isSessionSignatureFailure = status === 403 && SESSION_SIGNATURE_FAILURE_REASONS.has(classifyNonceFailure(readErrorBody(error)));
698
+ const isSessionSignatureFailure = status === 403 && reason !== undefined && SESSION_SIGNATURE_FAILURE_REASONS.has(reason);
699
+ const isRefreshableNonceFailure = status === 400 && reason !== undefined && REFRESHABLE_NONCE_FAILURE_REASONS.has(reason);
700
+ const canRefreshNow = isRefreshableNonceFailure || !hasRefreshed;
686
701
  const shouldInstrument = status === 400 || isSessionSignatureFailure;
687
702
  if (shouldInstrument) {
688
- const willRefresh = status === 400 && !hasRefreshed && this.canRefresh && this.supportsReverseChannel();
703
+ const willRefresh = status === 400 && canRefreshNow && this.canRefresh && this.supportsReverseChannel();
689
704
  this.instrumentNonceFailure({
690
705
  error,
691
706
  attempt,
@@ -695,7 +710,7 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
695
710
  logContext
696
711
  });
697
712
  }
698
- if (status === 400 && !hasRefreshed && this.canRefresh) {
713
+ if (status === 400 && canRefreshNow && this.canRefresh) {
699
714
  // canRefresh only proves a local callback exists, not that the host answers it.
700
715
  if (!this.supportsReverseChannel()) {
701
716
  this.logger.warn(`[${operationName}] host SDK version predates the signed-session reverse channel, declining nonce refresh`, _extends({
@@ -902,6 +917,8 @@ const SESSION_KEY_MISMATCH_REASONS = new Set([
902
917
  ]);
903
918
  const NONCE_REASON_TO_BACKUP_REASON = {
904
919
  nonce_already_used: 'nonce_already_used',
920
+ // Same dashboard bucket as before CKS split this code out of nonce_already_used.
921
+ nonce_not_found_or_expired: 'nonce_already_used',
905
922
  invalid_nonce_signature: 'invalid_nonce_signature',
906
923
  invalid_session_signature: 'invalid_session_signature',
907
924
  other: 'signed_session'
@@ -2946,6 +2963,15 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
2946
2963
  * Queue manager for wallet operations.
2947
2964
  * Manages heavy operation queues (refresh/reshare/recover) and sign queues per wallet.
2948
2965
  */ class WalletQueueManager {
2966
+ static releaseActiveSign(accountAddress) {
2967
+ var _this_activeSignCounts_get;
2968
+ const remaining = ((_this_activeSignCounts_get = this.activeSignCounts.get(accountAddress)) != null ? _this_activeSignCounts_get : 1) - 1;
2969
+ if (remaining > 0) {
2970
+ this.activeSignCounts.set(accountAddress, remaining);
2971
+ } else {
2972
+ this.activeSignCounts.delete(accountAddress);
2973
+ }
2974
+ }
2949
2975
  /**
2950
2976
  * Get or create the heavy operation queue for a wallet.
2951
2977
  * Heavy operations (refresh/reshare/recover) run with concurrency=1.
@@ -3115,7 +3141,8 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3115
3141
  const holdState = {};
3116
3142
  // Add to sign queue - allows concurrent signing
3117
3143
  const resultPromise = signQueue.add(()=>{
3118
- this.walletsWithActiveSignOp.set(accountAddress, true);
3144
+ var _this_activeSignCounts_get;
3145
+ this.activeSignCounts.set(accountAddress, ((_this_activeSignCounts_get = this.activeSignCounts.get(accountAddress)) != null ? _this_activeSignCounts_get : 0) + 1);
3119
3146
  const hooks = {
3120
3147
  reportPhase: (phase)=>{
3121
3148
  currentPhase = phase;
@@ -3141,7 +3168,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3141
3168
  try {
3142
3169
  return await callback(hooks);
3143
3170
  } finally{
3144
- this.walletsWithActiveSignOp.delete(accountAddress);
3171
+ this.releaseActiveSign(accountAddress);
3145
3172
  holdState.release == null ? void 0 : holdState.release.call(holdState);
3146
3173
  taskSettled = true;
3147
3174
  }
@@ -3240,7 +3267,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3240
3267
  const logRecoveryFinished = ()=>Logger.info(`[WalletQueueManager] Recovery finished for ${accountAddress} after ${Date.now() - recoveryStartedAt}ms`);
3241
3268
  // If we're already inside a heavy or sign operation for this wallet, execute directly
3242
3269
  // to avoid deadlock (heavy queue has concurrency=1, sign queue waits for idle)
3243
- if (this.walletsWithActiveHeavyOp.get(accountAddress) || this.walletsWithActiveSignOp.get(accountAddress)) {
3270
+ if (this.walletsWithActiveHeavyOp.get(accountAddress) || this.activeSignCounts.get(accountAddress)) {
3244
3271
  Logger.debug(`[WalletQueueManager] Recovery called from within active op for ${accountAddress}, executing directly`);
3245
3272
  const directPromise = (async ()=>{
3246
3273
  try {
@@ -3275,20 +3302,26 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3275
3302
  return recoveryPromise;
3276
3303
  }
3277
3304
  /**
3278
- * Waits for the wallet's in-flight recovery, if any.
3305
+ * Waits for the wallet's in-flight recovery, if any. With `bounded`, for at most `recoveryWaitCapMs`.
3279
3306
  * Never rejects: recovery failures are logged where they happen.
3280
3307
  *
3281
- * @returns `none` without a recovery, `settled` once it finished
3282
- */ static async waitForPendingRecovery(accountAddress) {
3308
+ * @returns `none` without a recovery, `settled` once it finished, `capped` when the cap hit first
3309
+ */ static async waitForPendingRecovery(accountAddress, { bounded = false } = {}) {
3283
3310
  const pending = this.pendingRecoveryPromises.get(normalizeAddress(accountAddress));
3284
3311
  if (!pending) {
3285
3312
  return 'none';
3286
3313
  }
3287
- // `pending` may reject; allSettled waits for it to end either way.
3288
- await Promise.allSettled([
3314
+ // `pending` may reject; allSettled turns either outcome into "the recovery is over".
3315
+ const recoveryOver = Promise.allSettled([
3289
3316
  pending
3290
- ]);
3291
- return 'settled';
3317
+ ]).then(()=>'settled');
3318
+ if (!bounded) {
3319
+ return recoveryOver;
3320
+ }
3321
+ return pTimeout(recoveryOver, {
3322
+ milliseconds: this.recoveryWaitCapMs,
3323
+ fallback: ()=>'capped'
3324
+ });
3292
3325
  }
3293
3326
  static getShareHolderQueue(address) {
3294
3327
  const existing = this.shareHolderQueues.get(address);
@@ -3320,19 +3353,21 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3320
3353
  this.pendingRecoveryPromises.clear();
3321
3354
  this.shareHolderQueues.clear();
3322
3355
  this.walletsWithActiveHeavyOp.clear();
3323
- this.walletsWithActiveSignOp.clear();
3356
+ this.activeSignCounts.clear();
3324
3357
  this.signQueueTimeoutMs = 20000;
3358
+ this.recoveryWaitCapMs = 10000;
3325
3359
  }
3326
3360
  }
3327
3361
  /** Heavy operation queues per wallet address. These run with concurrency=1. */ WalletQueueManager.heavyOpQueues = new Map();
3328
3362
  /** Sign queues per wallet address. These allow concurrent operations. */ WalletQueueManager.signQueues = new Map();
3329
3363
  /** Timeout applied to new sign queues. Mutable for tests via resetForTesting. */ WalletQueueManager.signQueueTimeoutMs = 20000;
3364
+ /** Longest a bounded wait on an in-flight recovery lasts before the sign carries on with the shares it has. */ WalletQueueManager.recoveryWaitCapMs = 10000;
3330
3365
  /** Track pending recovery promises for deduplication */ WalletQueueManager.pendingRecoveryPromises = new Map();
3331
3366
  /** Per wallet, the signs that have loaded their key shares and not finished. */ WalletQueueManager.shareHolderQueues = new Map();
3332
3367
  /** Track which wallets are currently executing inside a heavy operation.
3333
3368
  * Used to prevent deadlocks when recovery is called from within a heavy op. */ WalletQueueManager.walletsWithActiveHeavyOp = new Map();
3334
- /** Track which wallets are currently executing inside a sign operation.
3335
- * Used to prevent deadlocks when recovery is called from within a sign op. */ WalletQueueManager.walletsWithActiveSignOp = new Map();
3369
+ /** Count of signs currently running per wallet (a boolean is cleared by the first sign to finish).
3370
+ * Used to prevent deadlocks when recovery is called from within a sign op. */ WalletQueueManager.activeSignCounts = new Map();
3336
3371
 
3337
3372
  const FLAP_WINDOW_MS = 60000;
3338
3373
  // Bounds memory on a socket that flaps indefinitely; the count saturates here.
@@ -4088,13 +4123,7 @@ class DynamicWalletClient {
4088
4123
  this.restoreWallets()
4089
4124
  ];
4090
4125
  if (this.featureFlags[FEATURE_FLAGS.ENABLE_ROOM_CACHE_FLAG] === true) {
4091
- // Restore rooms first, then create rooms if needed (in series, but not blocking other initialization)
4092
- initializePromises.push(this.restoreRooms().then(()=>// assume two of two threshold signature scheme
4093
- this.createRooms({
4094
- roomType: RoomTypeEnum.Threshold,
4095
- thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO,
4096
- roomCount: ROOM_CACHE_COUNT
4097
- })));
4126
+ initializePromises.push(this.prefetchRoomPool());
4098
4127
  }
4099
4128
  await Promise.all(initializePromises);
4100
4129
  return {
@@ -4108,6 +4137,25 @@ class DynamicWalletClient {
4108
4137
  };
4109
4138
  }
4110
4139
  }
4140
+ /**
4141
+ * Restores the cached rooms, then creates more if the pool is low. Best effort: the pool is only a
4142
+ * prefetch, and takeRooms() falls back and refills it later, so a failure (e.g. a dropped request)
4143
+ * must not fail init. createRooms already logs its own error.
4144
+ */ async prefetchRoomPool() {
4145
+ try {
4146
+ await this.restoreRooms();
4147
+ // assume two of two threshold signature scheme
4148
+ await this.createRooms({
4149
+ roomType: RoomTypeEnum.Threshold,
4150
+ thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO,
4151
+ roomCount: ROOM_CACHE_COUNT
4152
+ });
4153
+ } catch (error) {
4154
+ this.logger.debug('[DynamicWaasWalletClient] Room cache prefetch failed during init', {
4155
+ error
4156
+ });
4157
+ }
4158
+ }
4111
4159
  async serverInitializeKeyGen({ chainName, clientKeygenIds, dynamicRequestId, thresholdSignatureScheme, bitcoinConfig, businessAccountId, idempotencyKey, traceContext, onError, onCeremonyComplete }) {
4112
4160
  // Initialize keygen, create room, and create the wallet account on the server.
4113
4161
  // HTTP transport (flag on + idempotency key present) uses a single JSON
@@ -4832,14 +4880,14 @@ class DynamicWalletClient {
4832
4880
  return data;
4833
4881
  }
4834
4882
  /**
4835
- * The server half of `signAndBroadcast`: asks Dynamic to open the signing rooms and returns the transaction record.
4883
+ * The server half of `signAndBroadcast`: asks Dynamic to open the signing room and returns the transaction record.
4836
4884
  *
4837
4885
  * @param params.walletId - The wallet that signs.
4838
4886
  * @param params.shareSetId - Which of the wallet's key sets to use. Defaults to its active one.
4839
- * @param params.messages - The transaction once per fee attempt, in fee order, as the bytes to sign (RLP hex on EVM, message bytes on Solana). Not hashed.
4840
- * @param params.contexts - What each message does in readable form (chain, recipient, amount), one per message in the same order. Dynamic checks each against the wallet and its policies.
4887
+ * @param params.message - The transaction as the bytes to sign (RLP hex on EVM, message bytes on Solana). Not hashed.
4888
+ * @param params.context - What the message does in readable form (chain, recipient, amount). Dynamic checks it against the wallet and its policies.
4841
4889
  * @param params.broadcastOptions - Solana send settings: other required signers' signatures, and skipping the pre-send dry run.
4842
- * @param params.roomIds - Signing rooms you already opened, one per message. Send all or none; Dynamic opens them when omitted.
4890
+ * @param params.roomId - A signing room you already opened; Dynamic opens one when omitted.
4843
4891
  * @param params.requestedCertainty - How sure to be before calling it confirmed: `fast`, `safe` or `final`, or `chain:` plus a chain-specific value. Defaults to `safe`.
4844
4892
  * @param params.externalReference - Your own label, returned on every event. Not unique.
4845
4893
  * @param params.idempotencyKey - Makes a retry safe: the same key replays the stored answer instead of sending twice.
@@ -4849,15 +4897,15 @@ class DynamicWalletClient {
4849
4897
  * @param params.onError - Called with the failure's message, never the raw error.
4850
4898
  * @param params.dynamicRequestId - Id of this one request, to match client and server logs.
4851
4899
  * @param params.traceContext - Ties this call's logs to the server's, across retries. Created when omitted.
4852
- */ async serverSignAndBroadcast({ walletId, shareSetId, contexts, broadcastOptions, messages, roomIds, requestedCertainty, externalReference, idempotencyKey, mfaToken, elevatedAccessToken, approvalQuorum, onError, dynamicRequestId, traceContext }) {
4853
- // Opens one signing room per message, unless `roomIds` names them, and returns the transaction record.
4900
+ */ async serverSignAndBroadcast({ walletId, shareSetId, context, broadcastOptions, message, roomId, requestedCertainty, externalReference, idempotencyKey, mfaToken, elevatedAccessToken, approvalQuorum, onError, dynamicRequestId, traceContext }) {
4901
+ // Opens a signing room, unless `roomId` names one, and returns the transaction record.
4854
4902
  return await this.apiClient.signAndBroadcast({
4855
4903
  walletId,
4856
4904
  shareSetId,
4857
- contexts: contexts.map((context)=>toJsonSafeContext(context)),
4905
+ context: toJsonSafeContext(context),
4858
4906
  broadcastOptions,
4859
- messages,
4860
- roomIds,
4907
+ message,
4908
+ roomId,
4861
4909
  isFormatted: false,
4862
4910
  requestedCertainty,
4863
4911
  externalReference,
@@ -5315,12 +5363,12 @@ class DynamicWalletClient {
5315
5363
  }));
5316
5364
  }
5317
5365
  /**
5318
- * Signs a transaction for Dynamic to broadcast, one signing ceremony per message. The signature stays on the server.
5366
+ * Signs a transaction for Dynamic to broadcast. The signature stays on the server.
5319
5367
  *
5320
5368
  * @param params.accountAddress - The wallet's address.
5321
5369
  * @param params.chainName - The wallet's chain, e.g. `EVM`.
5322
- * @param params.messages - The transaction once per fee attempt, in fee order, as the bytes to sign (RLP hex on EVM, message bytes on Solana). Not hashed.
5323
- * @param params.contexts - What each message does in readable form (chain, recipient, amount), one per message in the same order. Dynamic checks each against the wallet and its policies.
5370
+ * @param params.message - The transaction as the bytes to sign (RLP hex on EVM, message bytes on Solana). Not hashed.
5371
+ * @param params.context - What the message does in readable form (chain, recipient, amount). Dynamic checks it against the wallet and its policies.
5324
5372
  * @param params.broadcastOptions - Solana send settings: other required signers' signatures, and skipping the pre-send dry run.
5325
5373
  * @param params.password - The wallet's backup password, if it has one.
5326
5374
  * @param params.signedSessionId - The signed-in session, used to unlock the wallet's key share.
@@ -5333,18 +5381,13 @@ class DynamicWalletClient {
5333
5381
  * @param params.onError - Called with the failure's message, never the raw error.
5334
5382
  * @param params.traceContext - Ties this call's logs to the server's, across retries. Created when omitted.
5335
5383
  * @returns The transaction record. A finished one comes back without signing again.
5336
- * @throws Error when `contexts` and `messages` differ in length.
5337
- */ async signAndBroadcast({ accountAddress, chainName, contexts, broadcastOptions, messages, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey = v4(), onError, traceContext }) {
5338
- // Each fee attempt is one message and one context; a mismatch is the caller's, so it is named here, before any ceremony.
5339
- if (messages.length === 0 || contexts.length !== messages.length) {
5340
- throw new Error(`Sign and broadcast needs one signing context per message, got ${contexts.length} contexts for ${messages.length} messages`);
5341
- }
5342
- return this.queueSign(accountAddress, ({ reportPhase, readingShares })=>this.internalSignAndBroadcast({
5384
+ */ async signAndBroadcast({ accountAddress, chainName, context, broadcastOptions, message, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey = v4(), onError, traceContext }) {
5385
+ return this.queueSign(accountAddress, ({ reportPhase, reportDetails, readingShares })=>this.internalSignAndBroadcast({
5343
5386
  accountAddress,
5344
5387
  chainName,
5345
- contexts,
5388
+ context,
5346
5389
  broadcastOptions,
5347
- messages,
5390
+ message,
5348
5391
  password,
5349
5392
  signedSessionId,
5350
5393
  mfaToken,
@@ -5356,17 +5399,21 @@ class DynamicWalletClient {
5356
5399
  onError,
5357
5400
  traceContext,
5358
5401
  onSignPhase: reportPhase,
5402
+ onSignDetails: reportDetails,
5359
5403
  onReadingShares: readingShares
5360
5404
  }));
5361
5405
  }
5362
5406
  /**
5363
- * Does the work of `signAndBroadcast` once it has its turn in the wallet queue: loads the wallet, asks Dynamic to open the rooms, then signs in each.
5407
+ * Does the work of `signAndBroadcast` once it has its turn in the wallet queue: loads the wallet, asks Dynamic to open the room, then signs in it.
5364
5408
  *
5365
5409
  * Takes the same fields as `signAndBroadcast`.
5366
5410
  *
5367
5411
  * @param params.onSignPhase - Reports which step it is on, so the queue can tell which step used up the time.
5368
- */ async internalSignAndBroadcast({ accountAddress, chainName, contexts, broadcastOptions, messages, password, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey, onError, traceContext, onSignPhase, onReadingShares, staleShareRetry = false, roomIdRetry = false }) {
5412
+ */ async internalSignAndBroadcast({ accountAddress, chainName, context, broadcastOptions, message, password, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey, onError, traceContext, onSignPhase, onSignDetails, onReadingShares, staleShareRetry = false, roomIdRetry = false }) {
5369
5413
  const dynamicRequestId = v4();
5414
+ onSignDetails == null ? void 0 : onSignDetails({
5415
+ dynamicRequestId
5416
+ });
5370
5417
  traceContext = withTraceId(traceContext);
5371
5418
  let stage = 'prepareWallet';
5372
5419
  let roomIdSource;
@@ -5376,7 +5423,6 @@ class DynamicWalletClient {
5376
5423
  accountAddress,
5377
5424
  chainName,
5378
5425
  dynamicRequestId,
5379
- attemptCount: messages.length,
5380
5426
  staleShareRetry,
5381
5427
  roomIdRetry
5382
5428
  }, this.getTraceContext(traceContext)));
@@ -5390,8 +5436,15 @@ class DynamicWalletClient {
5390
5436
  });
5391
5437
  stage = 'resolveCachedRoom';
5392
5438
  onSignPhase == null ? void 0 : onSignPhase('resolveCachedRoom');
5393
- const roomIds = roomIdRetry ? undefined : await this.getCachedRoomsForSignAndBroadcast(wallet, accountAddress, chainName, messages.length, dynamicRequestId, traceContext);
5394
- roomIdSource = roomIds ? 'cache' : 'server';
5439
+ const cachedRoomId = roomIdRetry ? undefined : await this.getCachedRoom({
5440
+ wallet,
5441
+ accountAddress,
5442
+ chainName,
5443
+ key: 'signAndBroadcast',
5444
+ dynamicRequestId,
5445
+ traceContext
5446
+ });
5447
+ roomIdSource = cachedRoomId ? 'cache' : 'server';
5395
5448
  const roomsResolvedAt = Date.now();
5396
5449
  this.logSlowSignPreflight({
5397
5450
  accountAddress,
@@ -5409,10 +5462,10 @@ class DynamicWalletClient {
5409
5462
  const record = await this.serverSignAndBroadcast({
5410
5463
  walletId: wallet.walletId,
5411
5464
  shareSetId: wallet.shareSetId,
5412
- contexts,
5465
+ context,
5413
5466
  broadcastOptions,
5414
- messages,
5415
- roomIds,
5467
+ message,
5468
+ roomId: cachedRoomId,
5416
5469
  requestedCertainty,
5417
5470
  externalReference,
5418
5471
  idempotencyKey,
@@ -5423,9 +5476,7 @@ class DynamicWalletClient {
5423
5476
  dynamicRequestId,
5424
5477
  traceContext
5425
5478
  });
5426
- var _record_rooms;
5427
- const rooms = (_record_rooms = record.rooms) != null ? _record_rooms : [];
5428
- var _roomIds_length;
5479
+ const room = record.room;
5429
5480
  this.logger.info('[DynamicWaasWalletClient] Sign and broadcast accepted', _extends({
5430
5481
  key: 'signAndBroadcast',
5431
5482
  operation: 'accepted',
@@ -5434,38 +5485,30 @@ class DynamicWalletClient {
5434
5485
  dynamicRequestId,
5435
5486
  transactionId: record.transactionId,
5436
5487
  transactionStatus: record.status,
5437
- attemptCount: messages.length,
5438
- roomIdSource,
5439
- roomsSent: (_roomIds_length = roomIds == null ? void 0 : roomIds.length) != null ? _roomIds_length : 0,
5440
- roomsReturned: rooms.length,
5441
- // A retry of an existing row names that row's rooms, not the ones just sent.
5442
- returnedRoomsMatchSent: roomIds ? roomIds.every((roomId, index)=>{
5443
- var _rooms_index;
5444
- return ((_rooms_index = rooms[index]) == null ? void 0 : _rooms_index.roomId) === roomId;
5445
- }) : undefined
5488
+ roomIdSource
5446
5489
  }, this.getTraceContext(traceContext)));
5447
- // A retry of a finished request returns the record without rooms.
5490
+ // A retry of a finished request returns the record without a room.
5448
5491
  if (record.status !== 'pending_signature') {
5449
5492
  return record;
5450
5493
  }
5451
- if (rooms.length !== messages.length) {
5452
- throw new Error(`Expected ${messages.length} signing rooms, got ${rooms.length} for transaction ${record.transactionId}`);
5494
+ if (!room) {
5495
+ throw new Error(`Expected a signing room for transaction ${record.transactionId}`);
5453
5496
  }
5454
5497
  stage = 'ceremony';
5455
5498
  onSignPhase == null ? void 0 : onSignPhase('ceremony');
5456
- const ceremonies = Promise.all(rooms.map((room, index)=>this.clientSign({
5457
- chainName,
5458
- message: messages[index],
5459
- roomId: room.roomId,
5460
- keyShare: clientKeyShares[0],
5461
- derivationPath,
5462
- isFormatted: false,
5463
- dynamicRequestId,
5464
- traceContext,
5465
- variant: wallet.variant,
5466
- // Nothing mints a replacement room for a broadcast attempt.
5467
- skipForwardMPC: true
5468
- })));
5499
+ const ceremonies = this.clientSign({
5500
+ chainName,
5501
+ message,
5502
+ roomId: room.roomId,
5503
+ keyShare: clientKeyShares[0],
5504
+ derivationPath,
5505
+ isFormatted: false,
5506
+ dynamicRequestId,
5507
+ traceContext,
5508
+ variant: wallet.variant,
5509
+ // Nothing mints a replacement room for a broadcast.
5510
+ skipForwardMPC: true
5511
+ });
5469
5512
  const getClientSignMismatch = this.trackClientSignMismatch(ceremonies, {
5470
5513
  accountAddress,
5471
5514
  chainName,
@@ -5496,7 +5539,7 @@ class DynamicWalletClient {
5496
5539
  this.logger.info('[DynamicWaasWalletClient] Client sign completed', _extends({
5497
5540
  accountAddress,
5498
5541
  chainName,
5499
- roomIds: rooms.map((room)=>room.roomId),
5542
+ roomId: room.roomId,
5500
5543
  dynamicRequestId,
5501
5544
  key: 'signAndBroadcast',
5502
5545
  operation: 'clientSign'
@@ -5506,9 +5549,9 @@ class DynamicWalletClient {
5506
5549
  const retryParams = {
5507
5550
  accountAddress,
5508
5551
  chainName,
5509
- contexts,
5552
+ context,
5510
5553
  broadcastOptions,
5511
- messages,
5554
+ message,
5512
5555
  password,
5513
5556
  signedSessionId,
5514
5557
  mfaToken,
@@ -5520,6 +5563,7 @@ class DynamicWalletClient {
5520
5563
  onError,
5521
5564
  traceContext,
5522
5565
  onSignPhase,
5566
+ onSignDetails,
5523
5567
  onReadingShares
5524
5568
  };
5525
5569
  // The server rejected the local shares as stale before opening any room. Heal them
@@ -5551,9 +5595,9 @@ class DynamicWalletClient {
5551
5595
  }));
5552
5596
  }
5553
5597
  // A cached room was already claimed by an earlier ceremony. Retry once with the same
5554
- // idempotency key and no cached rooms, so the server opens fresh ones.
5598
+ // idempotency key and no cached room, so the server opens a fresh one.
5555
5599
  if (!roomIdRetry && isRoomIdAlreadyUsedError(error)) {
5556
- this.logger.info('[RoomCache] retrying sign and broadcast without cached rooms after 409 ROOM_ID_ALREADY_USED', _extends({
5600
+ this.logger.info('[RoomCache] retrying sign and broadcast without a cached room after 409 ROOM_ID_ALREADY_USED', _extends({
5557
5601
  key: 'signAndBroadcast',
5558
5602
  operation: 'roomIdRetry',
5559
5603
  accountAddress,
@@ -5576,7 +5620,6 @@ class DynamicWalletClient {
5576
5620
  operation: 'signAndBroadcast',
5577
5621
  details: {
5578
5622
  stage,
5579
- attemptCount: messages.length,
5580
5623
  roomIdSource,
5581
5624
  staleShareRetry,
5582
5625
  roomIdRetry
@@ -5614,95 +5657,67 @@ class DynamicWalletClient {
5614
5657
  }, this.getTraceContext(traceContext)));
5615
5658
  }
5616
5659
  /**
5617
- * Takes one already-opened room per message from the local pool, or none: a partial set saves nothing, so the server opens them all instead.
5660
+ * Takes one already-opened room from the local pool for a sign or a sign-and-broadcast, or none when the cache is off or empty, so the server opens one instead.
5618
5661
  *
5619
- * @param wallet - The wallet signing; its key scheme decides which rooms fit.
5620
- * @param accountAddress - The wallet's address, for logs.
5621
- * @param chainName - The wallet's chain, for logs.
5622
- * @param count - How many rooms are needed: one per message.
5623
- * @param dynamicRequestId - Id of this request, so the pool's logs join the sign's.
5624
- * @param traceContext - Trace ids for the same reason.
5625
- * @returns The room ids, or `undefined` when the pool cannot cover every message.
5626
- */ async getCachedRoomsForSignAndBroadcast(wallet, accountAddress, chainName, count, dynamicRequestId, traceContext) {
5662
+ * @param params.wallet - The wallet signing; its key scheme decides which room fits.
5663
+ * @param params.accountAddress - The wallet's address, for logs.
5664
+ * @param params.chainName - The wallet's chain, for logs.
5665
+ * @param params.key - Which operation is asking, for logs.
5666
+ * @param params.dynamicRequestId - Id of this request, so the pool's logs join the operation's.
5667
+ * @param params.traceContext - Trace ids for the same reason.
5668
+ * @returns The room id, or `undefined`.
5669
+ */ async getCachedRoom({ wallet, accountAddress, chainName, key, dynamicRequestId, traceContext }) {
5627
5670
  const logContext = _extends({
5628
5671
  dynamicRequestId
5629
5672
  }, this.getTraceContext(traceContext));
5630
5673
  if (this.featureFlags[FEATURE_FLAGS.ENABLE_ROOM_CACHE_FLAG] !== true) {
5631
- this.logCachedRoomsUnavailable({
5674
+ this.logCachedRoomUnavailable({
5632
5675
  reason: 'cache_disabled',
5676
+ key,
5633
5677
  accountAddress,
5634
5678
  chainName,
5635
- count,
5636
5679
  logContext
5637
5680
  });
5638
5681
  return undefined;
5639
5682
  }
5640
- const rooms = await this.takeRooms({
5641
- roomType: RoomTypeEnum.Threshold,
5642
- thresholdSignatureScheme: wallet.thresholdSignatureScheme,
5643
- count,
5644
- logContext
5645
- });
5646
- if (rooms.length === 0) {
5647
- this.logCachedRoomsUnavailable({
5648
- reason: 'pool_too_small',
5683
+ const room = await this.getRoom(RoomTypeEnum.Threshold, wallet.thresholdSignatureScheme, logContext);
5684
+ if (!room) {
5685
+ this.logCachedRoomUnavailable({
5686
+ reason: 'pool_empty',
5687
+ key,
5649
5688
  accountAddress,
5650
5689
  chainName,
5651
- count,
5652
5690
  logContext
5653
5691
  });
5654
5692
  return undefined;
5655
5693
  }
5656
- const roomIds = rooms.map((room)=>room.roomId);
5657
- this.logger.info('[DynamicWaasWalletClient] Using cached rooms', _extends({
5658
- key: 'signAndBroadcast',
5659
- operation: 'cachedRoomsFound',
5694
+ this.logger.info('[DynamicWaasWalletClient] Using cached room', _extends({
5695
+ key,
5696
+ operation: 'cachedRoomFound',
5660
5697
  accountAddress,
5661
5698
  chainName,
5662
5699
  walletId: wallet.walletId,
5663
- roomIds
5700
+ roomId: room.roomId
5664
5701
  }, logContext));
5665
- return roomIds;
5702
+ return room.roomId;
5666
5703
  }
5667
5704
  /**
5668
- * Logs why no cached rooms were sent, so how often the cache covers a request can be measured.
5705
+ * Logs why no cached room was sent, so how often the cache covers a request can be measured.
5669
5706
  *
5670
- * @param params.reason - `cache_disabled` or `pool_too_small`.
5707
+ * @param params.reason - `cache_disabled` or `pool_empty`.
5708
+ * @param params.key - Which operation asked.
5671
5709
  * @param params.accountAddress - The wallet's address.
5672
5710
  * @param params.chainName - The wallet's chain.
5673
- * @param params.count - How many rooms were needed.
5674
5711
  * @param params.logContext - Request and trace ids to attach.
5675
- */ logCachedRoomsUnavailable({ reason, accountAddress, chainName, count, logContext }) {
5676
- this.logger.info('[DynamicWaasWalletClient] Cached rooms unavailable; the server opens them', _extends({
5677
- key: 'signAndBroadcast',
5678
- operation: 'cachedRoomsUnavailable',
5712
+ */ logCachedRoomUnavailable({ reason, key, accountAddress, chainName, logContext }) {
5713
+ this.logger.info('[DynamicWaasWalletClient] Cached room unavailable; the server opens one', _extends({
5714
+ key,
5715
+ operation: 'cachedRoomUnavailable',
5679
5716
  reason,
5680
5717
  accountAddress,
5681
- chainName,
5682
- requestedCount: count
5718
+ chainName
5683
5719
  }, logContext));
5684
5720
  }
5685
- async getCachedRoomForSign(wallet, accountAddress, chainName, dynamicRequestId, traceContext) {
5686
- if (this.featureFlags[FEATURE_FLAGS.ENABLE_ROOM_CACHE_FLAG] !== true) {
5687
- return undefined;
5688
- }
5689
- const logContext = _extends({
5690
- dynamicRequestId
5691
- }, this.getTraceContext(traceContext));
5692
- const room = await this.getRoom(RoomTypeEnum.Threshold, wallet.thresholdSignatureScheme, logContext);
5693
- const roomId = room == null ? void 0 : room.roomId;
5694
- if (roomId) {
5695
- this.logger.info('[DynamicWaasWalletClient] Using cached room', _extends({
5696
- key: 'sign',
5697
- operation: 'cachedRoomFound',
5698
- accountAddress,
5699
- chainName,
5700
- walletId: wallet.walletId,
5701
- roomId
5702
- }, logContext));
5703
- }
5704
- return roomId;
5705
- }
5706
5721
  // Without a cached room the client depends on the server reporting one
5707
5722
  // before it can join the ceremony.
5708
5723
  async resolveSignRoom({ cachedRoomId, serverSignPromise, accountAddress, chainName, walletId, dynamicRequestId, traceContext }) {
@@ -5786,7 +5801,8 @@ class DynamicWalletClient {
5786
5801
  const derivationPath = wallet.derivationPath && wallet.derivationPath != '' ? new Uint32Array(Object.values(JSON.parse(wallet.derivationPath))) : undefined;
5787
5802
  onSignPhase == null ? void 0 : onSignPhase('ensureClientShare');
5788
5803
  const clientKeyShares = await this.ensureClientShare(accountAddress, {
5789
- onReadingShares
5804
+ onReadingShares,
5805
+ boundRecoveryWait: true
5790
5806
  });
5791
5807
  const sharesReadyAt = Date.now();
5792
5808
  return {
@@ -5892,7 +5908,14 @@ class DynamicWalletClient {
5892
5908
  onReadingShares
5893
5909
  });
5894
5910
  onSignPhase == null ? void 0 : onSignPhase('resolveCachedRoom');
5895
- const cachedRoomId = roomIdRetry ? undefined : await this.getCachedRoomForSign(wallet, accountAddress, chainName, dynamicRequestId, traceContext);
5911
+ const cachedRoomId = roomIdRetry ? undefined : await this.getCachedRoom({
5912
+ wallet,
5913
+ accountAddress,
5914
+ chainName,
5915
+ key: 'sign',
5916
+ dynamicRequestId,
5917
+ traceContext
5918
+ });
5896
5919
  const roomResolvedAt = Date.now();
5897
5920
  this.logSlowSignPreflight({
5898
5921
  accountAddress,
@@ -8349,22 +8372,26 @@ class DynamicWalletClient {
8349
8372
  * Note: This method only checks for existing shares in storage.
8350
8373
  * Auto-recovery logic has been removed in favor of the queue pattern.
8351
8374
  * Callers should handle recovery explicitly if needed.
8352
- */ async ensureClientShare(accountAddress, { onReadingShares } = {}) {
8375
+ */ async ensureClientShare(accountAddress, { onReadingShares, boundRecoveryWait = false } = {}) {
8353
8376
  // A recovery may be in flight (e.g. a stale-share heal scheduled when a
8354
8377
  // previous sign hit a public-key mismatch). Wait for it so this operation
8355
8378
  // reads the recovered shares instead of the superseded ones. Recovery
8356
8379
  // failures are logged where they happen; this operation should proceed
8357
- // with whatever shares exist rather than fail on the recovery.
8380
+ // with whatever shares exist rather than fail on the recovery. A sign bounds the wait so a slow recovery cannot
8381
+ // eat its whole queue budget; after the cap it continues with the stored shares (a stale one is retried).
8358
8382
  const waitStartedAt = Date.now();
8359
- const recoveryWait = await WalletQueueManager.waitForPendingRecovery(accountAddress);
8360
- if (recoveryWait === 'settled') {
8383
+ const recoveryWait = await WalletQueueManager.waitForPendingRecovery(accountAddress, {
8384
+ bounded: boundRecoveryWait
8385
+ });
8386
+ if (recoveryWait !== 'none') {
8361
8387
  // Every concurrent sign awaits this same promise, so it releases them all
8362
8388
  // at once. One line per waiter: the count is the size of the herd and the
8363
8389
  // shared finish timestamp is when it was released.
8364
8390
  this.logger.info('[SignPreflight] sign waited on in-flight recovery', {
8365
8391
  accountAddress,
8366
8392
  environmentId: this.environmentId,
8367
- recoveryWaitMs: Date.now() - waitStartedAt
8393
+ recoveryWaitMs: Date.now() - waitStartedAt,
8394
+ recoveryWaitCapped: recoveryWait === 'capped'
8368
8395
  });
8369
8396
  }
8370
8397
  // From here a recovery waits for this sign to finish, so it cannot replace the shares being read.
@@ -11209,6 +11236,17 @@ class DynamicWalletClient {
11209
11236
  }
11210
11237
  this.apiClient.syncAuthToken(authToken);
11211
11238
  }
11239
+ /** Reads the persisted room pool. A corrupt value counts as empty, so the next write replaces it. */ async readStoredRooms() {
11240
+ const rawData = await this.storage.getItem(`${this.storageKey}-rooms`);
11241
+ if (!rawData) {
11242
+ return {};
11243
+ }
11244
+ try {
11245
+ return JSON.parse(rawData);
11246
+ } catch (e) {
11247
+ return {};
11248
+ }
11249
+ }
11212
11250
  async createRooms({ roomType, thresholdSignatureScheme, roomCount = 5 }) {
11213
11251
  const numberOfParties = this.getNumberOfParties(roomType, thresholdSignatureScheme);
11214
11252
  const generation = DynamicWalletClient.roomsGeneration;
@@ -11250,8 +11288,7 @@ class DynamicWalletClient {
11250
11288
  // Re-read from storage under the lock so we don't clobber rooms another
11251
11289
  // tab just wrote, and so we don't lose rooms consumed by getRoom() calls
11252
11290
  // that ran while our API request was in-flight.
11253
- const rawData = await this.storage.getItem(`${this.storageKey}-rooms`);
11254
- const storedRooms = rawData ? JSON.parse(rawData) : {};
11291
+ const storedRooms = await this.readStoredRooms();
11255
11292
  const currentRooms = storedRooms[numberOfParties] || [];
11256
11293
  await this.setRooms(numberOfParties, [
11257
11294
  ...newRooms,
@@ -11368,8 +11405,7 @@ class DynamicWalletClient {
11368
11405
  // already consumed by other tabs. Combined with the Web Locks API below, this
11369
11406
  // makes room consumption atomic across tabs and prevents two tabs from both
11370
11407
  // receiving the same cached room.
11371
- const rawData = await this.storage.getItem(`${this.storageKey}-rooms`);
11372
- const storedRooms = rawData ? JSON.parse(rawData) : {};
11408
+ const storedRooms = await this.readStoredRooms();
11373
11409
  const now = Date.now();
11374
11410
  const schemeRooms = storedRooms[numberOfParties] || [];
11375
11411
  // Filter out expired rooms and remove the next available room.