@dynamic-labs-wallet/browser 1.1.28 → 1.1.30

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 redaction = require('@dynamic-labs/redaction');
13
14
  var ed25519 = require('@noble/curves/ed25519');
14
15
  var utils = require('@noble/curves/abstract/utils');
15
16
 
@@ -776,6 +777,7 @@ const ERROR_INCORRECT_PASSWORD = '[DynamicWaasWalletClient]: Incorrect password
776
777
  const ERROR_PASSWORD_REQUIRED_FOR_ENCRYPTED_WALLET = '[DynamicWaasWalletClient]: Password is required for refresh/reshare of a password-encrypted wallet.';
777
778
  const ERROR_EXISTING_PASSWORD_REQUIRED = '[DynamicWaasWalletClient]: Existing password is required to update the password for encrypted wallets.';
778
779
  const ERROR_WALLETS_ALREADY_ENCRYPTED = '[DynamicWaasWalletClient]: Cannot set password: wallets are already password-encrypted. Use updatePassword instead.';
780
+ const ERROR_EXISTING_WALLETS_NOT_ENCRYPTED = '[DynamicWaasWalletClient]: Cannot create a wallet with a password: existing wallets are not password-encrypted. Call setPassword first so all wallets share one password.';
779
781
  /**
780
782
  * Checks if password consistency validation is needed.
781
783
  * Returns false if no password provided or password is the environmentId default.
@@ -800,6 +802,18 @@ const ERROR_WALLETS_ALREADY_ENCRYPTED = '[DynamicWaasWalletClient]: Cannot set p
800
802
  }
801
803
  return undefined;
802
804
  }
805
+ /**
806
+ * Checks if the walletMap has a wallet with a recorded backup that is not password-encrypted.
807
+ * Wallets with no recorded backup are ignored: getWallets reports them as not
808
+ * password-encrypted, but their encryption state is unknown.
809
+ */ function hasPasswordlessWallet(walletMap) {
810
+ return Object.values(walletMap).some((wallet)=>{
811
+ const backupInfo = wallet.clientKeySharesBackupInfo;
812
+ if (!backupInfo || backupInfo.passwordEncrypted) return false;
813
+ var _backupInfo_backups;
814
+ return Object.values((_backupInfo_backups = backupInfo.backups) != null ? _backupInfo_backups : {}).some((locations)=>locations.length > 0);
815
+ });
816
+ }
803
817
  /**
804
818
  * Extracts the first encrypted key share credential from cached encrypted data.
805
819
  * Returns the base64-encoded encrypted credential string, or undefined if none found.
@@ -1634,6 +1648,7 @@ const NON_RETRYABLE_CEREMONY_ERROR_MESSAGES = [
1634
1648
  ERROR_PUBLIC_KEY_MISMATCH,
1635
1649
  ERROR_PASSWORD_MISMATCH,
1636
1650
  ERROR_INCORRECT_PASSWORD,
1651
+ ERROR_EXISTING_WALLETS_NOT_ENCRYPTED,
1637
1652
  ERROR_MULTIPLE_WALLETS_PER_CHAIN
1638
1653
  ];
1639
1654
  /**
@@ -2365,6 +2380,132 @@ const buildHttpCeremonyMeta = (fields)=>_extends({
2365
2380
  transport: WAAS_HTTPS_TRANSPORT
2366
2381
  }, Object.fromEntries(Object.entries(fields).filter(([, value])=>value !== undefined && value !== null)));
2367
2382
 
2383
+ /**
2384
+ * Lets a client-side ceremony fail as soon as the server's create stream
2385
+ * reports an error. The stream resolves early on `keygen_complete`, so a later
2386
+ * `error` event (e.g. a lock-contention 429) only reaches `onError`; without
2387
+ * this the client keeps waiting in a relay room the server never joined, or
2388
+ * (after the relay keygen) for a `ceremony_complete` that will never arrive.
2389
+ *
2390
+ * Usage:
2391
+ * 1. `watchStream()` for every create stream -> pass the result as its `onError`
2392
+ * 2. `watchCompletion(cb)` once -> pass the result as `onCeremonyComplete`
2393
+ * 3. `raceAgainstStreamError(work)` -> rejects early if a stream errors mid-work
2394
+ * 4. `waitForCompletion()` -> resolves on `ceremony_complete`
2395
+ */ const createCeremonyErrorWatch = ()=>{
2396
+ const streamError = core.createRejectablePromise();
2397
+ const completion = createResolvablePromise();
2398
+ const streams = {
2399
+ latestId: 0
2400
+ };
2401
+ return {
2402
+ /**
2403
+ * Registers a new create stream and returns its `onError` handler.
2404
+ * A fallback re-creates the stream, so only the newest stream's errors count.
2405
+ */ watchStream: ()=>{
2406
+ streams.latestId += 1;
2407
+ const streamId = streams.latestId;
2408
+ return (error)=>{
2409
+ if (streamId === streams.latestId) {
2410
+ streamError.reject(error);
2411
+ }
2412
+ };
2413
+ },
2414
+ /** Wraps the caller's `onCeremonyComplete` so `waitForCompletion` can observe it. */ watchCompletion: (onCeremonyComplete)=>onCeremonyComplete && ((...args)=>{
2415
+ onCeremonyComplete(...args);
2416
+ completion.resolve();
2417
+ }),
2418
+ /** Settles like `work`, but rejects early if the current stream errors first. */ raceAgainstStreamError: (work)=>Promise.race([
2419
+ work,
2420
+ streamError.promise
2421
+ ]),
2422
+ /** Resolves on `ceremony_complete`; rejects if the current stream errors first. */ waitForCompletion: ()=>Promise.race([
2423
+ completion.promise,
2424
+ streamError.promise
2425
+ ])
2426
+ };
2427
+ };
2428
+ const createResolvablePromise = ()=>{
2429
+ const handle = {
2430
+ resolve: ()=>undefined
2431
+ };
2432
+ const promise = new Promise((resolve)=>{
2433
+ handle.resolve = resolve;
2434
+ });
2435
+ return {
2436
+ promise,
2437
+ resolve: ()=>handle.resolve()
2438
+ };
2439
+ };
2440
+
2441
+ /** Bounded so a self-referencing or cyclic `cause` chain cannot spin. */ const MAX_CAUSE_DEPTH = 5;
2442
+ const MAX_DESCRIBED_LENGTH = 200;
2443
+ const redactErrorMessage = (message)=>redaction.redactSecretsFromText(message, redaction.KEY_MATERIAL_TEXT_PATTERNS).slice(0, MAX_DESCRIBED_LENGTH);
2444
+ /**
2445
+ * Keys safe to echo when a thrown object exposes no recognised message field.
2446
+ * Deliberately an allowlist: the alternative — serialising the whole object —
2447
+ * cannot be made safe, because a thrown MPC signer rejection or API body may
2448
+ * carry key-share material, a raw transaction body or an auth token.
2449
+ */ const SAFE_DETAIL_KEYS = [
2450
+ 'status',
2451
+ 'statusCode',
2452
+ 'code',
2453
+ 'error_code',
2454
+ 'errorCode',
2455
+ 'name',
2456
+ 'type'
2457
+ ];
2458
+ const isScalar = (value)=>typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
2459
+ /**
2460
+ * Builds a human-readable message for a thrown value that is not an `Error` —
2461
+ * most often an API response body, e.g.
2462
+ * `{ existingWalletId, error: 'Multiple wallets per chain not allowed' }`.
2463
+ *
2464
+ * `String(value)` would collapse that to `[object Object]`, losing the only
2465
+ * diagnostic content it had, so a recognised message field is preferred.
2466
+ *
2467
+ * Never serialises an unrecognised object wholesale. Where no message field
2468
+ * exists, only `SAFE_DETAIL_KEYS` scalars are echoed, and failing that a fixed
2469
+ * placeholder — so a value carrying secrets can never reach the logging backend
2470
+ * just because it lacked a `message`.
2471
+ */ const describeNonError = (value)=>{
2472
+ if (typeof value === 'string') return value.slice(0, MAX_DESCRIBED_LENGTH);
2473
+ if (value !== null && typeof value === 'object') {
2474
+ const record = value;
2475
+ const named = [
2476
+ record['error'],
2477
+ record['message'],
2478
+ record['detail']
2479
+ ].find((candidate)=>typeof candidate === 'string' && candidate.length > 0);
2480
+ if (named) return named.slice(0, MAX_DESCRIBED_LENGTH);
2481
+ const safeDetails = SAFE_DETAIL_KEYS.filter((key)=>isScalar(record[key])).map((key)=>`${key}=${String(record[key])}`);
2482
+ return safeDetails.length > 0 ? safeDetails.join(' ').slice(0, MAX_DESCRIBED_LENGTH) : 'Non-Error value thrown with no recognised message field';
2483
+ }
2484
+ return String(value).slice(0, MAX_DESCRIBED_LENGTH);
2485
+ };
2486
+ /**
2487
+ * `{ name, message }` for each link of an error's `cause` chain, outermost
2488
+ * first, so a wrapper like `new Error(ERROR_SIGN_MESSAGE, { cause })` still
2489
+ * shows the failure it wraps. Bounded in depth and length, and never echoes
2490
+ * raw fields (they may carry key material); non-Errors use `describeNonError`.
2491
+ */ const describeCauseChain = (error, depth = 0)=>{
2492
+ if (error === undefined || error === null || depth >= MAX_CAUSE_DEPTH) return [];
2493
+ const link = error instanceof Error ? {
2494
+ name: error.name,
2495
+ message: redactErrorMessage(error.message)
2496
+ } : {
2497
+ name: typeof error,
2498
+ message: describeNonError(error)
2499
+ };
2500
+ const next = error instanceof Error ? error.cause : undefined;
2501
+ return next === error ? [
2502
+ link
2503
+ ] : [
2504
+ link,
2505
+ ...describeCauseChain(next, depth + 1)
2506
+ ];
2507
+ };
2508
+
2368
2509
  /**
2369
2510
  * Returns true for errors that represent expected user-facing conditions
2370
2511
  * (e.g. wrong password). These are logged at `info` instead of `error`
@@ -2429,7 +2570,6 @@ const classifyOne = (error)=>{
2429
2570
  }
2430
2571
  return undefined;
2431
2572
  };
2432
- /** Bounded so a self-referencing or cyclic `cause` chain cannot spin. */ const MAX_CAUSE_DEPTH = 5;
2433
2573
  /**
2434
2574
  * Single source of truth for "how bad is this error". Exported so callers
2435
2575
  * classify identically instead of hard-coding `logger.error` for every failure.
@@ -2454,49 +2594,6 @@ const classifyOne = (error)=>{
2454
2594
  }
2455
2595
  return fallback;
2456
2596
  };
2457
- const MAX_DESCRIBED_LENGTH = 200;
2458
- /**
2459
- * Keys safe to echo when a thrown object exposes no recognised message field.
2460
- * Deliberately an allowlist: the alternative — serialising the whole object —
2461
- * cannot be made safe, because a thrown MPC signer rejection or API body may
2462
- * carry key-share material, a raw transaction body or an auth token.
2463
- */ const SAFE_DETAIL_KEYS = [
2464
- 'status',
2465
- 'statusCode',
2466
- 'code',
2467
- 'error_code',
2468
- 'errorCode',
2469
- 'name',
2470
- 'type'
2471
- ];
2472
- const isScalar = (value)=>typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
2473
- /**
2474
- * Builds a human-readable message for a thrown value that is not an `Error` —
2475
- * most often an API response body, e.g.
2476
- * `{ existingWalletId, error: 'Multiple wallets per chain not allowed' }`.
2477
- *
2478
- * `String(value)` would collapse that to `[object Object]`, losing the only
2479
- * diagnostic content it had, so a recognised message field is preferred.
2480
- *
2481
- * Never serialises an unrecognised object wholesale. Where no message field
2482
- * exists, only `SAFE_DETAIL_KEYS` scalars are echoed, and failing that a fixed
2483
- * placeholder — so a value carrying secrets can never reach the logging backend
2484
- * just because it lacked a `message`.
2485
- */ const describeNonError = (value)=>{
2486
- if (typeof value === 'string') return value.slice(0, MAX_DESCRIBED_LENGTH);
2487
- if (value !== null && typeof value === 'object') {
2488
- const record = value;
2489
- const named = [
2490
- record['error'],
2491
- record['message'],
2492
- record['detail']
2493
- ].find((candidate)=>typeof candidate === 'string' && candidate.length > 0);
2494
- if (named) return named.slice(0, MAX_DESCRIBED_LENGTH);
2495
- const safeDetails = SAFE_DETAIL_KEYS.filter((key)=>isScalar(record[key])).map((key)=>`${key}=${String(record[key])}`);
2496
- return safeDetails.length > 0 ? safeDetails.join(' ').slice(0, MAX_DESCRIBED_LENGTH) : 'Non-Error value thrown with no recognised message field';
2497
- }
2498
- return String(value).slice(0, MAX_DESCRIBED_LENGTH);
2499
- };
2500
2597
  /**
2501
2598
  * Decides how an operation failure reaches the Datadog logger, whose signature
2502
2599
  * is `(message, context, error)`.
@@ -2956,6 +3053,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
2956
3053
  // queue timeout to attribute the budget to the phase that consumed it.
2957
3054
  let currentPhase = 'queued';
2958
3055
  let phaseStartedAt = Date.now();
3056
+ const signDetails = {};
2959
3057
  // The task keeps running after the queue timeout rejects `add()`, so keep
2960
3058
  // a reference to observe how it eventually settles.
2961
3059
  let taskPromise;
@@ -2967,6 +3065,16 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
2967
3065
  reportPhase: (phase)=>{
2968
3066
  currentPhase = phase;
2969
3067
  phaseStartedAt = Date.now();
3068
+ },
3069
+ reportDetails: (details)=>{
3070
+ if (details.dynamicRequestId && details.dynamicRequestId !== signDetails.dynamicRequestId) {
3071
+ if (details.forwardMpcFailure) {
3072
+ return;
3073
+ }
3074
+ signDetails.dynamicRequestId = details.dynamicRequestId;
3075
+ delete signDetails.forwardMpcFailure;
3076
+ }
3077
+ Object.assign(signDetails, details);
2970
3078
  }
2971
3079
  };
2972
3080
  taskPromise = (async ()=>{
@@ -2990,7 +3098,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
2990
3098
  logError({
2991
3099
  message: '[WalletQueueManager] Sign queue timed out',
2992
3100
  error: error,
2993
- context: {
3101
+ context: _extends({
2994
3102
  accountAddress,
2995
3103
  operation,
2996
3104
  phase: currentPhase,
@@ -3001,7 +3109,7 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3001
3109
  signQueuePending: signQueue.pending,
3002
3110
  signQueueSize: signQueue.size,
3003
3111
  recoveryInProgress: this.isRecoveryInProgress(accountAddress)
3004
- },
3112
+ }, signDetails),
3005
3113
  level: 'warn'
3006
3114
  });
3007
3115
  // The caller already saw an error; record how the orphaned ceremony
@@ -3014,24 +3122,25 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
3014
3122
  logError({
3015
3123
  message: '[SignLateResult] Sign settled after queue timeout',
3016
3124
  error: new Error('signature produced after the caller saw a timeout'),
3017
- context: {
3125
+ context: _extends({
3018
3126
  accountAddress,
3019
3127
  operation,
3020
3128
  outcome: 'resolved',
3021
3129
  settledMs: Date.now() - timedOutAt
3022
- },
3130
+ }, signDetails),
3023
3131
  level: 'warn'
3024
3132
  });
3025
3133
  }, (lateError)=>{
3026
3134
  logError({
3027
3135
  message: '[SignLateResult] Sign settled after queue timeout',
3028
3136
  error: lateError instanceof Error ? lateError : new Error(String(lateError)),
3029
- context: {
3137
+ context: _extends({
3030
3138
  accountAddress,
3031
3139
  operation,
3032
3140
  outcome: 'rejected',
3033
- settledMs: Date.now() - timedOutAt
3034
- },
3141
+ settledMs: Date.now() - timedOutAt,
3142
+ causes: describeCauseChain(lateError)
3143
+ }, signDetails),
3035
3144
  level: 'warn'
3036
3145
  });
3037
3146
  });
@@ -4280,6 +4389,8 @@ class DynamicWalletClient {
4280
4389
  durationMs: Date.now() - clientInitStartedAt
4281
4390
  }, this.getTraceContext(traceContext)));
4282
4391
  const serverInitStartedAt = Date.now();
4392
+ const ceremonyWatch = createCeremonyErrorWatch();
4393
+ const watchedOnCeremonyComplete = ceremonyWatch.watchCompletion(onCeremonyComplete);
4283
4394
  const serverKeygenInit = await this.serverInitializeKeyGen({
4284
4395
  chainName,
4285
4396
  clientKeygenIds,
@@ -4289,7 +4400,8 @@ class DynamicWalletClient {
4289
4400
  businessAccountId,
4290
4401
  idempotencyKey,
4291
4402
  traceContext,
4292
- onCeremonyComplete
4403
+ onCeremonyComplete: watchedOnCeremonyComplete,
4404
+ onError: ceremonyWatch.watchStream()
4293
4405
  });
4294
4406
  const emittedAtMs = serverKeygenInit.emittedAtMs;
4295
4407
  let roomId = serverKeygenInit.roomId;
@@ -4308,7 +4420,7 @@ class DynamicWalletClient {
4308
4420
  } : {}, this.getTraceContext(traceContext)));
4309
4421
  const clientKeyGenStartedAt = Date.now();
4310
4422
  let refreshRoomInvoked = false;
4311
- const { rawPublicKey, clientKeygenResults: clientKeyShares, operationSource } = await this.clientKeyGen({
4423
+ const { rawPublicKey, clientKeygenResults: clientKeyShares, operationSource } = await ceremonyWatch.raceAgainstStreamError(this.clientKeyGen({
4312
4424
  chainName,
4313
4425
  roomId,
4314
4426
  serverKeygenIds,
@@ -4331,7 +4443,8 @@ class DynamicWalletClient {
4331
4443
  bitcoinConfig,
4332
4444
  businessAccountId,
4333
4445
  traceContext,
4334
- onCeremonyComplete
4446
+ onCeremonyComplete: watchedOnCeremonyComplete,
4447
+ onError: ceremonyWatch.watchStream()
4335
4448
  });
4336
4449
  // Track the relay ceremony's room and keygen ids so the logs below
4337
4450
  // report the ceremony that actually produced the wallet.
@@ -4339,7 +4452,7 @@ class DynamicWalletClient {
4339
4452
  serverKeygenIds = refreshed.serverKeygenIds;
4340
4453
  return refreshed;
4341
4454
  }
4342
- });
4455
+ }));
4343
4456
  this.logger.info('[DynamicWaasWalletClient] Client key generation completed', _extends({
4344
4457
  roomId,
4345
4458
  serverKeygenIds: serverKeygenIds.join(', '),
@@ -4359,7 +4472,8 @@ class DynamicWalletClient {
4359
4472
  // the wallet, then fire onCeremonyComplete manually.
4360
4473
  // Skip when refreshRoom was invoked: the SSE fallback already delivered
4361
4474
  // onCeremonyComplete via the event stream.
4362
- if (this.featureFlags[core.FEATURE_FLAGS.ENABLE_HTTP_WAAS_TRANSPORT] === true && idempotencyKey && onCeremonyComplete && !refreshRoomInvoked) {
4475
+ const pollsHttpCompletion = this.featureFlags[core.FEATURE_FLAGS.ENABLE_HTTP_WAAS_TRANSPORT] === true && idempotencyKey && onCeremonyComplete && !refreshRoomInvoked;
4476
+ if (pollsHttpCompletion) {
4363
4477
  const completion = await this.pollHttpCeremonyCompletion({
4364
4478
  chainName,
4365
4479
  clientKeygenIds,
@@ -4371,6 +4485,10 @@ class DynamicWalletClient {
4371
4485
  traceContext
4372
4486
  });
4373
4487
  onCeremonyComplete(completion.accountAddress, completion.walletId, completion.shareSetId);
4488
+ } else if (onCeremonyComplete) {
4489
+ // Wait for the stream's ceremony_complete here (not in each chain client)
4490
+ // so a stream error after the relay keygen rejects instead of hanging.
4491
+ await ceremonyWatch.waitForCompletion();
4374
4492
  }
4375
4493
  return {
4376
4494
  rawPublicKey,
@@ -4654,7 +4772,7 @@ class DynamicWalletClient {
4654
4772
  throw error;
4655
4773
  }
4656
4774
  }
4657
- async clientSign({ chainName, message, roomId: initialRoomId, keyShare, derivationPath, isFormatted, dynamicRequestId, traceContext, bitcoinConfig, variant, refreshRoom }) {
4775
+ async clientSign({ chainName, message, roomId: initialRoomId, keyShare, derivationPath, isFormatted, dynamicRequestId, traceContext, bitcoinConfig, variant, refreshRoom, onForwardMpcFailure }) {
4658
4776
  // Reassigned when forward MPC fails and we mint a fresh room for the
4659
4777
  // relay-based fallback below.
4660
4778
  let roomId = initialRoomId;
@@ -4717,6 +4835,14 @@ class DynamicWalletClient {
4717
4835
  return forwardMPCSignature;
4718
4836
  } catch (error) {
4719
4837
  const errorInfo = core.classifyForwardMpcError(error);
4838
+ try {
4839
+ onForwardMpcFailure == null ? void 0 : onForwardMpcFailure(errorInfo);
4840
+ } catch (observerError) {
4841
+ this.logger.warn('Forward MPC failure observer threw', {
4842
+ dynamicRequestId,
4843
+ observerErrorType: observerError instanceof Error ? observerError.name : typeof observerError
4844
+ });
4845
+ }
4720
4846
  this.logger.warn('Forward MPC signing failed', _extends({}, errorInfo, {
4721
4847
  chainName,
4722
4848
  environmentId: this.environmentId,
@@ -4983,7 +5109,7 @@ class DynamicWalletClient {
4983
5109
  }
4984
5110
  //todo: need to modify with imported flag
4985
5111
  async sign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, approvalQuorum, onError, traceContext, bitcoinConfig }) {
4986
- return WalletQueueManager.queueSignOperation(accountAddress, core.WalletOperation.SIGN_MESSAGE, ({ reportPhase })=>this.internalSign({
5112
+ return WalletQueueManager.queueSignOperation(accountAddress, core.WalletOperation.SIGN_MESSAGE, ({ reportPhase, reportDetails })=>this.internalSign({
4987
5113
  accountAddress,
4988
5114
  message,
4989
5115
  chainName,
@@ -4997,7 +5123,8 @@ class DynamicWalletClient {
4997
5123
  onError,
4998
5124
  traceContext,
4999
5125
  bitcoinConfig,
5000
- onSignPhase: reportPhase
5126
+ onSignPhase: reportPhase,
5127
+ onSignDetails: reportDetails
5001
5128
  }));
5002
5129
  }
5003
5130
  /**
@@ -5105,8 +5232,11 @@ class DynamicWalletClient {
5105
5232
  clearTimeout(warningTimer);
5106
5233
  }
5107
5234
  }
5108
- async internalSign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, approvalQuorum, onError, traceContext, bitcoinConfig, onSignPhase, staleShareRetry = false, roomIdRetry = false }) {
5235
+ async internalSign({ accountAddress, message, chainName, password = undefined, isFormatted = false, signedSessionId, mfaToken, elevatedAccessToken, context, approvalQuorum, onError, traceContext, bitcoinConfig, onSignPhase, onSignDetails, staleShareRetry = false, roomIdRetry = false }) {
5109
5236
  const dynamicRequestId = uuid.v4();
5237
+ onSignDetails == null ? void 0 : onSignDetails({
5238
+ dynamicRequestId
5239
+ });
5110
5240
  try {
5111
5241
  // Pre-flight phase boundaries. Nothing reaches the network until
5112
5242
  // serverSign below, so a stall here is invisible in server logs — these
@@ -5238,6 +5368,14 @@ class DynamicWalletClient {
5238
5368
  traceContext,
5239
5369
  bitcoinConfig,
5240
5370
  variant: wallet.variant,
5371
+ onForwardMpcFailure: ({ errorType, errorCode, sessionEstablished })=>onSignDetails == null ? void 0 : onSignDetails({
5372
+ dynamicRequestId,
5373
+ forwardMpcFailure: {
5374
+ errorType,
5375
+ errorCode,
5376
+ sessionEstablished
5377
+ }
5378
+ }),
5241
5379
  // On forward-MPC fallback, re-run the server sign with no roomId so a
5242
5380
  // brand-new server-joined room is created for the relay ceremony.
5243
5381
  refreshRoom: async ()=>{
@@ -5387,6 +5525,7 @@ class DynamicWalletClient {
5387
5525
  traceContext,
5388
5526
  bitcoinConfig,
5389
5527
  onSignPhase,
5528
+ onSignDetails,
5390
5529
  staleShareRetry: true,
5391
5530
  roomIdRetry
5392
5531
  });
@@ -5415,6 +5554,7 @@ class DynamicWalletClient {
5415
5554
  traceContext,
5416
5555
  bitcoinConfig,
5417
5556
  onSignPhase,
5557
+ onSignDetails,
5418
5558
  staleShareRetry,
5419
5559
  roomIdRetry: true
5420
5560
  });
@@ -8484,17 +8624,23 @@ class DynamicWalletClient {
8484
8624
  sessionPublicKey,
8485
8625
  passwordUpdateBatchId
8486
8626
  });
8627
+ let backupData;
8487
8628
  try {
8488
- await backup();
8629
+ backupData = await backup();
8489
8630
  } catch (error) {
8490
8631
  if (!(error instanceof StaleLocalSharesError) || !error.localSharesRefreshed) throw error;
8491
- await backup();
8632
+ backupData = await backup();
8492
8633
  }
8634
+ // Not named passwordUpdateStatus: the logger redacts string values of password* keys.
8493
8635
  this.logPasswordOperationSuccess('updatePassword', {
8494
8636
  accountAddress,
8495
8637
  walletId: wallet == null ? void 0 : wallet.walletId,
8496
- passwordUpdateBatchId
8638
+ passwordUpdateBatchId,
8639
+ batchStatus: backupData.passwordUpdateStatus
8497
8640
  });
8641
+ return {
8642
+ passwordUpdateStatus: backupData.passwordUpdateStatus
8643
+ };
8498
8644
  } catch (error) {
8499
8645
  var _this_storage_removeItem, _this_storage;
8500
8646
  // The pending encrypted-shares cache entry may have been written before the failure.
@@ -8524,7 +8670,7 @@ class DynamicWalletClient {
8524
8670
  if (isWalletPasswordEncrypted(wallet)) {
8525
8671
  throw new Error(ERROR_WALLETS_ALREADY_ENCRYPTED);
8526
8672
  }
8527
- await this.storeEncryptedBackupByWallet({
8673
+ const backupData = await this.storeEncryptedBackupByWallet({
8528
8674
  accountAddress,
8529
8675
  password: newPassword,
8530
8676
  signedSessionId,
@@ -8534,8 +8680,12 @@ class DynamicWalletClient {
8534
8680
  this.logPasswordOperationSuccess('setPassword', {
8535
8681
  accountAddress,
8536
8682
  walletId: wallet == null ? void 0 : wallet.walletId,
8537
- passwordUpdateBatchId
8683
+ passwordUpdateBatchId,
8684
+ batchStatus: backupData.passwordUpdateStatus
8538
8685
  });
8686
+ return {
8687
+ passwordUpdateStatus: backupData.passwordUpdateStatus
8688
+ };
8539
8689
  } catch (error) {
8540
8690
  var _this_storage_removeItem, _this_storage;
8541
8691
  // A pending cache entry may have been written if passwordUpdateBatchId was set.
@@ -8685,7 +8835,12 @@ class DynamicWalletClient {
8685
8835
  // Find first password-encrypted wallet
8686
8836
  const found = findPasswordEncryptedWallet(this.walletMap);
8687
8837
  if (!found) {
8688
- return; // No existing encrypted wallets
8838
+ // A password-encrypted wallet next to passwordless ones leaves a mixed state that
8839
+ // password updates cannot finish. setPassword encrypts all existing wallets at once.
8840
+ if (hasPasswordlessWallet(this.walletMap)) {
8841
+ throw new Error(ERROR_EXISTING_WALLETS_NOT_ENCRYPTED);
8842
+ }
8843
+ return; // No existing wallets
8689
8844
  }
8690
8845
  const [address, wallet] = found;
8691
8846
  try {
@@ -10055,6 +10210,41 @@ class DynamicWalletClient {
10055
10210
  }
10056
10211
  }
10057
10212
  /**
10213
+ * Re-locks every unlocked password-encrypted wallet: clears its decrypted
10214
+ * client key shares from storage and flips its ready state back to
10215
+ * ENCRYPTED, so the next operation prompts for the wallet password again.
10216
+ * The cached encrypted-shares blobs are kept, so a later unlockWallet does
10217
+ * not need a server round-trip. Wallets that are not password-encrypted have
10218
+ * no password to re-prompt for and are left untouched.
10219
+ *
10220
+ * When `accountAddress` is given, only that wallet is re-locked.
10221
+ */ async lockWallets(accountAddress) {
10222
+ const normalizedTarget = accountAddress ? normalizeAddress(accountAddress) : undefined;
10223
+ if (normalizedTarget && !this.walletMap[normalizedTarget]) {
10224
+ throw new Error(`Wallet not found for address: ${accountAddress}`);
10225
+ }
10226
+ const walletsToLock = Object.entries(this.walletMap).filter(([address, wallet])=>wallet.walletReadyState === core.WalletReadyState.READY && isWalletPasswordEncrypted(wallet) && (!normalizedTarget || address === normalizedTarget));
10227
+ const results = await Promise.allSettled(walletsToLock.map(async ([accountAddress])=>{
10228
+ await this.setClientKeySharesToStorage({
10229
+ accountAddress,
10230
+ clientKeyShares: []
10231
+ });
10232
+ this.updateWalletMap(accountAddress, {
10233
+ walletReadyState: core.WalletReadyState.ENCRYPTED
10234
+ });
10235
+ }));
10236
+ // Persist even on partial failure so the stored map matches the in-memory
10237
+ // one: a failed wallet stays READY with its shares still in storage.
10238
+ await this.storage.setItem(this.storageKey, JSON.stringify(this.walletMap));
10239
+ this.logger.info('[DynamicWaasWalletClient] lockWallets: re-locked wallets', {
10240
+ lockedCount: walletsToLock.length
10241
+ });
10242
+ const failure = results.find((result)=>result.status === 'rejected');
10243
+ if (failure) {
10244
+ throw failure.reason;
10245
+ }
10246
+ }
10247
+ /**
10058
10248
  * Decrypts cached encrypted key shares for a wallet and stores them locally.
10059
10249
  */ async decryptAndStoreWalletShares({ accountAddress, encryptedData, password }) {
10060
10250
  const data = JSON.parse(encryptedData);
@@ -10562,12 +10752,6 @@ class DynamicWalletClient {
10562
10752
  logger: this.logger,
10563
10753
  onUnauthorized: internalOptions == null ? void 0 : internalOptions.onUnauthorized
10564
10754
  });
10565
- // `reshare` left unbound until the signer-reshare flavor (reshare into a new
10566
- // signer's pending share set) exists; addSigner is stubbed until then.
10567
- this.businessAccounts = new core.BusinessAccountClient({
10568
- apiClient: this.apiClient,
10569
- logger: this.logger
10570
- });
10571
10755
  this.debug = Boolean(debug);
10572
10756
  this.featureFlags = featureFlags || {};
10573
10757
  this.argon2idRebackupTargets = argon2idRebackupTargets || [];