@dynamic-labs-wallet/browser 1.1.55 → 1.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BitcoinAddressType, MPC_RELAY_PROD_API_URL, DEFAULT_ED25519_VARIANT, getMPCChainConfig, parseNamespacedVersion, WalletApiError, NETWORK_ERROR_STATUS, formatRawMessageForSigning, BackupLocation, ENCRYPTED_SHARES_STORAGE_SUFFIX, Logger, createRejectablePromise, handleAxiosError, extractServerErrorCode, WalletOperation, ThresholdSignatureScheme, WalletReadyState, classifyForwardMpcError, isRecoveredForwardMpcConnectionChurn, FEATURE_FLAGS, getClientThreshold, MPC_CONFIG, forwardMpcMayHaveJoinedRoom, normalizeAndValidatePrivateKey, getTSSConfig, toJsonSafeContext, serializeMessageForForwardMPC, isEvmCompatibleChain, serializeError, withTraceId, attachRequestIds, getReshareConfig, isBusinessAccountActionRequired, createExportIntent, getRequiredExternalKeyShareId, verifiedCredentialNameToChainEnum, AuthMode, NoopLogger, DynamicApiClient, BusinessAccountClient, getEnvironmentFromUrl, IFRAME_DOMAIN_MAP } from '@dynamic-labs-wallet/core';
1
+ import { BitcoinAddressType, MPC_RELAY_PROD_API_URL, DEFAULT_ED25519_VARIANT, getMPCChainConfig, parseNamespacedVersion, WalletApiError, NETWORK_ERROR_STATUS, formatRawMessageForSigning, BackupLocation, ENCRYPTED_SHARES_STORAGE_SUFFIX, Logger, createRejectablePromise, handleAxiosError, extractServerErrorCode, WalletOperation, ThresholdSignatureScheme, WalletReadyState, classifyForwardMpcError, isRecoveredForwardMpcConnectionChurn, FEATURE_FLAGS, getClientThreshold, MPC_CONFIG, forwardMpcMayHaveJoinedRoom, normalizeAndValidatePrivateKey, getTSSConfig, toJsonSafeContext, serializeMessageForForwardMPC, isEvmCompatibleChain, serializeError, withTraceId, attachRequestIds, getReshareConfig, isBusinessAccountActionRequired, SigningConsentRequiredError, createExportIntent, createReshareIntent, getRequiredExternalKeyShareId, verifiedCredentialNameToChainEnum, AuthMode, NoopLogger, DynamicApiClient, BusinessAccountClient, getEnvironmentFromUrl, IFRAME_DOMAIN_MAP } from '@dynamic-labs-wallet/core';
2
2
  export * from '@dynamic-labs-wallet/core';
3
3
  export { Logger } from '@dynamic-labs-wallet/core';
4
4
  import { EdBls12377, BIP340, Ed25519, ExportableEd25519, Ecdsa, MessageHash, EcdsaSignature, EcdsaKeygenResult, ExportableEd25519KeygenResult, Ed25519KeygenResult, BIP340KeygenResult } from '#internal/web';
