@metamask-previews/keyring-controller 28.2.0-preview-66aa9ab6e → 28.2.0-preview-4a8d4a2

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.
@@ -8,11 +8,11 @@ import { SimpleKeyring as SimpleKeyringV2 } from '@metamask/eth-simple-keyring/v
8
8
  import { add0x, assertIsStrictHexString, bytesToHex, hasProperty, hexToBytes, isObject, isStrictHexString, isValidHexAddress, isValidJson, remove0x, } from '@metamask/utils';
9
9
  import { Mutex } from 'async-mutex';
10
10
  import * as ethereumjsWallet from 'ethereumjs-wallet';
11
- import { cloneDeep } from 'lodash-es';
12
11
  // When generating a ULID within the same millisecond, monotonicFactory provides some guarantees regarding sort order.
13
12
  import { ulid } from 'ulid';
14
13
  import { KeyringControllerErrorMessage } from './constants.js';
15
14
  import { KeyringControllerError } from './errors.js';
15
+ import { VaultTransaction } from './VaultTransaction.js';
16
16
  /**
17
17
  * `ethereumjs-wallet` is CommonJS, and Node cannot reliably detect its named
18
18
  * exports, so importing `thirdparty` directly fails at run time. It also
@@ -257,6 +257,14 @@ function normalize(address) {
257
257
  // address value!
258
258
  return isEthAddress(address) ? ethNormalize(address) : address;
259
259
  }
260
+ /**
261
+ * The standard wallet persist policy: the vault is written when the
262
+ * session state changed since the transaction began.
263
+ *
264
+ * @param hasChanged - Lazily checks whether the session state changed.
265
+ * @returns Whether the vault should be written.
266
+ */
267
+ const persistWhenSessionChanged = (hasChanged) => hasChanged();
260
268
  /**
261
269
  * Controller responsible for establishing and managing user identity.
262
270
  *
@@ -347,7 +355,9 @@ export class KeyringController extends BaseController {
347
355
  */
