@dynamic-labs-wallet/browser 1.1.56 → 1.1.58

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
@@ -661,6 +661,12 @@ function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion
661
661
  }
662
662
  return resolved;
663
663
  }
664
+ /**
665
+ * Replaces a session whose single-use nonce was already spent. Keeps `spent` when no
666
+ * reverse channel can mint one, so legacy hosts behave as before.
667
+ */ async renew(spent) {
668
+ return this.canRefresh && this.supportsReverseChannel() ? this.resolve() : spent;
669
+ }
664
670
  /** Whether the configured SDK version requires a signed session ID. */ isRequired() {
665
671
  return this.required;
666
672
  }
@@ -3857,7 +3863,9 @@ const validateOfflineRecoveryParams = ({ accountAddress, cloudProviderAccessToke
3857
3863
  ],
3858
3864
  offlineRecovery: true,
3859
3865
  mfaToken,
3860
- recoveryEncryptionPublicKey: params.recoveryEncryptionPublicKey
3866
+ recoveryEncryptionPublicKey: params.recoveryEncryptionPublicKey,
3867
+ approvalQuorum: params.approvalQuorum,
3868
+ proposalId: params.proposalId
3861
3869
  }, providerReshareArgs));
3862
3870
  core.Logger.info('offlineRecovery completed', {
3863
3871
  operation: 'offlineRecovery',
@@ -3987,6 +3995,25 @@ const BACKUP_OPERATION = {
3987
3995
  * path so normal signing stays quiet, well below the 20s queue timeout so a
3988
3996
  * stall is still captured while it is stalling.
3989
3997
  */ const SLOW_SIGN_PREFLIGHT_MS = 1000;
3998
+ // The server derives the gated operation from reshareKind, so a backup is
3999
+ // marked on every call, governed or not: unmarked, it reads as a plain reshare
4000
+ // and the backup's policy gate never runs.
4001
+ const reshareKindOf = ({ offlineRecovery, cloudBackup })=>{
4002
+ if (offlineRecovery) return 'offlineRecovery';
4003
+ return cloudBackup ? 'cloudBackup' : undefined;
4004
+ };
4005
+ /**
4006
+ * The reshare call resolves with an action-required body instead of a room
4007
+ * when a governed request is answered before the stream opens (a 422
4008
+ * `intent_required`). redcoast's reshare route answers on the stream today,
4009
+ * but a body with no room must never reach the ceremony: it would fail there
4010
+ * and roll back local shares that are intact.
4011
+ */ const assertReshareRoomOpened = (data)=>{
4012
+ if (core.isBusinessAccountActionRequired(data)) {
4013
+ throw new core.SigningConsentRequiredError(data);
4014
+ }
4015
+ return data;
4016
+ };
3990
4017
  /** 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;
3991
4018
  const KNOWN_SHARE_SET_TYPES = new Set([
3992
4019
  'rootUser',
@@ -6719,7 +6746,10 @@ class DynamicWalletClient {
6719
6746
  existingClientKeyShares
6720
6747
  };
6721
6748
  }
6722
- async reshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, sessionPublicKey, cloudProviders = [], delegateToProjectEnvironment = false, offlineRecovery = false, mfaToken, elevatedAccessToken, revokeDelegation = false, googleDriveAccessToken, googleDriveTokenSource, initialSignerRules, recoveryEncryptionPublicKey }) {
6749
+ 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 }) {
6750
+ if (cloudBackup && (offlineRecovery || delegateToProjectEnvironment)) {
6751
+ throw new Error('reshare: cloudBackup excludes offlineRecovery and delegateToProjectEnvironment');
6752
+ }
6723
6753
  if (delegateToProjectEnvironment && offlineRecovery) {
6724
6754
  throw new Error('reshare: delegateToProjectEnvironment and offlineRecovery are mutually exclusive');
6725
6755
  }
@@ -6755,7 +6785,10 @@ class DynamicWalletClient {
6755
6785
  googleDriveAccessToken,
6756
6786
  googleDriveTokenSource,
6757
6787
  initialSignerRules,
6758
- recoveryEncryptionPublicKey
6788
+ recoveryEncryptionPublicKey,
6789
+ approvalQuorum,
6790
+ proposalId,
6791
+ cloudBackup
6759
6792
  }));
6760
6793
  }
6761
6794
  /** Reshare into a new 2/2 share set for the target signer; seed it to CKS
@@ -7094,6 +7127,7 @@ class DynamicWalletClient {
7094
7127
  completePromise,
7095
7128
  errorGate.promise
7096
7129
  ]),
7130
+ failed: errorGate.promise,
7097
7131
  get fired () {
7098
7132
  return fired;
7099
7133
  }
@@ -7278,7 +7312,9 @@ class DynamicWalletClient {
7278
7312
  * {@link recordReshareThresholdScheme} guard on the success path. Wiping
7279
7313
  * here would strand a wallet whose local keys are still valid.
7280
7314
  */ async rollbackFailedReshareLocalState({ accountAddress, oldThresholdSignatureScheme, offlineRecovery, error }) {
7281
- if (offlineRecovery || isPublicKeyMismatchError(error) || isStaleClientSharesError(error)) return;
7315
+ if (offlineRecovery || isPublicKeyMismatchError(error) || isStaleClientSharesError(error) || // Consent refusals happen before any ceremony starts — the local shares
7316
+ // are still good and must not be rolled back.
7317
+ error instanceof core.SigningConsentRequiredError) return;
7282
7318
  this.updateWalletMap(accountAddress, {
7283
7319
  thresholdSignatureScheme: oldThresholdSignatureScheme
7284
7320
  });
@@ -7315,7 +7351,7 @@ class DynamicWalletClient {
7315
7351
  clientKeySharesBackupInfo: updatedBackupInfo
7316
7352
  } : {}));
7317
7353
  }
7318
- 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 }) {
7354
+ 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 }) {
7319
7355
  const dynamicRequestId = uuid.v4();
7320
7356
  // Password validation - wrapped in try-catch for consistent error handling
7321
7357
  // This path should NOT wipe key shares on failure (shares remain valid)
@@ -7430,7 +7466,11 @@ class DynamicWalletClient {
7430
7466
  dynamicRequestId
7431
7467
  }
7432
7468
  });
