diff --git a/constraints.pro b/constraints.pro index 18685626751..94d6db04a37 100644 --- a/constraints.pro +++ b/constraints.pro @@ -117,7 +117,7 @@ repo_name(RepoUrl, RepoName) :- RepoNameLength is End - Start, sub_atom(RepoUrl, PrefixLength, RepoNameLength, SuffixLength, RepoName). -% True if DependencyIdent starts with '@metamask' and ends with '-controller' +% True if DependencyIdent starts with '@metamask' and ends with '-controller'. is_controller(DependencyIdent) :- Prefix = '@metamask/', atom_length(Prefix, PrefixLength), @@ -308,43 +308,80 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, 'a range optionally start workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), \+ is_valid_version_range(DependencyRange). -% All references to a workspace package must be up to date with the current -% version of that package. -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectDependencyRange, DependencyType) :- - workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - workspace_ident(OtherWorkspaceCwd, DependencyIdent), - workspace_version(OtherWorkspaceCwd, OtherWorkspaceVersion), - atomic_list_concat(['^', OtherWorkspaceVersion], CorrectDependencyRange). - -% All dependency ranges for a package must be synchronized across the monorepo -% (the least version range wins), regardless of which "*dependencies" field -% where the package appears. +% All version ranges used to reference one workspace package in another +% workspace package's `dependencies` or `devDependencies` must be the same. +% Among all references to the same dependency across the monorepo, the one with +% the smallest version range will win. (We handle `peerDependencies` in another +% constraint, as it has slightly different logic.) gen_enforced_dependency(WorkspaceCwd, DependencyIdent, OtherDependencyRange, DependencyType) :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), workspace_has_dependency(OtherWorkspaceCwd, DependencyIdent, OtherDependencyRange, OtherDependencyType), WorkspaceCwd \= OtherWorkspaceCwd, DependencyRange \= OtherDependencyRange, - npm_version_range_out_of_sync(DependencyRange, OtherDependencyRange). + npm_version_range_out_of_sync(DependencyRange, OtherDependencyRange), + DependencyType \= 'peerDependencies', + OtherDependencyType \= 'peerDependencies'. -% If a dependency is listed under "dependencies", it should not be listed under -% "devDependencies". We match on the same dependency range so that if a -% dependency is listed under both lists, their versions are synchronized and -% then this constraint will apply and remove the "right" duplicate. -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, null, DependencyType) :- - workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'), +% All version ranges used to reference one workspace package in another +% workspace package's `dependencies` or `devDependencies` must match the current +% version of that package. (We handle `peerDependencies` in another rule.) +gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectDependencyRange, DependencyType) :- + DependencyType \= 'peerDependencies', workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - DependencyType == 'devDependencies'. + workspace_ident(OtherWorkspaceCwd, DependencyIdent), + workspace_version(OtherWorkspaceCwd, OtherWorkspaceVersion), + atomic_list_concat(['^', OtherWorkspaceVersion], CorrectDependencyRange). -% If a controller dependency (other than `base-controller`, `eth-keyring-controller` and -% `polling-controller`) is listed under "dependencies", it should also be -% listed under "peerDependencies". Each controller is a singleton, so we need -% to ensure the versions used match expectations. -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'peerDependencies') :- +% If a workspace package is listed under another workspace package's +% `dependencies`, it should not also be listed under its `devDependencies`. +gen_enforced_dependency(WorkspaceCwd, DependencyIdent, null, 'devDependencies') :- + workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'). + +% Each controller is a singleton, so we need to ensure the versions +% used match expectations. To accomplish this, if a controller (other than +% `base-controller`, `eth-keyring-controller` and `polling-controller`) is +% listed under a workspace package's `dependencies`, it should also be listed +% under its `peerDependencies`, and the major version of the peer dependency +% should match the major part of the current version dependency, with the minor +% and patch parts set to 0. If it is already listed there, then the major +% version should match the current version of the package and the minor and +% patch parts should be <= the corresponding parts. +gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectPeerDependencyRange, 'peerDependencies') :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'), + \+ workspace_has_dependency(WorkspaceCwd, DependencyIdent, _, 'peerDependencies'), + is_controller(DependencyIdent), DependencyIdent \= '@metamask/base-controller', DependencyIdent \= '@metamask/eth-keyring-controller', DependencyIdent \= '@metamask/polling-controller', - is_controller(DependencyIdent). + workspace_ident(DependencyWorkspaceCwd, DependencyIdent), + workspace_version(DependencyWorkspaceCwd, CurrentDependencyWorkspaceVersion), + parse_version_range(CurrentDependencyWorkspaceVersion, _, CurrentDependencyVersionMajor, _, _), + atomic_list_concat([CurrentDependencyVersionMajor, 0, 0], '.', CorrectPeerDependencyVersion), + atom_concat('^', CorrectPeerDependencyVersion, CorrectPeerDependencyRange). +gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectPeerDependencyRange, 'peerDependencies') :- + workspace_has_dependency(WorkspaceCwd, DependencyIdent, SpecifiedPeerDependencyRange, 'peerDependencies'), + is_controller(DependencyIdent), + DependencyIdent \= '@metamask/base-controller', + DependencyIdent \= '@metamask/eth-keyring-controller', + DependencyIdent \= '@metamask/polling-controller', + workspace_ident(DependencyWorkspaceCwd, DependencyIdent), + workspace_version(DependencyWorkspaceCwd, CurrentDependencyVersion), + parse_version_range(CurrentDependencyVersion, _, CurrentDependencyVersionMajor, CurrentDependencyVersionMinor, CurrentDependencyVersionPatch), + parse_version_range(SpecifiedPeerDependencyRange, _, SpecifiedPeerDependencyVersionMajor, SpecifiedPeerDependencyVersionMinor, SpecifiedPeerDependencyVersionPatch), + ( + ( + SpecifiedPeerDependencyVersionMajor == CurrentDependencyVersionMajor, + ( + SpecifiedPeerDependencyVersionMinor @< CurrentDependencyVersionMinor ; + ( + SpecifiedPeerDependencyVersionMinor == CurrentDependencyVersionMinor, + SpecifiedPeerDependencyVersionPatch @=< CurrentDependencyVersionPatch + ) + ) + ) -> + CorrectPeerDependencyRange = SpecifiedPeerDependencyRange ; + atom_concat('^', CurrentDependencyVersion, CorrectPeerDependencyRange) + ). % All packages must specify a minimum Node version of 16. gen_enforced_field(WorkspaceCwd, 'engines.node', '>=16.0.0'). diff --git a/package.json b/package.json index 54b911ba372..53882704999 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "113.0.0", + "version": "115.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index dd71903ea77..e570cce454f 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -249,6 +249,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< this.messagingSystem.subscribe('KeyringController:lock', () => { this.#isUnlocked = false; + this.#stopPolling(); }); this.messagingSystem.subscribe( @@ -313,7 +314,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< if (isNetworkClientIdChanged && this.#isDetectionEnabledForNetwork) { this.#networkClientId = selectedNetworkClientId; - await this.detectTokens({ + await this.#restartTokenDetection({ networkClientId: this.#networkClientId, }); } diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index 6bed303a762..4e5d944fa1f 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -120,21 +120,6 @@ describe('ComposableController', () => { }); }); - it('should compose flat controller state', () => { - const composableMessenger = new ControllerMessenger().getRestricted({ - name: 'ComposableController', - }); - const controller = new ComposableController({ - controllers: [new BarController(), new BazController()], - messenger: composableMessenger, - }); - - expect(controller.flatState).toStrictEqual({ - bar: 'bar', - baz: 'baz', - }); - }); - it('should notify listeners of nested state change', () => { const controllerMessenger = new ControllerMessenger< never, @@ -188,28 +173,6 @@ describe('ComposableController', () => { }); }); - it('should compose flat controller state', () => { - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ - name: 'FooController', - }); - const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], - }); - const composableController = new ComposableController({ - controllers: [fooController], - messenger: composableControllerMessenger, - }); - expect(composableController.flatState).toStrictEqual({ - foo: 'foo', - }); - }); - it('should notify listeners of nested state change', () => { const controllerMessenger = new ControllerMessenger< never, @@ -273,30 +236,6 @@ describe('ComposableController', () => { }); }); - it('should compose flat controller state', () => { - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted({ - name: 'FooController', - }); - const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], - }); - const composableController = new ComposableController({ - controllers: [barController, fooController], - messenger: composableControllerMessenger, - }); - expect(composableController.flatState).toStrictEqual({ - bar: 'bar', - foo: 'foo', - }); - }); - it('should notify listeners of BaseControllerV1 state change', () => { const barController = new BarController(); const controllerMessenger = new ControllerMessenger< diff --git a/packages/composable-controller/src/ComposableController.ts b/packages/composable-controller/src/ComposableController.ts index b1c7a08d692..6d4f6eb7d83 100644 --- a/packages/composable-controller/src/ComposableController.ts +++ b/packages/composable-controller/src/ComposableController.ts @@ -99,21 +99,6 @@ export class ComposableController extends BaseController< ); } - /** - * Flat state representation, one that isn't keyed - * of controller name. Instead, all child controller state is merged - * together into a single, flat object. - * - * @returns Merged state representation of all child controllers. - */ - get flatState() { - let flatState = {}; - for (const controller of this.#controllers) { - flatState = { ...flatState, ...controller.state }; - } - return flatState; - } - /** * Adds a child controller instance to composable controller state, * or updates the state of a child controller. diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index 0e525e1f766..14946730e8b 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, + branches: 95.71, functions: 100, - lines: 100, - statements: 100, + lines: 99.21, + statements: 99.22, }, }, diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index b2d0e4f1242..eb1315ceb29 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -33,7 +33,10 @@ "dependencies": { "@keystonehq/metamask-airgapped-keyring": "^0.13.1", "@metamask/base-controller": "^4.1.1", - "@metamask/eth-keyring-controller": "^17.0.1", + "@metamask/browser-passworder": "^4.3.0", + "@metamask/eth-hd-keyring": "^7.0.1", + "@metamask/eth-sig-util": "^7.0.1", + "@metamask/eth-simple-keyring": "^6.0.1", "@metamask/keyring-api": "^3.0.0", "@metamask/message-manager": "^7.3.8", "@metamask/utils": "^8.3.0", @@ -48,7 +51,6 @@ "@keystonehq/bc-ur-registry-eth": "^0.9.0", "@lavamoat/allow-scripts": "^2.3.1", "@metamask/auto-changelog": "^3.4.4", - "@metamask/eth-sig-util": "^7.0.1", "@metamask/scure-bip39": "^2.1.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index cacbc57121d..9c5118b37e8 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -3,10 +3,7 @@ import { TransactionFactory } from '@ethereumjs/tx'; import { CryptoHDKey, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as QRKeyring } from '@keystonehq/metamask-airgapped-keyring'; import { ControllerMessenger } from '@metamask/base-controller'; -import { - KeyringControllerError, - keyringBuilderFactory, -} from '@metamask/eth-keyring-controller'; +import HDKeyring from '@metamask/eth-hd-keyring'; import { normalize, recoverPersonalSignature, @@ -34,6 +31,7 @@ import { MockErc4337Keyring } from '../tests/mocks/mockErc4337Keyring'; import { MockKeyring } from '../tests/mocks/mockKeyring'; import MockShallowGetAccountsKeyring from '../tests/mocks/mockShallowGetAccountsKeyring'; import { buildMockTransaction } from '../tests/mocks/mockTransaction'; +import { KeyringControllerError } from './constants'; import type { KeyringControllerEvents, KeyringControllerMessenger, @@ -45,6 +43,7 @@ import { AccountImportStrategy, KeyringController, KeyringTypes, + keyringBuilderFactory, } from './KeyringController'; jest.mock('uuid', () => { @@ -527,6 +526,20 @@ describe('KeyringController', () => { }, ); }); + + it('should throw error if the first account is not found on the keyring', async () => { + jest + .spyOn(HDKeyring.prototype, 'getAccounts') + .mockResolvedValue([]); + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect( + controller.createNewVaultAndKeychain(password), + ).rejects.toThrow(KeyringControllerError.NoFirstAccount); + }, + ); + }); }); describe('when there is an existing vault', () => { @@ -1214,7 +1227,7 @@ describe('KeyringController', () => { data: '', from: initialState.keyrings[0].accounts[0], }), - ).toThrow("Can't sign an empty message"); + ).rejects.toThrow("Can't sign an empty message"); }); }); diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 94cbee33ff0..2ead8329497 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -5,14 +5,10 @@ import type { } from '@keystonehq/metamask-airgapped-keyring'; import type { RestrictedControllerMessenger } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { - KeyringController as EthKeyringController, - KeyringType, -} from '@metamask/eth-keyring-controller'; -import type { - ExportableKeyEncryptor, - GenericEncryptor, -} from '@metamask/eth-keyring-controller/dist/types'; +import * as encryptorUtils from '@metamask/browser-passworder'; +import HDKeyring from '@metamask/eth-hd-keyring'; +import { normalize } from '@metamask/eth-sig-util'; +import SimpleKeyring from '@metamask/eth-simple-keyring'; import type { EthBaseTransaction, EthBaseUserOperation, @@ -24,8 +20,20 @@ import type { PersonalMessageParams, TypedMessageParams, } from '@metamask/message-manager'; -import type { Eip1024EncryptedData, Hex, Json } from '@metamask/utils'; -import { assertIsStrictHexString, hasProperty } from '@metamask/utils'; +import type { + Eip1024EncryptedData, + Hex, + Json, + KeyringClass, +} from '@metamask/utils'; +import { + assertIsStrictHexString, + hasProperty, + isObject, + isValidHexAddress, + isValidJson, + remove0x, +} from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { addHexPrefix, @@ -38,6 +46,8 @@ import { import Wallet, { thirdparty as importers } from 'ethereumjs-wallet'; import type { Patch } from 'immer'; +import { KeyringControllerError } from './constants'; + const name = 'KeyringController'; /** @@ -248,6 +258,131 @@ export enum SignTypedDataVersion { V4 = 'V4', } +/** + * A serialized keyring object. + */ +export type SerializedKeyring = { + type: string; + data: Json; +}; + +/** + * A generic encryptor interface that supports encrypting and decrypting + * serializable data with a password. + */ +export type GenericEncryptor = { + /** + * Encrypts the given object with the given password. + * + * @param password - The password to encrypt with. + * @param object - The object to encrypt. + * @returns The encrypted string. + */ + encrypt: (password: string, object: Json) => Promise; + /** + * Decrypts the given encrypted string with the given password. + * + * @param password - The password to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object. + */ + decrypt: (password: string, encryptedString: string) => Promise; + /** + * Optional vault migration helper. Checks if the provided vault is up to date + * with the desired encryption algorithm. + * + * @param vault - The encrypted string to check. + * @param targetDerivationParams - The desired target derivation params. + * @returns The updated encrypted string. + */ + isVaultUpdated?: ( + vault: string, + targetDerivationParams?: encryptorUtils.KeyDerivationOptions, + ) => boolean; +}; + +/** + * An encryptor interface that supports encrypting and decrypting + * serializable data with a password, and exporting and importing keys. + */ +export type ExportableKeyEncryptor = GenericEncryptor & { + /** + * Encrypts the given object with the given encryption key. + * + * @param key - The encryption key to encrypt with. + * @param object - The object to encrypt. + * @returns The encryption result. + */ + encryptWithKey: ( + key: unknown, + object: Json, + ) => Promise; + /** + * Encrypts the given object with the given password, and returns the + * encryption result and the exported key string. + * + * @param password - The password to encrypt with. + * @param object - The object to encrypt. + * @param salt - The optional salt to use for encryption. + * @returns The encrypted string and the exported key string. + */ + encryptWithDetail: ( + password: string, + object: Json, + salt?: string, + ) => Promise; + /** + * Decrypts the given encrypted string with the given encryption key. + * + * @param key - The encryption key to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object. + */ + decryptWithKey: (key: unknown, encryptedString: string) => Promise; + /** + * Decrypts the given encrypted string with the given password, and returns + * the decrypted object and the salt and exported key string used for + * encryption. + * + * @param password - The password to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object and the salt and exported key string used for + * encryption. + */ + decryptWithDetail: ( + password: string, + encryptedString: string, + ) => Promise; + /** + * Generates an encryption key from exported key string. + * + * @param key - The exported key string. + * @returns The encryption key. + */ + importKey: (key: string) => Promise; +}; + +/** + * Get builder function for `Keyring` + * + * Returns a builder function for `Keyring` with a `type` property. + * + * @param KeyringConstructor - The Keyring class for the builder. + * @returns A builder function for the given Keyring. + */ +export function keyringBuilderFactory(KeyringConstructor: KeyringClass) { + const builder = () => new KeyringConstructor(); + + builder.type = KeyringConstructor.type; + + return builder; +} + +const defaultKeyringBuilders = [ + keyringBuilderFactory(SimpleKeyring), + keyringBuilderFactory(HDKeyring), +]; + export const getDefaultKeyringState = (): KeyringControllerState => { return { isUnlocked: false, @@ -274,6 +409,67 @@ function assertHasUint8ArrayMnemonic( } } +/** + * Assert that the provided encryptor supports + * encryption and encryption key export. + * + * @param encryptor - The encryptor to check. + * @throws If the encryptor does not support key encryption. + */ +function assertIsExportableKeyEncryptor( + encryptor: GenericEncryptor | ExportableKeyEncryptor, +): asserts encryptor is ExportableKeyEncryptor { + if ( + !( + 'importKey' in encryptor && + typeof encryptor.importKey === 'function' && + 'decryptWithKey' in encryptor && + typeof encryptor.decryptWithKey === 'function' && + 'encryptWithKey' in encryptor && + typeof encryptor.encryptWithKey === 'function' + ) + ) { + throw new Error(KeyringControllerError.UnsupportedEncryptionKeyExport); + } +} + +/** + * Checks if the provided value is a serialized keyrings array. + * + * @param array - The value to check. + * @returns True if the value is a serialized keyrings array. + */ +function isSerializedKeyringsArray( + array: unknown, +): array is SerializedKeyring[] { + return ( + typeof array === 'object' && + Array.isArray(array) && + array.every((value) => value.type && isValidJson(value.data)) + ); +} + +/** + * Display For Keyring + * + * Is used for adding the current keyrings to the state object. + * + * @param keyring - The keyring to display. + * @returns A keyring display object, with type and accounts properties. + */ +async function displayForKeyring( + keyring: EthKeyring, +): Promise<{ type: string; accounts: string[] }> { + const accounts = await keyring.getAccounts(); + + return { + type: keyring.type, + // Cast to `Hex[]` here is safe here because `accounts` has no nullish + // values, and `normalize` returns `Hex` unless given a nullish value + accounts: accounts.map(normalize) as Hex[], + }; +} + /** * Controller responsible for establishing and managing user identity. * @@ -298,7 +494,17 @@ export class KeyringController extends BaseController< private readonly setAccountLabel?: (address: string, label: string) => void; - #keyring: EthKeyringController; + #keyringBuilders: { (): EthKeyring; type: string }[]; + + #keyrings: EthKeyring[]; + + #unsupportedKeyrings: SerializedKeyring[]; + + #password?: string; + + #encryptor: GenericEncryptor | ExportableKeyEncryptor; + + #cacheEncryptionKey: boolean; #qrKeyringStateListener?: ( state: ReturnType, @@ -324,6 +530,7 @@ export class KeyringController extends BaseController< updateIdentities, setSelectedAddress, setAccountLabel, + encryptor = encryptorUtils, keyringBuilders, messenger, state, @@ -345,25 +552,20 @@ export class KeyringController extends BaseController< }, }); - if (options.cacheEncryptionKey) { - this.#keyring = new EthKeyringController({ - initState: state, - encryptor: options.encryptor, - keyringBuilders, - cacheEncryptionKey: options.cacheEncryptionKey, - }); - } else { - this.#keyring = new EthKeyringController({ - initState: state, - encryptor: options.encryptor, - keyringBuilders, - cacheEncryptionKey: options.cacheEncryptionKey ?? false, - }); + this.#keyringBuilders = keyringBuilders + ? defaultKeyringBuilders.concat(keyringBuilders) + : defaultKeyringBuilders; + + this.#encryptor = encryptor; + this.#keyrings = []; + this.#unsupportedKeyrings = []; + + // This option allows the controller to cache an exported key + // for use in decrypting and encrypting data without password + this.#cacheEncryptionKey = Boolean(options.cacheEncryptionKey); + if (this.#cacheEncryptionKey) { + assertIsExportableKeyEncryptor(encryptor); } - this.#keyring.memStore.subscribe(this.#fullUpdate.bind(this)); - this.#keyring.store.subscribe(this.#fullUpdate.bind(this)); - this.#keyring.on('lock', this.#handleLock.bind(this)); - this.#keyring.on('unlock', this.#handleUnlock.bind(this)); this.syncIdentities = syncIdentities; this.updateIdentities = updateIdentities; @@ -385,11 +587,13 @@ export class KeyringController extends BaseController< keyringState: KeyringControllerMemState; addedAccountAddress: string; }> { - const primaryKeyring = this.#keyring.getKeyringsByType('HD Key Tree')[0]; + const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as + | EthKeyring + | undefined; if (!primaryKeyring) { throw new Error('No HD keyring found'); } - const oldAccounts = await this.#keyring.getAccounts(); + const oldAccounts = await this.getAccounts(); if (accountCount && oldAccounts.length !== accountCount) { if (accountCount > oldAccounts.length) { @@ -403,17 +607,13 @@ export class KeyringController extends BaseController< }; } - await this.#keyring.addNewAccount(primaryKeyring); - const newAccounts = await this.#keyring.getAccounts(); - + const addedAccountAddress = await this.addNewAccountForKeyring( + primaryKeyring, + ); await this.verifySeedPhrase(); - this.updateIdentities(newAccounts); - const addedAccountAddress = newAccounts.find( - (selectedAddress: string) => !oldAccounts.includes(selectedAddress), - ); + this.updateIdentities(await this.getAccounts()); - assertIsStrictHexString(addedAccountAddress); return { keyringState: this.#getMemState(), addedAccountAddress, @@ -444,13 +644,15 @@ export class KeyringController extends BaseController< return existingAccount; } - await this.#keyring.addNewAccount(keyring); + await keyring.addAccounts(1); + await this.persistAllKeyrings(); + const addedAccountAddress = (await this.getAccounts()).find( (selectedAddress) => !oldAccounts.includes(selectedAddress), ); assertIsStrictHexString(addedAccountAddress); - this.updateIdentities(await this.#keyring.getAccounts()); + this.updateIdentities(await this.getAccounts()); return addedAccountAddress; } @@ -461,11 +663,14 @@ export class KeyringController extends BaseController< * @returns Promise resolving to current state when the account is added. */ async addNewAccountWithoutUpdate(): Promise { - const primaryKeyring = this.#keyring.getKeyringsByType('HD Key Tree')[0]; + const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as + | EthKeyring + | undefined; if (!primaryKeyring) { throw new Error('No HD keyring found'); } - await this.#keyring.addNewAccount(primaryKeyring); + await primaryKeyring.addAccounts(1); + await this.persistAllKeyrings(); await this.verifySeedPhrase(); return this.#getMemState(); } @@ -490,14 +695,14 @@ export class KeyringController extends BaseController< try { this.updateIdentities([]); - await this.#keyring.createNewVaultWithKeyring(password, { - type: KeyringType.HD, + await this.#createNewVaultWithKeyring(password, { + type: KeyringTypes.hd, opts: { mnemonic: seed, numberOfAccounts: 1, }, }); - this.updateIdentities(await this.#keyring.getAccounts()); + this.updateIdentities(await this.getAccounts()); return this.#getMemState(); } finally { releaseLock(); @@ -515,8 +720,8 @@ export class KeyringController extends BaseController< try { const accounts = await this.getAccounts(); if (!accounts.length) { - await this.#keyring.createNewVaultWithKeyring(password, { - type: KeyringType.HD, + await this.#createNewVaultWithKeyring(password, { + type: KeyringTypes.hd, }); this.updateIdentities(await this.getAccounts()); } @@ -542,7 +747,26 @@ export class KeyringController extends BaseController< return this.getOrAddQRKeyring(); } - return this.#keyring.addNewKeyring(type, opts); + const keyring = await this.#newKeyring(type, opts); + + if (type === KeyringTypes.hd && (!isObject(opts) || !opts.mnemonic)) { + if (!keyring.generateRandomMnemonic) { + throw new Error( + KeyringControllerError.UnsupportedGenerateRandomMnemonic, + ); + } + + keyring.generateRandomMnemonic(); + await keyring.addAccounts(1); + } + + const accounts = await keyring.getAccounts(); + await this.#checkForDuplicate(type, accounts); + + this.#keyrings.push(keyring); + await this.persistAllKeyrings(); + + return keyring; } /** @@ -552,7 +776,10 @@ export class KeyringController extends BaseController< * @param password - Password of the keyring. */ async verifyPassword(password: string) { - await this.#keyring.verifyPassword(password); + if (!this.state.vault) { + throw new Error(KeyringControllerError.VaultError); + } + await this.#encryptor.decrypt(password, this.state.vault); } /** @@ -572,8 +799,8 @@ export class KeyringController extends BaseController< */ async exportSeedPhrase(password: string): Promise { await this.verifyPassword(password); - assertHasUint8ArrayMnemonic(this.#keyring.keyrings[0]); - return this.#keyring.keyrings[0].mnemonic; + assertHasUint8ArrayMnemonic(this.#keyrings[0]); + return this.#keyrings[0].mnemonic; } /** @@ -585,7 +812,15 @@ export class KeyringController extends BaseController< */ async exportAccount(password: string, address: string): Promise { await this.verifyPassword(password); - return this.#keyring.exportAccount(address); + + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.exportAccount) { + throw new Error(KeyringControllerError.UnsupportedExportAccount); + } + + return await keyring.exportAccount(normalize(address) as Hex); } /** @@ -593,8 +828,19 @@ export class KeyringController extends BaseController< * * @returns A promise resolving to an array of addresses. */ - getAccounts(): Promise { - return this.#keyring.getAccounts(); + async getAccounts(): Promise { + const keyrings = this.#keyrings; + + const keyringArrays = await Promise.all( + keyrings.map(async (keyring) => keyring.getAccounts()), + ); + const addresses = keyringArrays.reduce((res, arr) => { + return res.concat(arr); + }, []); + + // Cast to `Hex[]` here is safe here because `addresses` has no nullish + // values, and `normalize` returns `Hex` unless given a nullish value + return addresses.map(normalize) as Hex[]; } /** @@ -609,7 +855,15 @@ export class KeyringController extends BaseController< account: string, opts?: Record, ): Promise { - return this.#keyring.getEncryptionPublicKey(account, opts); + const normalizedAddress = normalize(account) as Hex; + const keyring = (await this.getKeyringForAccount( + account, + )) as EthKeyring; + if (!keyring.getEncryptionPublicKey) { + throw new Error(KeyringControllerError.UnsupportedGetEncryptionPublicKey); + } + + return await keyring.getEncryptionPublicKey(normalizedAddress, opts); } /** @@ -624,7 +878,15 @@ export class KeyringController extends BaseController< from: string; data: Eip1024EncryptedData; }): Promise { - return this.#keyring.decryptMessage(messageParams); + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.decryptMessage) { + throw new Error(KeyringControllerError.UnsupportedDecryptMessage); + } + + return keyring.decryptMessage(address, messageParams.data); } /** @@ -638,7 +900,37 @@ export class KeyringController extends BaseController< * @returns Promise resolving to keyring of the `account` if one exists. */ async getKeyringForAccount(account: string): Promise { - return this.#keyring.getKeyringForAccount(account); + // Cast to `Hex` here is safe here because `address` is not nullish. + // `normalizeToHex` returns `Hex` unless given a nullish value. + const hexed = normalize(account) as Hex; + + const candidates = await Promise.all( + this.#keyrings.map(async (keyring) => { + return Promise.all([keyring, keyring.getAccounts()]); + }), + ); + + const winners = candidates.filter((candidate) => { + const accounts = candidate[1].map(normalize); + return accounts.includes(hexed); + }); + + if (winners.length && winners[0]?.length) { + return winners[0][0]; + } + + // Adding more info to the error + let errorInfo = ''; + if (!isValidHexAddress(hexed)) { + errorInfo = 'The address passed in is invalid/empty'; + } else if (!candidates.length) { + errorInfo = 'There are no keyrings'; + } else if (!winners.length) { + errorInfo = 'There are keyrings, but none match the address'; + } + throw new Error( + `${KeyringControllerError.NoKeyring}. Error info: ${errorInfo}`, + ); } /** @@ -651,7 +943,7 @@ export class KeyringController extends BaseController< * @returns An array of keyrings of the given type. */ getKeyringsByType(type: KeyringTypes | string): unknown[] { - return this.#keyring.getKeyringsByType(type); + return this.#keyrings.filter((keyring) => keyring.type === type); } /** @@ -661,7 +953,76 @@ export class KeyringController extends BaseController< * operation completes. */ async persistAllKeyrings(): Promise { - return this.#keyring.persistAllKeyrings(); + const { encryptionKey, encryptionSalt } = this.state; + + if (!this.#password && !encryptionKey) { + throw new Error(KeyringControllerError.MissingCredentials); + } + + const serializedKeyrings = await Promise.all( + this.#keyrings.map(async (keyring) => { + const [type, data] = await Promise.all([ + keyring.type, + keyring.serialize(), + ]); + return { type, data }; + }), + ); + + serializedKeyrings.push(...this.#unsupportedKeyrings); + + let vault: string | undefined; + let newEncryptionKey: string | undefined; + + if (this.#cacheEncryptionKey) { + assertIsExportableKeyEncryptor(this.#encryptor); + + if (encryptionKey) { + const key = await this.#encryptor.importKey(encryptionKey); + const vaultJSON = await this.#encryptor.encryptWithKey( + key, + serializedKeyrings, + ); + vaultJSON.salt = encryptionSalt; + vault = JSON.stringify(vaultJSON); + } else if (this.#password) { + const { vault: newVault, exportedKeyString } = + await this.#encryptor.encryptWithDetail( + this.#password, + serializedKeyrings, + ); + + vault = newVault; + newEncryptionKey = exportedKeyString; + } + } else { + if (typeof this.#password !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + vault = await this.#encryptor.encrypt(this.#password, serializedKeyrings); + } + + if (!vault) { + throw new Error(KeyringControllerError.MissingVaultData); + } + + this.update((state) => { + state.vault = vault; + }); + + // The keyring updates need to be announced before updating the encryptionKey + // so that the updated keyring gets propagated to the extension first. + // Not calling {@link updateKeyringsInState} results in the wrong account being selected + // in the extension. + await this.#updateKeyringsInState(); + if (newEncryptionKey) { + this.update((state) => { + state.encryptionKey = newEncryptionKey; + state.encryptionSalt = JSON.parse(vault as string).salt; + }); + } + + return true; } /** @@ -721,11 +1082,11 @@ export class KeyringController extends BaseController< default: throw new Error(`Unexpected import strategy: '${strategy}'`); } - const newKeyring = await this.#keyring.addNewKeyring(KeyringTypes.simple, [ + const newKeyring = (await this.addNewKeyring(KeyringTypes.simple, [ privateKey, - ]); + ])) as EthKeyring; const accounts = await newKeyring.getAccounts(); - const allAccounts = await this.#keyring.getAccounts(); + const allAccounts = await this.getAccounts(); this.updateIdentities(allAccounts); return { keyringState: this.#getMemState(), @@ -741,7 +1102,28 @@ export class KeyringController extends BaseController< * @returns Promise resolving current state when this account removal completes. */ async removeAccount(address: Hex): Promise { - await this.#keyring.removeAccount(address); + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + // Not all the keyrings support this, so we have to check + if (!keyring.removeAccount) { + throw new Error(KeyringControllerError.UnsupportedRemoveAccount); + } + + // The `removeAccount` method of snaps keyring is async. We have to update + // the interface of the other keyrings to be async as well. + // eslint-disable-next-line @typescript-eslint/await-thenable + await keyring.removeAccount(address); + + const accounts = await keyring.getAccounts(); + // Check if this was the last/only account + if (accounts.length === 0) { + await this.#removeEmptyKeyrings(); + } + + await this.persistAllKeyrings(); + this.messagingSystem.publish(`${name}:accountRemoved`, address); return this.#getMemState(); } @@ -753,7 +1135,16 @@ export class KeyringController extends BaseController< */ async setLocked(): Promise { this.#unsubscribeFromQRKeyringsEvents(); - await this.#keyring.setLocked(); + + this.#password = undefined; + this.update((state) => { + state.isUnlocked = false; + state.keyrings = []; + }); + await this.#clearKeyrings(); + + this.messagingSystem.publish(`${name}:lock`); + return this.#getMemState(); } @@ -763,11 +1154,20 @@ export class KeyringController extends BaseController< * @param messageParams - PersonalMessageParams object to sign. * @returns Promise resolving to a signed message string. */ - signMessage(messageParams: PersonalMessageParams) { + async signMessage(messageParams: PersonalMessageParams): Promise { if (!messageParams.data) { throw new Error("Can't sign an empty message"); } - return this.#keyring.signMessage(messageParams); + + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signMessage) { + throw new Error(KeyringControllerError.UnsupportedSignMessage); + } + + return await keyring.signMessage(address, messageParams.data); } /** @@ -776,8 +1176,18 @@ export class KeyringController extends BaseController< * @param messageParams - PersonalMessageParams object to sign. * @returns Promise resolving to a signed message string. */ - signPersonalMessage(messageParams: PersonalMessageParams) { - return this.#keyring.signPersonalMessage(messageParams); + async signPersonalMessage(messageParams: PersonalMessageParams) { + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signPersonalMessage) { + throw new Error(KeyringControllerError.UnsupportedSignPersonalMessage); + } + + const normalizedData = normalize(messageParams.data) as Hex; + + return await keyring.signPersonalMessage(address, normalizedData); } /** @@ -803,15 +1213,22 @@ export class KeyringController extends BaseController< throw new Error(`Unexpected signTypedMessage version: '${version}'`); } - return await this.#keyring.signTypedMessage( - { - from: messageParams.from, - data: - version !== SignTypedDataVersion.V1 && - typeof messageParams.data === 'string' - ? JSON.parse(messageParams.data) - : messageParams.data, - }, + // Cast to `Hex` here is safe here because `messageParams.from` is not nullish. + // `normalize` returns `Hex` unless given a nullish value. + const address = normalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signTypedData) { + throw new Error(KeyringControllerError.UnsupportedSignTypedMessage); + } + + return await keyring.signTypedData( + address, + version !== SignTypedDataVersion.V1 && + typeof messageParams.data === 'string' + ? JSON.parse(messageParams.data) + : messageParams.data, { version }, ); } catch (error) { @@ -827,12 +1244,20 @@ export class KeyringController extends BaseController< * @param opts - An optional options object. * @returns Promise resolving to a signed transaction string. */ - signTransaction( + async signTransaction( transaction: TypedTransaction, from: string, opts?: Record, ): Promise { - return this.#keyring.signTransaction(transaction, from, opts); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + if (!keyring.signTransaction) { + throw new Error(KeyringControllerError.UnsupportedSignTransaction); + } + + return await keyring.signTransaction(address, transaction, opts); } /** @@ -846,7 +1271,16 @@ export class KeyringController extends BaseController< from: string, transactions: EthBaseTransaction[], ): Promise { - return await this.#keyring.prepareUserOperation(from, transactions); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + if (!keyring.prepareUserOperation) { + throw new Error(KeyringControllerError.UnsupportedPrepareUserOperation); + } + + return await keyring.prepareUserOperation(address, transactions); } /** @@ -861,7 +1295,16 @@ export class KeyringController extends BaseController< from: string, userOp: EthUserOperation, ): Promise { - return await this.#keyring.patchUserOperation(from, userOp); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + if (!keyring.patchUserOperation) { + throw new Error(KeyringControllerError.UnsupportedPatchUserOperation); + } + + return await keyring.patchUserOperation(address, userOp); } /** @@ -875,7 +1318,16 @@ export class KeyringController extends BaseController< from: string, userOp: EthUserOperation, ): Promise { - return await this.#keyring.signUserOperation(from, userOp); + const address = normalize(from) as Hex; + const keyring = (await this.getKeyringForAccount( + address, + )) as EthKeyring; + + if (!keyring.signUserOperation) { + throw new Error(KeyringControllerError.UnsupportedSignUserOperation); + } + + return await keyring.signUserOperation(address, userOp); } /** @@ -890,7 +1342,12 @@ export class KeyringController extends BaseController< encryptionKey: string, encryptionSalt: string, ): Promise { - await this.#keyring.submitEncryptionKey(encryptionKey, encryptionSalt); + this.#keyrings = await this.#unlockKeyrings( + undefined, + encryptionKey, + encryptionSalt, + ); + this.#setUnlocked(); const qrKeyring = this.getQRKeyring(); if (qrKeyring) { @@ -910,8 +1367,10 @@ export class KeyringController extends BaseController< * @returns Promise resolving to the current state. */ async submitPassword(password: string): Promise { - await this.#keyring.submitPassword(password); - const accounts = await this.#keyring.getAccounts(); + this.#keyrings = await this.#unlockKeyrings(password); + this.#setUnlocked(); + + const accounts = await this.getAccounts(); const qrKeyring = this.getQRKeyring(); if (qrKeyring) { @@ -930,7 +1389,9 @@ export class KeyringController extends BaseController< * @returns Promise resolving to the seed phrase as Uint8Array. */ async verifySeedPhrase(): Promise { - const primaryKeyring = this.#keyring.getKeyringsByType(KeyringTypes.hd)[0]; + const primaryKeyring = this.getKeyringsByType(KeyringTypes.hd)[0] as + | EthKeyring + | undefined; if (!primaryKeyring) { throw new Error('No HD keyring found.'); } @@ -946,9 +1407,7 @@ export class KeyringController extends BaseController< // The HD Keyring Builder is a default keyring builder // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const hdKeyringBuilder = this.#keyring.getKeyringBuilderForType( - KeyringTypes.hd, - )!; + const hdKeyringBuilder = this.#getKeyringBuilderForType(KeyringTypes.hd)!; const hdKeyring = hdKeyringBuilder(); // @ts-expect-error @metamask/eth-hd-keyring correctly handles @@ -982,9 +1441,7 @@ export class KeyringController extends BaseController< */ getQRKeyring(): QRKeyring | undefined { // QRKeyring is not yet compatible with Keyring type from @metamask/utils - return this.#keyring.getKeyringsByType( - KeyringTypes.qr, - )[0] as unknown as QRKeyring; + return this.getKeyringsByType(KeyringTypes.qr)[0] as unknown as QRKeyring; } /** @@ -1000,8 +1457,8 @@ export class KeyringController extends BaseController< // eslint-disable-next-line @typescript-eslint/no-explicit-any async restoreQRKeyring(serialized: any): Promise { (await this.getOrAddQRKeyring()).deserialize(serialized); - await this.#keyring.persistAllKeyrings(); - this.updateIdentities(await this.#keyring.getAccounts()); + await this.persistAllKeyrings(); + this.updateIdentities(await this.getAccounts()); } async resetQRKeyringState(): Promise { @@ -1074,13 +1531,13 @@ export class KeyringController extends BaseController< const keyring = await this.getOrAddQRKeyring(); keyring.setAccountToUnlock(index); - const oldAccounts = await this.#keyring.getAccounts(); + const oldAccounts = await this.getAccounts(); // QRKeyring is not yet compatible with Keyring from // @metamask/utils, but we can use the `addNewAccount` method // as it internally calls `addAccounts` from on the keyring instance, // which is supported by QRKeyring API. - await this.#keyring.addNewAccount(keyring as unknown as EthKeyring); - const newAccounts = await this.#keyring.getAccounts(); + await this.addNewAccountForKeyring(keyring as unknown as EthKeyring); + const newAccounts = await this.getAccounts(); this.updateIdentities(newAccounts); newAccounts.forEach((address: string) => { if (!oldAccounts.includes(address)) { @@ -1090,11 +1547,14 @@ export class KeyringController extends BaseController< this.setSelectedAddress(address); } }); - await this.#keyring.persistAllKeyrings(); + await this.persistAllKeyrings(); } async getAccountKeyringType(account: string): Promise { - return (await this.#keyring.getKeyringForAccount(account)).type; + const keyring = (await this.getKeyringForAccount( + account, + )) as EthKeyring; + return keyring.type; } async forgetQRDevice(): Promise<{ @@ -1102,14 +1562,14 @@ export class KeyringController extends BaseController< remainingAccounts: string[]; }> { const keyring = await this.getOrAddQRKeyring(); - const allAccounts = (await this.#keyring.getAccounts()) as string[]; + const allAccounts = (await this.getAccounts()) as string[]; keyring.forgetDevice(); - const remainingAccounts = (await this.#keyring.getAccounts()) as string[]; + const remainingAccounts = (await this.getAccounts()) as string[]; const removedAccounts = allAccounts.filter( (address: string) => !remainingAccounts.includes(address), ); this.updateIdentities(remainingAccounts); - await this.#keyring.persistAllKeyrings(); + await this.persistAllKeyrings(); return { removedAccounts, remainingAccounts }; } @@ -1179,6 +1639,20 @@ export class KeyringController extends BaseController< ); } + /** + * Get the keyring builder for the given `type`. + * + * @param type - The type of keyring to get the builder for. + * @returns The keyring builder, or undefined if none exists. + */ + #getKeyringBuilderForType( + type: string, + ): { (): EthKeyring; type: string } | undefined { + return this.#keyringBuilders.find( + (keyringBuilder) => keyringBuilder.type === type, + ); + } + /** * Add qr hardware keyring. * @@ -1188,9 +1662,15 @@ export class KeyringController extends BaseController< */ async #addQRKeyring(): Promise { // QRKeyring is not yet compatible with Keyring type from @metamask/utils - const qrKeyring = (await this.#keyring.addNewKeyring( - KeyringTypes.qr, - )) as unknown as QRKeyring; + const qrKeyring = (await this.#newKeyring(KeyringTypes.qr, { + accounts: [], + })) as unknown as QRKeyring; + + const accounts = await qrKeyring.getAccounts(); + await this.#checkForDuplicate(KeyringTypes.qr, accounts); + + this.#keyrings.push(qrKeyring as unknown as EthKeyring); + await this.persistAllKeyrings(); this.#subscribeToQRKeyringEvents(qrKeyring); @@ -1212,7 +1692,7 @@ export class KeyringController extends BaseController< } #unsubscribeFromQRKeyringsEvents() { - const qrKeyrings = this.#keyring.getKeyringsByType( + const qrKeyrings = this.getKeyringsByType( KeyringTypes.qr, ) as unknown as QRKeyring[]; @@ -1224,40 +1704,312 @@ export class KeyringController extends BaseController< } /** - * Sync controller state with current keyring store - * and memStore states. + * Create new vault with an initial keyring + * + * Destroys any old encrypted storage, + * creates a new encrypted store with the given password, + * creates a new wallet with 1 account. * - * @fires KeyringController:stateChange + * @fires KeyringController:unlock + * @param password - The password to encrypt the vault with. + * @param keyring - A object containing the params to instantiate a new keyring. + * @param keyring.type - The keyring type. + * @param keyring.opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves to the state. */ - #fullUpdate() { - const { vault } = this.#keyring.store.getState(); - const { keyrings, isUnlocked, encryptionKey, encryptionSalt } = - this.#keyring.memStore.getState(); - - this.update(() => ({ - vault, - keyrings, - isUnlocked, - encryptionKey, - encryptionSalt, - })); + async #createNewVaultWithKeyring( + password: string, + keyring: { + type: string; + opts?: unknown; + }, + ): Promise { + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + this.#password = password; + + await this.#clearKeyrings(); + await this.#createKeyringWithFirstAccount(keyring.type, keyring.opts); + this.#setUnlocked(); + return this.#getMemState(); } /** - * Handle keyring lock event. + * Update the controller state with its current keyrings. + */ + async #updateKeyringsInState(): Promise { + const keyrings = await Promise.all(this.#keyrings.map(displayForKeyring)); + this.update((state) => { + state.keyrings = keyrings; + }); + } + + /** + * Unlock Keyrings, decrypting the vault and deserializing all + * keyrings contained in it, using a password or an encryption key with salt. * - * @fires KeyringController:lock + * @param password - The keyring controller password. + * @param encryptionKey - An exported key string to unlock keyrings with. + * @param encryptionSalt - The salt used to encrypt the vault. + * @returns A promise resolving to the deserialized keyrings array. */ - #handleLock() { - this.messagingSystem.publish(`${name}:lock`); + async #unlockKeyrings( + password: string | undefined, + encryptionKey?: string, + encryptionSalt?: string, + ): Promise[]> { + const encryptedVault = this.state.vault; + if (!encryptedVault) { + throw new Error(KeyringControllerError.VaultError); + } + + await this.#clearKeyrings(); + + let vault; + + if (this.#cacheEncryptionKey) { + assertIsExportableKeyEncryptor(this.#encryptor); + + if (password) { + const result = await this.#encryptor.decryptWithDetail( + password, + encryptedVault, + ); + vault = result.vault; + this.#password = password; + + this.update((state) => { + state.encryptionKey = result.exportedKeyString; + state.encryptionSalt = result.salt; + }); + } else { + const parsedEncryptedVault = JSON.parse(encryptedVault); + + if (encryptionSalt !== parsedEncryptedVault.salt) { + throw new Error(KeyringControllerError.ExpiredCredentials); + } + + if (typeof encryptionKey !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + + const key = await this.#encryptor.importKey(encryptionKey); + vault = await this.#encryptor.decryptWithKey(key, parsedEncryptedVault); + + // This call is required on the first call because encryptionKey + // is not yet inside the memStore + this.update((state) => { + state.encryptionKey = encryptionKey; + // we can safely assume that encryptionSalt is defined here + // because we compare it with the salt from the vault + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state.encryptionSalt = encryptionSalt!; + }); + } + } else { + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerError.WrongPasswordType); + } + + vault = await this.#encryptor.decrypt(password, encryptedVault); + this.#password = password; + } + + if (!isSerializedKeyringsArray(vault)) { + throw new Error(KeyringControllerError.VaultDataError); + } + + await Promise.all(vault.map(this.#restoreKeyring.bind(this))); + await this.#updateKeyringsInState(); + + if ( + this.#password && + (!this.#cacheEncryptionKey || !encryptionKey) && + this.#encryptor.isVaultUpdated && + !this.#encryptor.isVaultUpdated(encryptedVault) + ) { + // Re-encrypt the vault with safer method if one is available + await this.persistAllKeyrings(); + } + + return this.#keyrings; + } + + /** + * Create a new keyring, ensuring that the first account is + * also created. + * + * @param type - Keyring type to instantiate. + * @param opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves if the operation is successful. + */ + async #createKeyringWithFirstAccount(type: string, opts?: unknown) { + const keyring = (await this.addNewKeyring(type, opts)) as EthKeyring; + + const [firstAccount] = await keyring.getAccounts(); + if (!firstAccount) { + throw new Error(KeyringControllerError.NoFirstAccount); + } } /** - * Handle keyring unlock event. + * Instantiate, initialize and return a new keyring of the given `type`, + * using the given `opts`. The keyring is built using the keyring builder + * registered for the given `type`. + * + * @param type - The type of keyring to add. + * @param data - The data to restore a previously serialized keyring. + * @returns The new keyring. + */ + async #newKeyring(type: string, data: unknown): Promise> { + const keyringBuilder = this.#getKeyringBuilderForType(type); + + if (!keyringBuilder) { + throw new Error( + `${KeyringControllerError.NoKeyringBuilder}. Keyring type: ${type}`, + ); + } + + const keyring = keyringBuilder(); + + // @ts-expect-error Enforce data type after updating clients + await keyring.deserialize(data); + + if (keyring.init) { + await keyring.init(); + } + + return keyring; + } + + /** + * Remove all managed keyrings, destroying all their + * instances in memory. + */ + async #clearKeyrings() { + for (const keyring of this.#keyrings) { + await this.#destroyKeyring(keyring); + } + this.#keyrings = []; + this.update((state) => { + state.keyrings = []; + }); + } + + /** + * Restore a Keyring from a provided serialized payload. + * On success, returns the resulting keyring instance. + * + * @param serialized - The serialized keyring. + * @returns The deserialized keyring or undefined if the keyring type is unsupported. + */ + async #restoreKeyring( + serialized: SerializedKeyring, + ): Promise | undefined> { + try { + const { type, data } = serialized; + const keyring = await this.#newKeyring(type, data); + + // getAccounts also validates the accounts for some keyrings + await keyring.getAccounts(); + this.#keyrings.push(keyring); + + return keyring; + } catch (_) { + this.#unsupportedKeyrings.push(serialized); + return undefined; + } + } + + /** + * Destroy Keyring + * + * Some keyrings support a method called `destroy`, that destroys the + * keyring along with removing all its event listeners and, in some cases, + * clears the keyring bridge iframe from the DOM. + * + * @param keyring - The keyring to destroy. + */ + async #destroyKeyring(keyring: EthKeyring) { + await keyring.destroy?.(); + } + + /** + * Remove empty keyrings. + * + * Loops through the keyrings and removes the ones with empty accounts + * (usually after removing the last / only account) from a keyring. + */ + async #removeEmptyKeyrings(): Promise { + const validKeyrings: EthKeyring[] = []; + + // Since getAccounts returns a Promise + // We need to wait to hear back form each keyring + // in order to decide which ones are now valid (accounts.length > 0) + + await Promise.all( + this.#keyrings.map(async (keyring: EthKeyring) => { + const accounts = await keyring.getAccounts(); + if (accounts.length > 0) { + validKeyrings.push(keyring); + } else { + await this.#destroyKeyring(keyring); + } + }), + ); + this.#keyrings = validKeyrings; + } + + /** + * Checks for duplicate keypairs, using the the first account in the given + * array. Rejects if a duplicate is found. + * + * Only supports 'Simple Key Pair'. + * + * @param type - The key pair type to check for. + * @param newAccountArray - Array of new accounts. + * @returns The account, if no duplicate is found. + */ + async #checkForDuplicate( + type: string, + newAccountArray: string[], + ): Promise { + const accounts = await this.getAccounts(); + + switch (type) { + case KeyringTypes.simple: { + const isIncluded = Boolean( + accounts.find( + (key) => + newAccountArray[0] && + (key === newAccountArray[0] || + key === remove0x(newAccountArray[0])), + ), + ); + + if (isIncluded) { + throw new Error(KeyringControllerError.DuplicatedAccount); + } + return newAccountArray; + } + + default: { + return newAccountArray; + } + } + } + + /** + * Set the `isUnlocked` to true and notify listeners + * through the messenger. * * @fires KeyringController:unlock */ - #handleUnlock() { + #setUnlocked(): void { + this.update((state) => { + state.isUnlocked = true; + }); this.messagingSystem.publish(`${name}:unlock`); } diff --git a/packages/keyring-controller/src/constants.ts b/packages/keyring-controller/src/constants.ts new file mode 100644 index 00000000000..f9f06dd0f92 --- /dev/null +++ b/packages/keyring-controller/src/constants.ts @@ -0,0 +1,29 @@ +export enum KeyringControllerError { + NoKeyring = 'KeyringController - No keyring found', + WrongPasswordType = 'KeyringController - Password must be of type string.', + NoFirstAccount = 'KeyringController - First Account not found.', + DuplicatedAccount = 'KeyringController - The account you are trying to import is a duplicate', + VaultError = 'KeyringController - Cannot unlock without a previous vault.', + VaultDataError = 'KeyringController - The decrypted vault has an unexpected shape.', + UnsupportedEncryptionKeyExport = 'KeyringController - The encryptor does not support encryption key export.', + UnsupportedGenerateRandomMnemonic = 'KeyringController - The current keyring does not support the method generateRandomMnemonic.', + UnsupportedExportAccount = '`KeyringController - The keyring for the current address does not support the method exportAccount', + UnsupportedRemoveAccount = '`KeyringController - The keyring for the current address does not support the method removeAccount', + UnsupportedSignTransaction = 'KeyringController - The keyring for the current address does not support the method signTransaction.', + UnsupportedSignMessage = 'KeyringController - The keyring for the current address does not support the method signMessage.', + UnsupportedSignPersonalMessage = 'KeyringController - The keyring for the current address does not support the method signPersonalMessage.', + UnsupportedGetEncryptionPublicKey = 'KeyringController - The keyring for the current address does not support the method getEncryptionPublicKey.', + UnsupportedDecryptMessage = 'KeyringController - The keyring for the current address does not support the method decryptMessage.', + UnsupportedSignTypedMessage = 'KeyringController - The keyring for the current address does not support the method signTypedMessage.', + UnsupportedGetAppKeyAddress = 'KeyringController - The keyring for the current address does not support the method getAppKeyAddress.', + UnsupportedExportAppKeyForAddress = 'KeyringController - The keyring for the current address does not support the method exportAppKeyForAddress.', + UnsupportedPrepareUserOperation = 'KeyringController - The keyring for the current address does not support the method prepareUserOperation.', + UnsupportedPatchUserOperation = 'KeyringController - The keyring for the current address does not support the method patchUserOperation.', + UnsupportedSignUserOperation = 'KeyringController - The keyring for the current address does not support the method signUserOperation.', + NoAccountOnKeychain = "KeyringController - The keychain doesn't have accounts.", + MissingCredentials = 'KeyringController - Cannot persist vault without password and encryption key', + MissingVaultData = 'KeyringController - Cannot persist vault without vault information', + ExpiredCredentials = 'KeyringController - Encryption key and salt provided are expired', + NoKeyringBuilder = 'KeyringController - No keyringBuilder found for keyring', + DataType = 'KeyringController - Incorrect data type provided', +} diff --git a/packages/keyring-controller/tests/mocks/mockEncryptor.ts b/packages/keyring-controller/tests/mocks/mockEncryptor.ts index 9eaa82b3051..30a40b97ab9 100644 --- a/packages/keyring-controller/tests/mocks/mockEncryptor.ts +++ b/packages/keyring-controller/tests/mocks/mockEncryptor.ts @@ -1,5 +1,4 @@ -import type { ExportableKeyEncryptor } from '@metamask/eth-keyring-controller/dist/types'; -import type { Json } from '@metamask/utils'; +import type { ExportableKeyEncryptor } from '../../src/KeyringController'; export const PASSWORD = 'password123'; export const MOCK_ENCRYPTION_KEY = JSON.stringify({ @@ -18,7 +17,7 @@ export const MOCK_HEX = '0xabcdef0123456789'; const MOCK_KEY = Buffer.alloc(32); const INVALID_PASSWORD_ERROR = 'Incorrect password.'; -let cacheVal: Json; +let cacheVal: string; export default class MockEncryptor implements ExportableKeyEncryptor { // TODO: Replace `any` with type @@ -35,13 +34,13 @@ export default class MockEncryptor implements ExportableKeyEncryptor { throw new Error(INVALID_PASSWORD_ERROR); } - return cacheVal ?? {}; + return JSON.parse(cacheVal) ?? {}; } // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any async encryptWithKey(_key: unknown, dataObj: any) { - cacheVal = dataObj; + cacheVal = JSON.stringify(dataObj); return { data: MOCK_HEX, iv: 'anIv', diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f602ba30ad3..e685f7fc5ec 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [21.2.0] + +### Added + +- Add optional `publish` hook to support custom logic instead of submission to the RPC provider ([#3883](https://github.com/MetaMask/core/pull/3883)) +- Add `hasNonce` option to `approveTransactionsWithSameNonce` method ([#3883](https://github.com/MetaMask/core/pull/3883)) + +## [21.1.0] + +### Added + +- Add `abortTransactionSigning` method ([#3870](https://github.com/MetaMask/core/pull/3870)) + ## [21.0.1] ### Fixed @@ -464,7 +477,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.2.0...HEAD +[21.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.1.0...@metamask/transaction-controller@21.2.0 +[21.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.0.1...@metamask/transaction-controller@21.1.0 [21.0.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.0.0...@metamask/transaction-controller@21.0.1 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@20.0.0...@metamask/transaction-controller@21.0.0 [20.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@19.0.1...@metamask/transaction-controller@20.0.0 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index a0ce0efdc72..1878745d945 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "21.0.1", + "version": "21.2.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 90d60b60c3d..346f316772f 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -20,6 +20,7 @@ import type { } from '@metamask/network-controller'; import { NetworkClientType, NetworkStatus } from '@metamask/network-controller'; import { errorCodes, providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import { createDeferredPromise } from '@metamask/utils'; import * as NonceTrackerPackage from 'nonce-tracker'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; @@ -3566,6 +3567,37 @@ describe('TransactionController', () => { ]), ).rejects.toThrow(mockSignError); }); + + it('does not create nonce lock if hasNonce set', async () => { + const getNonceLockMock = jest + .spyOn(NonceTrackerPackage.NonceTracker.prototype, 'getNonceLock') + .mockImplementation(); + + const controller = newController(); + + const mockTransactionParam = { + from: ACCOUNT_MOCK, + nonce: '0x1', + gas: '0x111', + to: ACCOUNT_2_MOCK, + value: '0x0', + }; + + const mockTransactionParam2 = { + from: ACCOUNT_MOCK, + nonce: '0x1', + gas: '0x222', + to: ACCOUNT_2_MOCK, + value: '0x1', + }; + + await controller.approveTransactionsWithSameNonce( + [mockTransactionParam, mockTransactionParam2], + { hasNonce: true }, + ); + + expect(getNonceLockMock).not.toHaveBeenCalled(); + }); }); describe('with hooks', () => { @@ -3691,6 +3723,47 @@ describe('TransactionController', () => { 'TransactionController#signTransaction - Update after sign', ); }); + + it('gets transaction hash from publish hook and does not submit to provider', async () => { + const controller = newController({ + options: { + hooks: { + publish: async () => ({ + transactionHash: '0x123', + }), + }, + }, + approve: true, + }); + + const { result } = await controller.addTransaction(paramsMock); + + await result; + + expect(controller.state.transactions[0].hash).toBe('0x123'); + expect(mockSendRawTransaction).not.toHaveBeenCalled(); + }); + + it('submits to provider if publish hook returns no transaction hash', async () => { + const controller = newController({ + options: { + hooks: { + publish: async () => ({}), + }, + }, + approve: true, + }); + + const { result } = await controller.addTransaction(paramsMock); + + await result; + + expect(controller.state.transactions[0].hash).toBe( + ethQueryMockResults.sendRawTransaction, + ); + + expect(mockSendRawTransaction).toHaveBeenCalledTimes(1); + }); }); describe('updateSecurityAlertResponse', () => { @@ -4485,4 +4558,61 @@ describe('TransactionController', () => { Current tx status: ${TransactionStatus.submitted}`); }); }); + + describe('abortTransactionSigning', () => { + it('throws if transaction does not exist', () => { + const controller = newController(); + + expect(() => + controller.abortTransactionSigning(TRANSACTION_META_MOCK.id), + ).toThrow('Cannot abort signing as no transaction metadata found'); + }); + + it('throws if transaction not being signed', () => { + const controller = newController(); + + controller.state.transactions = [TRANSACTION_META_MOCK]; + + expect(() => + controller.abortTransactionSigning(TRANSACTION_META_MOCK.id), + ).toThrow( + 'Cannot abort signing as transaction is not waiting for signing', + ); + }); + + it('sets status to failed if transaction being signed', async () => { + const controller = newController({ + approve: true, + config: { + sign: jest.fn().mockReturnValue(createDeferredPromise().promise), + }, + }); + + const { transactionMeta, result } = await controller.addTransaction({ + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }); + + result.catch(() => { + // Ignore error + }); + + await flushPromises(); + + controller.abortTransactionSigning(transactionMeta.id); + + await flushPromises(); + + expect(controller.state.transactions[0].status).toBe( + TransactionStatus.failed, + ); + expect( + ( + controller.state.transactions[0] as TransactionMeta & { + status: TransactionStatus.failed; + } + ).error.message, + ).toBe('Signing aborted by user'); + }); + }); }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 4dcead5e0c4..63a2c70cb25 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -273,6 +273,8 @@ export class TransactionController extends BaseControllerV1< private readonly speedUpMultiplier: number; + private readonly signAbortCallbacks: Map void> = new Map(); + private readonly afterSign: ( transactionMeta: TransactionMeta, signedTx: TypedTransaction, @@ -288,6 +290,11 @@ export class TransactionController extends BaseControllerV1< private readonly beforePublish: (transactionMeta: TransactionMeta) => boolean; + private readonly publish: ( + transactionMeta: TransactionMeta, + rawTx: string, + ) => Promise<{ transactionHash?: string }>; + private readonly getAdditionalSignArguments: ( transactionMeta: TransactionMeta, ) => (TransactionMeta | undefined)[]; @@ -375,6 +382,7 @@ export class TransactionController extends BaseControllerV1< * @param options.hooks.beforeCheckPendingTransaction - Additional logic to execute before checking pending transactions. Return false to prevent the broadcast of the transaction. * @param options.hooks.beforePublish - Additional logic to execute before publishing a transaction. Return false to prevent the broadcast of the transaction. * @param options.hooks.getAdditionalSignArguments - Returns additional arguments required to sign a transaction. + * @param options.hooks.publish - Alternate logic to publish a transaction. * @param config - Initial options used to configure this controller. * @param state - Initial state to set on this controller. */ @@ -444,6 +452,9 @@ export class TransactionController extends BaseControllerV1< getAdditionalSignArguments?: ( transactionMeta: TransactionMeta, ) => (TransactionMeta | undefined)[]; + publish?: ( + transactionMeta: TransactionMeta, + ) => Promise<{ transactionHash: string }>; }; }, config?: Partial, @@ -496,6 +507,8 @@ export class TransactionController extends BaseControllerV1< this.beforePublish = hooks?.beforePublish ?? (() => true); this.getAdditionalSignArguments = hooks?.getAdditionalSignArguments ?? (() => []); + this.publish = + hooks?.publish ?? (() => Promise.resolve({ transactionHash: undefined })); this.nonceTracker = new NonceTracker({ // @ts-expect-error provider types misaligned: SafeEventEmitterProvider vs Record @@ -1520,10 +1533,13 @@ export class TransactionController extends BaseControllerV1< * Signs and returns the raw transaction data for provided transaction params list. * * @param listOfTxParams - The list of transaction params to approve. + * @param opts - Options bag. + * @param opts.hasNonce - Whether the transactions already have a nonce. * @returns The raw transactions. */ async approveTransactionsWithSameNonce( listOfTxParams: TransactionParams[] = [], + { hasNonce }: { hasNonce?: boolean } = {}, ): Promise { log('Approving transactions with same nonce', { transactions: listOfTxParams, @@ -1552,14 +1568,23 @@ export class TransactionController extends BaseControllerV1< try { // TODO: we should add a check to verify that all transactions have the same from address const fromAddress = initialTx.from; - nonceLock = await this.nonceTracker.getNonceLock(fromAddress); - const nonce = nonceLock.nextNonce; + const requiresNonce = hasNonce !== true; - log('Using nonce from nonce tracker', nonce, nonceLock.nonceDetails); + nonceLock = requiresNonce + ? await this.nonceTracker.getNonceLock(fromAddress) + : undefined; + + const nonce = nonceLock + ? addHexPrefix(nonceLock.nextNonce.toString(16)) + : initialTx.nonce; + + if (nonceLock) { + log('Using nonce from nonce tracker', nonce, nonceLock.nonceDetails); + } rawTransactions = await Promise.all( listOfTxParams.map((txParams) => { - txParams.nonce = addHexPrefix(nonce.toString(16)); + txParams.nonce = nonce; return this.signExternalTransaction(txParams); }), ); @@ -1569,9 +1594,7 @@ export class TransactionController extends BaseControllerV1< // continue with error chain throw err; } finally { - if (nonceLock) { - nonceLock.releaseLock(); - } + nonceLock?.releaseLock(); this.inProcessOfSigning.delete(initialTxAsSerializedHex); } return rawTransactions; @@ -1818,6 +1841,31 @@ export class TransactionController extends BaseControllerV1< this.update({ transactions: this.trimTransactionsForState(transactions) }); } + /** + * Stop the signing process for a specific transaction. + * Throws an error causing the transaction status to be set to failed. + * @param transactionId - The ID of the transaction to stop signing. + */ + abortTransactionSigning(transactionId: string) { + const transactionMeta = this.getTransaction(transactionId); + + if (!transactionMeta) { + throw new Error(`Cannot abort signing as no transaction metadata found`); + } + + const abortCallback = this.signAbortCallbacks.get(transactionId); + + if (!abortCallback) { + throw new Error( + `Cannot abort signing as transaction is not waiting for signing`, + ); + } + + abortCallback(); + + this.signAbortCallbacks.delete(transactionId); + } + private addMetadata(transactionMeta: TransactionMeta) { const { transactions } = this.state; transactions.push(transactionMeta); @@ -2087,7 +2135,14 @@ export class TransactionController extends BaseControllerV1< log('Publishing transaction', txParams); - const hash = await this.publishTransaction(rawTx); + let { transactionHash: hash } = await this.publish( + transactionMeta, + rawTx, + ); + + if (hash === undefined) { + hash = await this.publishTransaction(rawTx); + } log('Publish successful', hash); @@ -2572,11 +2627,19 @@ export class TransactionController extends BaseControllerV1< this.inProcessOfSigning.add(transactionMeta.id); - const signedTx = await this.sign?.( - unsignedEthTx, - txParams.from, - ...this.getAdditionalSignArguments(transactionMeta), - ); + const signedTx = await new Promise((resolve, reject) => { + this.sign?.( + unsignedEthTx, + txParams.from, + ...this.getAdditionalSignArguments(transactionMeta), + ).then(resolve, reject); + + this.signAbortCallbacks.set(transactionMeta.id, () => + reject(new Error('Signing aborted by user')), + ); + }); + + this.signAbortCallbacks.delete(transactionMeta.id); if (!signedTx) { log('Skipping signed status as no signed transaction'); diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index cb6d93ec0eb..21d2b9faa79 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -41,7 +41,7 @@ "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", "@metamask/rpc-errors": "^6.1.0", - "@metamask/transaction-controller": "^21.0.1", + "@metamask/transaction-controller": "^21.2.0", "@metamask/utils": "^8.3.0", "ethereumjs-util": "^7.0.10", "immer": "^9.0.6", @@ -64,7 +64,7 @@ "@metamask/gas-fee-controller": "^13.0.0", "@metamask/keyring-controller": "^12.2.0", "@metamask/network-controller": "^17.2.0", - "@metamask/transaction-controller": "^21.0.1" + "@metamask/transaction-controller": "^21.2.0" }, "engines": { "node": ">=16.0.0" diff --git a/types/@metamask/eth-hd-keyring.d.ts b/types/@metamask/eth-hd-keyring.d.ts new file mode 100644 index 00000000000..650803e985f --- /dev/null +++ b/types/@metamask/eth-hd-keyring.d.ts @@ -0,0 +1 @@ +declare module '@metamask/eth-hd-keyring'; diff --git a/types/@metamask/eth-simple-keyring.d.ts b/types/@metamask/eth-simple-keyring.d.ts new file mode 100644 index 00000000000..3778872a782 --- /dev/null +++ b/types/@metamask/eth-simple-keyring.d.ts @@ -0,0 +1 @@ +declare module '@metamask/eth-simple-keyring'; diff --git a/yarn.lock b/yarn.lock index 1d6cc429caa..5f01fe8ac27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1947,22 +1947,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-keyring-controller@npm:^17.0.1": - version: 17.0.1 - resolution: "@metamask/eth-keyring-controller@npm:17.0.1" - dependencies: - "@ethereumjs/tx": ^4.2.0 - "@metamask/browser-passworder": ^4.3.0 - "@metamask/eth-hd-keyring": ^7.0.1 - "@metamask/eth-sig-util": ^7.0.0 - "@metamask/eth-simple-keyring": ^6.0.1 - "@metamask/keyring-api": ^3.0.0 - "@metamask/obs-store": ^9.0.0 - "@metamask/utils": ^8.2.0 - checksum: ddaeeb15510fd1896bbe94ca3c47c5867730a1370ec19b7ba4206b1048e303a846c67592a64efbfed7f19d82eaa782f84e802d0d66e0d4764420684199e47c33 - languageName: node - linkType: hard - "@metamask/eth-query@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/eth-query@npm:4.0.0" @@ -2248,8 +2232,10 @@ __metadata: "@lavamoat/allow-scripts": ^2.3.1 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/eth-keyring-controller": ^17.0.1 + "@metamask/browser-passworder": ^4.3.0 + "@metamask/eth-hd-keyring": ^7.0.1 "@metamask/eth-sig-util": ^7.0.1 + "@metamask/eth-simple-keyring": ^6.0.1 "@metamask/keyring-api": ^3.0.0 "@metamask/message-manager": ^7.3.8 "@metamask/scure-bip39": ^2.1.1 @@ -2411,16 +2397,6 @@ __metadata: languageName: node linkType: hard -"@metamask/obs-store@npm:^9.0.0": - version: 9.0.0 - resolution: "@metamask/obs-store@npm:9.0.0" - dependencies: - "@metamask/safe-event-emitter": ^3.0.0 - readable-stream: ^3.6.2 - checksum: 1c202a5bbdc79a6b8b3fba946c09dc5521e87260956d30db6543e7bf3d95bd44ebd958f509e3e7332041845176487fe78d3b40bdedbc213061ba849fd978e468 - languageName: node - linkType: hard - "@metamask/permission-controller@npm:^7.0.0, @metamask/permission-controller@npm:^7.1.0": version: 7.1.0 resolution: "@metamask/permission-controller@npm:7.1.0" @@ -2898,7 +2874,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@^21.0.1, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@^21.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -2955,7 +2931,7 @@ __metadata: "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 "@metamask/rpc-errors": ^6.1.0 - "@metamask/transaction-controller": ^21.0.1 + "@metamask/transaction-controller": ^21.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -2974,7 +2950,7 @@ __metadata: "@metamask/gas-fee-controller": ^13.0.0 "@metamask/keyring-controller": ^12.2.0 "@metamask/network-controller": ^17.2.0 - "@metamask/transaction-controller": ^21.0.1 + "@metamask/transaction-controller": ^21.2.0 languageName: unknown linkType: soft