348
356
  async addNewAccount(accountCount) {
349
357
  this.#assertIsUnlocked();
350
- return this.#persistOrRollback(async () => {
358
+ // TODO: Migrate this to `#withKeyringOrRollback` so that only the
359
+ // operated keyring is snapshotted and diffed.
360
+ return this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async () => {
351
361
  const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0];
352
362
  if (!primaryKeyring) {
353
363
  throw new KeyringControllerError('No HD keyring found');
@@ -382,7 +392,9 @@ export class KeyringController extends BaseController {
382
392
  // and account using a "Snap Keyring". This function assume the `keyring` is
383
393
  // ethereum compatible, but "Snap Keyring" might not be.
384
394
  this.#assertIsUnlocked();
385
- return this.#persistOrRollback(async () => {
395
+ // TODO: Migrate this to `#withKeyringOrRollback` so that only the
396
+ // operated keyring is snapshotted and diffed.
397
+ return this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async () => {
386
398
  const oldAccounts = await this.#getAccountsFromKeyrings();
387
399
  if (accountCount && oldAccounts.length !== accountCount) {
388
400
  if (accountCount > oldAccounts.length) {
@@ -408,9 +420,9 @@ export class KeyringController extends BaseController {
408
420
  * @returns Promise resolving when the operation ends successfully.
409
421
  */
410
422
  async createNewVaultAndRestore(password, seed) {
411
- return this.#persistOrRollback(async () => {
423
+ return this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async (transaction) => {
412
424
  assertIsValidPassword(password);
413
- await this.#createNewVaultWithKeyring(password, {
425
+ await this.#createNewVaultWithKeyring(transaction, password, {
414
426
  type: KeyringTypes.hd,
415
427
  opts: {
416
428
  mnemonic: seed,
@@ -429,10 +441,10 @@ export class KeyringController extends BaseController {
429
441
  * @returns Promise resolving when the operation ends successfully.
430
442
  */
431
443
  async createNewVaultAndKeychain(password) {
432
- return this.#persistOrRollback(async () => {
444
+ return this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async (transaction) => {
433
445
  const accounts = await this.#getAccountsFromKeyrings();
434
446
  if (!accounts.length) {
435
- await this.#createNewVaultWithKeyring(password, {
447
+ await this.#createNewVaultWithKeyring(transaction, password, {
436
448
  type: KeyringTypes.hd,
437
449
  });
438
450
  }
@@ -448,7 +460,9 @@ export class KeyringController extends BaseController {
448
460
  */
449
461
  async addNewKeyring(type, opts) {
450
462
  this.#assertIsUnlocked();
451
- return this.#getKeyringMetadata(await this.#persistOrRollback(async () => this.#newKeyring(type, opts)));
463
+ // TODO: Migrate this to `#withKeyringOrRollback` so that only the
464
+ // operated keyring is snapshotted and diffed.
465
+ return this.#getKeyringMetadata(await this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async () => this.#newKeyring(type, opts)));
452
466
  }
453
467
  /**
454
468
  * Method to verify a given password validity. Throws an
@@ -649,9 +663,10 @@ export class KeyringController extends BaseController {
649
663
  * operation completes.
650
664
  */
651
665
  async persistAllKeyrings() {
652
- return this.#withRollback(async () => {
666
+ return this.#withTransaction(
667
+ // The deprecated method's contract is an unconditional vault write.
668
+ () => this.#beginWalletTransaction(() => true), async () => {
653
669
  this.#assertIsUnlocked();
654
- await this.#updateVault();
655
670
  return true;
656
671
  });
657
672
  }
@@ -668,7 +683,9 @@ export class KeyringController extends BaseController {
668
683
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
669
684
  args) {
670
685
  this.#assertIsUnlocked();
671
- return this.#persistOrRollback(async () => {
686
+ // TODO: Migrate this to `#withKeyringOrRollback` so that only the
687
+ // operated keyring is snapshotted and diffed.
688
+ return this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async () => {
672
689
  let privateKey;
673
690
  switch (strategy) {
674
691
  case AccountImportStrategy.privateKey: {
@@ -724,12 +741,15 @@ export class KeyringController extends BaseController {
724
741
  */
725
742
  async removeAccount(address) {
726
743
  this.#assertIsUnlocked();
727
- await this.#persistOrRollback(async () => {
744
+ // TODO: Migrate this to `#withKeyringOrRollback` so that only the
745
+ // operated keyring is snapshotted and diffed.
746
+ await this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async (transaction) => {
728
747
  const keyringIndex = await this.#findKeyringIndexForAccount(address);
729
748
  if (keyringIndex === -1) {
730
749
  throw new KeyringControllerError(KeyringControllerErrorMessage.NoKeyring);
731
750
  }
732
- const { keyring, keyringV2 } = this.#keyrings[keyringIndex];
751
+ const entry = this.#keyrings[keyringIndex];
752
+ const { keyring } = entry;
733
753
  const isPrimaryKeyring = keyringIndex === 0;
734
754
  const shouldRemoveKeyring = (await keyring.getAccounts()).length === 1;
735
755
  // Primary keyring should never be removed, so we need to keep at least one account in it
@@ -749,8 +769,7 @@ export class KeyringController extends BaseController {
749
769
  // eslint-disable-next-line @typescript-eslint/await-thenable
750
770
  await keyring.removeAccount(address);
751
771
  if (shouldRemoveKeyring) {
752
- this.#keyrings.splice(keyringIndex, 1);
753
- await this.#destroyKeyring(keyring, keyringV2);
772
+ await transaction.remove(entry);
754
773
  }
755
774
  });
756
775
  this.messenger.publish(`${name}:accountRemoved`, address);
@@ -762,9 +781,12 @@ export class KeyringController extends BaseController {
762
781
  */
763
782
  async setLocked() {
764
783
  this.#assertIsUnlocked();
765
- return this.#withRollback(async () => {
784
+ return this.#withTransaction(
785
+ // Locking clears the session without rewriting the vault, and the
786
+ // encryption key is gone by commit time anyway.
787
+ () => this.#beginWalletTransaction(() => false), async (transaction) => {
766
788
  this.#encryptionKey = undefined;
767
- await this.#clearKeyrings();
789
+ await this.#clearKeyrings(transaction);
768
790
  this.update((state) => {
769
791
  state.isUnlocked = false;
770
792
  state.keyrings = [];
@@ -946,7 +968,7 @@ export class KeyringController extends BaseController {
946
968
  */
947
969
  changePassword(password) {
948
970
  this.#assertIsUnlocked();
949
- return this.#persistOrRollback(async () => {
971
+ return this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async () => {
950
972
  assertIsValidPassword(password);
951
973
  await this.#deriveAndSetEncryptionKey(password, {
952
974
  ignoreExistingVault: true,
@@ -963,8 +985,12 @@ export class KeyringController extends BaseController {
963
985
  * @returns Promise resolving when the operation completes.
964
986
  */
965
987
  async submitEncryptionKey(encryptionKey, encryptionSalt) {
966
- const { hasChanged } = await this.#withRollback(async () => {
967
- const result = await this.#unlockKeyrings({
988
+ const { hasChanged } = await this.#withTransaction(
989
+ // The unlock transaction is a rollback safety net: whether to
990
+ // upgrade the vault is the next transaction's decision, on the
991
+ // restoration's own change flag.
992
+ () => this.#beginWalletTransaction(() => false), async (transaction) => {
993
+ const result = await this.#unlockKeyrings(transaction, {
968
994
  encryptionKey,
969
995
  encryptionSalt,
970
996
  });
@@ -974,11 +1000,10 @@ export class KeyringController extends BaseController {
974
1000
  try {
975
1001
  // if new metadata has been generated during login, we
976
1002
  // can attempt to upgrade the vault.
977
- await this.#withRollback(async () => {
978
- if (hasChanged) {
979
- await this.#updateVault();
980
- }
981
- });
1003
+ await this.#withTransaction(
1004
+ // The criterion is the restoration's change flag from the unlock
1005
+ // above — not this transaction's own session diff.
1006
+ () => this.#beginWalletTransaction(() => hasChanged), async () => undefined);
982
1007
  }
983
1008
  catch (error) {
984
1009
  // We don't want to throw an error if the upgrade fails
@@ -1006,8 +1031,11 @@ export class KeyringController extends BaseController {
1006
1031
  * @returns Promise resolving when the operation completes.
1007
1032
  */
1008
1033
  async submitPassword(password) {
1009
- const { hasChanged } = await this.#withRollback(async () => {
1010
- const result = await this.#unlockKeyrings({ password });
1034
+ const { hasChanged } = await this.#withTransaction(
1035
+ // The unlock transaction is a rollback safety net: whether to
1036
+ // upgrade the vault is the next transaction's decision.
1037
+ () => this.#beginWalletTransaction(() => false), async (transaction) => {
1038
+ const result = await this.#unlockKeyrings(transaction, { password });
1011
1039
  this.#setUnlocked();
1012
1040
  return result;
1013
1041
  });
@@ -1015,17 +1043,24 @@ export class KeyringController extends BaseController {
1015
1043
  // If there are stronger encryption params available, or
1016
1044
  // if the keyring state has changed during deserialization, we
1017
1045
  // can attempt to upgrade the vault.
1018
- await this.#withRollback(async () => {
1046
+ await this.#withTransaction(
1047
+ // The criterion combines the restoration's change flag from the
1048
+ // unlock above — not this transaction's own session diff — with
1049
+ // the available encryption params, and gates the re-derivation
1050
+ // of the encryption key on it.
1051
+ () => this.#beginWalletTransaction(async () => {
1019
1052
  if (hasChanged || this.#isNewEncryptionAvailable()) {
1053
+ // If the vault is being upgraded, we want to ignore the
1054
+ // metadata that is already in the vault, so we can
1055
+ // effectively re-encrypt the vault with the new encryption
1056
+ // config.
1020
1057
  await this.#deriveAndSetEncryptionKey(password, {
1021
- // If the vault is being upgraded, we want to ignore the metadata
1022
- // that is already in the vault, so we can effectively
1023
- // re-encrypt the vault with the new encryption config.
1024
1058
  ignoreExistingVault: true,
1025
1059
  });
1026
- await this.#updateVault();
1060
+ return true;
1027
1061
  }
1028
- });
1062
+ return false;
1063
+ }), async () => undefined);
1029
1064
  }
1030
1065
  catch (error) {
1031
1066
  // We don't want to throw an error if the upgrade fails
@@ -1065,22 +1100,16 @@ export class KeyringController extends BaseController {
1065
1100
  createIfMissing: false,
1066
1101
  }) {
1067
1102
  this.#assertIsUnlocked();
1068
- return this.#persistOrRollback(async () => {
1069
- let entry = await this.#selectKeyringEntry({
1070
- v2: false,
1071
- selector,
1072
- });
1073
- if (!entry && 'type' in selector && options.createIfMissing) {
1074
- const newKeyring = (await this.#newKeyring(selector.type, options.createWithData));
1075
- entry = this.#keyrings.find(({ keyring }) => keyring === newKeyring);
1076
- }
1077
- if (!entry) {
1078
- throw new KeyringControllerError(KeyringControllerErrorMessage.KeyringNotFound);
1103
+ return this.#withKeyringOrRollback({ v2: false }, () => this.#selectKeyringEntry({ v2: false, selector }), async () => {
1104
+ if (!options.createIfMissing || !('type' in selector)) {
1105
+ return;
1079
1106
  }
1080
- const { metadata } = entry;
1081
- const keyring = entry.keyring;
1082
- return this.#assertNoUnsafeDirectKeyringAccess(await this.#cleanUpEmptiedKeyringsAfter(async () => operation({ keyring, metadata })), keyring);
1083
- });
1107
+ const newKeyring = (await this.#newKeyring(selector.type, options.createWithData));
1108
+ return this.#keyrings.find(({ keyring }) => keyring === newKeyring);
1109
+ }, async ({ keyring, metadata }) => this.#assertNoUnsafeDirectKeyringAccess(await operation({
1110
+ keyring: keyring,
1111
+ metadata,
1112
+ }), keyring));
1084
1113
  }
1085
1114
  /**
1086
1115
  * Select a keyring and execute the given operation with the selected
@@ -1144,6 +1173,9 @@ export class KeyringController extends BaseController {
1144
1173
  * function execution, or rolls back the changes if an error
1145
1174
  * is thrown.
1146
1175
  *
1176
+ * Only the selected keyring is snapshotted, persisted, and rolled back:
1177
+ * the operation must not mutate other keyrings.
1178
+ *
1147
1179
  * @param selector - Keyring selector object.
1148
1180
  * @param operation - Function to execute with the wrapped V2 keyring.
1149
1181
  * @returns Promise resolving to the result of the function execution.
@@ -1151,24 +1183,10 @@ export class KeyringController extends BaseController {
1151
1183
  */
1152
1184
  async withKeyringV2(selector, operation) {
1153
1185
  this.#assertIsUnlocked();
1154
- return this.#persistOrRollback(async () => {
1155
- const entry = await this.#selectKeyringEntry({
1156
- v2: true,
1157
- selector,
1158
- });
1159
- if (!entry) {
1160
- throw new KeyringControllerError(KeyringControllerErrorMessage.KeyringNotFound);
1161
- }
1162
- if (!entry.keyringV2) {
1163
- throw new KeyringControllerError(KeyringControllerErrorMessage.KeyringV2NotSupported);
1164
- }
1165
- const { metadata } = entry;
1166
- const keyring = entry.keyringV2;
1167
- return this.#assertNoUnsafeDirectKeyringAccess(await this.#cleanUpEmptiedKeyringsAfter(async () => operation({
1168
- keyring,
1169
- metadata,
1170
- })), keyring);
1171
- });
1186
+ return this.#withKeyringOrRollback({ v2: true }, () => this.#selectKeyringEntry({ v2: true, selector }), async () => undefined, async ({ keyringV2, metadata }) => this.#assertNoUnsafeDirectKeyringAccess(await operation({
1187
+ keyring: keyringV2,
1188
+ metadata,
1189
+ }), keyringV2));
1172
1190
  }