7433
- const data = await this.apiClient.reshare(_extends({
7469
+ const reshareKind = reshareKindOf({
7470
+ offlineRecovery,
7471
+ cloudBackup
7472
+ });
7473
+ const data = assertReshareRoomOpened(await this.apiClient.reshare(_extends({
7434
7474
  walletId: wallet.walletId,
7435
7475
  shareSetId: wallet.shareSetId,
7436
7476
  clientKeygenIds: clientKeygenIds,
@@ -7442,17 +7482,19 @@ class DynamicWalletClient {
7442
7482
  elevatedAccessToken,
7443
7483
  revokeDelegation,
7444
7484
  initialSignerRules
7445
- }, offlineRecovery ? {
7446
- reshareKind: 'offlineRecovery'
7485
+ }, reshareKind ? {
7486
+ reshareKind
7447
7487
  } : {}, recoveryEncryptionPublicKey ? {
7448
7488
  offlineRecoveryEncryptionKey: {
7449
7489
  alg: 'HYBRID-RSA-AES-256',
7450
7490
  publicKeyPemB64: recoveryEncryptionPublicKey
7451
7491
  }
7452
7492
  } : {}, {
7493
+ approvalQuorum,
7494
+ proposalId,
7453
7495
  onError: ceremonyGate.onError,
7454
7496
  onCeremonyComplete: ceremonyGate.onCeremonyComplete
7455
- }));
7497
+ })));
7456
7498
  this.logger.info('[WaasReshare] room_created received, starting local MPC', {
7457
7499
  context: {
7458
7500
  walletId: wallet.walletId,
@@ -7497,49 +7539,23 @@ class DynamicWalletClient {
7497
7539
  let existingReshareResults;
7498
7540
  let newReshareResults;
7499
7541
  try {
7500
- const [existingSettled, newSettled] = await Promise.all([
7501
- Promise.allSettled(existingClientKeyShares.map((keyShare)=>mpcSigner.reshareRemainingParty(roomId, newMpcConfig.threshold, keyShare, allPartyKeygenIds))),
7502
- Promise.allSettled(newClientInitKeygenResults.map((keygenResult)=>mpcSigner.reshareNewParty(roomId, oldMpcConfig.threshold, newMpcConfig.threshold, keygenResult, allPartyKeygenIds)))
7503
- ]);
7504
- const collectRejections = (results, keygenIds, role)=>results.reduce((acc, r, i)=>{
7505
- if (r.status === 'rejected') {
7506
- acc.push({
7507
- role,
7508
- partyIndex: i,
7509
- keygenId: keygenIds[i],
7510
- error: r.reason
7511
- });
7512
- }
7513
- return acc;
7514
- }, []);
7515
- const failures = [
7516
- ...collectRejections(existingSettled, existingClientKeygenIds, 'existingParty'),
7517
- ...collectRejections(newSettled, newClientKeygenIds, 'newParty')
7518
- ];
7519
- if (failures.length > 0) {
7520
- const firstError = failures[0].error;
7521
- const error = firstError instanceof Error ? firstError : new Error(String(firstError));
7522
- logError({
7523
- message: 'Reshare MPC ceremony party failures',
7524
- error,
7525
- context: {
7526
- dynamicRequestId,
7527
- roomId,
7528
- failures: failures.map((f)=>({
7529
- role: f.role,
7530
- partyIndex: f.partyIndex,
7531
- keygenId: f.keygenId,
7532
- error: f.error instanceof Error ? f.error.message : String(f.error)
7533
- })),
7534
- existingSuccessCount: existingSettled.filter((r)=>r.status === 'fulfilled').length,
7535
- newSuccessCount: newSettled.filter((r)=>r.status === 'fulfilled').length
7536
- }
7537
- });
7538
- throw error;
7539
- }
7540
- existingReshareResults = existingSettled.map((r)=>r.value);
7541
- newReshareResults = newSettled.map((r)=>r.value);
7542
+ ({ existingReshareResults, newReshareResults } = await this.runReshareParties({
7543
+ mpcSigner,
7544
+ roomId,
7545
+ oldThreshold: oldMpcConfig.threshold,
7546
+ newThreshold: newMpcConfig.threshold,
7547
+ allPartyKeygenIds,
7548
+ existingClientKeyShares,
7549
+ existingClientKeygenIds,
7550
+ newClientInitKeygenResults,
7551
+ newClientKeygenIds,
7552
+ refused: ceremonyGate.failed,
7553
+ dynamicRequestId
7554
+ }));
7542
7555
  } catch (reshareError) {
7556
+ // A consent refusal never started the ceremony: the local shares are
7557
+ // intact, so there is nothing to heal.
7558
+ if (reshareError instanceof core.SigningConsentRequiredError) throw reshareError;
7543
7559
  // Heal stale local shares before rethrowing so a retry succeeds.
7544
7560
  await this.healStaleShares({
7545
7561
  accountAddress,
@@ -7669,7 +7685,10 @@ class DynamicWalletClient {
7669
7685
  googleDriveTokenSource,
7670
7686
  staleShareRetry: true,
7671
7687
  initialSignerRules,
7672
- recoveryEncryptionPublicKey
7688
+ recoveryEncryptionPublicKey,
7689
+ approvalQuorum,
7690
+ proposalId,
7691
+ cloudBackup
7673
7692
  });
7674
7693
  }
7675
7694
  logError({
@@ -7695,7 +7714,67 @@ class DynamicWalletClient {
7695
7714
  throw error;
7696
7715
  }
7697
7716
  }
7698
- async performDelegationOperation({ accountAddress, password, signedSessionId, sessionPublicKey, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, useShareSetReshare = false, operationName, initialSignerRules }) {
7717
+ /**
7718
+ * Runs the client's MPC parties for a reshare until they finish or the
7719
+ * server refuses the reshare on the stream, whichever comes first.
7720
+ */ async runReshareParties({ mpcSigner, roomId, oldThreshold, newThreshold, allPartyKeygenIds, existingClientKeyShares, existingClientKeygenIds, newClientInitKeygenResults, newClientKeygenIds, refused, dynamicRequestId }) {
7721
+ // redcoast answers room_created before wallet-service has accepted the
7722
+ // reshare, so a refusal (consent_required, policy violation) lands on
7723
+ // the stream while the local parties wait for a server that never
7724
+ // joins. Race them against it, as export does, so the refusal surfaces
7725
+ // now rather than as a relay timeout.
7726
+ const mpcPartiesPromise = Promise.all([
7727
+ Promise.allSettled(existingClientKeyShares.map((keyShare)=>mpcSigner.reshareRemainingParty(roomId, newThreshold, keyShare, allPartyKeygenIds))),
7728
+ Promise.allSettled(newClientInitKeygenResults.map((keygenResult)=>mpcSigner.reshareNewParty(roomId, oldThreshold, newThreshold, keygenResult, allPartyKeygenIds)))
7729
+ ]);
7730
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
7731
+ mpcPartiesPromise.catch(()=>{});
7732
+ const [existingSettled, newSettled] = await Promise.race([
7733
+ mpcPartiesPromise,
7734
+ refused
7735
+ ]);
7736
+ const collectRejections = (results, keygenIds, role)=>results.reduce((acc, r, i)=>{
7737
+ if (r.status === 'rejected') {
7738
+ acc.push({
7739
+ role,
7740
+ partyIndex: i,
7741
+ keygenId: keygenIds[i],
7742
+ error: r.reason
7743
+ });
7744
+ }
7745
+ return acc;
7746
+ }, []);
7747
+ const failures = [
7748
+ ...collectRejections(existingSettled, existingClientKeygenIds, 'existingParty'),
7749
+ ...collectRejections(newSettled, newClientKeygenIds, 'newParty')
7750
+ ];
7751
+ if (failures.length > 0) {
7752
+ const firstError = failures[0].error;
7753
+ const error = firstError instanceof Error ? firstError : new Error(String(firstError));
7754
+ logError({
7755
+ message: 'Reshare MPC ceremony party failures',
7756
+ error,
7757
+ context: {
7758
+ dynamicRequestId,
7759
+ roomId,
7760
+ failures: failures.map((f)=>({
7761
+ role: f.role,
7762
+ partyIndex: f.partyIndex,
7763
+ keygenId: f.keygenId,
7764
+ error: f.error instanceof Error ? f.error.message : String(f.error)
7765
+ })),
7766
+ existingSuccessCount: existingSettled.filter((r)=>r.status === 'fulfilled').length,
7767
+ newSuccessCount: newSettled.filter((r)=>r.status === 'fulfilled').length
7768
+ }
7769
+ });
7770
+ throw error;
7771
+ }
7772
+ return {
7773
+ existingReshareResults: existingSettled.map((r)=>r.value),
7774
+ newReshareResults: newSettled.map((r)=>r.value)
7775
+ };
7776
+ }
7777
+ async performDelegationOperation({ accountAddress, password, signedSessionId, sessionPublicKey, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, useShareSetReshare = false, operationName, initialSignerRules, approvalQuorum, proposalId }) {
7699
7778
  try {
7700
7779
  const delegateToProjectEnvironment = this.featureFlags && this.featureFlags[core.FEATURE_FLAGS.ENABLE_DELEGATED_KEY_SHARES_FLAG] === true;
7701
7780
  if (!delegateToProjectEnvironment) {
@@ -7732,7 +7811,9 @@ class DynamicWalletClient {
7732
7811
  mfaToken,
7733
7812
  revokeDelegation,
7734
7813
  // Do not attach rules on the revoke path.
7735
- initialSignerRules: revokeDelegation ? undefined : initialSignerRules
7814
+ initialSignerRules: revokeDelegation ? undefined : initialSignerRules,
7815
+ approvalQuorum,
7816
+ proposalId
7736
7817
  });
7737
7818
  } catch (error) {
7738
7819
  logError({
@@ -7745,7 +7826,7 @@ class DynamicWalletClient {
7745
7826
  throw error;
7746
7827
  }
7747
7828
  }
7748
- async delegateKeyShares({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, initialSignerRules }) {
7829
+ async delegateKeyShares({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, initialSignerRules, approvalQuorum, proposalId }) {
7749
7830
  var _this_featureFlags;
7750
7831
  const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[core.FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
7751
7832
  if (useShareSetReshare) {
@@ -7780,15 +7861,12 @@ class DynamicWalletClient {
7780
7861
  signedSessionId,
7781
7862
  sessionPublicKey,
7782
7863
  mfaToken,
7783
- // FF on: reshare to 2/2 — the new delegated share set is always 2/2
7784
- // (server + delegated webhook). getReshareConfig now has a 2/2 → 2/2
7785
- // + delegation case that returns newClientShareCount=1 (the share
7786
- // routed to the webhook) so the ceremony generates it correctly.
7787
- // FF off: legacy 2/2 → 2/3 reshare that mutates rootUser.
7788
- newThresholdSignatureScheme: useShareSetReshare ? core.ThresholdSignatureScheme.TWO_OF_TWO : core.ThresholdSignatureScheme.TWO_OF_THREE,
7864
+ newThresholdSignatureScheme: this.delegationThresholdSignatureScheme(useShareSetReshare),
7789
7865
  operationName: 'delegateKeyShares',
7790
7866
  useShareSetReshare,
7791
- initialSignerRules
7867
+ initialSignerRules,
7868
+ approvalQuorum,
7869
+ proposalId
7792
7870
  });
7793
7871
  const backupInfo = this.getRequiredWalletFromMap(accountAddress).clientKeySharesBackupInfo;
7794
7872
  const delegatedKeyShares = backupInfo.backups[core.BackupLocation.DELEGATED] || [];
@@ -7852,7 +7930,7 @@ class DynamicWalletClient {
7852
7930
  * operation logic lives in `./offlineRecovery.ts` (cloud-provider-agnostic,
7853
7931
  * so iCloud / other providers slot in there); this method just drives it
7854
7932
  * with the client instance and returns the exported set's own backups.
7855
- */ async offlineRecovery({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, cloudProvider = DEFAULT_OFFLINE_RECOVERY_CLOUD_PROVIDER, cloudProviderAccessToken, recoveryEncryptionPublicKey }) {
7933
+ */ async offlineRecovery({ accountAddress, password = undefined, signedSessionId, sessionPublicKey, mfaToken, cloudProvider = DEFAULT_OFFLINE_RECOVERY_CLOUD_PROVIDER, cloudProviderAccessToken, recoveryEncryptionPublicKey, approvalQuorum, proposalId }) {
7856
7934
  await performOfflineRecoveryOperation(this, {
7857
7935
  accountAddress,
7858
7936
  password,
@@ -7861,7 +7939,9 @@ class DynamicWalletClient {
7861
7939
  mfaToken,
7862
7940
  cloudProvider,
7863
7941
  cloudProviderAccessToken,
7864
- recoveryEncryptionPublicKey
7942
+ recoveryEncryptionPublicKey,
7943
+ approvalQuorum,
7944
+ proposalId
7865
7945
  });
7866
7946
  // The exported set's own locations. Not `backups[cloudProvider]`: that is
7867
7947
  // rootUser's record for the provider, which this operation never writes.
@@ -7916,6 +7996,73 @@ class DynamicWalletClient {
7916
7996
  initiatorUserId
7917
7997
  });
7918
7998
  }
7999
+ /** The scheme a delegation reshare moves the wallet to: share-set delegation mints its own 2/2 set. */ delegationThresholdSignatureScheme(useShareSetReshare) {
8000
+ // FF on: reshare to 2/2 — the new delegated share set is always 2/2
8001
+ // (server + delegated webhook). getReshareConfig now has a 2/2 → 2/2
8002
+ // + delegation case that returns newClientShareCount=1 (the share
8003
+ // routed to the webhook) so the ceremony generates it correctly.
8004
+ // FF off: legacy 2/2 → 2/3 reshare that mutates rootUser.
8005
+ return useShareSetReshare ? core.ThresholdSignatureScheme.TWO_OF_TWO : core.ThresholdSignatureScheme.TWO_OF_THREE;
8006
+ }
8007
+ /**
8008
+ * Builds the reshare Intent for a governed reshare entry point, to be
8009
+ * signed with the session key and sent as `approvalQuorum` on that same
8010
+ * entry point (`backupKeySharesToGoogleDrive`/`backupKeySharesToICloud`,
8011
+ * `delegateKeyShares` or `offlineRecovery`).
8012
+ *
8013
+ * The Intent echoes the fields that entry point sends, so it is built here
8014
+ * from the same wallet state. An offline recovery's Intent must also echo
8015
+ * the recovery key the server will use: pass the one the `INTENT_REQUIRED`
8016
+ * consent error carried. Makes no API call and starts no ceremony.
8017
+ */ async prepareReshareIntent({ accountAddress, password = undefined, signedSessionId, businessAccountId, initiatorUserId, completeBy, operation, offlineRecoveryEncryptionKey }) {
8018
+ if (operation === 'offlineRecovery' !== !!offlineRecoveryEncryptionKey) {
8019
+ throw new Error('prepareReshareIntent: offlineRecoveryEncryptionKey is required for offlineRecovery, from its INTENT_REQUIRED consent error, and invalid otherwise');
8020
+ }
8021
+ // Refreshed from the server, as the backup entry point does before it
8022
+ // picks its route: a wallet delegated from another device since it was
8023
+ // cached backs up without a reshare, and must not get an intent for one.
8024
+ await this.getWallet({
8025
+ accountAddress,
8026
+ password,
8027
+ signedSessionId,
8028
+ refresh: true
8029
+ });
8030
+ const walletData = this.getRequiredWalletFromMap(accountAddress);
8031
+ const identity = {
8032
+ accountAddress,
8033
+ businessAccountId,
8034
+ completeBy,
8035
+ initiatorUserId
8036
+ };
8037
+ if (operation === 'delegatedAccess') {
8038
+ var _this_featureFlags;
8039
+ const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[core.FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
8040
+ return core.createReshareIntent(_extends({}, identity, {
8041
+ newThresholdSignatureScheme: this.delegationThresholdSignatureScheme(useShareSetReshare),
8042
+ reshareType: 'delegatedAccess'
8043
+ }));
8044
+ }
8045
+ if (operation === 'offlineRecovery') {
8046
+ return core.createReshareIntent(_extends({}, identity, {
8047
+ // performOfflineRecoveryOperation always pins the exported set at 2/2.
8048
+ newThresholdSignatureScheme: core.ThresholdSignatureScheme.TWO_OF_TWO,
8049
+ offlineRecoveryEncryptionKey,
8050
+ reshareType: 'offlineRecovery'
8051
+ }));
8052
+ }
8053
+ // A cloud backup reshares only from 2/2, or from a non-delegated 2/3
8054
+ // (backupKeySharesToCloudProvider); any other wallet backs up without a
8055
+ // reshare, so there is nothing to approve.
8056
+ const scheme = walletData.thresholdSignatureScheme;
8057
+ const reshares = scheme === core.ThresholdSignatureScheme.TWO_OF_TWO || scheme === core.ThresholdSignatureScheme.TWO_OF_THREE && !hasDelegatedBackup(walletData.clientKeySharesBackupInfo, walletData.otherShareSets);
8058
+ if (!reshares) {
8059
+ throw new Error('prepareReshareIntent: this wallet backs up without a reshare, so no approval applies');
8060
+ }
8061
+ return core.createReshareIntent(_extends({}, identity, {
8062
+ newThresholdSignatureScheme: core.ThresholdSignatureScheme.TWO_OF_THREE,
8063
+ reshareType: 'cloudBackup'
8064
+ }));
8065
+ }
7919
8066
  async exportKey({ accountAddress, chainName, bitcoinConfig, password = undefined, signedSessionId, mfaToken, elevatedAccessToken, approvalQuorum, proposalId, traceContext }) {
7920
8067
  // Public entry point delegates to the private implementation. Keeping the
7921
8068
  // `staleShareRetry` flag off the public signature mirrors sign/refresh/
@@ -9540,11 +9687,12 @@ class DynamicWalletClient {
9540
9687
  passwordUpdateBatchId,
9541
9688
  dynamicRequestId
9542
9689
  });
9690
+ let spentSignedSession = false;
9543
9691
  if (isWalletPasswordEncrypted(wallet)) {
9544
9692
  if (!resolvedExistingPassword) {
9545
9693
  throw new Error(ERROR_EXISTING_PASSWORD_REQUIRED);
9546
9694
  }
9547
- await this.validatePasswordAgainstEncryptedShares({
9695
+ spentSignedSession = await this.validatePasswordAgainstEncryptedShares({
9548
9696
  wallet: wallet,
9549
9697
  address: accountAddress,
9550
9698
  password: resolvedExistingPassword,
@@ -9552,21 +9700,24 @@ class DynamicWalletClient {
9552
9700
  errorMessage: ERROR_INCORRECT_PASSWORD
9553
9701
  });
9554
9702
  }
9555
- const backup = ()=>this.storeEncryptedBackupByWallet({
9703
+ // Every relay call burns its session's single-use nonce, so the backup needs a fresh one.
9704
+ const backupSessionId = spentSignedSession ? await this.signedSession.renew(signedSessionId) : signedSessionId;
9705
+ const backup = (sessionId)=>this.storeEncryptedBackupByWallet({
9556
9706
  accountAddress,
9557
9707
  password: newPassword,
9558
9708
  existingPassword: resolvedExistingPassword,
9559
- signedSessionId,
9709
+ signedSessionId: sessionId,
9560
9710
  sessionPublicKey,
9561
9711
  passwordUpdateBatchId,
9562
9712
  passwordSetByBA: false
9563
9713
  });
9564
9714
  let backupData;
9565
9715
  try {
9566
- backupData = await backup();
9716
+ backupData = await backup(backupSessionId);
9567
9717
  } catch (error) {
9568
9718
  if (!(error instanceof StaleLocalSharesError) || !error.localSharesRefreshed) throw error;
9569
- backupData = await backup();
9719
+ // The stale-share refresh spent the session the failed attempt carried.
9720
+ backupData = await backup(await this.signedSession.renew(backupSessionId));
9570
9721
  }
9571
9722
  // Not named passwordUpdateStatus: the logger redacts string values of password* keys.
9572
9723
  this.logPasswordOperationSuccess('updatePassword', {
@@ -9705,6 +9856,8 @@ class DynamicWalletClient {
9705
9856
  */ async validatePasswordAgainstEncryptedShares({ wallet, address, password, signedSessionId, errorMessage = ERROR_PASSWORD_MISMATCH }) {
9706
9857
  const storageKey = `${normalizeAddress(address)}${core.ENCRYPTED_SHARES_STORAGE_SUFFIX}`;
9707
9858
  let encryptedData = await this.storage.getItem(storageKey);
9859
+ // Fetching the shares spends the session's single-use nonce; callers must renew it.
9860
+ const spentSignedSession = !encryptedData;
9708
9861
  if (!encryptedData) {
9709
9862
  const { shares } = this.recoverStrategy({
9710
9863
  clientKeyShareBackupInfo: wallet.clientKeySharesBackupInfo,
@@ -9743,7 +9896,7 @@ class DynamicWalletClient {
9743
9896
  }
9744
9897
  const credential = extractFirstEncryptedCredential(encryptedData);
9745
9898
  if (!credential) {
9746
- return;
9899
+ return spentSignedSession;
9747
9900
  }
9748
9901
  try {
9749
9902
  await this.decryptKeyShare({
@@ -9756,6 +9909,7 @@ class DynamicWalletClient {
9756
9909
  }
9757
9910
  throw error;
9758
9911
  }
9912
+ return spentSignedSession;
9759
9913
  }
9760
9914
  isPasswordRequired() {
9761
9915
  var _readEnvironmentSettings_sdk_waas, _readEnvironmentSettings_sdk, _readEnvironmentSettings;
@@ -10083,7 +10237,7 @@ class DynamicWalletClient {
10083
10237
  * - For 2-of-2 wallets: Automatically reshares to 2-of-3 threshold, then distributes shares (1 to backend, 1 to cloud)
10084
10238
  * - For non-delegated 2-of-3 wallets: same-scheme reshare to mint a fresh recovery share
10085
10239
  * - For delegated 2-of-3 (and other) wallets: storeEncryptedBackupByWallet backs up to backend and cloud
10086
- */ async backupKeySharesToCloudProvider({ accountAddress, password, signedSessionId, sessionPublicKey, backupLocation, googleDriveAccessToken, googleDriveTokenSource }) {
10240
+ */ async backupKeySharesToCloudProvider({ accountAddress, password, signedSessionId, sessionPublicKey, backupLocation, googleDriveAccessToken, googleDriveTokenSource, approvalQuorum, proposalId }) {
10087
10241
  try {
10088
10242
  await this.getWallet({
10089
10243
  accountAddress,
@@ -10107,7 +10261,10 @@ class DynamicWalletClient {
10107
10261
  backupLocation
10108
10262
  ],
10109
10263
  googleDriveAccessToken,
10110
- googleDriveTokenSource
10264
+ googleDriveTokenSource,
10265
+ cloudBackup: true,
10266
+ approvalQuorum,
10267
+ proposalId
10111
10268
  });
10112
10269
  } else if (currentThresholdSignatureScheme === core.ThresholdSignatureScheme.TWO_OF_THREE && !hasDelegatedBackup(walletData.clientKeySharesBackupInfo, walletData.otherShareSets)) {
10113
10270
  await this.reshareNonDelegatedTwoOfThree({
@@ -10117,7 +10274,9 @@ class DynamicWalletClient {
10117
10274
  signedSessionId,
10118
10275
  sessionPublicKey,
10119
10276
  googleDriveAccessToken,
10120
- googleDriveTokenSource
10277
+ googleDriveTokenSource,
10278
+ approvalQuorum,
10279
+ proposalId
10121
10280
  });
10122
10281
  } else {
10123
10282
  await this.storeEncryptedBackupByWallet({
@@ -10149,7 +10308,7 @@ class DynamicWalletClient {
10149
10308
  * reshare that mints a fresh recovery share. The fresh share is redistributed to
10150
10309
  * every provider already holding a backup plus the requested one,
10151
10310
  * restricted to providers the reshare can deliver to.
10152
- */ async reshareNonDelegatedTwoOfThree({ accountAddress, backupLocation, password, signedSessionId, sessionPublicKey, googleDriveAccessToken, googleDriveTokenSource }) {
10311
+ */ async reshareNonDelegatedTwoOfThree({ accountAddress, backupLocation, password, signedSessionId, sessionPublicKey, googleDriveAccessToken, googleDriveTokenSource, approvalQuorum, proposalId }) {
10153
10312
  const walletData = this.getRequiredWalletFromMap(accountAddress);
10154
10313
  const cloudProviders = new Set([
10155
10314
  ...getActiveCloudProviders(walletData.clientKeySharesBackupInfo).filter((provider)=>provider === core.BackupLocation.GOOGLE_DRIVE || provider === core.BackupLocation.ICLOUD),
@@ -10184,7 +10343,10 @@ class DynamicWalletClient {
10184
10343
  sessionPublicKey,
10185
10344
  cloudProviders: Array.from(cloudProviders),
10186
10345
  googleDriveAccessToken: resolvedDrive.googleDriveAccessToken,
10187
- googleDriveTokenSource: resolvedDrive.googleDriveTokenSource
10346
+ googleDriveTokenSource: resolvedDrive.googleDriveTokenSource,
10347
+ cloudBackup: true,
10348
+ approvalQuorum,
10349
+ proposalId
10188
10350
  });
10189
10351
  }
10190
10352
  /**
@@ -10254,7 +10416,7 @@ class DynamicWalletClient {
10254
10416
  * @param params.signedSessionId - Optional signed session ID for authentication
10255
10417
  * @param params.googleDriveAccessToken - Optional Google OAuth access token. When provided, the SDK uses it directly
10256
10418
  * instead of looking up the user's linked Google account and exchanging it for a token via the Dynamic backend.
10257
- */ async backupKeySharesToGoogleDrive({ accountAddress, password, signedSessionId, sessionPublicKey, googleDriveAccessToken }) {
10419
+ */ async backupKeySharesToGoogleDrive({ accountAddress, password, signedSessionId, sessionPublicKey, googleDriveAccessToken, approvalQuorum, proposalId }) {
10258
10420
  // Track whether the Drive token was supplied by the caller (external) or
10259
10421
  // will be resolved from the user's stored OAuth account (stored_oauth), so
10260
10422
  // backups using a token we don't control are auditable. Enum only — the
@@ -10292,7 +10454,9 @@ class DynamicWalletClient {
10292
10454
  googleDriveAccessToken: resolvedAccessToken,
10293
10455
  // Persist which token source backed up the Google Drive location for
10294
10456
  // audit (enum only — never the token value).
10295
- googleDriveTokenSource: tokenSource
10457
+ googleDriveTokenSource: tokenSource,
10458
+ approvalQuorum,
10459
+ proposalId
10296
10460
  });
10297
10461
  success = true;
10298
10462
  } finally{
@@ -10365,13 +10529,23 @@ class DynamicWalletClient {
10365
10529
  * @param params.accountAddress - The wallet account address to backup
10366
10530
  * @param params.password - Optional password for encryption (uses environment ID if not provided)
10367
10531
  * @param params.signedSessionId - Optional signed session ID for authentication
10368
- */ async backupKeySharesToICloud({ accountAddress, password, signedSessionId, sessionPublicKey }) {
10532
+ */ async backupKeySharesToICloud({ accountAddress, password, signedSessionId, sessionPublicKey, approvalQuorum, proposalId }) {
10533
+ // Refreshed from the server, as the Google Drive backup and
10534
+ // prepareReshareIntent do: the route (reshare or not) must be picked from
10535
+ // the same wallet state the reshare intent was built from.
10536
+ await this.getWallet({
10537
+ accountAddress,
10538
+ signedSessionId,
10539
+ refresh: true
10540
+ });
10369
10541
  return this.backupKeySharesToCloudProvider({
10370
10542
  accountAddress,
10371
10543
  password,
10372
10544
  signedSessionId,
10373
10545
  sessionPublicKey,
10374
- backupLocation: core.BackupLocation.ICLOUD
10546
+ backupLocation: core.BackupLocation.ICLOUD,
10547
+ approvalQuorum,
10548
+ proposalId
10375
10549
  });
10376
10550
  }
10377
10551
  /**