@@ -2978,9 +2978,9 @@ class WalletQueueManager {
2978
2978
  * lock within `acquireTimeoutMs` (a stuck holder in another context) fails
2979
2979
  * like a queue timeout instead of racing unlocked; dead contexts release
2980
2980
  * their locks automatically. Falls back to the in-memory queues when
2981
- * `navigator.locks` is unavailable or the share lock flag is off.
2981
+ * `navigator.locks` is unavailable.
2982
2982
  */ static async withShareLock(accountAddress, acquireTimeoutMs, mode, callback) {
2983
- if (!this.shareLockEnabled || typeof navigator === 'undefined' || !navigator.locks) {
2983
+ if (typeof navigator === 'undefined' || !navigator.locks) {
2984
2984
  return callback();
2985
2985
  }
2986
2986
  const abort = new AbortController();
@@ -3342,18 +3342,30 @@ class WalletQueueManager {
3342
3342
  const heavyQueue = this.getHeavyOpQueue(accountAddress);
3343
3343
  // Create promise and track it
3344
3344
  const recoveryPromise = (async ()=>{
3345
- // A recovery rewrites the shares, so it cannot start while a sign is using them.
3346
- await this.waitForSignQueueIdle(accountAddress);
3347
- // Add to heavy queue - will wait if other heavy operations are in progress
3348
- return heavyQueue.add(()=>this.withShareLock(accountAddress, HEAVY_OP_TIMEOUT_MS, 'exclusive', async ()=>{
3349
- try {
3350
- return await callback();
3351
- } finally{
3352
- // Clean up tracking when done
3353
- logRecoveryFinished();
3354
- this.clearPendingRecoveryPromise(accountAddress);
3355
- }
3356
- }));
3345
+ let started = false;
3346
+ try {
3347
+ // A recovery rewrites the shares, so it cannot start while a sign is using them.
3348
+ await this.waitForSignQueueIdle(accountAddress);
3349
+ // Add to heavy queue - will wait if other heavy operations are in progress
3350
+ return await heavyQueue.add(()=>this.withShareLock(accountAddress, HEAVY_OP_TIMEOUT_MS, 'exclusive', async ()=>{
3351
+ started = true;
3352
+ try {
3353
+ return await callback();
3354
+ } finally{
3355
+ // Clean up tracking when done
3356
+ logRecoveryFinished();
3357
+ this.clearPendingRecoveryPromise(accountAddress);
3358
+ }
3359
+ }));
3360
+ } catch (error) {
3361
+ // Failed before the callback ran (e.g. lock acquisition timeout): its `finally` never will,
3362
+ // and a stale rejected promise would be returned to every later recovery call.
3363
+ if (!started) {
3364
+ logRecoveryFinished();
3365
+ this.clearPendingRecoveryPromise(accountAddress);
3366
+ }
3367
+ throw error;
3368
+ }
3357
3369
  })();
3358
3370
  // Track this recovery
3359
3371
  this.setPendingRecoveryPromise(accountAddress, recoveryPromise);
@@ -3405,11 +3417,7 @@ class WalletQueueManager {
3405
3417
  /**
3406
3418
  * Reset static state for testing purposes.
3407
3419
  * This clears all wallet queues and pending recovery promises.
3408
- */ static setShareLockEnabled(enabled) {
3409
- this.shareLockEnabled = enabled;
3410
- }
3411
- static resetForTesting() {
3412
- this.shareLockEnabled = false;
3420
+ */ static resetForTesting() {
3413
3421
  this.heavyOpQueues.clear();
3414
3422
  this.signQueues.clear();
3415
3423
  this.pendingRecoveryPromises.clear();
@@ -3423,7 +3431,6 @@ class WalletQueueManager {
3423
3431
  /** Heavy operation queues per wallet address. These run with concurrency=1. */ WalletQueueManager.heavyOpQueues = new Map();
3424
3432
  /** Sign queues per wallet address. These allow concurrent operations. */ WalletQueueManager.signQueues = new Map();
3425
3433
  /** Timeout applied to new sign queues. Mutable for tests via resetForTesting. */ WalletQueueManager.signQueueTimeoutMs = 20000;
3426
- /** Gates the cross-instance Web Lock; set from the `enable-share-web-lock` flag. */ WalletQueueManager.shareLockEnabled = false;
3427
3434
  /** Longest a bounded wait on an in-flight recovery lasts before the sign carries on with the shares it has. */ WalletQueueManager.recoveryWaitCapMs = 10000;
3428
3435
  /** Track pending recovery promises for deduplication */ WalletQueueManager.pendingRecoveryPromises = new Map();
3429
3436
  /** Per wallet, the signs that have loaded their key shares and not finished. */ WalletQueueManager.shareHolderQueues = new Map();
@@ -3851,7 +3858,9 @@ const validateOfflineRecoveryParams = ({ accountAddress, cloudProviderAccessToke
3851
3858
  ],
3852
3859
  offlineRecovery: true,
3853
3860
  mfaToken,
3854
- recoveryEncryptionPublicKey: params.recoveryEncryptionPublicKey
3861
+ recoveryEncryptionPublicKey: params.recoveryEncryptionPublicKey,
3862
+ approvalQuorum: params.approvalQuorum,
3863
+ proposalId: params.proposalId
3855
3864
  }, providerReshareArgs));
3856
3865
  Logger.info('offlineRecovery completed', {
3857
3866
  operation: 'offlineRecovery',
@@ -3981,6 +3990,25 @@ const BACKUP_OPERATION = {
3981
3990
  * path so normal signing stays quiet, well below the 20s queue timeout so a
3982
3991
  * stall is still captured while it is stalling.
3983
3992
  */ const SLOW_SIGN_PREFLIGHT_MS = 1000;
3993
+ // The server derives the gated operation from reshareKind, so a backup is
3994
+ // marked on every call, governed or not: unmarked, it reads as a plain reshare
3995
+ // and the backup's policy gate never runs.
3996
+ const reshareKindOf = ({ offlineRecovery, cloudBackup })=>{
3997
+ if (offlineRecovery) return 'offlineRecovery';
3998
+ return cloudBackup ? 'cloudBackup' : undefined;
3999
+ };
4000
+ /**
4001
+ * The reshare call resolves with an action-required body instead of a room
4002
+ * when a governed request is answered before the stream opens (a 422
4003
+ * `intent_required`). redcoast's reshare route answers on the stream today,
4004
+ * but a body with no room must never reach the ceremony: it would fail there
4005
+ * and roll back local shares that are intact.
4006
+ */ const assertReshareRoomOpened = (data)=>{
4007
+ if (isBusinessAccountActionRequired(data)) {
4008
+ throw new SigningConsentRequiredError(data);
4009
+ }
4010
+ return data;
4011
+ };
3984
4012
  /** A secure-storage key share read slower than this is logged on its own, so reads before the sign queue are visible too. */ const SLOW_KEY_SHARE_READ_MS = 1000;
3985
4013
  const KNOWN_SHARE_SET_TYPES = new Set([
3986
4014
  'rootUser',
@@ -6713,7 +6741,10 @@ class DynamicWalletClient {
6713
6741
  existingClientKeyShares
6714
6742
  };
6715
6743
  }
6716
- async reshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegateToProjectEnvironment = false, offlineRecovery = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource, initialSignerRules, recoveryEncryptionPublicKey }) {
6744
+ async reshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegateToProjectEnvironment = false, offlineRecovery = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource, initialSignerRules, recoveryEncryptionPublicKey, approvalQuorum, proposalId, cloudBackup = false }) {
6745
+ if (cloudBackup && (offlineRecovery || delegateToProjectEnvironment)) {
6746
+ throw new Error('reshare: cloudBackup excludes offlineRecovery and delegateToProjectEnvironment');
6747
+ }
6717
6748
  if (delegateToProjectEnvironment && offlineRecovery) {
6718
6749
  throw new Error('reshare: delegateToProjectEnvironment and offlineRecovery are mutually exclusive');
6719
6750
  }
@@ -6749,7 +6780,10 @@ class DynamicWalletClient {
6749
6780
  googleDriveAccessToken,
6750
6781
  googleDriveTokenSource,
6751
6782
  initialSignerRules,
6752
- recoveryEncryptionPublicKey
6783
+ recoveryEncryptionPublicKey,
6784
+ approvalQuorum,
6785
+ proposalId,
6786
+ cloudBackup
6753
6787
  }));
6754
6788
  }
6755
6789
  /** Reshare into a new 2/2 share set for the target signer; seed it to CKS
@@ -7088,6 +7122,7 @@ class DynamicWalletClient {
7088
7122
  completePromise,
7089
7123
  errorGate.promise
7090
7124
  ]),
7125
+ failed: errorGate.promise,
7091
7126
  get fired () {
7092
7127
  return fired;
7093
7128
  }
@@ -7272,7 +7307,9 @@ class DynamicWalletClient {
7272
7307
  * {@link recordReshareThresholdScheme} guard on the success path. Wiping
7273
7308
  * here would strand a wallet whose local keys are still valid.
7274
7309
  */ async rollbackFailedReshareLocalState({ accountAddress, oldThresholdSignatureScheme, offlineRecovery, error }) {
7275
- if (offlineRecovery || isPublicKeyMismatchError(error) || isStaleClientSharesError(error)) return;
7310
+ if (offlineRecovery || isPublicKeyMismatchError(error) || isStaleClientSharesError(error) || // Consent refusals happen before any ceremony starts — the local shares
7311
+ // are still good and must not be rolled back.
7312
+ error instanceof SigningConsentRequiredError) return;
7276
7313
  this.updateWalletMap(accountAddress, {
7277
7314
  thresholdSignatureScheme: oldThresholdSignatureScheme
7278
7315
  });
@@ -7309,7 +7346,7 @@ class DynamicWalletClient {
7309
7346
  clientKeySharesBackupInfo: updatedBackupInfo
7310
7347
  } : {}));
7311
7348
  }
7312
- async internalReshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegateToProjectEnvironment = false, offlineRecovery = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource, staleShareRetry = false, initialSignerRules, recoveryEncryptionPublicKey }) {
7349
+ async internalReshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegateToProjectEnvironment = false, offlineRecovery = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource, staleShareRetry = false, initialSignerRules, recoveryEncryptionPublicKey, approvalQuorum, proposalId, cloudBackup = false }) {
7313
7350
  const dynamicRequestId = v4();
7314
7351
  // Password validation - wrapped in try-catch for consistent error handling
7315
7352
  // This path should NOT wipe key shares on failure (shares remain valid)
@@ -7424,7 +7461,11 @@ class DynamicWalletClient {
7424
7461
  dynamicRequestId
7425
7462
  }
7426
7463
  });