1173
1191
  /**
1174
1192
  * Select a keyring, wrap it in a `KeyringV2` adapter, and execute
@@ -1237,7 +1255,7 @@ export class KeyringController extends BaseController {
1237
1255
  */
1238
1256
  async withController(operation) {
1239
1257
  this.#assertIsUnlocked();
1240
- return this.#persistOrRollback(async () => {
1258
+ return this.#withTransaction(() => this.#beginWalletTransaction(persistWhenSessionChanged), async (transaction) => {
1241
1259
  // Track created and removed keyrings during the operation execution.
1242
1260
  const createdEntries = new Set();
1243
1261
  const removedEntries = new Set();
@@ -1273,7 +1291,7 @@ export class KeyringController extends BaseController {
1273
1291
  },
1274
1292
  };
1275
1293
  const destroyKeyrings = async (entries) => {
1276
- await Promise.all([...entries].map(({ keyring, keyringV2 }) => this.#destroyKeyring(keyring, keyringV2)));
1294
+ await Promise.all([...entries].map((entry) => transaction.destroy(entry)));
1277
1295
  };
1278
1296
  let result;
1279
1297
  try {
@@ -1423,13 +1441,14 @@ export class KeyringController extends BaseController {
1423
1441
  * creates a new wallet with 1 account.
1424
1442
  *
1425
1443
  * @fires KeyringController:unlock
1444
+ * @param transaction - The transaction creating the new vault.
1426
1445
  * @param password - The password to encrypt the vault with.
1427
1446
  * @param keyring - A object containing the params to instantiate a new keyring.
1428
1447
  * @param keyring.type - The keyring type.
1429
1448
  * @param keyring.opts - Optional parameters required to instantiate the keyring.
1430
1449
  * @returns A promise that resolves to the state.
1431
1450
  */
1432
- async #createNewVaultWithKeyring(password, keyring) {
1451
+ async #createNewVaultWithKeyring(transaction, password, keyring) {
1433
1452
  this.#assertControllerMutexIsLocked();
1434
1453
  if (typeof password !== 'string') {
1435
1454
  throw new TypeError(KeyringControllerErrorMessage.WrongPasswordType);
@@ -1441,7 +1460,7 @@ export class KeyringController extends BaseController {
1441
1460
  await this.#deriveAndSetEncryptionKey(password, {
1442
1461
  ignoreExistingVault: true,
1443
1462
  });
1444
- await this.#clearKeyrings();
1463
+ await this.#clearKeyrings(transaction);
1445
1464
  await this.#createKeyringWithFirstAccount(keyring.type, keyring.opts);
1446
1465
  this.#setUnlocked();
1447
1466
  }
@@ -1568,15 +1587,11 @@ export class KeyringController extends BaseController {
1568
1587
  }
1569
1588
  /**
1570
1589
  * Serialize the current array of keyring instances,
1571
- * including unsupported keyrings by default.
1590
+ * including unsupported keyrings.
1572
1591
  *
1573
- * @param options - Method options.
1574
- * @param options.includeUnsupported - Whether to include unsupported keyrings.
1575
1592
  * @returns The serialized keyrings.
1576
1593
  */
1577
- async #getSerializedKeyrings({ includeUnsupported } = {
1578
- includeUnsupported: true,
1579
- }) {
1594
+ async #getSerializedKeyrings() {
1580
1595
  const serializedKeyrings = await Promise.all(this.#keyrings.map(async ({ keyring, metadata }) => {
1581
1596
  return {
1582
1597
  type: keyring.type,
@@ -1584,31 +1599,18 @@ export class KeyringController extends BaseController {
1584
1599
  metadata,
1585
1600
  };
1586
1601
  }));
1587
- if (includeUnsupported) {
1588
- serializedKeyrings.push(...this.#unsupportedKeyrings);
1589
- }
1602
+ serializedKeyrings.push(...this.#unsupportedKeyrings);
1590
1603
  return serializedKeyrings;
1591
1604
  }
1592
- /**
1593
- * Get a snapshot of session data held by instance variables.
1594
- *
1595
- * @returns An object with serialized keyrings, keyrings metadata,
1596
- * and the user password.
1597
- */
1598
- async #getSessionState() {
1599
- return {
1600
- keyrings: await this.#getSerializedKeyrings(),
1601
- encryptionKey: this.#encryptionKey,
1602
- };
1603
- }
1604
1605
  /**
1605
1606
  * Restore a serialized keyrings array.
1606
1607
  *
1608
+ * @param transaction - The transaction restoring the keyrings.
1607
1609
  * @param serializedKeyrings - The serialized keyrings array.
1608
1610
  * @returns The restored keyrings.
1609
1611
  */
1610
- async #restoreSerializedKeyrings(serializedKeyrings) {
1611
- await this.#clearKeyrings();
1612
+ async #restoreSerializedKeyrings(transaction, serializedKeyrings) {
1613
+ await this.#clearKeyrings(transaction);
1612
1614
  const keyrings = [];
1613
1615
  let hasChanged = false;
1614
1616
  for (const serializedKeyring of serializedKeyrings) {
@@ -1627,10 +1629,11 @@ export class KeyringController extends BaseController {
1627
1629
  * Unlock Keyrings, decrypting the vault and deserializing all
1628
1630
  * keyrings contained in it, using a password or an encryption key with salt.
1629
1631
  *
1632
+ * @param transaction - The transaction unlocking the keyrings.
1630
1633
  * @param credentials - The credentials to unlock the keyrings.
1631
1634
  * @returns A promise resolving to the deserialized keyrings array.
1632
1635
  */
1633
- async #unlockKeyrings(credentials) {
1636
+ async #unlockKeyrings(transaction, credentials) {
1634
1637
  return this.#withVaultLock(async () => {
1635
1638
  if (!this.state.vault) {
1636
1639
  throw new KeyringControllerError(KeyringControllerErrorMessage.VaultError);
@@ -1651,7 +1654,7 @@ export class KeyringController extends BaseController {
1651
1654
  if (!isSerializedKeyringsArray(vault)) {
1652
1655
  throw new KeyringControllerError(KeyringControllerErrorMessage.VaultDataError);
1653
1656
  }
1654
- const { keyrings, hasChanged } = await this.#restoreSerializedKeyrings(vault);
1657
+ const { keyrings, hasChanged } = await this.#restoreSerializedKeyrings(transaction, vault);
1655
1658
  const updatedKeyrings = await this.#getUpdatedKeyrings();
1656
1659
  this.update((state) => {
1657
1660
  state.keyrings = updatedKeyrings;
@@ -1718,14 +1721,10 @@ export class KeyringController extends BaseController {
1718
1721
  * @returns A promise resolving to an array of accounts.
1719
1722
  */
1720
1723
  async #getAccountsFromKeyrings(additionalKeyrings = []) {
1721
- const keyrings = this.#keyrings.map(({ keyring }) => keyring);
1722
- const keyringArrays = await Promise.all([...keyrings, ...additionalKeyrings].map(async (keyring) => keyring.getAccounts()));
1723
- const addresses = keyringArrays.reduce((res, arr) => {
1724
- return res.concat(arr);
1725
- }, []);
1726
- // Cast to `string[]` here is safe here because `addresses` has no nullish
1727
- // values, and `normalize` returns `string` unless given a nullish value
1728
- return addresses.map(normalize);
1724
+ return getAccountsFromKeyrings([
1725
+ ...this.#keyrings.map(({ keyring }) => keyring),
1726
+ ...additionalKeyrings,
1727
+ ]);
1729
1728
  }
1730
1729
  /**
1731
1730
  * Create a new keyring, ensuring that the first account is
@@ -1814,60 +1813,16 @@ export class KeyringController extends BaseController {
1814
1813
  }
1815
1814
  return { keyring, keyringV2, metadata: keyringMetadata };
1816
1815
  }
1817
- /**
1818
- * Run the given operation and afterwards clean up any keyring whose
1819
- * account list transitioned from non-empty to empty during the operation.
1820
- *
1821
- * This mirrors the cleanup behavior of {@link KeyringController.removeAccount}
1822
- * for code paths where the consumer mutates a keyring directly via
1823
- * {@link KeyringController.withKeyring} or
1824
- * {@link KeyringController.withKeyringV2}: if the consumer drains the last
1825
- * account from a keyring, the now-empty keyring is removed from
1826
- * {@link KeyringController.#keyrings} and destroyed before persistence runs.
1827
- *
1828
- * Pre-existing empty keyrings (e.g. those created intentionally via
1829
- * {@link KeyringController.addNewKeyring} without subsequent account
1830
- * creation) are left alone, as are keyrings created within the operation
1831
- * itself (they are not part of the pre-operation snapshot). The primary
1832
- * keyring (see {@link KeyringController.#isPrimaryKeyring}) is also preserved
1833
- * unconditionally to keep `removeAccount`'s primary-keyring invariant intact.
1834
- *
1835
- * @param operation - The operation to execute.
1836
- * @returns The result of the operation.
1837
- * @template Result - The type of the value resolved by the operation.
1838
- */
1839
- async #cleanUpEmptiedKeyringsAfter(operation) {
1840
- // Only the primary keyring exists, which is never auto-removed, so there
1841
- // is nothing to clean up regardless of what the operation does.
1842
- if (this.#keyrings.length <= 1) {
1843
- return operation();
1844
- }
1845
- const wasNonEmpty = new WeakSet();
1846
- await Promise.all(this.#keyrings.map(async ({ keyring }) => {
1847
- if ((await keyring.getAccounts()).length > 0) {
1848
- wasNonEmpty.add(keyring);
1849
- }
1850
- }));
1851
- const result = await operation();
1852
- const isNowEmpty = await Promise.all(this.#keyrings.map(async ({ keyring }) => (await keyring.getAccounts()).length === 0));
1853
- const emptied = this.#keyrings.filter((entry, index) => !this.#isPrimaryKeyring(entry, this.#keyrings) &&
1854
- wasNonEmpty.has(entry.keyring) &&
1855
- isNowEmpty[index]);
1856
- if (emptied.length > 0) {
1857
- const removed = new Set(emptied);
1858
- this.#keyrings = this.#keyrings.filter((entry) => !removed.has(entry));
1859
- await Promise.all(emptied.map(({ keyring, keyringV2 }) => this.#destroyKeyring(keyring, keyringV2)));
1860
- }
1861
- return result;
1862
- }
1863
1816
  /**
1864
1817
  * Remove all managed keyrings, destroying all their
1865
1818
  * instances in memory.
1819
+ *
1820
+ * @param transaction - The transaction clearing the keyrings.
1866
1821
  */
1867
- async #clearKeyrings() {
1822
+ async #clearKeyrings(transaction) {
1868
1823
  this.#assertControllerMutexIsLocked();
1869
- for (const { keyring, keyringV2 } of this.#keyrings) {
1870
- await this.#destroyKeyring(keyring, keyringV2);
1824
+ for (const entry of this.#keyrings) {
1825
+ await transaction.destroy(entry);
1871
1826
  }
1872
1827
  this.#keyrings = [];
1873
1828
  this.#unsupportedKeyrings = [];
@@ -1912,22 +1867,6 @@ export class KeyringController extends BaseController {
1912
1867
  return undefined;
1913
1868
  }
1914
1869
  }
1915
- /**
1916
- * Destroy Keyring
1917
- *
1918
- * Some keyrings support a method called `destroy`, that destroys the
1919
- * keyring along with removing all its event listeners and, in some cases,
1920
- * clears the keyring bridge iframe from the DOM.
1921
- *
1922
- * @param keyring - The keyring to destroy.
1923
- * @param keyringV2 - The keyring v2 to destroy (if any).
1924
- */
1925
- async #destroyKeyring(keyring, keyringV2) {
1926
- await keyring.destroy?.();
1927
- if (keyringV2) {
1928
- await keyringV2.destroy?.();
1929
- }
1930
- }
1931
1870
  /**
1932
1871
  * Assert that there are no duplicate accounts in the keyrings.
1933
1872
  *
@@ -1935,10 +1874,10 @@ export class KeyringController extends BaseController {
1935
1874
  * @throws If there are duplicate accounts.
1936
1875
  */
1937
1876
  async #assertNoDuplicateAccounts(additionalKeyrings = []) {
1938
- const accounts = await this.#getAccountsFromKeyrings(additionalKeyrings);
1939
- if (new Set(accounts).size !== accounts.length) {
1940
- throw new KeyringControllerError(KeyringControllerErrorMessage.DuplicatedAccount);
1941
- }
1877
+ await assertNoDuplicateAccounts([
1878
+ ...this.#keyrings.map(({ keyring }) => keyring),
1879
+ ...additionalKeyrings,
1880
+ ]);
1942
1881
  }
1943
1882
  /**
1944
1883
  * Set the `isUnlocked` to true and notify listeners
@@ -1964,47 +1903,174 @@ export class KeyringController extends BaseController {
1964
1903
  }
1965
1904
  }
1966
1905
  /**
1967
- * Execute the given function after acquiring the controller lock
1968
- * and save the vault to state after it (only if needed), or rollback to their
1969
- * previous state in case of error.
1906
+ * Execute a transaction that operates on a single keyring entry, selected
1907
+ * (and possibly created) before the transaction starts. Only that keyring
1908
+ * is snapshotted, diffed, persisted, and rolled back; the other keyrings
1909
+ * keep their instances even if the transaction fails. This relies on the
1910
+ * `withKeyring` family only mutating the keyring it is given.
1970
1911
  *
1971
- * @param callback - The function to execute.
1972
- * @returns The result of the function.
1912
+ * If the operation drains the operated keyring of all its accounts, the
1913
+ * keyring is removed and destroyed (mirroring `removeAccount`), unless it
1914
+ * is the primary keyring. A setup failure — selection, creation, or
1915
+ * validation — never triggers a rollback; see
1916
+ * `#beginKeyringTransaction`.
1917
+ *
1918
+ * @param options - Transaction options.
1919
+ * @param options.v2 - Whether the transaction operates on the keyring's
1920
+ * V2 adapter. The selected or created entry must then have one.
1921
+ * @param select - Selects the keyring entry to operate on, if any.
1922
+ * @param create - Creates the keyring entry to operate on when selection
1923
+ * found nothing, if needed.
1924
+ * @param run - Runs the operation with the selected or created entry.
1925
+ * @returns The result of the operation.
1973
1926
  */
1974
- async #persistOrRollback(callback) {
1975
- return this.#withRollback(async ({ releaseLock }) => {
1976
- const oldState = JSON.stringify(await this.#getSessionState());
1977
- const callbackResult = await callback({ releaseLock });
1978
- const newState = JSON.stringify(await this.#getSessionState());
1979
- // State is committed only if the operation is successful and need to trigger a vault update.
1980
- if (oldState !== newState) {
1981
- await this.#updateVault();
1927
+ async #withKeyringOrRollback({ v2 }, select, create, run) {
1928
+ return this.#withTransaction(() => this.#beginKeyringTransaction({ v2 }, select, create), async (transaction, entry) => {
1929
+ // The drain cleanup below can only fire when the wallet holds
1930
+ // other keyrings: the bracketing account reads are skipped
1931
+ // entirely otherwise, so single-keyring wallets make no extra
1932
+ // `getAccounts` calls. For a created keyring, the wallet holds it
1933
+ // already, so even a `getAccounts` rejection before the operation
1934
+ // runs must still be rolled back.
1935
+ const canDrain = this.#keyrings.length > 1;
1936
+ const wasNonEmpty = canDrain && (await entry.keyring.getAccounts()).length > 0;
1937
+ const result = await run(entry);
1938
+ const isEmpty = canDrain && (await entry.keyring.getAccounts()).length === 0;
1939
+ // Only the operated keyring can have transitioned to empty.
1940
+ if (wasNonEmpty &&
1941
+ isEmpty &&
1942
+ !this.#isPrimaryKeyring(entry, this.#keyrings)) {
1943
+ await transaction.remove(entry);
1982
1944
  }
1983
- return callbackResult;
1945
+ // The vault is updated by the commit, only if the operated keyring
1946
+ // changed.
1947
+ return result;
1984
1948
  });
1985
1949
  }
1986
1950
  /**
1987
- * Execute the given function after acquiring the controller lock
1988
- * and rollback keyrings and password states in case of error.
1951
+ * Begin a keyring-scoped transaction: select, or create, the entry to
1952
+ * operate on, validate it, and snapshot the operated keyring.
1989
1953
  *
1990
- * @param callback - The function to execute atomically.
1991
- * @returns The result of the function.
1954
+ * Everything here runs before the transaction starts, so a failure
1955
+ * leaves the controller untouched and never triggers a rollback: the
1956
+ * rollback of an entry without a snapshot would misread it as created
1957
+ * by the transaction, destroying a pre-existing keyring. The V2
1958
+ * validation happens before any of the keyring's accounts are read, so
1959
+ * that the missing adapter takes precedence over keyring-level errors.
1960
+ *
1961
+ * @param options - Transaction options.
1962
+ * @param options.v2 - Whether the transaction operates on the keyring's
1963
+ * V2 adapter.
1964
+ * @param select - Selects the keyring entry to operate on, if any.
1965
+ * @param create - Creates the keyring entry to operate on when selection
1966
+ * found nothing, if needed.
1967
+ * @returns The transaction, and the operated entry.
1968
+ */
1969
+ async #beginKeyringTransaction({ v2 }, select, create) {
1970
+ const selected = await select();
1971
+ const entry = selected ?? (await create());
1972
+ if (!entry) {
1973
+ throw new KeyringControllerError(KeyringControllerErrorMessage.KeyringNotFound);
1974
+ }
1975
+ if (v2 && !entry.keyringV2) {
1976
+ throw new KeyringControllerError(KeyringControllerErrorMessage.KeyringV2NotSupported);
1977
+ }
1978
+ const transaction = new VaultTransaction({ type: 'keyring', entry, created: !selected }, this.#vaultSession(), this.#vaultOperations());
1979
+ await transaction.begin();
1980
+ return { transaction, setup: entry };
1981
+ }
1982
+ /**
1983
+ * The vault session state, as the transactions see it: what they
1984
+ * snapshot at `begin()`, mutate through, and restore on `rollback()`.
1985
+ *
1986
+ * @returns The vault session.
1992
1987
  */
1993
- async #withRollback(callback) {
1994
- return this.#withControllerLock(async ({ releaseLock }) => {
1995
- const currentSerializedKeyrings = await this.#getSerializedKeyrings();
1996
- const currentEncryptionKey = cloneDeep(this.#encryptionKey);
1988
+ #vaultSession() {
1989
+ return {
1990
+ getKeyrings: () => [...this.#keyrings],
1991
+ replaceKeyrings: (keyrings) => {
1992
+ this.#keyrings = keyrings;
1993
+ },
1994
+ removeKeyring: (entry) => {
1995
+ const index = this.#keyrings.indexOf(entry);
1996
+ if (index !== -1) {
1997
+ this.#keyrings.splice(index, 1);
1998
+ return true;
1999
+ }
2000
+ return false;
2001
+ },
2002
+ insertKeyringAt: (index, entry) => {
2003
+ this.#keyrings.splice(index, 0, entry);
2004
+ },
2005
+ getUnsupportedKeyrings: () => [...this.#unsupportedKeyrings],
2006
+ setUnsupportedKeyrings: (keyrings) => {
2007
+ this.#unsupportedKeyrings = keyrings;
2008
+ },
2009
+ parkUnsupportedKeyring: (keyring) => {
2010
+ this.#unsupportedKeyrings.push(keyring);
2011
+ },
2012
+ getEncryptionKey: () => this.#encryptionKey,
2013
+ setEncryptionKey: (key) => {
2014
+ this.#encryptionKey = key;
2015
+ },
2016
+ };
2017
+ }
2018
+ /**
2019
+ * The controller-owned primitives the transactions drive: building new
2020
+ * keyring entries, and enforcing the no-duplicate-accounts invariant on
2021
+ * an explicit list of keyrings.
2022
+ *
2023
+ * @returns The vault operations.
2024
+ */
2025
+ #vaultOperations() {
2026
+ return {
2027
+ createKeyring: async (type, data, metadata) => this.#createKeyring(type, data, metadata),
2028
+ assertNoDuplicateAccounts: async (keyrings) => assertNoDuplicateAccounts(keyrings),
2029
+ updateVault: async () => this.#updateVault(),
2030
+ };
2031
+ }
2032
+ /**
2033
+ * Execute the given function inside a vault transaction, after
2034
+ * acquiring the controller lock. The transaction is constructed and
2035
+ * begun before the function runs — a failure there leaves the
2036
+ * controller untouched — and committed after the function succeeds, or
2037
+ * rolled back if it throws.
2038
+ *
2039
+ * @param construct - Constructs and begins the transaction, plus
2040
+ * whatever the operation needs from the construction.
2041
+ * @param run - Runs the operation, with the transaction and what its
2042
+ * construction produced.
2043
+ * @returns The result of the operation.
2044
+ */
2045
+ async #withTransaction(construct, run) {
2046
+ return this.#withControllerLock(async () => {
2047
+ const { transaction, setup } = await construct();
1997
2048
  try {
1998
- return await callback({ releaseLock });
2049
+ const result = await run(transaction, setup);
2050
+ await transaction.commit();
2051
+ return result;
1999
2052
  }
2000
2053
  catch (error) {
2001
- // Keyrings and encryption credentials are restored to their previous state
2002
- this.#encryptionKey = currentEncryptionKey;
2003
- await this.#restoreSerializedKeyrings(currentSerializedKeyrings);
2054
+ // What the transaction owns is restored to its previous state.
2055
+ await transaction.rollback();
2004
2056
  throw error;
2005
2057
  }
2006
2058
  });
2007
2059
  }
2060
+ /**
2061
+ * Begin a wallet-scoped transaction, snapshotting the whole session.
2062
+ *
2063
+ * @param persist - Decides, when the transaction commits, whether the
2064
+ * vault is written.
2065
+ * @returns The transaction, and nothing else for the operation.
2066
+ */
2067
+ async #beginWalletTransaction(persist) {
2068
+ const transaction = new VaultTransaction({ type: 'wallet', persist }, this.#vaultSession(), this.#vaultOperations());
2069
+ // The snapshot is taken before the transaction starts: a failure
2070
+ // here leaves the controller untouched.
2071
+ await transaction.begin();
2072
+ return { transaction, setup: undefined };
2073
+ }
2008
2074
  /**
2009
2075
  * Assert that the controller mutex is locked.
2010
2076
  *
@@ -2100,5 +2166,32 @@ async function withLock(mutex, callback) {
2100
2166
  function getDefaultKeyringMetadata() {
2101
2167
  return { id: ulid(), name: '' };
2102
2168
  }
2169
+ /**
2170
+ * Retrieves all the accounts held by the given keyrings.
2171
+ *
2172
+ * @param keyrings - The keyrings to read the accounts from.
2173
+ * @returns A promise resolving to an array of accounts.
2174
+ */
2175
+ async function getAccountsFromKeyrings(keyrings) {
2176
+ const keyringArrays = await Promise.all(keyrings.map(async (keyring) => keyring.getAccounts()));
2177
+ const addresses = keyringArrays.reduce((res, arr) => {
2178
+ return res.concat(arr);
2179
+ }, []);
2180
+ // Cast to `string[]` here is safe here because `addresses` has no nullish
2181
+ // values, and `normalize` returns `string` unless given a nullish value
2182
+ return addresses.map(normalize);
2183
+ }
2184
+ /**
2185
+ * Assert that there are no duplicate accounts across the given keyrings.
2186
+ *
2187
+ * @param keyrings - The keyrings to check.
2188
+ * @throws If there are duplicate accounts.
2189
+ */
2190
+ async function assertNoDuplicateAccounts(keyrings) {
2191
+ const accounts = await getAccountsFromKeyrings(keyrings);
2192
+ if (new Set(accounts).size !== accounts.length) {
2193
+ throw new KeyringControllerError(KeyringControllerErrorMessage.DuplicatedAccount);
2194
+ }
2195
+ }
2103
2196
  export default KeyringController;
2104
2197
  //# sourceMappingURL=KeyringController.js.map