@dynamic-labs-wallet/browser 1.1.41 → 1.1.43

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
@@ -588,6 +588,10 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
588
588
  'invalid_nonce_signature',
589
589
  'invalid_session_signature'
590
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
+ ]);
591
595
  /** Best-effort extraction of the server error `code`/`message` from a 4xx body. */ const readErrorBody = (error)=>{
592
596
  var _error_response;
593
597
  if (!(error instanceof AxiosError)) {
@@ -618,6 +622,9 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
618
622
  };
619
623
  /** Maps the server error `code`/`message` onto a {@link NonceFailureReason}. */ const classifyNonceFailure = ({ code, message })=>{
620
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
+ }
621
628
  if (haystack.includes('already_used') || haystack.includes('already used') || haystack.includes('consumption failed')) {
622
629
  return 'nonce_already_used';
623
630
  }
@@ -662,7 +669,8 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
662
669
  }
663
670
  /**
664
671
  * Builds a `retryPromise` `shouldRetry` predicate that refreshes a consumed
665
- * 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).
666
674
  * The signed session carries a single-use replay nonce: it is valid on its
667
675
  * first use, but once consumed (e.g. by a preceding operation) the server
668
676
  * rejects with a 400, at which point a fresh session is fetched and the call
@@ -674,18 +682,24 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
674
682
  // triggered an `alsoRetry`), so the replay 400 can land on any attempt, not
675
683
  // just the first. Refresh on the first such 400 seen, whichever attempt
676
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).
677
688
  let hasRefreshed = false;
678
689
  return async (error, attempt)=>{
679
690
  const status = getHttpStatus(error);
691
+ const reason = status === 400 || status === 403 ? classifyNonceFailure(readErrorBody(error)) : undefined;
680
692
  // A 400 is a refreshable replay nonce; a 403 is a session-key
681
693
  // mismatch the server returns to break the refresh loop (DYNT-1282) — logged only
682
694
  // when the body classifies as a genuine session-signature failure so unrelated 403s
683
695
  // (e.g. an exhausted add-signer grant) don't pollute the DYNT-1599 metric.
684
696
  // Only the 400 can actually refresh.
685
- 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;
686
700
  const shouldInstrument = status === 400 || isSessionSignatureFailure;
687
701
  if (shouldInstrument) {
688
- const willRefresh = status === 400 && !hasRefreshed && this.canRefresh && this.supportsReverseChannel();
702
+ const willRefresh = status === 400 && canRefreshNow && this.canRefresh && this.supportsReverseChannel();
689
703
  this.instrumentNonceFailure({
690
704
  error,
691
705
  attempt,
@@ -695,7 +709,7 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
695
709
  logContext
696
710
  });
697
711
  }
698
- if (status === 400 && !hasRefreshed && this.canRefresh) {
712
+ if (status === 400 && canRefreshNow && this.canRefresh) {
699
713
  // canRefresh only proves a local callback exists, not that the host answers it.
700
714
  if (!this.supportsReverseChannel()) {
701
715
  this.logger.warn(`[${operationName}] host SDK version predates the signed-session reverse channel, declining nonce refresh`, _extends({
@@ -902,6 +916,8 @@ const SESSION_KEY_MISMATCH_REASONS = new Set([
902
916
  ]);
903
917
  const NONCE_REASON_TO_BACKUP_REASON = {
904
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',
905
921
  invalid_nonce_signature: 'invalid_nonce_signature',
906
922
  invalid_session_signature: 'invalid_session_signature',
907
923
  other: 'signed_session'
@@ -3112,6 +3128,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3112
3128
  // a reference to observe how it eventually settles.
3113
3129
  let taskPromise;
3114
3130
  let taskSettled = false;
3131
+ const holdState = {};
3115
3132
  // Add to sign queue - allows concurrent signing
3116
3133
  const resultPromise = signQueue.add(()=>{
3117
3134
  this.walletsWithActiveSignOp.set(accountAddress, true);
@@ -3129,6 +3146,11 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3129
3146
  delete signDetails.forwardMpcFailure;
3130
3147
  }
3131
3148
  Object.assign(signDetails, details);
3149
+ },
3150
+ readingShares: ()=>{
3151
+ var _holdState;
3152
+ var _release;
3153
+ (_release = (_holdState = holdState).release) != null ? _release : _holdState.release = this.holdShares(accountAddress);
3132
3154
  }
3133
3155
  };
3134
3156
  taskPromise = (async ()=>{
@@ -3136,6 +3158,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3136
3158
  return await callback(hooks);
3137
3159
  } finally{
3138
3160
  this.walletsWithActiveSignOp.delete(accountAddress);
3161
+ holdState.release == null ? void 0 : holdState.release.call(holdState);
3139
3162
  taskSettled = true;
3140
3163
  }
3141
3164
  })();
@@ -3149,6 +3172,8 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3149
3172
  if (!isQueueTimeout) {
3150
3173
  return;
3151
3174
  }
3175
+ // A timed-out sign no longer counts for the queue, so it stops counting as a share holder too.
3176
+ holdState.release == null ? void 0 : holdState.release.call(holdState);
3152
3177
  logError({
3153
3178
  message: '[WalletQueueManager] Sign queue timed out',
3154
3179
  error: error,
@@ -3266,12 +3291,50 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3266
3291
  return recoveryPromise;
3267
3292
  }
3268
3293
  /**
3294
+ * Waits for the wallet's in-flight recovery, if any.
3295
+ * Never rejects: recovery failures are logged where they happen.
3296
+ *
3297
+ * @returns `none` without a recovery, `settled` once it finished
3298
+ */ static async waitForPendingRecovery(accountAddress) {
3299
+ const pending = this.pendingRecoveryPromises.get(normalizeAddress(accountAddress));
3300
+ if (!pending) {
3301
+ return 'none';
3302
+ }
3303
+ // `pending` may reject; allSettled waits for it to end either way.
3304
+ await Promise.allSettled([
3305
+ pending
3306
+ ]);
3307
+ return 'settled';
3308
+ }
3309
+ static getShareHolderQueue(address) {
3310
+ const existing = this.shareHolderQueues.get(address);
3311
+ if (existing) {
3312
+ return existing;
3313
+ }
3314
+ const queue = new PQueue({
3315
+ concurrency: Infinity
3316
+ });
3317
+ this.shareHolderQueues.set(address, queue);
3318
+ return queue;
3319
+ }
3320
+ /** Registers a sign as holding its shares until the returned function is called. */ static holdShares(address) {
3321
+ const hold = {
3322
+ release: ()=>undefined
3323
+ };
3324
+ const held = new Promise((resolve)=>{
3325
+ hold.release = resolve;
3326
+ });
3327
+ void this.getShareHolderQueue(address).add(()=>held);
3328
+ return ()=>hold.release();
3329
+ }
3330
+ /**
3269
3331
  * Reset static state for testing purposes.
3270
3332
  * This clears all wallet queues and pending recovery promises.
3271
3333
  */ static resetForTesting() {
3272
3334
  this.heavyOpQueues.clear();
3273
3335
  this.signQueues.clear();
3274
3336
  this.pendingRecoveryPromises.clear();
3337
+ this.shareHolderQueues.clear();
3275
3338
  this.walletsWithActiveHeavyOp.clear();
3276
3339
  this.walletsWithActiveSignOp.clear();
3277
3340
  this.signQueueTimeoutMs = 20000;
@@ -3281,6 +3344,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3281
3344
  /** Sign queues per wallet address. These allow concurrent operations. */ WalletQueueManager.signQueues = new Map();
3282
3345
  /** Timeout applied to new sign queues. Mutable for tests via resetForTesting. */ WalletQueueManager.signQueueTimeoutMs = 20000;
3283
3346
  /** Track pending recovery promises for deduplication */ WalletQueueManager.pendingRecoveryPromises = new Map();
3347
+ /** Per wallet, the signs that have loaded their key shares and not finished. */ WalletQueueManager.shareHolderQueues = new Map();
3284
3348
  /** Track which wallets are currently executing inside a heavy operation.
3285
3349
  * Used to prevent deadlocks when recovery is called from within a heavy op. */ WalletQueueManager.walletsWithActiveHeavyOp = new Map();
3286
3350
  /** Track which wallets are currently executing inside a sign operation.
@@ -4040,13 +4104,7 @@ class DynamicWalletClient {
4040
4104
  this.restoreWallets()
4041
4105
  ];
4042
4106
  if (this.featureFlags[FEATURE_FLAGS.ENABLE_ROOM_CACHE_FLAG] === true) {
4043
- // Restore rooms first, then create rooms if needed (in series, but not blocking other initialization)
4044
- initializePromises.push(this.restoreRooms().then(()=>// assume two of two threshold signature scheme
4045
- this.createRooms({
4046
- roomType: RoomTypeEnum.Threshold,
4047
- thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO,
4048
- roomCount: ROOM_CACHE_COUNT
4049
- })));
4107
+ initializePromises.push(this.prefetchRoomPool());
4050
4108
  }
4051
4109
  await Promise.all(initializePromises);
4052
4110
  return {
@@ -4060,6 +4118,25 @@ class DynamicWalletClient {
4060
4118
  };
4061
4119
  }
4062
4120
  }
4121
+ /**
4122
+ * Restores the cached rooms, then creates more if the pool is low. Best effort: the pool is only a
4123
+ * prefetch, and takeRooms() falls back and refills it later, so a failure (e.g. a dropped request)
4124
+ * must not fail init. createRooms already logs its own error.
4125
+ */ async prefetchRoomPool() {
4126
+ try {
4127
+ await this.restoreRooms();
4128
+ // assume two of two threshold signature scheme
4129
+ await this.createRooms({
4130
+ roomType: RoomTypeEnum.Threshold,
4131
+ thresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO,
4132
+ roomCount: ROOM_CACHE_COUNT
4133
+ });
4134
+ } catch (error) {
4135
+ this.logger.debug('[DynamicWaasWalletClient] Room cache prefetch failed during init', {
4136
+ error
4137
+ });
4138
+ }
4139
+ }
4063
4140
  async serverInitializeKeyGen({ chainName, clientKeygenIds, dynamicRequestId, thresholdSignatureScheme, bitcoinConfig, businessAccountId, idempotencyKey, traceContext, onError, onCeremonyComplete }) {
4064
4141
  // Initialize keygen, create room, and create the wallet account on the server.
4065
4142
  // HTTP transport (flag on + idempotency key present) uses a single JSON
@@ -4784,14 +4861,14 @@ class DynamicWalletClient {
4784
4861
  return data;
4785
4862
  }
4786
4863
  /**
4787
- * The server half of `signAndBroadcast`: asks Dynamic to open the signing rooms and returns the transaction record.
4864
+ * The server half of `signAndBroadcast`: asks Dynamic to open the signing room and returns the transaction record.
4788
4865
  *
4789
4866
  * @param params.walletId - The wallet that signs.
4790
4867
  * @param params.shareSetId - Which of the wallet's key sets to use. Defaults to its active one.
4791
- * @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.
4792
- * @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.
4868
+ * @param params.message - The transaction as the bytes to sign (RLP hex on EVM, message bytes on Solana). Not hashed.
4869
+ * @param params.context - What the message does in readable form (chain, recipient, amount). Dynamic checks it against the wallet and its policies.
4793
4870
  * @param params.broadcastOptions - Solana send settings: other required signers' signatures, and skipping the pre-send dry run.
4794
- * @param params.roomIds - Signing rooms you already opened, one per message. Send all or none; Dynamic opens them when omitted.
4871
+ * @param params.roomId - A signing room you already opened; Dynamic opens one when omitted.
4795
4872
  * @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`.
4796
4873
  * @param params.externalReference - Your own label, returned on every event. Not unique.
4797
4874
  * @param params.idempotencyKey - Makes a retry safe: the same key replays the stored answer instead of sending twice.
@@ -4801,15 +4878,15 @@ class DynamicWalletClient {
4801
4878
  * @param params.onError - Called with the failure's message, never the raw error.
4802
4879
  * @param params.dynamicRequestId - Id of this one request, to match client and server logs.
4803
4880
  * @param params.traceContext - Ties this call's logs to the server's, across retries. Created when omitted.
4804
- */ async serverSignAndBroadcast({ walletId, shareSetId, contexts, broadcastOptions, messages, roomIds, requestedCertainty, externalReference, idempotencyKey, mfaToken, elevatedAccessToken, approvalQuorum, onError, dynamicRequestId, traceContext }) {
4805
- // Opens one signing room per message, unless `roomIds` names them, and returns the transaction record.
4881
+ */ async serverSignAndBroadcast({ walletId, shareSetId, context, broadcastOptions, message, roomId, requestedCertainty, externalReference, idempotencyKey, mfaToken, elevatedAccessToken, approvalQuorum, onError, dynamicRequestId, traceContext }) {
4882
+ // Opens a signing room, unless `roomId` names one, and returns the transaction record.
4806
4883
  return await this.apiClient.signAndBroadcast({
4807
4884
  walletId,
4808
4885
  shareSetId,
4809
- contexts: contexts.map((context)=>toJsonSafeContext(context)),
4886
+ context: toJsonSafeContext(context),
4810
4887
  broadcastOptions,
4811
- messages,
4812
- roomIds,
4888
+ message,
4889
+ roomId,
4813
4890
  isFormatted: false,
4814
4891
  requestedCertainty,
4815
4892
  externalReference,
@@ -5247,7 +5324,7 @@ class DynamicWalletClient {
5247
5324
  }
5248
5325
  //todo: need to modify with imported flag
5249
5326
  async sign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, approvalQuorum, onError, traceContext, bitcoinConfig }) {
5250
- return this.queueSign(accountAddress, ({ reportPhase, reportDetails })=>this.internalSign({
5327
+ return this.queueSign(accountAddress, ({ reportPhase, reportDetails, readingShares })=>this.internalSign({
5251
5328
  accountAddress,
5252
5329
  message,
5253
5330
  chainName,
@@ -5262,16 +5339,17 @@ class DynamicWalletClient {
5262
5339
  traceContext,
5263
5340
  bitcoinConfig,
5264
5341
  onSignPhase: reportPhase,
5265
- onSignDetails: reportDetails
5342
+ onSignDetails: reportDetails,
5343
+ onReadingShares: readingShares
5266
5344
  }));
5267
5345
  }
5268
5346
  /**
5269
- * Signs a transaction for Dynamic to broadcast, one signing ceremony per message. The signature stays on the server.
5347
+ * Signs a transaction for Dynamic to broadcast. The signature stays on the server.
5270
5348
  *
5271
5349
  * @param params.accountAddress - The wallet's address.
5272
5350
  * @param params.chainName - The wallet's chain, e.g. `EVM`.
5273
- * @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.
5274
- * @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.
5351
+ * @param params.message - The transaction as the bytes to sign (RLP hex on EVM, message bytes on Solana). Not hashed.
5352
+ * @param params.context - What the message does in readable form (chain, recipient, amount). Dynamic checks it against the wallet and its policies.
5275
5353
  * @param params.broadcastOptions - Solana send settings: other required signers' signatures, and skipping the pre-send dry run.
5276
5354
  * @param params.password - The wallet's backup password, if it has one.
5277
5355
  * @param params.signedSessionId - The signed-in session, used to unlock the wallet's key share.
@@ -5284,18 +5362,13 @@ class DynamicWalletClient {
5284
5362
  * @param params.onError - Called with the failure's message, never the raw error.
5285
5363
  * @param params.traceContext - Ties this call's logs to the server's, across retries. Created when omitted.
5286
5364
  * @returns The transaction record. A finished one comes back without signing again.
5287
- * @throws Error when `contexts` and `messages` differ in length.
5288
- */ async signAndBroadcast({ accountAddress, chainName, contexts, broadcastOptions, messages, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey = v4(), onError, traceContext }) {
5289
- // Each fee attempt is one message and one context; a mismatch is the caller's, so it is named here, before any ceremony.
5290
- if (messages.length === 0 || contexts.length !== messages.length) {
5291
- throw new Error(`Sign and broadcast needs one signing context per message, got ${contexts.length} contexts for ${messages.length} messages`);
5292
- }
5293
- return this.queueSign(accountAddress, ({ reportPhase })=>this.internalSignAndBroadcast({
5365
+ */ async signAndBroadcast({ accountAddress, chainName, context, broadcastOptions, message, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey = v4(), onError, traceContext }) {
5366
+ return this.queueSign(accountAddress, ({ reportPhase, reportDetails, readingShares })=>this.internalSignAndBroadcast({
5294
5367
  accountAddress,
5295
5368
  chainName,
5296
- contexts,
5369
+ context,
5297
5370
  broadcastOptions,
5298
- messages,
5371
+ message,
5299
5372
  password,
5300
5373
  signedSessionId,
5301
5374
  mfaToken,
@@ -5306,17 +5379,22 @@ class DynamicWalletClient {
5306
5379
  idempotencyKey,
5307
5380
  onError,
5308
5381
  traceContext,
5309
- onSignPhase: reportPhase
5382
+ onSignPhase: reportPhase,
5383
+ onSignDetails: reportDetails,
5384
+ onReadingShares: readingShares
5310
5385
  }));
5311
5386
  }
5312
5387
  /**
5313
- * 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.
5388
+ * 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.
5314
5389
  *
5315
5390
  * Takes the same fields as `signAndBroadcast`.
5316
5391
  *
5317
5392
  * @param params.onSignPhase - Reports which step it is on, so the queue can tell which step used up the time.
5318
- */ async internalSignAndBroadcast({ accountAddress, chainName, contexts, broadcastOptions, messages, password, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey, onError, traceContext, onSignPhase, staleShareRetry = false, roomIdRetry = false }) {
5393
+ */ async internalSignAndBroadcast({ accountAddress, chainName, context, broadcastOptions, message, password, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, requestedCertainty, externalReference, idempotencyKey, onError, traceContext, onSignPhase, onSignDetails, onReadingShares, staleShareRetry = false, roomIdRetry = false }) {
5319
5394
  const dynamicRequestId = v4();
5395
+ onSignDetails == null ? void 0 : onSignDetails({
5396
+ dynamicRequestId
5397
+ });
5320
5398
  traceContext = withTraceId(traceContext);
5321
5399
  let stage = 'prepareWallet';
5322
5400
  let roomIdSource;
@@ -5326,7 +5404,6 @@ class DynamicWalletClient {
5326
5404
  accountAddress,
5327
5405
  chainName,
5328
5406
  dynamicRequestId,
5329
- attemptCount: messages.length,
5330
5407
  staleShareRetry,
5331
5408
  roomIdRetry
5332
5409
  }, this.getTraceContext(traceContext)));
@@ -5335,12 +5412,20 @@ class DynamicWalletClient {
5335
5412
  accountAddress,
5336
5413
  password,
5337
5414
  signedSessionId,
5338
- onSignPhase
5415
+ onSignPhase,
5416
+ onReadingShares
5339
5417
  });
5340
5418
  stage = 'resolveCachedRoom';
5341
5419
  onSignPhase == null ? void 0 : onSignPhase('resolveCachedRoom');
5342
- const roomIds = roomIdRetry ? undefined : await this.getCachedRoomsForSignAndBroadcast(wallet, accountAddress, chainName, messages.length, dynamicRequestId, traceContext);
5343
- roomIdSource = roomIds ? 'cache' : 'server';
5420
+ const cachedRoomId = roomIdRetry ? undefined : await this.getCachedRoom({
5421
+ wallet,
5422
+ accountAddress,
5423
+ chainName,
5424
+ key: 'signAndBroadcast',
5425
+ dynamicRequestId,
5426
+ traceContext
5427
+ });
5428
+ roomIdSource = cachedRoomId ? 'cache' : 'server';
5344
5429
  const roomsResolvedAt = Date.now();
5345
5430
  this.logSlowSignPreflight({
5346
5431
  accountAddress,
@@ -5358,10 +5443,10 @@ class DynamicWalletClient {
5358
5443
  const record = await this.serverSignAndBroadcast({
5359
5444
  walletId: wallet.walletId,
5360
5445
  shareSetId: wallet.shareSetId,
5361
- contexts,
5446
+ context,
5362
5447
  broadcastOptions,
5363
- messages,
5364
- roomIds,
5448
+ message,
5449
+ roomId: cachedRoomId,
5365
5450
  requestedCertainty,
5366
5451
  externalReference,
5367
5452
  idempotencyKey,
@@ -5372,9 +5457,7 @@ class DynamicWalletClient {
5372
5457
  dynamicRequestId,
5373
5458
  traceContext
5374
5459
  });
5375
- var _record_rooms;
5376
- const rooms = (_record_rooms = record.rooms) != null ? _record_rooms : [];
5377
- var _roomIds_length;
5460
+ const room = record.room;
5378
5461
  this.logger.info('[DynamicWaasWalletClient] Sign and broadcast accepted', _extends({
5379
5462
  key: 'signAndBroadcast',
5380
5463
  operation: 'accepted',
@@ -5383,38 +5466,30 @@ class DynamicWalletClient {
5383
5466
  dynamicRequestId,
5384
5467
  transactionId: record.transactionId,
5385
5468
  transactionStatus: record.status,
5386
- attemptCount: messages.length,
5387
- roomIdSource,
5388
- roomsSent: (_roomIds_length = roomIds == null ? void 0 : roomIds.length) != null ? _roomIds_length : 0,
5389
- roomsReturned: rooms.length,
5390
- // A retry of an existing row names that row's rooms, not the ones just sent.
5391
- returnedRoomsMatchSent: roomIds ? roomIds.every((roomId, index)=>{
5392
- var _rooms_index;
5393
- return ((_rooms_index = rooms[index]) == null ? void 0 : _rooms_index.roomId) === roomId;
5394
- }) : undefined
5469
+ roomIdSource
5395
5470
  }, this.getTraceContext(traceContext)));
5396
- // A retry of a finished request returns the record without rooms.
5471
+ // A retry of a finished request returns the record without a room.
5397
5472
  if (record.status !== 'pending_signature') {
5398
5473
  return record;
5399
5474
  }
5400
- if (rooms.length !== messages.length) {
5401
- throw new Error(`Expected ${messages.length} signing rooms, got ${rooms.length} for transaction ${record.transactionId}`);
5475
+ if (!room) {
5476
+ throw new Error(`Expected a signing room for transaction ${record.transactionId}`);
5402
5477
  }
5403
5478
  stage = 'ceremony';
5404
5479
  onSignPhase == null ? void 0 : onSignPhase('ceremony');
5405
- const ceremonies = Promise.all(rooms.map((room, index)=>this.clientSign({
5406
- chainName,
5407
- message: messages[index],
5408
- roomId: room.roomId,
5409
- keyShare: clientKeyShares[0],
5410
- derivationPath,
5411
- isFormatted: false,
5412
- dynamicRequestId,
5413
- traceContext,
5414
- variant: wallet.variant,
5415
- // Nothing mints a replacement room for a broadcast attempt.
5416
- skipForwardMPC: true
5417
- })));
5480
+ const ceremonies = this.clientSign({
5481
+ chainName,
5482
+ message,
5483
+ roomId: room.roomId,
5484
+ keyShare: clientKeyShares[0],
5485
+ derivationPath,
5486
+ isFormatted: false,
5487
+ dynamicRequestId,
5488
+ traceContext,
5489
+ variant: wallet.variant,
5490
+ // Nothing mints a replacement room for a broadcast.
5491
+ skipForwardMPC: true
5492
+ });
5418
5493
  const getClientSignMismatch = this.trackClientSignMismatch(ceremonies, {
5419
5494
  accountAddress,
5420
5495
  chainName,
@@ -5445,7 +5520,7 @@ class DynamicWalletClient {
5445
5520
  this.logger.info('[DynamicWaasWalletClient] Client sign completed', _extends({
5446
5521
  accountAddress,
5447
5522
  chainName,
5448
- roomIds: rooms.map((room)=>room.roomId),
5523
+ roomId: room.roomId,
5449
5524
  dynamicRequestId,
5450
5525
  key: 'signAndBroadcast',
5451
5526
  operation: 'clientSign'
@@ -5455,9 +5530,9 @@ class DynamicWalletClient {
5455
5530
  const retryParams = {
5456
5531
  accountAddress,
5457
5532
  chainName,
5458
- contexts,
5533
+ context,
5459
5534
  broadcastOptions,
5460
- messages,
5535
+ message,
5461
5536
  password,
5462
5537
  signedSessionId,
5463
5538
  mfaToken,
@@ -5468,7 +5543,9 @@ class DynamicWalletClient {
5468
5543
  idempotencyKey,
5469
5544
  onError,
5470
5545
  traceContext,
5471
- onSignPhase
5546
+ onSignPhase,
5547
+ onSignDetails,
5548
+ onReadingShares
5472
5549
  };
5473
5550
  // The server rejected the local shares as stale before opening any room. Heal them
5474
5551
  // from backup and retry once with the same idempotency key, as plain signing does.
@@ -5499,9 +5576,9 @@ class DynamicWalletClient {
5499
5576
  }));
5500
5577
  }
5501
5578
  // A cached room was already claimed by an earlier ceremony. Retry once with the same
5502
- // idempotency key and no cached rooms, so the server opens fresh ones.
5579
+ // idempotency key and no cached room, so the server opens a fresh one.
5503
5580
  if (!roomIdRetry && isRoomIdAlreadyUsedError(error)) {
5504
- this.logger.info('[RoomCache] retrying sign and broadcast without cached rooms after 409 ROOM_ID_ALREADY_USED', _extends({
5581
+ this.logger.info('[RoomCache] retrying sign and broadcast without a cached room after 409 ROOM_ID_ALREADY_USED', _extends({
5505
5582
  key: 'signAndBroadcast',
5506
5583
  operation: 'roomIdRetry',
5507
5584
  accountAddress,
@@ -5524,7 +5601,6 @@ class DynamicWalletClient {
5524
5601
  operation: 'signAndBroadcast',
5525
5602
  details: {
5526
5603
  stage,
5527
- attemptCount: messages.length,
5528
5604
  roomIdSource,
5529
5605
  staleShareRetry,
5530
5606
  roomIdRetry
@@ -5562,95 +5638,67 @@ class DynamicWalletClient {
5562
5638
  }, this.getTraceContext(traceContext)));
5563
5639
  }
5564
5640
  /**
5565
- * 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.
5641
+ * 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.
5566
5642
  *
5567
- * @param wallet - The wallet signing; its key scheme decides which rooms fit.
5568
- * @param accountAddress - The wallet's address, for logs.
5569
- * @param chainName - The wallet's chain, for logs.
5570
- * @param count - How many rooms are needed: one per message.
5571
- * @param dynamicRequestId - Id of this request, so the pool's logs join the sign's.
5572
- * @param traceContext - Trace ids for the same reason.
5573
- * @returns The room ids, or `undefined` when the pool cannot cover every message.
5574
- */ async getCachedRoomsForSignAndBroadcast(wallet, accountAddress, chainName, count, dynamicRequestId, traceContext) {
5643
+ * @param params.wallet - The wallet signing; its key scheme decides which room fits.
5644
+ * @param params.accountAddress - The wallet's address, for logs.
5645
+ * @param params.chainName - The wallet's chain, for logs.
5646
+ * @param params.key - Which operation is asking, for logs.
5647
+ * @param params.dynamicRequestId - Id of this request, so the pool's logs join the operation's.
5648
+ * @param params.traceContext - Trace ids for the same reason.
5649
+ * @returns The room id, or `undefined`.
5650
+ */ async getCachedRoom({ wallet, accountAddress, chainName, key, dynamicRequestId, traceContext }) {
5575
5651
  const logContext = _extends({
5576
5652
  dynamicRequestId
5577
5653
  }, this.getTraceContext(traceContext));
5578
5654
  if (this.featureFlags[FEATURE_FLAGS.ENABLE_ROOM_CACHE_FLAG] !== true) {
5579
- this.logCachedRoomsUnavailable({
5655
+ this.logCachedRoomUnavailable({
5580
5656
  reason: 'cache_disabled',
5657
+ key,
5581
5658
  accountAddress,
5582
5659
  chainName,
5583
- count,
5584
5660
  logContext
5585
5661
  });
5586
5662
  return undefined;
5587
5663
  }
5588
- const rooms = await this.takeRooms({
5589
- roomType: RoomTypeEnum.Threshold,
5590
- thresholdSignatureScheme: wallet.thresholdSignatureScheme,
5591
- count,
5592
- logContext
5593
- });
5594
- if (rooms.length === 0) {
5595
- this.logCachedRoomsUnavailable({
5596
- reason: 'pool_too_small',
5664
+ const room = await this.getRoom(RoomTypeEnum.Threshold, wallet.thresholdSignatureScheme, logContext);
5665
+ if (!room) {
5666
+ this.logCachedRoomUnavailable({
5667
+ reason: 'pool_empty',
5668
+ key,
5597
5669
  accountAddress,
5598
5670
  chainName,
5599
- count,
5600
5671
  logContext
5601
5672
  });
5602
5673
  return undefined;
5603
5674
  }
5604
- const roomIds = rooms.map((room)=>room.roomId);
5605
- this.logger.info('[DynamicWaasWalletClient] Using cached rooms', _extends({
5606
- key: 'signAndBroadcast',
5607
- operation: 'cachedRoomsFound',
5675
+ this.logger.info('[DynamicWaasWalletClient] Using cached room', _extends({
5676
+ key,
5677
+ operation: 'cachedRoomFound',
5608
5678
  accountAddress,
5609
5679
  chainName,
5610
5680
  walletId: wallet.walletId,
5611
- roomIds
5681
+ roomId: room.roomId
5612
5682
  }, logContext));
5613
- return roomIds;
5683
+ return room.roomId;
5614
5684
  }
5615
5685
  /**
5616
- * Logs why no cached rooms were sent, so how often the cache covers a request can be measured.
5686
+ * Logs why no cached room was sent, so how often the cache covers a request can be measured.
5617
5687
  *
5618
- * @param params.reason - `cache_disabled` or `pool_too_small`.
5688
+ * @param params.reason - `cache_disabled` or `pool_empty`.
5689
+ * @param params.key - Which operation asked.
5619
5690
  * @param params.accountAddress - The wallet's address.
5620
5691
  * @param params.chainName - The wallet's chain.
5621
- * @param params.count - How many rooms were needed.
5622
5692
  * @param params.logContext - Request and trace ids to attach.
5623
- */ logCachedRoomsUnavailable({ reason, accountAddress, chainName, count, logContext }) {
5624
- this.logger.info('[DynamicWaasWalletClient] Cached rooms unavailable; the server opens them', _extends({
5625
- key: 'signAndBroadcast',
5626
- operation: 'cachedRoomsUnavailable',
5693
+ */ logCachedRoomUnavailable({ reason, key, accountAddress, chainName, logContext }) {
5694
+ this.logger.info('[DynamicWaasWalletClient] Cached room unavailable; the server opens one', _extends({
5695
+ key,
5696
+ operation: 'cachedRoomUnavailable',
5627
5697
  reason,
5628
5698
  accountAddress,
5629
- chainName,
5630
- requestedCount: count
5699
+ chainName
5631
5700
  }, logContext));
5632
5701
  }
5633
- async getCachedRoomForSign(wallet, accountAddress, chainName, dynamicRequestId, traceContext) {
5634
- if (this.featureFlags[FEATURE_FLAGS.ENABLE_ROOM_CACHE_FLAG] !== true) {
5635
- return undefined;
5636
- }
5637
- const logContext = _extends({
5638
- dynamicRequestId
5639
- }, this.getTraceContext(traceContext));
5640
- const room = await this.getRoom(RoomTypeEnum.Threshold, wallet.thresholdSignatureScheme, logContext);
5641
- const roomId = room == null ? void 0 : room.roomId;
5642
- if (roomId) {
5643
- this.logger.info('[DynamicWaasWalletClient] Using cached room', _extends({
5644
- key: 'sign',
5645
- operation: 'cachedRoomFound',
5646
- accountAddress,
5647
- chainName,
5648
- walletId: wallet.walletId,
5649
- roomId
5650
- }, logContext));
5651
- }
5652
- return roomId;
5653
- }
5654
5702
  // Without a cached room the client depends on the server reporting one
5655
5703
  // before it can join the ceremony.
5656
5704
  async resolveSignRoom({ cachedRoomId, serverSignPromise, accountAddress, chainName, walletId, dynamicRequestId, traceContext }) {
@@ -5714,7 +5762,7 @@ class DynamicWalletClient {
5714
5762
  clearTimeout(warningTimer);
5715
5763
  }
5716
5764
  }
5717
- /** Verifies the password, loads the wallet and makes sure its client share exists: the start of every sign. */ async prepareWalletForSign({ accountAddress, password, signedSessionId, onSignPhase }) {
5765
+ /** Verifies the password, loads the wallet and makes sure its client share exists: the start of every sign. */ async prepareWalletForSign({ accountAddress, password, signedSessionId, onSignPhase, onReadingShares }) {
5718
5766
  const startedAt = Date.now();
5719
5767
  onSignPhase == null ? void 0 : onSignPhase('verifyPassword');
5720
5768
  await this.verifyPassword({
@@ -5733,7 +5781,9 @@ class DynamicWalletClient {
5733
5781
  const walletLoadedAt = Date.now();
5734
5782
  const derivationPath = wallet.derivationPath && wallet.derivationPath != '' ? new Uint32Array(Object.values(JSON.parse(wallet.derivationPath))) : undefined;
5735
5783
  onSignPhase == null ? void 0 : onSignPhase('ensureClientShare');
5736
- const clientKeyShares = await this.ensureClientShare(accountAddress);
5784
+ const clientKeyShares = await this.ensureClientShare(accountAddress, {
5785
+ onReadingShares
5786
+ });
5737
5787
  const sharesReadyAt = Date.now();
5738
5788
  return {
5739
5789
  wallet,
@@ -5809,7 +5859,7 @@ class DynamicWalletClient {
5809
5859
  }, describeErrorForLog(error), details, this.getTraceContext(traceContext))
5810
5860
  });
5811
5861
  }
5812
- async internalSign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, approvalQuorum, onError, traceContext, bitcoinConfig, onSignPhase, onSignDetails, staleShareRetry = false, roomIdRetry = false }) {
5862
+ async internalSign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, approvalQuorum, onError, traceContext, bitcoinConfig, onSignPhase, onSignDetails, onReadingShares, staleShareRetry = false, roomIdRetry = false }) {
5813
5863
  const dynamicRequestId = v4();
5814
5864
  onSignDetails == null ? void 0 : onSignDetails({
5815
5865
  dynamicRequestId
@@ -5834,10 +5884,18 @@ class DynamicWalletClient {
5834
5884
  accountAddress,
5835
5885
  password,
5836
5886
  signedSessionId,
5837
- onSignPhase
5887
+ onSignPhase,
5888
+ onReadingShares
5838
5889
  });
5839
5890
  onSignPhase == null ? void 0 : onSignPhase('resolveCachedRoom');
5840
- const cachedRoomId = roomIdRetry ? undefined : await this.getCachedRoomForSign(wallet, accountAddress, chainName, dynamicRequestId, traceContext);
5891
+ const cachedRoomId = roomIdRetry ? undefined : await this.getCachedRoom({
5892
+ wallet,
5893
+ accountAddress,
5894
+ chainName,
5895
+ key: 'sign',
5896
+ dynamicRequestId,
5897
+ traceContext
5898
+ });
5841
5899
  const roomResolvedAt = Date.now();
5842
5900
  this.logSlowSignPreflight({
5843
5901
  accountAddress,
@@ -6080,6 +6138,7 @@ class DynamicWalletClient {
6080
6138
  bitcoinConfig,
6081
6139
  onSignPhase,
6082
6140
  onSignDetails,
6141
+ onReadingShares,
6083
6142
  staleShareRetry: true,
6084
6143
  roomIdRetry
6085
6144
  });
@@ -6109,6 +6168,7 @@ class DynamicWalletClient {
6109
6168
  bitcoinConfig,
6110
6169
  onSignPhase,
6111
6170
  onSignDetails,
6171
+ onReadingShares,
6112
6172
  staleShareRetry,
6113
6173
  roomIdRetry: true
6114
6174
  });
@@ -8292,17 +8352,15 @@ class DynamicWalletClient {
8292
8352
  * Note: This method only checks for existing shares in storage.
8293
8353
  * Auto-recovery logic has been removed in favor of the queue pattern.
8294
8354
  * Callers should handle recovery explicitly if needed.
8295
- */ async ensureClientShare(accountAddress) {
8355
+ */ async ensureClientShare(accountAddress, { onReadingShares } = {}) {
8296
8356
  // A recovery may be in flight (e.g. a stale-share heal scheduled when a
8297
8357
  // previous sign hit a public-key mismatch). Wait for it so this operation
8298
8358
  // reads the recovered shares instead of the superseded ones. Recovery
8299
8359
  // failures are logged where they happen; this operation should proceed
8300
8360
  // with whatever shares exist rather than fail on the recovery.
8301
- const pendingRecovery = WalletQueueManager.getPendingRecoveryPromise(accountAddress);
8302
- if (pendingRecovery) {
8303
- const waitStartedAt = Date.now();
8304
- // eslint-disable-next-line @typescript-eslint/no-empty-function
8305
- await pendingRecovery.catch(()=>{});
8361
+ const waitStartedAt = Date.now();
8362
+ const recoveryWait = await WalletQueueManager.waitForPendingRecovery(accountAddress);
8363
+ if (recoveryWait === 'settled') {
8306
8364
  // Every concurrent sign awaits this same promise, so it releases them all
8307
8365
  // at once. One line per waiter: the count is the size of the herd and the
8308
8366
  // shared finish timestamp is when it was released.
@@ -8312,6 +8370,8 @@ class DynamicWalletClient {
8312
8370
  recoveryWaitMs: Date.now() - waitStartedAt
8313
8371
  });
8314
8372
  }
8373
+ // From here a recovery waits for this sign to finish, so it cannot replace the shares being read.
8374
+ onReadingShares == null ? void 0 : onReadingShares();
8315
8375
  const shares = await this.getClientKeySharesFromStorage({
8316
8376
  accountAddress
8317
8377
  });
@@ -11152,6 +11212,17 @@ class DynamicWalletClient {
11152
11212
  }
11153
11213
  this.apiClient.syncAuthToken(authToken);
11154
11214
  }
11215
+ /** Reads the persisted room pool. A corrupt value counts as empty, so the next write replaces it. */ async readStoredRooms() {
11216
+ const rawData = await this.storage.getItem(`${this.storageKey}-rooms`);
11217
+ if (!rawData) {
11218
+ return {};
11219
+ }
11220
+ try {
11221
+ return JSON.parse(rawData);
11222
+ } catch (e) {
11223
+ return {};
11224
+ }
11225
+ }
11155
11226
  async createRooms({ roomType, thresholdSignatureScheme, roomCount = 5 }) {
11156
11227
  const numberOfParties = this.getNumberOfParties(roomType, thresholdSignatureScheme);
11157
11228
  const generation = DynamicWalletClient.roomsGeneration;
@@ -11193,8 +11264,7 @@ class DynamicWalletClient {
11193
11264
  // Re-read from storage under the lock so we don't clobber rooms another
11194
11265
  // tab just wrote, and so we don't lose rooms consumed by getRoom() calls
11195
11266
  // that ran while our API request was in-flight.
11196
- const rawData = await this.storage.getItem(`${this.storageKey}-rooms`);
11197
- const storedRooms = rawData ? JSON.parse(rawData) : {};
11267
+ const storedRooms = await this.readStoredRooms();
11198
11268
  const currentRooms = storedRooms[numberOfParties] || [];
11199
11269
  await this.setRooms(numberOfParties, [
11200
11270
  ...newRooms,
@@ -11311,8 +11381,7 @@ class DynamicWalletClient {
11311
11381
  // already consumed by other tabs. Combined with the Web Locks API below, this
11312
11382
  // makes room consumption atomic across tabs and prevents two tabs from both
11313
11383
  // receiving the same cached room.
11314
- const rawData = await this.storage.getItem(`${this.storageKey}-rooms`);
11315
- const storedRooms = rawData ? JSON.parse(rawData) : {};
11384
+ const storedRooms = await this.readStoredRooms();
11316
11385
  const now = Date.now();
11317
11386
  const schemeRooms = storedRooms[numberOfParties] || [];
11318
11387
  // Filter out expired rooms and remove the next available room.