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