@dynamic-labs-wallet/browser 1.0.1 → 1.0.3

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
@@ -229,6 +229,25 @@ class InvalidPasswordError extends Error {
229
229
  this.name = 'InvalidPasswordError';
230
230
  }
231
231
  }
232
+ const KEY_SHARE_DECRYPTION_ERROR = 'Decryption failed.';
233
+ /**
234
+ * Thrown when a key share cannot be decrypted but no user-supplied password was
235
+ * involved. Distinct from InvalidPasswordError, which is reserved for cases
236
+ * where a user-entered password fails to decrypt a password-encrypted wallet.
237
+ *
238
+ * Internal-only signal: the user-facing message is deliberately generic so it
239
+ * does not leak implementation detail (e.g. environmentId fallback,
240
+ * cross-environment cipher, KDF/version mismatch). The `name` and any attached
241
+ * `cause` carry the real diagnostic for logs / monitoring.
242
+ */ class KeyShareDecryptionError extends Error {
243
+ constructor(options){
244
+ super(KEY_SHARE_DECRYPTION_ERROR);
245
+ this.name = 'KeyShareDecryptionError';
246
+ if ((options == null ? void 0 : options.cause) !== undefined) {
247
+ this.cause = options.cause;
248
+ }
249
+ }
250
+ }
232
251
  /**
233
252
  * Check if an error is an OperationError from SubtleCrypto.decrypt,
234
253
  * which indicates authentication failure (wrong password/derived key)
@@ -514,7 +533,15 @@ const writeCachedReason = (error, reason)=>{
514
533
  // Frozen/sealed errors — ignore, just lose the cache on this path.
515
534
  }
516
535
  };
536
+ const isKeyShareDecryptionError = (error)=>error instanceof KeyShareDecryptionError || error instanceof Error && error.name === 'KeyShareDecryptionError';
517
537
  const computeReason = (error)=>{
538
+ // KeyShareDecryptionError represents a system-side decryption failure where
539
+ // no user password was involved (e.g. envId-fallback failed on a
540
+ // non-password-encrypted wallet). Classify ahead of the InvalidPasswordError
541
+ // check so it doesn't get mislabeled as `wrong_password`.
542
+ if (isKeyShareDecryptionError(error)) {
543
+ return 'decryption_failure';
544
+ }
518
545
  if (isPasswordMismatchError(error)) {
519
546
  return 'wrong_password';
520
547
  }
@@ -1053,6 +1080,7 @@ const logRetryExhausted = (operationName, maxAttempts, errorContext, logContext)
1053
1080
  if ((error == null ? void 0 : error.isRetryable) === false) return true;
1054
1081
  if (isPasswordMismatchError(error)) return true;
1055
1082
  if (error instanceof InvalidPasswordError) return true;
1083
+ if (error instanceof KeyShareDecryptionError) return true;
1056
1084
  const message = error instanceof Error ? error.message : '';
1057
1085
  if (!message) return false;
1058
1086
  return NON_RETRYABLE_CEREMONY_ERROR_MESSAGES.some((msg)=>message.includes(msg));
@@ -2097,6 +2125,50 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
2097
2125
  /** Track which wallets are currently executing inside a sign operation.
2098
2126
  * Used to prevent deadlocks when recovery is called from within a sign op. */ WalletQueueManager.walletsWithActiveSignOp = new Map();
2099
2127
 
2128
+ // Pure routing helper: maps a completed reshare ceremony's results into the
2129
+ // ShareDistribution that storage/publish should follow. Lives outside the
2130
+ // client class so it's straightforward to reason about and unit-test
2131
+ // without spinning up DynamicWalletClient.
2132
+ const selectReshareDistribution = ({ resolvedDelegation, resolvedCloudProviders, existingReshareResults, newReshareResults })=>{
2133
+ const allClientShares = [
2134
+ ...existingReshareResults,
2135
+ ...newReshareResults
2136
+ ];
2137
+ // Share-set delegation signal: same-parties reshare with zero new client
2138
+ // party. The refreshed rootUser-client output IS the delegated share —
2139
+ // it goes to the webhook out-of-band, NOT to Dynamic backup, and local
2140
+ // cache stays with gen1.
2141
+ if (resolvedDelegation && newReshareResults.length === 0 && existingReshareResults.length === 1) {
2142
+ return {
2143
+ clientShares: [],
2144
+ cloudProviderShares: {},
2145
+ delegatedShare: existingReshareResults[0]
2146
+ };
2147
+ }
2148
+ if (resolvedDelegation && resolvedCloudProviders.length > 0) {
2149
+ return createDelegationWithCloudProviderDistribution({
2150
+ providers: resolvedCloudProviders,
2151
+ existingShares: existingReshareResults,
2152
+ delegatedShare: newReshareResults[0]
2153
+ });
2154
+ }
2155
+ if (resolvedDelegation) {
2156
+ return createDelegationOnlyDistribution({
2157
+ existingShares: existingReshareResults,
2158
+ delegatedShare: newReshareResults[0]
2159
+ });
2160
+ }
2161
+ if (resolvedCloudProviders.length > 0) {
2162
+ return createCloudProviderDistribution({
2163
+ providers: resolvedCloudProviders,
2164
+ allShares: allClientShares
2165
+ });
2166
+ }
2167
+ return createDynamicOnlyDistribution({
2168
+ allShares: allClientShares
2169
+ });
2170
+ };
2171
+
2100
2172
  const ALG_LABEL_RSA = 'HYBRID-RSA-AES-256';