7427
- const data = await this.apiClient.reshare(_extends({
7464
+ const reshareKind = reshareKindOf({
7465
+ offlineRecovery,
7466
+ cloudBackup
7467
+ });
7468
+ const data = assertReshareRoomOpened(await this.apiClient.reshare(_extends({
7428
7469
  walletId: wallet.walletId,
7429
7470
  shareSetId: wallet.shareSetId,
7430
7471
  clientKeygenIds: clientKeygenIds,
@@ -7436,17 +7477,19 @@ class DynamicWalletClient {
7436
7477
  elevatedAccessToken,
7437
7478
  revokeDelegation,
7438
7479
  initialSignerRules
7439
- }, offlineRecovery ? {
7440
- reshareKind: 'offlineRecovery'
7480
+ }, reshareKind ? {
7481
+ reshareKind
7441
7482
  } : {}, recoveryEncryptionPublicKey ? {
7442
7483
  offlineRecoveryEncryptionKey: {
7443
7484
  alg: 'HYBRID-RSA-AES-256',
7444
7485
  publicKeyPemB64: recoveryEncryptionPublicKey
7445
7486
  }
7446
7487
  } : {}, {
7488
+ approvalQuorum,
7489
+ proposalId,
7447
7490
  onError: ceremonyGate.onError,
7448
7491
  onCeremonyComplete: ceremonyGate.onCeremonyComplete
7449
- }));
7492
+ })));
7450
7493
  this.logger.info('[WaasReshare] room_created received, starting local MPC', {
7451
7494
  context: {
7452
7495
  walletId: wallet.walletId,
@@ -7491,49 +7534,23 @@ class DynamicWalletClient {
7491
7534
  let existingReshareResults;
7492
7535
  let newReshareResults;
7493
7536
  try {
7494
- const [existingSettled, newSettled] = await Promise.all([
7495
- Promise.allSettled(existingClientKeyShares.map((keyShare)=>mpcSigner.reshareRemainingParty(roomId, newMpcConfig.threshold, keyShare, allPartyKeygenIds))),
7496
- Promise.allSettled(newClientInitKeygenResults.map((keygenResult)=>mpcSigner.reshareNewParty(roomId, oldMpcConfig.threshold, newMpcConfig.threshold, keygenResult, allPartyKeygenIds)))
7497
- ]);
7498
- const collectRejections = (results, keygenIds, role)=>results.reduce((acc, r, i)=>{
7499
- if (r.status === 'rejected') {
7500
- acc.push({
7501
- role,
7502
- partyIndex: i,
7503
- keygenId: keygenIds[i],
7504
- error: r.reason
7505
- });
7506
- }
7507
- return acc;
7508
- }, []);
7509
- const failures = [
7510
- ...collectRejections(existingSettled, existingClientKeygenIds, 'existingParty'),
7511
- ...collectRejections(newSettled, newClientKeygenIds, 'newParty')
7512
- ];
7513
- if (failures.length > 0) {
7514
- const firstError = failures[0].error;
7515
- const error = firstError instanceof Error ? firstError : new Error(String(firstError));
7516
- logError({
7517
- message: 'Reshare MPC ceremony party failures',
7518
- error,
7519
- context: {
7520
- dynamicRequestId,
7521
- roomId,
7522
- failures: failures.map((f)=>({
7523
- role: f.role,
7524
- partyIndex: f.partyIndex,
7525
- keygenId: f.keygenId,
7526
- error: f.error instanceof Error ? f.error.message : String(f.error)
7527
- })),
7528
- existingSuccessCount: existingSettled.filter((r)=>r.status === 'fulfilled').length,
7529
- newSuccessCount: newSettled.filter((r)=>r.status === 'fulfilled').length
7530
- }
7531
- });
7532
- throw error;
7533
- }
7534
- existingReshareResults = existingSettled.map((r)=>r.value);
7535
- newReshareResults = newSettled.map((r)=>r.value);
7537
+ ({ existingReshareResults, newReshareResults } = await this.runReshareParties({
7538
+ mpcSigner,
7539
+ roomId,
7540
+ oldThreshold: oldMpcConfig.threshold,
7541
+ newThreshold: newMpcConfig.threshold,
7542
+ allPartyKeygenIds,
7543
+ existingClientKeyShares,
7544
+ existingClientKeygenIds,
7545
+ newClientInitKeygenResults,
7546
+ newClientKeygenIds,
7547
+ refused: ceremonyGate.failed,
7548
+ dynamicRequestId
7549
+ }));
7536
7550
  } catch (reshareError) {
7551
+ // A consent refusal never started the ceremony: the local shares are
7552
+ // intact, so there is nothing to heal.
7553
+ if (reshareError instanceof SigningConsentRequiredError) throw reshareError;
7537
7554
  // Heal stale local shares before rethrowing so a retry succeeds.
7538
7555
  await this.healStaleShares({
7539
7556
  accountAddress,
@@ -7663,7 +7680,10 @@ class DynamicWalletClient {
7663
7680
  googleDriveTokenSource,
7664
7681
  staleShareRetry: true,
7665
7682
  initialSignerRules,
7666
- recoveryEncryptionPublicKey
7683
+ recoveryEncryptionPublicKey,
7684
+ approvalQuorum,
7685
+ proposalId,
7686
+ cloudBackup
7667
7687
  });
7668
7688
  }
7669
7689
  logError({
@@ -7689,7 +7709,67 @@ class DynamicWalletClient {
7689
7709
  throw error;
7690
7710
  }
7691
7711
  }
7692
- async performDelegationOperation({ accountAddress, password, signedSessionId, sessionPublicKey, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, useShareSetReshare = false, operationName, initialSignerRules }) {
7712
+ /**
7713
+ * Runs the client's MPC parties for a reshare until they finish or the
7714
+ * server refuses the reshare on the stream, whichever comes first.
7715
+ */ async runReshareParties({ mpcSigner, roomId, oldThreshold, newThreshold, allPartyKeygenIds, existingClientKeyShares, existingClientKeygenIds, newClientInitKeygenResults, newClientKeygenIds, refused, dynamicRequestId }) {
7716
+ // redcoast answers room_created before wallet-service has accepted the
7717
+ // reshare, so a refusal (consent_required, policy violation) lands on
7718
+ // the stream while the local parties wait for a server that never
7719
+ // joins. Race them against it, as export does, so the refusal surfaces
7720
+ // now rather than as a relay timeout.
7721
+ const mpcPartiesPromise = Promise.all([
7722
+ Promise.allSettled(existingClientKeyShares.map((keyShare)=>mpcSigner.reshareRemainingParty(roomId, newThreshold, keyShare, allPartyKeygenIds))),
7723
+ Promise.allSettled(newClientInitKeygenResults.map((keygenResult)=>mpcSigner.reshareNewParty(roomId, oldThreshold, newThreshold, keygenResult, allPartyKeygenIds)))
7724
+ ]);
7725
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
7726
+ mpcPartiesPromise.catch(()=>{});
7727
+ const [existingSettled, newSettled] = await Promise.race([
7728
+ mpcPartiesPromise,
7729
+ refused
7730
+ ]);
7731
+ const collectRejections = (results, keygenIds, role)=>results.reduce((acc, r, i)=>{
7732
+ if (r.status === 'rejected') {
7733
+ acc.push({
7734
+ role,
7735
+ partyIndex: i,
7736
+ keygenId: keygenIds[i],
7737
+ error: r.reason
7738
+ });
7739
+ }
7740
+ return acc;
7741
+ }, []);
7742
+ const failures = [
7743
+ ...collectRejections(existingSettled, existingClientKeygenIds, 'existingParty'),
7744
+ ...collectRejections(newSettled, newClientKeygenIds, 'newParty')
7745
+ ];
7746
+ if (failures.length > 0) {
7747
+ const firstError = failures[0].error;
7748
+ const error = firstError instanceof Error ? firstError : new Error(String(firstError));
7749
+ logError({
7750
+ message: 'Reshare MPC ceremony party failures',
7751
+ error,
7752
+ context: {
7753
+ dynamicRequestId,
7754
+ roomId,
7755
+ failures: failures.map((f)=>({
7756
+ role: f.role,
7757
+ partyIndex: f.partyIndex,
7758
+ keygenId: f.keygenId,
7759
+ error: f.error instanceof Error ? f.error.message : String(f.error)
7760
+ })),
7761
+ existingSuccessCount: existingSettled.filter((r)=>r.status === 'fulfilled').length,
7762
+ newSuccessCount: newSettled.filter((r)=>r.status === 'fulfilled').length
7763
+ }
7764
+ });
7765
+ throw error;
7766
+ }
7767
+ return {
7768
+ existingReshareResults: existingSettled.map((r)=>r.value),
7769
+ newReshareResults: newSettled.map((r)=>r.value)
7770
+ };
7771
+ }
7772
+ async performDelegationOperation({ accountAddress, password, signedSessionId, sessionPublicKey, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, useShareSetReshare = false, operationName, initialSignerRules, approvalQuorum, proposalId }) {
7693
7773
  try {
7694
7774
  const delegateToProjectEnvironment = this.featureFlags && this.featureFlags[FEATURE_FLAGS.ENABLE_DELEGATED_KEY_SHARES_FLAG] === true;
7695
7775
  if (!delegateToProjectEnvironment) {
@@ -7726,7 +7806,9 @@ class DynamicWalletClient {
7726
7806
  mfaToken,
7727
7807
  revokeDelegation,
7728
7808
  // Do not attach rules on the revoke path.
7729
- initialSignerRules: revokeDelegation ? undefined : initialSignerRules
7809
+ initialSignerRules: revokeDelegation ? undefined : initialSignerRules,
7810
+ approvalQuorum,
7811
+ proposalId
7730
7812
  });
7731
7813
  } catch (error) {
7732
7814
  logError({
@@ -7739,7 +7821,7 @@ class DynamicWalletClient {
7739
7821
  throw error;
7740
7822
  }
7741
7823
  }
7742
- async delegateKeyShares({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, initialSignerRules }) {
7824
+ async delegateKeyShares({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, initialSignerRules, approvalQuorum, proposalId }) {
7743
7825
  var _this_featureFlags;
7744
7826
  const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
7745
7827
  if (useShareSetReshare) {
@@ -7774,15 +7856,12 @@ class DynamicWalletClient {
7774
7856
  signedSessionId,
7775
7857
  sessionPublicKey,
7776
7858
  mfaToken,
7777
- // FF on: reshare to 2/2 — the new delegated share set is always 2/2
7778
- // (server + delegated webhook). getReshareConfig now has a 2/2 → 2/2
7779
- // + delegation case that returns newClientShareCount=1 (the share
7780
- // routed to the webhook) so the ceremony generates it correctly.
7781
- // FF off: legacy 2/2 → 2/3 reshare that mutates rootUser.
7782
- newThresholdSignatureScheme: useShareSetReshare ? ThresholdSignatureScheme.TWO_OF_TWO : ThresholdSignatureScheme.TWO_OF_THREE,
7859
+ newThresholdSignatureScheme: this.delegationThresholdSignatureScheme(useShareSetReshare),
7783
7860
  operationName: 'delegateKeyShares',
7784
7861
  useShareSetReshare,
7785
- initialSignerRules
7862
+ initialSignerRules,
7863
+ approvalQuorum,
7864
+ proposalId
7786
7865
  });
7787
7866
  const backupInfo = this.getRequiredWalletFromMap(accountAddress).clientKeySharesBackupInfo;
7788
7867
  const delegatedKeyShares = backupInfo.backups[BackupLocation.DELEGATED] || [];
@@ -7846,7 +7925,7 @@ class DynamicWalletClient {
7846
7925
  * operation logic lives in `./offlineRecovery.ts` (cloud-provider-agnostic,
7847
7926
  * so iCloud / other providers slot in there); this method just drives it
7848
7927
  * with the client instance and returns the exported set's own backups.
7849
- */ async offlineRecovery({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, cloudProvider = DEFAULT_OFFLINE_RECOVERY_CLOUD_PROVIDER, cloudProviderAccessToken, recoveryEncryptionPublicKey }) {
7928
+ */ async offlineRecovery({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, cloudProvider = DEFAULT_OFFLINE_RECOVERY_CLOUD_PROVIDER, cloudProviderAccessToken, recoveryEncryptionPublicKey, approvalQuorum, proposalId }) {
7850
7929
  await performOfflineRecoveryOperation(this, {
7851
7930
  accountAddress,
7852
7931
  password,
@@ -7855,7 +7934,9 @@ class DynamicWalletClient {
7855
7934
  mfaToken,
7856
7935
  cloudProvider,
7857
7936
  cloudProviderAccessToken,
7858
- recoveryEncryptionPublicKey
7937
+ recoveryEncryptionPublicKey,
7938
+ approvalQuorum,
7939
+ proposalId
7859
7940
  });
7860
7941
  // The exported set's own locations. Not `backups[cloudProvider]`: that is
7861
7942
  // rootUser's record for the provider, which this operation never writes.
@@ -7910,6 +7991,73 @@ class DynamicWalletClient {
7910
7991
  initiatorUserId
7911
7992
  });
7912
7993
  }
7994
+ /** The scheme a delegation reshare moves the wallet to: share-set delegation mints its own 2/2 set. */ delegationThresholdSignatureScheme(useShareSetReshare) {
7995
+ // FF on: reshare to 2/2 — the new delegated share set is always 2/2
7996
+ // (server + delegated webhook). getReshareConfig now has a 2/2 → 2/2
7997
+ // + delegation case that returns newClientShareCount=1 (the share
7998
+ // routed to the webhook) so the ceremony generates it correctly.
7999
+ // FF off: legacy 2/2 → 2/3 reshare that mutates rootUser.
8000
+ return useShareSetReshare ? ThresholdSignatureScheme.TWO_OF_TWO : ThresholdSignatureScheme.TWO_OF_THREE;
8001
+ }
8002
+ /**
8003
+ * Builds the reshare Intent for a governed reshare entry point, to be
8004
+ * signed with the session key and sent as `approvalQuorum` on that same
8005
+ * entry point (`backupKeySharesToGoogleDrive`/`backupKeySharesToICloud`,
8006
+ * `delegateKeyShares` or `offlineRecovery`).
8007
+ *
8008
+ * The Intent echoes the fields that entry point sends, so it is built here
8009
+ * from the same wallet state. An offline recovery's Intent must also echo
8010
+ * the recovery key the server will use: pass the one the `INTENT_REQUIRED`
8011
+ * consent error carried. Makes no API call and starts no ceremony.
8012
+ */ async prepareReshareIntent({ accountAddress, password = undefined, signedSessionId, businessAccountId, initiatorUserId, completeBy, operation, offlineRecoveryEncryptionKey }) {
8013
+ if (operation === 'offlineRecovery' !== !!offlineRecoveryEncryptionKey) {
8014
+ throw new Error('prepareReshareIntent: offlineRecoveryEncryptionKey is required for offlineRecovery, from its INTENT_REQUIRED consent error, and invalid otherwise');
8015
+ }
8016
+ // Refreshed from the server, as the backup entry point does before it
8017
+ // picks its route: a wallet delegated from another device since it was
8018
+ // cached backs up without a reshare, and must not get an intent for one.
8019
+ await this.getWallet({
8020
+ accountAddress,
8021
+ password,
8022
+ signedSessionId,
8023
+ refresh: true
8024
+ });
8025
+ const walletData = this.getRequiredWalletFromMap(accountAddress);
8026
+ const identity = {
8027
+ accountAddress,
8028
+ businessAccountId,
8029
+ completeBy,
8030
+ initiatorUserId
8031
+ };
8032
+ if (operation === 'delegatedAccess') {
8033
+ var _this_featureFlags;
8034
+ const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
8035
+ return createReshareIntent(_extends({}, identity, {
8036
+ newThresholdSignatureScheme: this.delegationThresholdSignatureScheme(useShareSetReshare),
8037
+ reshareType: 'delegatedAccess'
8038
+ }));
8039
+ }
8040
+ if (operation === 'offlineRecovery') {
8041
+ return createReshareIntent(_extends({}, identity, {
8042
+ // performOfflineRecoveryOperation always pins the exported set at 2/2.
8043
+ newThresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_TWO,
8044
+ offlineRecoveryEncryptionKey,
8045
+ reshareType: 'offlineRecovery'
8046
+ }));
8047
+ }
8048
+ // A cloud backup reshares only from 2/2, or from a non-delegated 2/3
8049
+ // (backupKeySharesToCloudProvider); any other wallet backs up without a
8050
+ // reshare, so there is nothing to approve.
8051
+ const scheme = walletData.thresholdSignatureScheme;
8052
+ const reshares = scheme === ThresholdSignatureScheme.TWO_OF_TWO || scheme === ThresholdSignatureScheme.TWO_OF_THREE && !hasDelegatedBackup(walletData.clientKeySharesBackupInfo, walletData.otherShareSets);
8053
+ if (!reshares) {
8054
+ throw new Error('prepareReshareIntent: this wallet backs up without a reshare, so no approval applies');
8055
+ }
8056
+ return createReshareIntent(_extends({}, identity, {
8057
+ newThresholdSignatureScheme: ThresholdSignatureScheme.TWO_OF_THREE,
8058
+ reshareType: 'cloudBackup'
8059
+ }));
8060
+ }
7913
8061
  async exportKey({ accountAddress, chainName, bitcoinConfig, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, proposalId, traceContext }) {
7914
8062
  // Public entry point delegates to the private implementation. Keeping the
7915
8063
  // `staleShareRetry` flag off the public signature mirrors sign/refresh/
@@ -9224,10 +9372,10 @@ class DynamicWalletClient {
9224
9372
  recordedKeygenIds
9225
9373
  };
9226
9374
  // Per-wallet operations (e.g. updatePassword) emit one detection per wallet; keep the
9227
- // warn to one per burst so log monitors count stale sessions, not stale wallets.
9375
+ // warn to one per burst so log monitors count stale sessions, not stale wallets. In-window
9376
+ // repeats emit nothing — the count is folded into the next burst's warn.
9228
9377
  if (Date.now() - DynamicWalletClient.lastStaleSharesWarnAt < DynamicWalletClient.STALE_SHARES_WARN_WINDOW_MS) {
9229
9378
  DynamicWalletClient.suppressedStaleSharesWarns++;
9230
- this.logger.debug('[storeEncryptedBackupByWallet] Stale local shares detected; refreshing local storage (deduplicated)', staleSharesLogContext);
9231
9379
  } else {
9232
9380
  DynamicWalletClient.lastStaleSharesWarnAt = Date.now();
9233
9381
  const suppressedStaleShareWarnings = DynamicWalletClient.suppressedStaleSharesWarns;
@@ -9246,6 +9394,7 @@ class DynamicWalletClient {
9246
9394
  resolvedSessionId = undefined;
9247
9395
  }
9248
9396
  let localSharesRefreshed = false;
9397
+ let recoveryErrorReason;
9249
9398
  if (resolvedSessionId) {
9250
9399
  // The recovery decrypt key is whatever the server's blob was encrypted with — not the
9251
9400
  // caller's `password` arg, which is the key we'll re-encrypt the *next* upload with.
@@ -9267,17 +9416,14 @@ class DynamicWalletClient {
9267
9416
  });
9268
9417
  localSharesRefreshed = true;
9269
9418
  } catch (recoveryError) {
9270
- this.logger.error('[storeEncryptedBackupByWallet] Failed to refresh local shares from server', {
9271
- accountAddress,
9272
- walletId: walletData.walletId,
9273
- error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
9274
- });
9419
+ recoveryErrorReason = this.logStaleSharesRefreshFailure(recoveryError, accountAddress, walletData.walletId);
9275
9420
  }
9276
9421
  }
9277
9422
  this.logStaleSharesRecoveryOutcome({
9278
9423
  context: staleSharesLogContext,
9279
9424
  localSharesRefreshed,
9280
- resolvedSessionId
9425
+ resolvedSessionId,
9426
+ recoveryErrorReason
9281
9427
  });
9282
9428
  throw new StaleLocalSharesError({
9283
9429
  accountAddress,
@@ -9286,9 +9432,26 @@ class DynamicWalletClient {
9286
9432
  localSharesRefreshed
9287
9433
  });
9288
9434
  }
9435
+ // Network failures are transient (the caller retries), so they log at warn;
9436
+ // everything else stays error.
9437
+ logStaleSharesRefreshFailure(recoveryError, accountAddress, walletId) {
9438
+ const reason = classifyPasswordBackupError(recoveryError);
9439
+ const refreshFailureContext = {
9440
+ accountAddress,
9441
+ walletId,
9442
+ error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
9443
+ recoveryErrorReason: reason
9444
+ };
9445
+ if (reason === 'network') {
9446
+ this.logger.warn('[storeEncryptedBackupByWallet] Failed to refresh local shares from server', refreshFailureContext);
9447
+ } else {
9448
+ this.logger.error('[storeEncryptedBackupByWallet] Failed to refresh local shares from server', refreshFailureContext);
9449
+ }
9450
+ return reason;
9451
+ }
9289
9452
  // One outcome line per stale event so monitors count recoverable vs
9290
9453
  // unrecoverable staleness (refreshOutcome facet) rather than raw detections.
9291
- logStaleSharesRecoveryOutcome({ context, localSharesRefreshed, resolvedSessionId }) {
9454
+ logStaleSharesRecoveryOutcome({ context, localSharesRefreshed, resolvedSessionId, recoveryErrorReason }) {
9292
9455
  let refreshOutcome;
9293
9456
  if (localSharesRefreshed) {
9294
9457
  refreshOutcome = 'refreshed';
@@ -9299,10 +9462,14 @@ class DynamicWalletClient {
9299
9462
  }
9300
9463
  const recoveryContext = _extends({}, context, {
9301
9464
  refreshOutcome,
9302
- sessionId: resolvedSessionId
9465
+ sessionId: resolvedSessionId,
9466
+ recoveryErrorReason
9303
9467
  });
9304
9468
  if (localSharesRefreshed) {
9305
9469
  this.logger.info('[storeEncryptedBackupByWallet] Stale local shares recovery; caller should retry', recoveryContext);
9470
+ } else if (recoveryErrorReason === 'network') {
9471
+ // Connectivity blips are transient, so they aren't an oncall-actionable error.
9472
+ this.logger.warn('[storeEncryptedBackupByWallet] Stale local shares recovery hit a network error; backup refused', recoveryContext);
9306
9473
  } else {
9307
9474
  this.logger.error('[storeEncryptedBackupByWallet] Stale local shares recovery failed; backup refused', recoveryContext);
9308
9475
  }
@@ -10058,7 +10225,7 @@ class DynamicWalletClient {
10058
10225
  * - For 2-of-2 wallets: Automatically reshares to 2-of-3 threshold, then distributes shares (1 to backend, 1 to cloud)
10059
10226
  * - For non-delegated 2-of-3 wallets: same-scheme reshare to mint a fresh recovery share
10060
10227
  * - For delegated 2-of-3 (and other) wallets: storeEncryptedBackupByWallet backs up to backend and cloud
10061
- */ async backupKeySharesToCloudProvider({ accountAddress, password, signedSessionId, sessionPublicKey, backupLocation, googleDriveAccessToken, googleDriveTokenSource }) {
10228
+ */ async backupKeySharesToCloudProvider({ accountAddress, password, signedSessionId, sessionPublicKey, backupLocation, googleDriveAccessToken, googleDriveTokenSource, approvalQuorum, proposalId }) {
10062
10229
  try {
10063
10230
  await this.getWallet({
10064
10231
  accountAddress,
@@ -10082,7 +10249,10 @@ class DynamicWalletClient {
10082
10249
  backupLocation
10083
10250
  ],
10084
10251
  googleDriveAccessToken,
10085
- googleDriveTokenSource
10252
+ googleDriveTokenSource,
10253
+ cloudBackup: true,
10254
+ approvalQuorum,
10255
+ proposalId
10086
10256
  });
10087
10257
  } else if (currentThresholdSignatureScheme === ThresholdSignatureScheme.TWO_OF_THREE && !hasDelegatedBackup(walletData.clientKeySharesBackupInfo, walletData.otherShareSets)) {
10088
10258
  await this.reshareNonDelegatedTwoOfThree({
@@ -10092,7 +10262,9 @@ class DynamicWalletClient {
10092
10262
  signedSessionId,
10093
10263
  sessionPublicKey,
10094
10264
  googleDriveAccessToken,
10095
- googleDriveTokenSource
10265
+ googleDriveTokenSource,
10266
+ approvalQuorum,
10267
+ proposalId
10096
10268
  });
10097
10269
  } else {
10098
10270
  await this.storeEncryptedBackupByWallet({
@@ -10124,7 +10296,7 @@ class DynamicWalletClient {
10124
10296
  * reshare that mints a fresh recovery share. The fresh share is redistributed to
10125
10297
  * every provider already holding a backup plus the requested one,
10126
10298
  * restricted to providers the reshare can deliver to.
10127
- */ async reshareNonDelegatedTwoOfThree({ accountAddress, backupLocation, password, signedSessionId, sessionPublicKey, googleDriveAccessToken, googleDriveTokenSource }) {
10299
+ */ async reshareNonDelegatedTwoOfThree({ accountAddress, backupLocation, password, signedSessionId, sessionPublicKey, googleDriveAccessToken, googleDriveTokenSource, approvalQuorum, proposalId }) {
10128
10300
  const walletData = this.getRequiredWalletFromMap(accountAddress);
10129
10301
  const cloudProviders = new Set([
10130
10302
  ...getActiveCloudProviders(walletData.clientKeySharesBackupInfo).filter((provider)=>provider === BackupLocation.GOOGLE_DRIVE || provider === BackupLocation.ICLOUD),
@@ -10159,7 +10331,10 @@ class DynamicWalletClient {
10159
10331
  sessionPublicKey,
10160
10332
  cloudProviders: Array.from(cloudProviders),
10161
10333
  googleDriveAccessToken: resolvedDrive.googleDriveAccessToken,
10162
- googleDriveTokenSource: resolvedDrive.googleDriveTokenSource
10334
+ googleDriveTokenSource: resolvedDrive.googleDriveTokenSource,
10335
+ cloudBackup: true,
10336
+ approvalQuorum,
10337
+ proposalId
10163
10338
  });
10164
10339
  }
10165
10340
  /**
@@ -10229,7 +10404,7 @@ class DynamicWalletClient {
10229
10404
  * @param params.signedSessionId - Optional signed session ID for authentication
10230
10405
  * @param params.googleDriveAccessToken - Optional Google OAuth access token. When provided, the SDK uses it directly
10231
10406
  * instead of looking up the user's linked Google account and exchanging it for a token via the Dynamic backend.
10232
- */ async backupKeySharesToGoogleDrive({ accountAddress, password, signedSessionId, sessionPublicKey, googleDriveAccessToken }) {
10407
+ */ async backupKeySharesToGoogleDrive({ accountAddress, password, signedSessionId, sessionPublicKey, googleDriveAccessToken, approvalQuorum, proposalId }) {
10233
10408
  // Track whether the Drive token was supplied by the caller (external) or
10234
10409
  // will be resolved from the user's stored OAuth account (stored_oauth), so
10235
10410
  // backups using a token we don't control are auditable. Enum only — the
@@ -10267,7 +10442,9 @@ class DynamicWalletClient {
10267
10442
  googleDriveAccessToken: resolvedAccessToken,
10268
10443
  // Persist which token source backed up the Google Drive location for
10269
10444
  // audit (enum only — never the token value).
10270
- googleDriveTokenSource: tokenSource
10445
+ googleDriveTokenSource: tokenSource,
10446
+ approvalQuorum,
10447
+ proposalId
10271
10448
  });
10272
10449
  success = true;
10273
10450
  } finally{
@@ -10340,13 +10517,23 @@ class DynamicWalletClient {
10340
10517
  * @param params.accountAddress - The wallet account address to backup
10341
10518
  * @param params.password - Optional password for encryption (uses environment ID if not provided)
10342
10519
  * @param params.signedSessionId - Optional signed session ID for authentication
10343
- */ async backupKeySharesToICloud({ accountAddress, password, signedSessionId, sessionPublicKey }) {
10520
+ */ async backupKeySharesToICloud({ accountAddress, password, signedSessionId, sessionPublicKey, approvalQuorum, proposalId }) {
10521
+ // Refreshed from the server, as the Google Drive backup and
10522
+ // prepareReshareIntent do: the route (reshare or not) must be picked from
10523
+ // the same wallet state the reshare intent was built from.
10524
+ await this.getWallet({
10525
+ accountAddress,
10526
+ signedSessionId,
10527
+ refresh: true
10528
+ });
10344
10529
  return this.backupKeySharesToCloudProvider({
10345
10530
  accountAddress,
10346
10531
  password,
10347
10532
  signedSessionId,
10348
10533
  sessionPublicKey,
10349
- backupLocation: BackupLocation.ICLOUD
10534
+ backupLocation: BackupLocation.ICLOUD,
10535
+ approvalQuorum,
10536
+ proposalId
10350
10537
  });
10351
10538
  }
10352
10539
  /**
@@ -11765,7 +11952,6 @@ class DynamicWalletClient {
11765
11952
  });
11766
11953
  this.debug = Boolean(debug);
11767
11954
  this.featureFlags = featureFlags || {};
11768
- WalletQueueManager.setShareLockEnabled(this.featureFlags[FEATURE_FLAGS.ENABLE_SHARE_WEB_LOCK_FLAG] === true);
11769
11955
  this.argon2idRebackupTargets = argon2idRebackupTargets || [];
11770
11956
  // setup storage for walletMap metadata (used regardless of secureStorage)
11771
11957
  if (supportsLocalStorage()) {