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