2101
2173
  /**
2102
2174
  * Convert base64 to base64url encoding
@@ -3486,11 +3558,19 @@ class DynamicWalletClient {
3486
3558
  // Backup & activate server shares BEFORE saving to local storage.
3487
3559
  // This prevents a mismatch where new client shares are stored locally
3488
3560
  // but old server shares remain active if backup/activation fails.
3489
- await this.storeEncryptedBackupByWallet({
3561
+ // Rollback semantics — see runBackupWithRotationRollback.
3562
+ await this.runBackupWithRotationRollback({
3490
3563
  accountAddress,
3491
- clientKeyShares: refreshResults,
3492
- password: password != null ? password : this.environmentId,
3493
- signedSessionId
3564
+ ceremony: 'refresh',
3565
+ sourceShareSetId: wallet.shareSetId,
3566
+ walletId: wallet.walletId,
3567
+ dynamicRequestId,
3568
+ runBackup: ()=>this.storeEncryptedBackupByWallet({
3569
+ accountAddress,
3570
+ clientKeyShares: refreshResults,
3571
+ password: password != null ? password : this.environmentId,
3572
+ signedSessionId
3573
+ })
3494
3574
  });
3495
3575
  await this.setClientKeySharesToStorage({
3496
3576
  accountAddress,
@@ -3574,7 +3654,7 @@ class DynamicWalletClient {
3574
3654
  * existingClientKeyShares: ClientKeyShare[]
3575
3655
  * }>} Object containing new and existing client keygen results, IDs and shares
3576
3656
  * @todo Support higher to lower reshare strategies
3577
- */ async reshareStrategy({ chainName, wallet, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme }) {
3657
+ */ async reshareStrategy({ chainName, wallet, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, delegateToProjectEnvironment }) {
3578
3658
  const bitcoinConfig = this.getBitcoinConfigForChain(chainName, accountAddress);
3579
3659
  const mpcSigner = getMPCSigner({
3580
3660
  chainName,
@@ -3584,7 +3664,8 @@ class DynamicWalletClient {
3584
3664
  // Determine share counts based on threshold signature schemes
3585
3665
  const { newClientShareCount, existingClientShareCount } = core.getReshareConfig({
3586
3666
  oldThresholdSignatureScheme,
3587
- newThresholdSignatureScheme
3667
+ newThresholdSignatureScheme,
3668
+ delegateToProjectEnvironment
3588
3669
  });
3589
3670
  // Create new client shares
3590
3671
  const newClientInitKeygenResults = await Promise.all(Array.from({
@@ -3660,16 +3741,50 @@ class DynamicWalletClient {
3660
3741
  });
3661
3742
  }
3662
3743
  }
3663
- // Rotates walletMap.shareSetId when a ceremony_complete event reports a
3664
- // new rootUser share set. Skipped for `delegated` (rootUser unchanged;
3665
- // the delegation flow owns the otherShareSets[] write), `server`, and
3666
- // empty payloads (legacy redcoast that predates the share-set rollout —
3667
- // server falls back to walletId resolution on the follow-up backup call).
3744
+ // Runs the post-ceremony backup-activation step (storeEncryptedBackupByWallet
3745
+ // for refresh, backupSharesWithDistribution for reshare). On failure, rolls
3746
+ // walletMap.shareSetId back to the source row.
3747
+ //
3748
+ // Why: ceremony_complete rotates walletMap.shareSetId forward to the new
3749
+ // pending row before this call, so the activation request carries the
3750
+ // correct id. If activation fails (server 500, network error, etc.), the
3751
+ // pending row stays `pending` on the server but walletMap is still pointing
3752
+ // at it locally — every subsequent sign / refresh sends the pending id and
3753
+ // the server rejects with 422 "Wallet is not active." Rolling the local
3754
+ // rotation back to `sourceShareSetId` (the still-active row at ceremony
3755
+ // start) keeps the wallet usable after a failed refresh / reshare.
3756
+ async runBackupWithRotationRollback({ accountAddress, ceremony, sourceShareSetId, walletId, dynamicRequestId, runBackup }) {
3757
+ try {
3758
+ await runBackup();
3759
+ } catch (backupError) {
3760
+ this.updateWalletMap(accountAddress, {
3761
+ shareSetId: sourceShareSetId
3762
+ });
3763
+ const tag = ceremony === 'refresh' ? '[WaasRefresh]' : '[WaasReshare]';
3764
+ this.logger.warn(`${tag} backup activation failed — rolled walletMap.shareSetId back to source`, {
3765
+ context: {
3766
+ walletId,
3767
+ restoredShareSetId: sourceShareSetId,
3768
+ dynamicRequestId
3769
+ }
3770
+ });
3771
+ throw backupError;
3772
+ }
3773
+ }
3774
+ // Persists the ceremony_complete payload into the wallet map.
3775
+ // - rootUser → rotate walletMap.shareSetId (if it actually changed).
3776
+ // - delegated → append/replace under otherShareSets[] via recordDelegatedShareSet
3777
+ // (requires newThresholdSignatureScheme passed in by the reshare caller).
3778
+ // Reshare delegation flow owns this branch; refresh hits the non-rootUser
3779
+ // skip path below because newThresholdSignatureScheme isn't threaded.
3780
+ // - server / undefined → no-op with a skip log so DataDog shows the path
3781
+ // (undefined is legacy redcoast that predates the share-set rollout —
3782
+ // server falls back to walletId resolution on the follow-up backup call).
3668
3783
  //
3669
3784
  // Each skip reason is logged distinctly so operators can grep DataDog for
3670
3785
  // "legacy server callbacks" vs "delegation skips" vs "no-op same id"
3671
3786
  // separately — three operationally different states.
3672
- rotateRootUserShareSetIdIfChanged({ accountAddress, wallet, ceremony, shareSetId, shareSetType, extraContext }) {
3787
+ rotateRootUserShareSetIdIfChanged({ accountAddress, wallet, ceremony, shareSetId, shareSetType, newThresholdSignatureScheme, extraContext }) {
3673
3788
  const baseContext = _extends({
3674
3789
  walletId: wallet.walletId,
3675
3790
  ceremony
@@ -3686,10 +3801,20 @@ class DynamicWalletClient {
3686
3801
  });
3687
3802
  return;
3688
3803
  }
3804
+ if (shareSetType === 'delegated' && newThresholdSignatureScheme) {
3805
+ this.recordDelegatedShareSet({
3806
+ accountAddress,
3807
+ wallet,
3808
+ newShareSetId: shareSetId,
3809
+ newThresholdSignatureScheme
3810
+ });
3811
+ return;
3812
+ }
3689
3813
  if (shareSetType !== 'rootUser') {
3690
- // 'delegated' delegation flow owns the otherShareSets[] write (#947).
3691
- // 'server' → internal server share set, not for SDK rotation.
3692
- // undefined → server emitted partial payload (already warned upstream).
3814
+ // 'delegated' (no newThresholdSignatureScheme, e.g. refresh) SDK doesn't
3815
+ // own the otherShareSets[] write from refresh.
3816
+ // 'server' internal server share set, not for SDK rotation.
3817
+ // undefined → server emitted partial payload (already warned upstream).
3693
3818
  this.logger.info('[WaasShareSet] rotation skipped: non-rootUser share-set type', {
3694
3819
  context: _extends({}, baseContext, {
3695
3820
  shareSetType,
@@ -3716,6 +3841,38 @@ class DynamicWalletClient {
3716
3841
  shareSetId
3717
3842
  });
3718
3843
  }
3844
+ // Records a new `delegated` share set under wallet.otherShareSets[],
3845
+ // replacing an existing delegated entry if one is already there (rather
3846
+ // than appending duplicates on retry). Only reachable from the reshare
3847
+ // delegation flow — refresh doesn't thread newThresholdSignatureScheme
3848
+ // so it falls through to the skip path before reaching here.
3849
+ recordDelegatedShareSet({ accountAddress, wallet, newShareSetId, newThresholdSignatureScheme }) {
3850
+ const walletProps = this.getWalletFromMap(accountAddress);
3851
+ var _walletProps_otherShareSets;
3852
+ const existing = (_walletProps_otherShareSets = walletProps == null ? void 0 : walletProps.otherShareSets) != null ? _walletProps_otherShareSets : [];
3853
+ const newEntry = {
3854
+ shareSetId: newShareSetId,
3855
+ shareSetType: 'delegated',
3856
+ thresholdSignatureScheme: newThresholdSignatureScheme,
3857
+ createdAt: new Date().toISOString()
3858
+ };
3859
+ const delegatedIdx = existing.findIndex((s)=>s.shareSetType === 'delegated');
3860
+ const updated = delegatedIdx >= 0 ? existing.map((s, i)=>i === delegatedIdx ? newEntry : s) : [
3861
+ ...existing,
3862
+ newEntry
3863
+ ];
3864
+ this.logger.info('[WaasShareSet] delegated shareSetId added by reshare ceremony', {
3865
+ context: {
3866
+ walletId: wallet.walletId,
3867
+ rootUserShareSetId: wallet.shareSetId,
3868
+ delegatedShareSetId: newShareSetId,
3869
+ replacedExistingDelegated: delegatedIdx >= 0
3870
+ }
3871
+ });
3872
+ this.updateWalletMap(accountAddress, {
3873
+ otherShareSets: updated
3874
+ });
3875
+ }
3719
3876
  async internalReshare({ chainName, accountAddress, oldThresholdSignatureScheme, newThresholdSignatureScheme, password = undefined, signedSessionId, cloudProviders = [], delegateToProjectEnvironment = false, mfaToken, elevatedAccessToken, revokeDelegation = false, hasAttemptedKeyShareRecovery = false, googleDriveAccessToken }) {
3720
3877
  const dynamicRequestId = uuid.v4();
3721
3878
  // Password validation - wrapped in try-catch for consistent error handling
@@ -3758,7 +3915,8 @@ class DynamicWalletClient {
3758
3915
  try {
3759
3916
  const { existingClientShareCount } = core.getReshareConfig({
3760
3917
  oldThresholdSignatureScheme,
3761
- newThresholdSignatureScheme
3918
+ newThresholdSignatureScheme,
3919
+ delegateToProjectEnvironment: resolvedDelegation
3762
3920
  });
3763
3921
  const wallet = await this.getWallet({
3764
3922
  accountAddress,
@@ -3772,11 +3930,14 @@ class DynamicWalletClient {
3772
3930
  accountAddress,
3773
3931
  wallet,
3774
3932
  oldThresholdSignatureScheme,
3775
- newThresholdSignatureScheme
3933
+ newThresholdSignatureScheme,
3934
+ delegateToProjectEnvironment: resolvedDelegation
3776
3935
  });
3777
- // Ensure existing client key shares exist before reshare
3778
- if (existingClientKeyShares.length === 0) {
3779
- throw new Error(`Client key shares are required for reshare operation but none were found for account address: ${accountAddress}`);
3936
+ // Ensure existing client key shares match what getReshareConfig expected.
3937
+ // For most flows this is >= 1; for 2/2 → 2/2 + delegation it's 0 (the
3938
+ // ceremony has no surviving client-side party from the old quorum).
3939
+ if (existingClientKeyShares.length < existingClientShareCount) {
3940
+ throw new Error(`Expected ${existingClientShareCount} existing client key share(s) for reshare operation but only found ${existingClientKeyShares.length} for account address: ${accountAddress}`);
3780
3941
  }
3781
3942
  const clientKeygenIds = [
3782
3943
  ...newClientKeygenIds,
@@ -3840,6 +4001,7 @@ class DynamicWalletClient {
3840
4001
  ceremony: 'reshare',
3841
4002
  shareSetId,
3842
4003
  shareSetType,
4004
+ newThresholdSignatureScheme,
3843
4005
  extraContext: {
3844
4006
  oldThresholdSignatureScheme,
3845
4007
  newThresholdSignatureScheme,
@@ -3963,41 +4125,12 @@ class DynamicWalletClient {
3963
4125
  }
3964
4126
  throw reshareError;
3965
4127
  }
3966
- const allClientShares = [
3967
- ...existingReshareResults,
3968
- ...newReshareResults
3969
- ];
3970
- let distribution;
3971
- // Generic distribution logic - works with any cloud providers
3972
- // Use effective* variables which may have been populated from existing wallet state
3973
- if (resolvedDelegation && resolvedCloudProviders.length > 0) {
3974
- // Delegation + Cloud Providers: Client's existing share backs up to both Dynamic and cloud providers.
3975
- // The new share goes to the webhook for delegation.
3976
- distribution = createDelegationWithCloudProviderDistribution({
3977
- providers: resolvedCloudProviders,
3978
- existingShares: existingReshareResults,
3979
- delegatedShare: newReshareResults[0]
3980
- });
3981
- } else if (resolvedDelegation) {
3982
- // Delegation only: Client's existing share backs up to Dynamic.
3983
- // The new share goes to the webhook for delegation. No cloud provider backup.
3984
- distribution = createDelegationOnlyDistribution({
3985
- existingShares: existingReshareResults,
3986
- delegatedShare: newReshareResults[0]
3987
- });
3988
- } else if (resolvedCloudProviders.length > 0) {
3989
- // Cloud Providers only: Split shares between Dynamic (N-1) and cloud providers (1).
3990
- // The last share (new share) goes to cloud providers.
3991
- distribution = createCloudProviderDistribution({
3992
- providers: resolvedCloudProviders,
3993
- allShares: allClientShares
3994
- });
3995
- } else {
3996
- // No delegation, no cloud providers: All shares go to Dynamic backend only.
3997
- distribution = createDynamicOnlyDistribution({
3998
- allShares: allClientShares
3999
- });
4000
- }
4128
+ const distribution = selectReshareDistribution({
4129
+ resolvedDelegation,
4130
+ resolvedCloudProviders,
4131
+ existingReshareResults,
4132
+ newReshareResults
4133
+ });
4001
4134
  this.updateWalletMap(accountAddress, {
4002
4135
  thresholdSignatureScheme: newThresholdSignatureScheme
4003
4136
  });
@@ -4039,19 +4172,39 @@ class DynamicWalletClient {
4039
4172
  // Backup & activate server shares BEFORE saving to local storage.
4040
4173
  // This prevents a mismatch where new client shares are stored locally
4041
4174
  // but old server shares remain active if backup/activation fails.
4042
- await this.backupSharesWithDistribution({
4175
+ // Rollback semantics — see runBackupWithRotationRollback.
4176
+ await this.runBackupWithRotationRollback({
4043
4177
  accountAddress,
4044
- password,
4045
- signedSessionId,
4046
- distribution,
4047
- googleDriveAccessToken
4178
+ ceremony: 'reshare',
4179
+ sourceShareSetId: wallet.shareSetId,
4180
+ walletId: wallet.walletId,
4181
+ dynamicRequestId,
4182
+ runBackup: ()=>this.backupSharesWithDistribution({
4183
+ accountAddress,
4184
+ password,
4185
+ signedSessionId,
4186
+ distribution,
4187
+ googleDriveAccessToken
4188
+ })
4048
4189
  });
4049
4190
  // Store client key shares to storage (localStorage or secureStorage)
4050
4191
  // only after backup succeeds.
4051
- await this.setClientKeySharesToStorage({
4052
- accountAddress,
4053
- clientKeyShares: distribution.clientShares
4054
- });
4192
+ //
4193
+ // Share-set delegation (FF=on) skips this entirely — the ceremony
4194
+ // output's refreshed rootUser-client share went to the webhook
4195
+ // (delegatedShare), and the original gen1 rootUser-client share must
4196
+ // stay in local cache so the user can still sign rootUser. See the
4197
+ // mixed-share-sign isolation property tested in
4198
+ // `wallet-service/scripts/twoOfTwoReshareExperiment.ts`. Calling this
4199
+ // with `clientShares: []` would clear the local cache (per the
4200
+ // method's "full replacement" semantics), breaking signing until
4201
+ // recovery refetches from Dynamic.
4202
+ if (distribution.clientShares.length > 0) {
4203
+ await this.setClientKeySharesToStorage({
4204
+ accountAddress,
4205
+ clientKeyShares: distribution.clientShares
4206
+ });
4207
+ }
4055
4208
  // Operation summary — correlates against downstream sign failures /
4056
4209
  // pending-rotation reports. `ceremonyCallbackFired: false` here means
4057
4210
  // either the server is legacy (didn't emit ceremony_complete) or the
@@ -4093,7 +4246,7 @@ class DynamicWalletClient {
4093
4246
  throw error;
4094
4247
  }
4095
4248
  }
4096
- async performDelegationOperation({ accountAddress, password, signedSessionId, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, operationName }) {
4249
+ async performDelegationOperation({ accountAddress, password, signedSessionId, mfaToken, newThresholdSignatureScheme, revokeDelegation = false, useShareSetReshare = false, operationName }) {
4097
4250
  try {
4098
4251
  const delegateToProjectEnvironment = this.featureFlags && this.featureFlags[core.FEATURE_FLAGS.ENABLE_DELEGATED_KEY_SHARES_FLAG] === true;
4099
4252
  if (!delegateToProjectEnvironment) {
@@ -4110,8 +4263,11 @@ class DynamicWalletClient {
4110
4263
  }
4111
4264
  const walletData = this.getWalletFromMap(accountAddress);
4112
4265
  const currentThresholdSignatureScheme = walletData.thresholdSignatureScheme;
4113
- // Get active cloud providers to maintain existing backups
4114
- const activeProviders = getActiveCloudProviders(walletData == null ? void 0 : walletData.clientKeySharesBackupInfo);
4266
+ // Under the share-set reshare flow, rootUser is not mutated — the
4267
+ // delegation creates a separate `delegated` share set. So we don't
4268
+ // re-pin existing cloud providers; preservation is irrelevant when the
4269
+ // primary share set is untouched.
4270
+ const activeProviders = useShareSetReshare ? [] : getActiveCloudProviders(walletData == null ? void 0 : walletData.clientKeySharesBackupInfo);
4115
4271
  await this.reshare({
4116
4272
  chainName: walletData.chainName,
4117
4273
  accountAddress,
@@ -4136,19 +4292,82 @@ class DynamicWalletClient {
4136
4292
  }
4137
4293
  }
4138
4294
  async delegateKeyShares({ accountAddress, password = undefined, signedSessionId, mfaToken }) {
4295
+ var _this_featureFlags;
4296
+ const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[core.FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
4297
+ if (useShareSetReshare) {
4298
+ var _this_getWalletFromMap;
4299
+ // FF on: delegation creates a separate `delegated` share set without
4300
+ // touching rootUser. If the wallet already has one, this is a no-op.
4301
+ const existingDelegated = getDelegatedShareSet((_this_getWalletFromMap = this.getWalletFromMap(accountAddress)) == null ? void 0 : _this_getWalletFromMap.otherShareSets);
4302
+ if (existingDelegated) {
4303
+ var _this_getWalletFromMap1;
4304
+ this.logger.info('[WaasShareSet] delegateKeyShares no-op — wallet already has a delegated share set', {
4305
+ accountAddress,
4306
+ delegatedShareSetId: existingDelegated.shareSetId
4307
+ });
4308
+ const backupInfo = (_this_getWalletFromMap1 = this.getWalletFromMap(accountAddress)) == null ? void 0 : _this_getWalletFromMap1.clientKeySharesBackupInfo;
4309
+ return (backupInfo == null ? void 0 : backupInfo.backups[core.BackupLocation.DELEGATED]) || [];
4310
+ }
4311
+ this.logger.info('[WaasShareSet] delegateKeyShares using share-set reshare path', {
4312
+ accountAddress
4313
+ });
4314
+ }
4139
4315
  await this.performDelegationOperation({
4140
4316
  accountAddress,
4141
4317
  password,
4142
4318
  signedSessionId,
4143
4319
  mfaToken,
4144
- newThresholdSignatureScheme: core.ThresholdSignatureScheme.TWO_OF_THREE,
4145
- operationName: 'delegateKeyShares'
4320
+ // FF on: reshare to 2/2 — the new delegated share set is always 2/2
4321
+ // (server + delegated webhook). getReshareConfig now has a 2/2 → 2/2
4322
+ // + delegation case that returns newClientShareCount=1 (the share
4323
+ // routed to the webhook) so the ceremony generates it correctly.
4324
+ // FF off: legacy 2/2 → 2/3 reshare that mutates rootUser.
4325
+ newThresholdSignatureScheme: useShareSetReshare ? core.ThresholdSignatureScheme.TWO_OF_TWO : core.ThresholdSignatureScheme.TWO_OF_THREE,
4326
+ operationName: 'delegateKeyShares',
4327
+ useShareSetReshare
4146
4328
  });
4147
4329
  const backupInfo = this.getWalletFromMap(accountAddress).clientKeySharesBackupInfo;
4148
4330
  const delegatedKeyShares = backupInfo.backups[core.BackupLocation.DELEGATED] || [];
4149
4331
  return delegatedKeyShares;
4150
4332
  }
4151
4333
  async revokeDelegation({ accountAddress, password = undefined, signedSessionId, mfaToken }) {
4334
+ var _this_featureFlags;
4335
+ const useShareSetReshare = ((_this_featureFlags = this.featureFlags) == null ? void 0 : _this_featureFlags[core.FEATURE_FLAGS.ENABLE_SHARE_SET_RESHARE]) === true;
4336
+ if (useShareSetReshare) {
4337
+ var _this_getWalletFromMap;
4338
+ const wallet = await this.getWallet({
4339
+ accountAddress,
4340
+ walletOperation: core.WalletOperation.NO_OPERATION,
4341
+ password,
4342
+ signedSessionId
4343
+ });
4344
+ const delegatedShareSet = getDelegatedShareSet((_this_getWalletFromMap = this.getWalletFromMap(accountAddress)) == null ? void 0 : _this_getWalletFromMap.otherShareSets);
4345
+ if (!delegatedShareSet) {
4346
+ this.logger.info('[WaasShareSet] revokeDelegation called but no delegated share set found', {
4347
+ accountAddress
4348
+ });
4349
+ return;
4350
+ }
4351
+ this.logger.info('[WaasShareSet] revokeDelegation via share-set REST DELETE (no MPC ceremony)', {
4352
+ accountAddress,
4353
+ delegatedShareSetId: delegatedShareSet.shareSetId,
4354
+ walletId: wallet.walletId
4355
+ });
4356
+ await this.apiClient.revokeDelegation({
4357
+ walletId: wallet.walletId,
4358
+ shareSetId: delegatedShareSet.shareSetId,
4359
+ signedSessionId,
4360
+ dynamicRequestId: uuid.v4().replaceAll('-', '')
4361
+ });
4362
+ // Refresh wallet state so otherShareSets / backups reflect the cleared delegation
4363
+ await this.getWallet({
4364
+ accountAddress,
4365
+ walletOperation: core.WalletOperation.NO_OPERATION,
4366
+ password,
4367
+ signedSessionId
4368
+ });
4369
+ return;
4370
+ }
4152
4371
  await this.performDelegationOperation({
4153
4372
  accountAddress,
4154
4373
  password,
@@ -4695,7 +4914,7 @@ class DynamicWalletClient {
4695
4914
  hasDelegatedShare: !!distribution.delegatedShare
4696
4915
  }));
4697
4916
  try {
4698
- var _backupData_locationsWithKeyShares, _backupData_locationsWithKeyShares1;
4917
+ var _this_getWalletFromMap, _backupData_locationsWithKeyShares, _backupData_locationsWithKeyShares1;
4699
4918
  // `let` so the retry path can swap in a freshly-signed session via the reverse channel on 400.
4700
4919
  let resolvedSignedSessionId = await this.resolveSignedSessionId(signedSessionId);
4701
4920
  const canRefreshSignedSessionId = this.getSignedSessionIdCallback !== undefined;
@@ -4790,10 +5009,24 @@ class DynamicWalletClient {
4790
5009
  googleDriveAccessToken
4791
5010
  }));
4792
5011
  }
5012
+ // When this ceremony minted a separate `delegated` share set (FF=on
5013
+ // path), backup activation must target that share set — not rootUser —
5014
+ // so `/delegatedAccess/delivery` and `/backup/locations` resolve to the
5015
+ // pending delegated row instead of mutating rootUser. The FF=off path
5016
+ // leaves `otherShareSets` empty and falls back to rootUser, matching
5017
+ // legacy in-place delegation behavior.
5018
+ //
5019
+ // Read otherShareSets fresh from the wallet map — `recordDelegatedShareSet`
5020
+ // wrote the new entry during `ceremony_complete`, which fired between
5021
+ // when `walletData` was captured at the top of this method and now.
5022
+ const freshOtherShareSets = (_this_getWalletFromMap = this.getWalletFromMap(accountAddress)) == null ? void 0 : _this_getWalletFromMap.otherShareSets;
5023
+ const delegatedShareSet = distribution.delegatedShare ? getDelegatedShareSet(freshOtherShareSets) : undefined;
5024
+ var _delegatedShareSet_shareSetId;
5025
+ const targetShareSetId = (_delegatedShareSet_shareSetId = delegatedShareSet == null ? void 0 : delegatedShareSet.shareSetId) != null ? _delegatedShareSet_shareSetId : walletData.shareSetId;
4793
5026
  if (distribution.delegatedShare) {
4794
5027
  uploadPromises.push(retryPromise(()=>this.publishDelegatedShare({
4795
5028
  walletId: walletData.walletId,
4796
- shareSetId: walletData.shareSetId,
5029
+ shareSetId: targetShareSetId,
4797
5030
  delegatedShare: distribution.delegatedShare,
4798
5031
  signedSessionId: resolvedSignedSessionId,
4799
5032
  dynamicRequestId,
@@ -4823,7 +5056,10 @@ class DynamicWalletClient {
4823
5056
  // won't fix.
4824
5057
  const backupData = await retryPromise(()=>this.apiClient.markKeySharesAsBackedUp({
4825
5058
  walletId: walletData.walletId,
4826
- shareSetId: walletData.shareSetId,
5059
+ // Same routing rule as publishDelegatedShare above: when this
5060
+ // ceremony minted a separate `delegated` share set, the atomic
5061
+ // swap in /backup/locations must activate THAT row, not rootUser.
5062
+ shareSetId: targetShareSetId,
4827
5063
  locations,
4828
5064
  dynamicRequestId,
4829
5065
  passwordUpdateBatchId
@@ -5221,13 +5457,35 @@ class DynamicWalletClient {
5221
5457
  }
5222
5458
  async decryptKeyShare({ keyShare, password }) {
5223
5459
  const decodedKeyShare = JSON.parse(Buffer.from(keyShare, 'base64').toString());
5224
- const decryptedKeyShare = await decryptData({
5225
- data: decodedKeyShare,
5226
- password: password != null ? password : this.environmentId
5227
- });
5228
- this.logPasswordSharePresence(password, 'decrypt');
5229
- const deserializedKeyShare = JSON.parse(decryptedKeyShare);
5230
- return deserializedKeyShare;
5460
+ // Track whether a user-supplied password was provided so we can emit a
5461
+ // distinct error class on failure. The default `environmentId` fallback is
5462
+ // applied here (and only here) so the original `password` argument retains
5463
+ // the caller's intent — see `recoverEncryptedBackupByWallet` / `getWallet`,
5464
+ // which no longer apply their own fallback.
5465
+ const usedDefaultPassword = !password;
5466
+ const effectivePassword = password != null ? password : this.environmentId;
5467
+ try {
5468
+ const decryptedKeyShare = await decryptData({
5469
+ data: decodedKeyShare,
5470
+ password: effectivePassword
5471
+ });
5472
+ this.logPasswordSharePresence(password, 'decrypt');
5473
+ const deserializedKeyShare = JSON.parse(decryptedKeyShare);
5474
+ return deserializedKeyShare;
5475
+ } catch (error) {
5476
+ // When no user password was supplied, an InvalidPasswordError is
5477
+ // misleading — the user never entered a password, so the "wrong password"
5478
+ // framing doesn't apply. Re-throw as a distinct system-side error so
5479
+ // dashboards/logs classify it as `decryption_failure` instead of
5480
+ // `wrong_password`, and surface that the cause is most likely an
5481
+ // environment-ID mismatch (e.g. wallet encrypted in another environment).
5482
+ if (usedDefaultPassword && error instanceof InvalidPasswordError) {
5483
+ throw new KeyShareDecryptionError({
5484
+ cause: error
5485
+ });
5486
+ }
5487
+ throw error;
5488
+ }
5231
5489
  }
5232
5490
  /**
5233
5491
  * Validates that the provided password is consistent with existing encrypted wallets.
@@ -5483,9 +5741,12 @@ class DynamicWalletClient {
5483
5741
  userId: this.userId
5484
5742
  });
5485
5743
  const dynamicKeyShares = data.keyShares.filter((keyShare)=>keyShare.encryptedAccountCredential !== null && keyShare.backupLocation === core.BackupLocation.DYNAMIC);
5486
- const decryptedKeyShares = await Promise.all(dynamicKeyShares.map((keyShare)=>this.decryptKeyShare({
5744
+ const decryptedKeyShares = await Promise.all(dynamicKeyShares.map((keyShare)=>// `decryptKeyShare` owns the `environmentId` fallback; passing
5745
+ // `password` (possibly undefined) lets it distinguish a user-supplied
5746
+ // password failure from an envId-fallback failure.
5747
+ this.decryptKeyShare({
5487
5748
  keyShare: keyShare.encryptedAccountCredential,
5488
- password: password != null ? password : this.environmentId
5749
+ password
5489
5750
  })));
5490
5751
  if (storeRecoveredShares) {
5491
5752
  await this.setClientKeySharesToStorage({
@@ -6186,9 +6447,11 @@ class DynamicWalletClient {
6186
6447
  return wallet;
6187
6448
  }
6188
6449
  // TODO(zfaizal2): throw error if signedSessionId is not provided after service deploy
6450
+ // `decryptKeyShare` owns the environmentId fallback so the original
6451
+ // `password` (possibly undefined) propagates here.
6189
6452
  const decryptedKeyShares = await this.recoverEncryptedBackupByWallet({
6190
6453
  accountAddress,
6191
- password: password != null ? password : this.environmentId,
6454
+ password,
6192
6455
  walletOperation: walletOperation,
6193
6456
  signedSessionId,
6194
6457
  shareCount