diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index c17b4f1fac8..3b9b98d26be 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -9,11 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `{import,export}State` actions ([#9663](https://github.com/MetaMask/core/pull/9663)) + - Those methods/actions can be used to export a proper snapshot of the account-tree (including secrets or not). + - The payload is versionned and will auto-migrate its payload if needed on the receiving end. + - Currently, wallet and group IDs are not the same as the local ones, mostly because local IDs are not stable and cannot be used in a cross-client context. - Add `getAccountFromSelectedAccountGroup` and `setSelectedAccountGroupByAccountId` methods ([#9739](https://github.com/MetaMask/core/pull/9739)) ### Changed -- Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/keyring-api` from `^23.5.0` to `^24.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.6` ([#9735](https://github.com/MetaMask/core/pull/9735)) ## [7.5.5] diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index feeb2ede01e..aa05f8760c5 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -57,22 +57,24 @@ "dependencies": { "@metamask/accounts-controller": "^39.0.6", "@metamask/base-controller": "^9.1.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", + "@metamask/keyring-sdk": "^3.0.0", "@metamask/messenger": "^2.0.0", "@metamask/multichain-account-service": "^13.0.0", "@metamask/profile-sync-controller": "^28.3.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", "@metamask/snaps-utils": "^12.1.2", - "@metamask/superstruct": "^3.1.0", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" }, "devDependencies": { - "@metamask/account-api": "^1.1.1", + "@metamask/account-api": "^2.0.0", "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-hd-keyring": "^15.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index be1ee052f3f..8722815f8cd 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -233,6 +233,37 @@ export type AccountTreeControllerSyncWithUserStorageAtLeastOnceAction = { handler: AccountTreeController['syncWithUserStorageAtLeastOnce']; }; +/** + * Produces a versioned snapshot of the current wallet and group state. + * + * When `options.includeSecrets` is `true` and the vault is unlocked, + * mnemonic phrases and private keys are included in the snapshot. + * + * @param options - Export options. + * @returns A promise resolving to an `AccountTreeSnapshot`. + */ +export type AccountTreeControllerExportStateAction = { + type: `AccountTreeController:exportState`; + handler: AccountTreeController['exportState']; +}; + +/** + * Applies a validated snapshot to the current state. + * + * Accepts an `AccountTreeSnapshot` only — untrusted wire data must be parsed + * with `AccountTreeSnapshot.deserialize` first. New mnemonic wallets are + * imported via `MultichainAccountService` and new private-key accounts via + * `KeyringController`. Metadata (name, pinned, hidden) is applied to all + * existing and newly created wallets / groups. + * + * @param snapshot - The validated snapshot to import. + * @returns A promise that resolves when the import is complete. + */ +export type AccountTreeControllerImportStateAction = { + type: `AccountTreeController:importState`; + handler: AccountTreeController['importState']; +}; + /** * Union of all AccountTreeController action types. */ @@ -254,4 +285,6 @@ export type AccountTreeControllerMethodActions = | AccountTreeControllerSetAccountGroupHiddenAction | AccountTreeControllerClearStateAction | AccountTreeControllerSyncWithUserStorageAction - | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction; + | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction + | AccountTreeControllerExportStateAction + | AccountTreeControllerImportStateAction; diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 8ec81ea7d95..0d9f91a55e4 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -6389,4 +6389,161 @@ describe('AccountTreeController', () => { }); }); }); + + describe('exportState / importState round-trip', () => { + it('preserves wallet and group metadata across a metadata-only export/import cycle', async () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + // Set custom metadata before export. + controller.setAccountWalletName(walletId, 'My Custom Wallet'); + controller.setAccountGroupName(groupId, 'My Custom Account'); + controller.setAccountGroupPinned(groupId, true); + controller.setAccountGroupHidden(groupId, false); + + // Register handlers that export needs but the default setup() doesn't provide. + // withKeyringV2Unsafe: returns the entropy source ID derived from the keyring. + messenger.registerActionHandler( + 'KeyringController:withKeyringV2Unsafe', + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => + callback({ + keyring: { + toEntropySourceId: async () => MOCK_HD_KEYRING_1.metadata.id, + mnemonic: null, + }, + }), + ); + + // --- EXPORT --- + const snapshot = await controller.exportState(); + const payload = snapshot.serialize(); + + expect(payload.data.wallets).toHaveLength(1); + const exportedWallet = payload.data.wallets[0]; + expect(exportedWallet.type).toBe('mnemonic'); + expect(exportedWallet.metadata.name).toBe('My Custom Wallet'); + expect(exportedWallet.groups[0]?.metadata.name).toBe('My Custom Account'); + expect(exportedWallet.groups[0]?.metadata.pinned).toBe(true); + expect(exportedWallet.groups[0]?.metadata.hidden).toBe(false); + + // The snapshot's idMap bridges local IDs ↔ payload IDs. + expect(snapshot.toPayloadId(walletId)).toBe( + `wallet:${MOCK_HD_KEYRING_1.metadata.id}`, + ); + expect( + snapshot.toLocalId(`wallet:${MOCK_HD_KEYRING_1.metadata.id}`), + ).toBe(walletId); + + // Mutate metadata so the import can restore it. + controller.setAccountWalletName(walletId, 'Overwritten Wallet Name'); + controller.setAccountGroupName(groupId, 'Overwritten Account Name'); + controller.setAccountGroupPinned(groupId, false); + controller.setAccountGroupHidden(groupId, true); + + expect( + controller.state.accountTree.wallets[walletId]?.metadata.name, + ).toBe('Overwritten Wallet Name'); + + // --- IMPORT --- + // withKeyringV2Unsafe is called again during import to find the matching wallet. + // It's already registered; the existing handler stays in place. + await controller.importState(snapshot); + + // After import, original metadata should be restored. + expect( + controller.state.accountTree.wallets[walletId]?.metadata.name, + ).toBe('My Custom Wallet'); + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId] + ?.metadata.name, + ).toBe('My Custom Account'); + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId] + ?.metadata.pinned, + ).toBe(true); + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId] + ?.metadata.hidden, + ).toBe(false); + }); + + it('round-trips a snapshot with includeSecrets: false and vault locked', async () => { + const { controller, messenger, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + // Override KeyringController:getState to report a locked vault. + mocks.KeyringController.getState.mockReturnValue({ + isUnlocked: false, + keyrings: mocks.KeyringController.keyrings, + }); + + messenger.registerActionHandler( + 'KeyringController:withKeyringV2Unsafe', + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => + callback({ + keyring: { + toEntropySourceId: async () => MOCK_HD_KEYRING_1.metadata.id, + mnemonic: null, + }, + }), + ); + + // Export without secrets is allowed even when the vault is locked. + const snapshot = await controller.exportState({ includeSecrets: false }); + const payload = snapshot.serialize(); + + // Exported mnemonic wallet has no secret value. + expect((payload.data.wallets[0] as { value?: string }).value).toBeUndefined(); + + // Reimport is a no-op for metadata when nothing changed. + expect(await controller.importState(snapshot)).toBeUndefined(); + }); + + it('throws when exporting with includeSecrets: true and the vault is locked', async () => { + const { controller, messenger, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + mocks.KeyringController.getState.mockReturnValue({ + isUnlocked: false, + keyrings: mocks.KeyringController.keyrings, + }); + + messenger.registerActionHandler( + 'KeyringController:withKeyringV2Unsafe', + async () => undefined, + ); + + await expect( + controller.exportState({ includeSecrets: true }), + ).rejects.toThrow( + 'Cannot include secrets in export when vault is locked', + ); + }); + }); }); diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index f2460bb3fc5..f7b31c1a39b 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -36,6 +36,10 @@ import type { Rule } from './rule.js'; import { EntropyRule } from './rules/entropy.js'; import { KeyringRule } from './rules/keyring.js'; import { SnapRule } from './rules/snap.js'; +import { exportState } from './state/export.js'; +import { importState } from './state/import.js'; +import type { ExportStateOptions } from './state/payload.js'; +import type { AccountTreeSnapshot } from './state/snapshot.js'; import type { AccountTreeControllerConfig, AccountTreeControllerInternalBackupAndSyncConfig, @@ -65,6 +69,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'syncWithUserStorageAtLeastOnce', 'init', 'reinit', + 'exportState', + 'importState', ] as const; const accountTreeControllerMetadata: StateMetadata = @@ -1855,6 +1861,54 @@ export class AccountTreeController extends BaseController< return this.#backupAndSyncService.performFullSyncAtLeastOnce(); } + /** + * Produces a versioned snapshot of the current wallet and group state. + * + * When `options.includeSecrets` is `true` and the vault is unlocked, + * mnemonic phrases and private keys are included in the snapshot. + * + * @param options - Export options. + * @returns A promise resolving to an `AccountTreeSnapshot`. + */ + async exportState( + options?: ExportStateOptions, + ): Promise { + return exportState( + { getState: () => this.state, messenger: this.messenger }, + options, + ); + } + + /** + * Applies a validated snapshot to the current state. + * + * Accepts an {@link AccountTreeSnapshot} only — untrusted wire data must be + * parsed with {@link AccountTreeSnapshot.deserialize} first. Callers may + * filter the snapshot with {@link AccountTreeSnapshot.filterWallets}, + * {@link AccountTreeSnapshot.filterGroups}, or + * {@link AccountTreeSnapshot.filterAllGroups} before importing. + * + * New mnemonic wallets are imported via `MultichainAccountService` and new + * private-key accounts via `KeyringController`. Metadata (name, pinned, + * hidden) is applied to all existing and newly created wallets / groups. + * + * @param snapshot - The validated snapshot to import. + * @returns A promise that resolves when the import is complete. + */ + async importState(snapshot: AccountTreeSnapshot): Promise { + return importState( + { + getState: () => this.state, + messenger: this.messenger, + setWalletName: (id, name) => this.setAccountWalletName(id, name), + setGroupName: (id, name) => this.setAccountGroupName(id, name, true), + setGroupPinned: (id, pinned) => this.setAccountGroupPinned(id, pinned), + setGroupHidden: (id, hidden) => this.setAccountGroupHidden(id, hidden), + }, + snapshot, + ); + } + /** * Creates an backup and sync context for sync operations. * Used by the backup and sync service. diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index bbc654628f4..4409d563e59 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -40,6 +40,8 @@ export type { AccountTreeControllerSyncWithUserStorageAtLeastOnceAction, AccountTreeControllerInitAction, AccountTreeControllerReinitAction, + AccountTreeControllerExportStateAction, + AccountTreeControllerImportStateAction, } from './AccountTreeController-method-action-types.js'; export type { AccountContext } from './AccountTreeController.js'; @@ -48,3 +50,29 @@ export { AccountTreeController, getDefaultAccountTreeControllerState, } from './AccountTreeController.js'; + +export type { + AccountTreePayload, + AccountTreePayloadStructType, + AccountWalletMnemonicPayload, + AccountWalletPrivateKeyPayload, + AccountWalletMnemonicGroupEntry, + AccountWalletPrivateKeyGroupEntry, + AccountWalletPayloadId, + AccountGroupPayloadId, + AccountTreeSnapshotWallet, + AccountTreeSnapshotGroup, + ExportStateOptions, +} from './state/payload.js'; + +export { + AccountTreePayloadStruct, + assertValidAccountTreePayload, + migrate, + migrations, +} from './state/payload.js'; + +export type { VersionedState } from '@metamask/keyring-sdk'; + +export { AccountTreeSnapshot } from './state/snapshot.js'; +export { IdMap } from './state/id-map.js'; diff --git a/packages/account-tree-controller/src/state/export.test.ts b/packages/account-tree-controller/src/state/export.test.ts new file mode 100644 index 00000000000..70d9934812e --- /dev/null +++ b/packages/account-tree-controller/src/state/export.test.ts @@ -0,0 +1,594 @@ +import { + AccountWalletType, + toAccountGroupId, + toAccountWalletId, +} from '@metamask/account-api'; +import { AccountGroupType } from '@metamask/account-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; + +import type { + AccountTreeControllerMessenger, + AccountTreeControllerState, +} from '../types.js'; +import type { ExportContext } from './export.js'; +import { + exportState, + isMnemonicWalletObject, + isPrivateKeyWalletObject, +} from './export.js'; + +const MOCK_HD_WALLET_ID = toAccountWalletId( + AccountWalletType.Entropy, + 'mock-entropy-id', +); +const MOCK_HD_GROUP_ID = toAccountGroupId(MOCK_HD_WALLET_ID, '0'); +const MOCK_PK_WALLET_ID = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.simple, +); +const MOCK_PK_GROUP_ID = toAccountGroupId(MOCK_PK_WALLET_ID, '0xabc'); + +const MOCK_HD_WALLET_STATE: AccountTreeControllerState['accountTree']['wallets'] = + { + [MOCK_HD_WALLET_ID]: { + id: MOCK_HD_WALLET_ID, + type: AccountWalletType.Entropy, + status: 'ready', + groups: { + [MOCK_HD_GROUP_ID]: { + id: MOCK_HD_GROUP_ID, + type: AccountGroupType.MultichainAccount, + accounts: ['account-1'], + metadata: { + name: 'Account 1', + entropy: { groupIndex: 0 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Wallet 1', + entropy: { id: 'mock-entropy-id' }, + }, + }, + }; + +const MOCK_PK_WALLET_STATE: AccountTreeControllerState['accountTree']['wallets'] = + { + [MOCK_PK_WALLET_ID]: { + id: MOCK_PK_WALLET_ID, + type: AccountWalletType.Keyring, + status: 'ready', + groups: { + [MOCK_PK_GROUP_ID]: { + id: MOCK_PK_GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: ['account-pk-1'], + metadata: { + name: 'Imported 1', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Imported Accounts', + keyring: { type: KeyringTypes.simple }, + }, + }, + }; + +/** + * Creates an ExportContext with individual jest mocks per action so tests can + * configure them with `.mockReturnValue` / `.mockImplementation`. + * + * @param options - Setup options. + * @param options.wallets - Initial wallet state. + * @param options.isUnlocked - Whether the vault reports as unlocked (default: true). + * @returns context, mocks (per-action jest.fn()s), and the raw messenger mock. + */ +function setup({ + wallets = {} as AccountTreeControllerState['accountTree']['wallets'], + isUnlocked = true, +}: { + wallets?: AccountTreeControllerState['accountTree']['wallets']; + isUnlocked?: boolean; +} = {}): { + context: ExportContext; + /* eslint-disable @typescript-eslint/naming-convention */ + mocks: { + KeyringController: { + getState: jest.Mock; + withKeyringV2Unsafe: jest.Mock; + withKeyringV2: jest.Mock; + }; + AccountsController: { getAccount: jest.Mock }; + }; + /* eslint-enable @typescript-eslint/naming-convention */ + messenger: AccountTreeControllerMessenger; +} { + const mocks = { + KeyringController: { + getState: jest.fn().mockReturnValue({ isUnlocked, keyrings: [] }), + withKeyringV2Unsafe: jest.fn(), + withKeyringV2: jest.fn(), + }, + AccountsController: { + getAccount: jest.fn(), + }, + }; + + const messenger = { + call: jest.fn().mockImplementation((action: string, ...args: unknown[]) => { + switch (action) { + case 'KeyringController:getState': + return mocks.KeyringController.getState(); + case 'KeyringController:withKeyringV2Unsafe': + return mocks.KeyringController.withKeyringV2Unsafe(...args); + case 'KeyringController:withKeyringV2': + return mocks.KeyringController.withKeyringV2(...args); + case 'AccountsController:getAccount': + return mocks.AccountsController.getAccount(...args); + default: + return undefined; + } + }), + } as unknown as AccountTreeControllerMessenger; + + const state: AccountTreeControllerState = { + accountTree: { wallets }, + selectedAccountGroup: '', + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }; + + const context: ExportContext = { + getState: () => state, + messenger, + }; + + return { context, mocks, messenger }; +} + +function makeHdKeyringHandler( + entropySourceId: string, + mnemonic: Uint8Array | null = null, +): jest.Mock { + return jest + .fn() + .mockImplementation( + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => + callback({ + keyring: { + toEntropySourceId: async () => entropySourceId, + mnemonic, + }, + }), + ); +} + +function makePrivateKeyExportHandler( + result: { privateKey: string; encoding: string } | undefined, +): jest.Mock { + return jest + .fn() + .mockImplementation( + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => + callback({ + keyring: { + exportAccount: async () => result, + }, + }), + ); +} + +describe('isMnemonicWalletObject', () => { + it('returns true for an entropy wallet', () => { + expect( + isMnemonicWalletObject(MOCK_HD_WALLET_STATE[MOCK_HD_WALLET_ID]), + ).toBe(true); + }); + + it('returns false for a keyring wallet', () => { + expect( + isMnemonicWalletObject(MOCK_PK_WALLET_STATE[MOCK_PK_WALLET_ID]), + ).toBe(false); + }); +}); + +describe('isPrivateKeyWalletObject', () => { + it('returns true for a simple-keyring wallet', () => { + expect( + isPrivateKeyWalletObject(MOCK_PK_WALLET_STATE[MOCK_PK_WALLET_ID]), + ).toBe(true); + }); + + it('returns false for an entropy wallet', () => { + expect( + isPrivateKeyWalletObject(MOCK_HD_WALLET_STATE[MOCK_HD_WALLET_ID]), + ).toBe(false); + }); + + it('returns false for a non-simple keyring wallet (e.g. ledger)', () => { + const ledgerWalletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.ledger, + ); + const ledgerGroupId = toAccountGroupId(ledgerWalletId, '0xhw'); + const ledgerWallet: AccountTreeControllerState['accountTree']['wallets'][string] = + { + id: ledgerWalletId, + type: AccountWalletType.Keyring, + status: 'ready', + groups: { + [ledgerGroupId]: { + id: ledgerGroupId, + type: AccountGroupType.SingleAccount, + accounts: ['account-hw-1'], + metadata: { + name: 'Ledger 1', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { name: 'Ledger', keyring: { type: KeyringTypes.ledger } }, + }; + expect(isPrivateKeyWalletObject(ledgerWallet)).toBe(false); + }); +}); + +describe('exportState', () => { + describe('vault locking', () => { + it('throws when includeSecrets is true and the vault is locked', async () => { + const { context } = setup({ isUnlocked: false }); + await expect( + exportState(context, { includeSecrets: true }), + ).rejects.toThrow( + 'Cannot include secrets in export when vault is locked', + ); + }); + + it('does not throw when includeSecrets is false and vault is locked', async () => { + const { context } = setup({ isUnlocked: false }); + expect(await exportState(context)).toBeDefined(); + }); + }); + + describe('with no wallets', () => { + it('returns an empty snapshot', async () => { + const { context } = setup(); + const snapshot = await exportState(context); + expect(snapshot.serialize().data.wallets).toHaveLength(0); + }); + }); + + describe('with an HD wallet', () => { + it('exports the wallet without secrets by default', async () => { + const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); + // encodeMnemonic uses Uint16Array internally -- must be even-length. + mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( + 'stable-entropy-id', + new Uint8Array([1, 2, 3, 4]), + ); + + const snapshot = await exportState(context); + const wallet = snapshot.serialize().data.wallets[0]; + + expect(wallet?.id).toBe('wallet:stable-entropy-id'); + expect(wallet?.type).toBe('mnemonic'); + expect(wallet?.metadata.name).toBe('Wallet 1'); + expect((wallet as { value?: string }).value).toBeUndefined(); + }); + + it('exports the wallet with the mnemonic when includeSecrets is true', async () => { + const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); + mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( + 'stable-entropy-id', + new Uint8Array([1, 2, 3, 4]), + ); + + const snapshot = await exportState(context, { includeSecrets: true }); + const wallet = snapshot.serialize().data.wallets[0] as { value?: string }; + + expect(wallet.value).toBeDefined(); + expect(typeof wallet.value).toBe('string'); + }); + + it('throws when includeSecrets is true but mnemonic is unavailable', async () => { + const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); + // mnemonic: null -> includeMnemonic will be false -> throws after export. + mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( + 'stable-entropy-id', + null, + ); + + await expect( + exportState(context, { includeSecrets: true }), + ).rejects.toThrow('Failed to export mnemonic'); + }); + + it('populates the idMap with wallet and group local↔payload ID pairs', async () => { + const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); + mocks.KeyringController.withKeyringV2Unsafe = + makeHdKeyringHandler('stable-entropy-id'); + + const snapshot = await exportState(context); + + expect(snapshot.toLocalId('wallet:stable-entropy-id')).toBe( + MOCK_HD_WALLET_ID, + ); + expect(snapshot.toLocalId('wallet:stable-entropy-id/0')).toBe( + MOCK_HD_GROUP_ID, + ); + expect(snapshot.toPayloadId(MOCK_HD_WALLET_ID)).toBe( + 'wallet:stable-entropy-id', + ); + expect(snapshot.toPayloadId(MOCK_HD_GROUP_ID)).toBe( + 'wallet:stable-entropy-id/0', + ); + }); + + it('skips snap and hardware wallets', async () => { + const snapWalletId = toAccountWalletId( + AccountWalletType.Snap, + 'local:mock-snap', + ); + const ledgerWalletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.ledger, + ); + const snapGroupId = toAccountGroupId(snapWalletId, '0xsnap'); + const ledgerGroupId = toAccountGroupId(ledgerWalletId, '0xhw'); + + const mixedWallets: AccountTreeControllerState['accountTree']['wallets'] = + { + [snapWalletId]: { + id: snapWalletId, + type: AccountWalletType.Snap, + status: 'ready', + groups: { + [snapGroupId]: { + id: snapGroupId, + type: AccountGroupType.SingleAccount, + accounts: ['snap-account-1'], + metadata: { + name: 'Snap 1', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { name: 'Snap Wallet', snap: { id: 'local:mock-snap' } }, + }, + [ledgerWalletId]: { + id: ledgerWalletId, + type: AccountWalletType.Keyring, + status: 'ready', + groups: { + [ledgerGroupId]: { + id: ledgerGroupId, + type: AccountGroupType.SingleAccount, + accounts: ['hw-account-1'], + metadata: { + name: 'Ledger 1', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Ledger', + keyring: { type: KeyringTypes.ledger }, + }, + }, + }; + + const { context } = setup({ wallets: mixedWallets }); + const snapshot = await exportState(context); + expect(snapshot.serialize().data.wallets).toHaveLength(0); + }); + }); + + describe('with a private-key wallet', () => { + it('exports the wallet without secrets', async () => { + const { context, mocks } = setup({ wallets: MOCK_PK_WALLET_STATE }); + mocks.AccountsController.getAccount.mockReturnValue({ + id: 'account-pk-1', + address: '0xabc', + }); + + const snapshot = await exportState(context); + const payload = snapshot.serialize(); + + expect(payload.data.wallets).toHaveLength(1); + const wallet = payload.data.wallets[0]; + expect(wallet?.id).toBe('wallet:private-key'); + expect(wallet?.type).toBe('private-key'); + expect(wallet?.groups).toHaveLength(1); + expect(wallet?.groups[0]?.id).toBe('wallet:private-key/0xabc'); + expect((wallet?.groups[0] as { value?: unknown })?.value).toBeUndefined(); + }); + + it('exports the wallet with secrets when includeSecrets is true', async () => { + const { context, mocks } = setup({ wallets: MOCK_PK_WALLET_STATE }); + mocks.AccountsController.getAccount.mockReturnValue({ + id: 'account-pk-1', + address: '0xabc', + }); + mocks.KeyringController.withKeyringV2 = makePrivateKeyExportHandler({ + privateKey: '0xdeadbeef', + encoding: 'hexadecimal', + }); + + const snapshot = await exportState(context, { includeSecrets: true }); + const group = snapshot.serialize().data.wallets[0]?.groups[0] as { + value?: { privateKey: string; encoding: string; type: string }; + }; + + expect(group.value?.privateKey).toBe('0xdeadbeef'); + expect(group.value?.encoding).toBe('hexadecimal'); + expect(group.value?.type).toBe('eip155:eoa'); + }); + + it('throws when includeSecrets is true but keyring does not support exportAccount', async () => { + const { context, mocks } = setup({ wallets: MOCK_PK_WALLET_STATE }); + mocks.AccountsController.getAccount.mockReturnValue({ + id: 'account-pk-1', + address: '0xabc', + }); + mocks.KeyringController.withKeyringV2.mockImplementation( + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => callback({ keyring: {} }), // No exportAccount method. + ); + + await expect( + exportState(context, { includeSecrets: true }), + ).rejects.toThrow('does not support exportAccount'); + }); + + it('throws when includeSecrets is true but the exported value is absent', async () => { + const { context, mocks } = setup({ wallets: MOCK_PK_WALLET_STATE }); + mocks.AccountsController.getAccount.mockReturnValue({ + id: 'account-pk-1', + address: '0xabc', + }); + mocks.KeyringController.withKeyringV2 = + makePrivateKeyExportHandler(undefined); + + await expect( + exportState(context, { includeSecrets: true }), + ).rejects.toThrow('Failed to export private key'); + }); + + it('skips groups whose first account cannot be found', async () => { + const { context, mocks } = setup({ wallets: MOCK_PK_WALLET_STATE }); + mocks.AccountsController.getAccount.mockReturnValue(undefined); + + const snapshot = await exportState(context); + expect(snapshot.serialize().data.wallets[0]?.groups).toHaveLength(0); + }); + + it('skips groups with no accounts', async () => { + const emptyGroupWalletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.simple, + ); + const emptyGroupId = toAccountGroupId(emptyGroupWalletId, '0xempty'); + const wallets: AccountTreeControllerState['accountTree']['wallets'] = { + [emptyGroupWalletId]: { + id: emptyGroupWalletId, + type: AccountWalletType.Keyring, + status: 'ready', + groups: { + [emptyGroupId]: { + id: emptyGroupId, + type: AccountGroupType.SingleAccount, + // @ts-expect-error -- deliberately empty for the test + accounts: [], + metadata: { + name: 'Empty', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Imported Accounts', + keyring: { type: KeyringTypes.simple }, + }, + }, + }; + + const { context } = setup({ wallets }); + const snapshot = await exportState(context); + expect(snapshot.serialize().data.wallets[0]?.groups).toHaveLength(0); + }); + + it('populates the idMap with private-key wallet and group pairs', async () => { + const { context, mocks } = setup({ wallets: MOCK_PK_WALLET_STATE }); + mocks.AccountsController.getAccount.mockReturnValue({ + id: 'account-pk-1', + address: '0xabc', + }); + + const snapshot = await exportState(context); + + expect(snapshot.toLocalId('wallet:private-key')).toBe(MOCK_PK_WALLET_ID); + expect(snapshot.toLocalId('wallet:private-key/0xabc')).toBe( + MOCK_PK_GROUP_ID, + ); + }); + + it('merges multiple simple-keyring wallets into one private-key payload entry', async () => { + const secondPkWalletId = + 'keyring:simple:legacy' as typeof MOCK_PK_WALLET_ID; + const secondPkGroupId = toAccountGroupId(secondPkWalletId, '0xdef'); + + const wallets: AccountTreeControllerState['accountTree']['wallets'] = { + ...MOCK_PK_WALLET_STATE, + [secondPkWalletId]: { + id: secondPkWalletId, + type: AccountWalletType.Keyring, + status: 'ready', + groups: { + [secondPkGroupId]: { + id: secondPkGroupId, + type: AccountGroupType.SingleAccount, + accounts: ['account-pk-2'], + metadata: { + name: 'Imported 2', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Imported Accounts 2', + keyring: { type: KeyringTypes.simple }, + }, + }, + }; + + const { context, mocks } = setup({ wallets }); + mocks.AccountsController.getAccount.mockImplementation((accountId) => { + if (accountId === 'account-pk-1') { + return { id: 'account-pk-1', address: '0xabc' }; + } + if (accountId === 'account-pk-2') { + return { id: 'account-pk-2', address: '0xdef' }; + } + return undefined; + }); + + const snapshot = await exportState(context); + const payload = snapshot.serialize(); + + expect(payload.data.wallets).toHaveLength(1); + expect(payload.data.wallets[0]?.type).toBe('private-key'); + expect(payload.data.wallets[0]?.groups).toHaveLength(2); + expect(payload.data.wallets[0]?.groups.map((group) => group.id)).toEqual([ + 'wallet:private-key/0xabc', + 'wallet:private-key/0xdef', + ]); + }); + }); +}); diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts new file mode 100644 index 00000000000..55bcfd8e700 --- /dev/null +++ b/packages/account-tree-controller/src/state/export.ts @@ -0,0 +1,306 @@ +import { AccountWalletType } from '@metamask/account-api'; +import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import { EthAccountType } from '@metamask/keyring-api'; +import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { encodeMnemonicWords } from '@metamask/keyring-sdk'; + +import type { + AccountTreeControllerMessenger, + AccountTreeControllerState, +} from '../types.js'; +import type { AccountWalletObject } from '../wallet.js'; +import { + AccountWalletEntropyObject, + AccountWalletKeyringObject, +} from '../wallet.js'; +import { IdMap } from './id-map.js'; +import type { + AccountTreeWalletEntry, + AccountWalletMnemonicGroupEntry, + AccountWalletMnemonicPayload, + AccountWalletPrivateKeyGroupEntry, + AccountWalletPrivateKeyPayload, + ExportStateOptions, +} from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +/** + * Returns `true` if `wallet` is an HD entropy wallet ({@link AccountWalletEntropyObject}). + * + * @param wallet - The wallet object to test. + * @returns Type predicate narrowing to {@link AccountWalletEntropyObject}. + */ +export function isMnemonicWalletObject( + wallet: AccountWalletObject, +): wallet is AccountWalletEntropyObject { + return wallet.type === AccountWalletType.Entropy; +} + +/** + * Returns `true` if `wallet` is an imported private-key wallet + * ({@link AccountWalletKeyringObject} with keyring type {@link KeyringTypes.simple}). + * + * @param wallet - The wallet object to test. + * @returns Type predicate narrowing to {@link AccountWalletKeyringObject}. + */ +export function isPrivateKeyWalletObject( + wallet: AccountWalletObject, +): wallet is AccountWalletKeyringObject { + return ( + wallet.type === AccountWalletType.Keyring && + wallet.metadata.keyring.type === KeyringTypes.simple + ); +} + +/** Context required by {@link exportState}. */ +export type ExportContext = { + getState: () => AccountTreeControllerState; + messenger: AccountTreeControllerMessenger; +}; + +/** + * Exports a single entropy (HD) wallet object as an {@link AccountWalletMnemonicPayload}. + * + * Calls `KeyringController:withKeyringV2Unsafe` to derive the stable entropy source ID + * via {@link HdKeyring.toEntropySourceId} and, when `includeSecrets` is `true`, to read + * the raw mnemonic bytes. + * + * @param context - Export context. + * @param walletObj - The local entropy wallet to export. + * @param includeSecrets - When `true`, the BIP-39 mnemonic is included in the payload. + * @param idMap - ID map to populate with local↔payload ID pairs for this wallet and its groups. + * @returns The mnemonic wallet payload entry. + * @throws If `includeSecrets` is `true` but the mnemonic cannot be read from the keyring. + */ +async function exportMnemonicWalletObject( + context: ExportContext, + walletObj: AccountWalletEntropyObject, + includeSecrets: boolean, + idMap: IdMap, +): Promise { + const result = await context.messenger.call( + 'KeyringController:withKeyringV2Unsafe', + // The local wallet entropy ID is the keyring ID. + { id: walletObj.metadata.entropy.id }, + async ({ keyring }) => { + const hdKeyring = keyring as HdKeyring; + const includeMnemonic = + includeSecrets && + hdKeyring.mnemonic !== null && + hdKeyring.mnemonic !== undefined; + + return { + // Compute the stable entropy source ID from the keyring's mnemonic (BIP-39 seed). + entropySourceId: await hdKeyring.toEntropySourceId(), + // No need to include the mnemonic here if we're not exporting secrets. + mnemonic: includeMnemonic ? hdKeyring.mnemonic : undefined, + }; + }, + ); + const { entropySourceId, mnemonic } = result as { + entropySourceId: string; + mnemonic?: Uint8Array; + }; + + // We use the stable entropy source ID as the payload wallet ID, rather than the local wallet ID, to + // ensure that the exported snapshot is stable across different installations and sessions. + const wallet: AccountWalletMnemonicPayload = { + type: 'mnemonic', + id: `wallet:${entropySourceId}`, + metadata: { name: walletObj.metadata.name }, + groups: [], + }; + + idMap.add(walletObj.id, wallet.id); + + for (const groupObj of Object.values(walletObj.groups)) { + const { groupIndex } = groupObj.metadata.entropy; + + const group: AccountWalletMnemonicGroupEntry = { + id: `${wallet.id}/${groupIndex}`, + groupIndex, + metadata: { + name: groupObj.metadata.name, + pinned: groupObj.metadata.pinned, + hidden: groupObj.metadata.hidden, + }, + }; + + idMap.add(groupObj.id, group.id); + + wallet.groups.push(group); + } + + // This should never happen, but we check just in case. + if (includeSecrets) { + if (mnemonic === undefined) { + throw new Error(`Failed to export mnemonic for wallet ${wallet.id}`); + } + + wallet.value = encodeMnemonicWords(mnemonic); + } + + return wallet; +} + +/** + * Exports a single simple-keyring wallet object as an {@link AccountWalletPrivateKeyPayload}. + * + * All groups from the wallet are merged into the `'private-key'` singleton payload entry. + * When `includeSecrets` is `true`, each group's private key is exported via + * `KeyringController:withKeyringV2`. + * + * @param context - Export context. + * @param walletObj - The local simple-keyring wallet to export. + * @param includeSecrets - When `true`, private keys are included in the payload. + * @param idMap - ID map to populate with local↔payload ID pairs for this wallet and its groups. + * @returns The private-key wallet payload entry. + * @throws If `includeSecrets` is `true` but a private key cannot be exported for an account. + */ +async function exportPrivateKeyWalletObject( + context: ExportContext, + walletObj: AccountWalletKeyringObject, + includeSecrets: boolean, + idMap: IdMap, +): Promise { + // We use a singleton wallet ID for private keys. + const wallet: AccountWalletPrivateKeyPayload = { + type: 'private-key', + id: `wallet:private-key`, + metadata: { name: walletObj.metadata.name }, + groups: [], + }; + + idMap.add(walletObj.id, wallet.id); + + for (const groupObj of Object.values(walletObj.groups)) { + const accountId = groupObj.accounts[0]; + if (!accountId) { + continue; + } + const account = context.messenger.call( + 'AccountsController:getAccount', + accountId, + ); + if (!account) { + continue; + } + + const { address } = account; + + let exported: PrivateKeyExportedAccount | undefined; + if (includeSecrets) { + const result = await context.messenger.call( + 'KeyringController:withKeyringV2', + { address }, + async ({ keyring }) => { + if (!keyring.exportAccount) { + throw new Error( + `Keyring for account ${accountId} does not support exportAccount`, + ); + } + + return keyring.exportAccount(accountId, { + type: 'private-key', + encoding: 'hexadecimal', + }); + }, + ); + + exported = result as PrivateKeyExportedAccount; + } + + const group: AccountWalletPrivateKeyGroupEntry = { + id: `${wallet.id}/${address}`, + metadata: { + name: groupObj.metadata.name, + pinned: groupObj.metadata.pinned, + hidden: groupObj.metadata.hidden, + }, + }; + + if (includeSecrets) { + if (!exported) { + throw new Error( + `Failed to export private key for account ${accountId}`, + ); + } + group.value = { + privateKey: exported.privateKey, + encoding: exported.encoding, + type: EthAccountType.Eoa, + }; + } + + idMap.add(groupObj.id, group.id); + + wallet.groups.push(group); + } + + return wallet; +} + +/** + * Builds an {@link AccountTreeSnapshot} from the current controller state. + * + * Iterates over all wallets in the tree: + * - {@link AccountWalletType.Entropy} (HD) wallets -> `'mnemonic'` payload entries. + * - {@link AccountWalletType.Keyring} wallets of type `simple` -> `'private-key'` payload entries. + * - Snap wallets and hardware keyrings are skipped in v1. + * + * @param context - Export context providing state and messenger access. + * @param options - Export options. + * @returns A promise that resolves to the built snapshot. + * @throws If `options.includeSecrets` is `true` and the vault is locked. + */ +export async function exportState( + context: ExportContext, + options: ExportStateOptions = {}, +): Promise { + const state = context.getState(); + + const includeSecrets = options.includeSecrets ?? false; + const { isUnlocked } = context.messenger.call('KeyringController:getState'); + if (includeSecrets && !isUnlocked) { + throw new Error('Cannot include secrets in export when vault is locked'); + } + + const idMap = new IdMap(); + const entries: AccountTreeWalletEntry[] = []; + let privateKeyWallet: AccountWalletPrivateKeyPayload | undefined; + + for (const walletObj of Object.values(state.accountTree.wallets)) { + if (isMnemonicWalletObject(walletObj)) { + entries.push( + await exportMnemonicWalletObject( + context, + walletObj, + includeSecrets, + idMap, + ), + ); + } else if (isPrivateKeyWalletObject(walletObj)) { + const exported = await exportPrivateKeyWalletObject( + context, + walletObj, + includeSecrets, + idMap, + ); + + if (privateKeyWallet) { + privateKeyWallet.groups.push(...exported.groups); + } else { + privateKeyWallet = exported; + } + } else { + // AccountWalletType.Snap and hardware keyrings: skipped for now. + } + } + + if (privateKeyWallet) { + entries.push(privateKeyWallet); + } + + return new AccountTreeSnapshot(entries, idMap); +} diff --git a/packages/account-tree-controller/src/state/id-map.test.ts b/packages/account-tree-controller/src/state/id-map.test.ts new file mode 100644 index 00000000000..c9619d04b70 --- /dev/null +++ b/packages/account-tree-controller/src/state/id-map.test.ts @@ -0,0 +1,97 @@ +import { IdMap } from './id-map.js'; + +describe('IdMap', () => { + describe('constructor', () => { + it('creates an empty map when called with no arguments', () => { + const map = new IdMap(); + expect(map.getPayloadId('entropy:wallet-1')).toBeUndefined(); + expect(map.getLocalId('wallet:entropy-source-1')).toBeUndefined(); + }); + + it('pre-populates the map from the provided entries', () => { + const map = new IdMap([ + ['entropy:wallet-1', 'wallet:entropy-source-1'], + ['keyring:wallet-2', 'wallet:private-key'], + ]); + expect(map.getPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-1', + ); + expect(map.getPayloadId('keyring:wallet-2')).toBe('wallet:private-key'); + expect(map.getLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + expect(map.getLocalId('wallet:private-key')).toBe('keyring:wallet-2'); + }); + }); + + describe('add', () => { + it('registers a local-to-payload pair and its reverse', () => { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + expect(map.getPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-1', + ); + expect(map.getLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + }); + + it('overwrites an existing entry for the same local ID', () => { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + map.add('entropy:wallet-1', 'wallet:entropy-source-2'); + expect(map.getPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-2', + ); + }); + + it('handles wallet and group IDs in the same map', () => { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + map.add('entropy:wallet-1/0', 'wallet:entropy-source-1/0'); + expect(map.getPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-1', + ); + expect(map.getPayloadId('entropy:wallet-1/0')).toBe( + 'wallet:entropy-source-1/0', + ); + }); + }); + + describe('getPayloadId', () => { + it('returns the payload ID for a known local wallet ID', () => { + const map = new IdMap([['entropy:wallet-1', 'wallet:entropy-source-1']]); + expect(map.getPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-1', + ); + }); + + it('returns undefined for an unknown local ID', () => { + const map = new IdMap(); + expect(map.getPayloadId('entropy:wallet-unknown')).toBeUndefined(); + }); + }); + + describe('getLocalId', () => { + it('returns the local ID for a known payload wallet ID', () => { + const map = new IdMap([['entropy:wallet-1', 'wallet:entropy-source-1']]); + expect(map.getLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + }); + + it('returns undefined for an unknown payload ID', () => { + const map = new IdMap(); + expect(map.getLocalId('wallet:entropy-source-unknown')).toBeUndefined(); + }); + + it('returns the local ID for a group payload ID', () => { + const map = new IdMap([ + ['entropy:wallet-1/0', 'wallet:entropy-source-1/0'], + ]); + expect(map.getLocalId('wallet:entropy-source-1/0')).toBe( + 'entropy:wallet-1/0', + ); + }); + }); +}); diff --git a/packages/account-tree-controller/src/state/id-map.ts b/packages/account-tree-controller/src/state/id-map.ts new file mode 100644 index 00000000000..e63d4df1956 --- /dev/null +++ b/packages/account-tree-controller/src/state/id-map.ts @@ -0,0 +1,62 @@ +import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; + +import type { + AccountGroupPayloadId, + AccountWalletPayloadId, +} from './payload.js'; + +type LocalId = AccountWalletId | AccountGroupId; +type PayloadId = AccountWalletPayloadId | AccountGroupPayloadId; + +/** + * Bidirectional map between local controller IDs and portable payload IDs. + * + * Populated during {@link exportState} so that callers can bridge between + * device-local wallet/group IDs and the stable cross-device IDs that appear + * in a serialized {@link AccountTreePayload}. + */ +export class IdMap { + readonly #localToPayload: Map = new Map(); + + readonly #payloadToLocal: Map = new Map(); + + /** + * @param entries - Optional seed pairs `[localId, payloadId]` to pre-populate the map. + */ + constructor(entries: [localId: LocalId, payloadId: PayloadId][] = []) { + for (const [localId, payloadId] of entries) { + this.add(localId, payloadId); + } + } + + /** + * Registers a local↔payload ID pair. + * + * @param localId - Local controller wallet or group ID. + * @param payloadId - Corresponding portable payload ID. + */ + add(localId: LocalId, payloadId: PayloadId): void { + this.#localToPayload.set(localId, payloadId); + this.#payloadToLocal.set(payloadId, localId); + } + + /** + * Returns the portable payload ID for a given local ID. + * + * @param localId - Local controller wallet or group ID. + * @returns The payload ID, or `undefined` if not registered. + */ + getPayloadId(localId: LocalId): PayloadId | undefined { + return this.#localToPayload.get(localId); + } + + /** + * Returns the local controller ID for a given payload ID. + * + * @param payloadId - Portable payload ID. + * @returns The local wallet or group ID, or `undefined` if not registered. + */ + getLocalId(payloadId: PayloadId): LocalId | undefined { + return this.#payloadToLocal.get(payloadId); + } +} diff --git a/packages/account-tree-controller/src/state/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts new file mode 100644 index 00000000000..8fd7ed18301 --- /dev/null +++ b/packages/account-tree-controller/src/state/import.test.ts @@ -0,0 +1,893 @@ +import { + AccountWalletType, + toAccountGroupId, + toAccountWalletId, + toMultichainAccountGroupId, + toMultichainAccountWalletId, +} from '@metamask/account-api'; +import { AccountGroupType } from '@metamask/account-api'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; +import { EthAccountType } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; + +import type { + AccountTreeControllerMessenger, + AccountTreeControllerState, +} from '../types.js'; +import type { ImportContext } from './import.js'; +import { importState } from './import.js'; +import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION } from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +// Valid 20-byte hex addresses for use with getUUIDFromAddressOfNormalAccount. +const ADDR_A = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const ADDR_B = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const ADDR_C = '0xcccccccccccccccccccccccccccccccccccccccc'; + +const MOCK_ENTROPY_ID = 'mock-entropy-id'; +const MOCK_HD_WALLET_ID = toMultichainAccountWalletId(MOCK_ENTROPY_ID); +const MOCK_HD_GROUP_ID_0 = toMultichainAccountGroupId(MOCK_HD_WALLET_ID, 0); +const MOCK_HD_GROUP_ID_1 = toMultichainAccountGroupId(MOCK_HD_WALLET_ID, 1); +const MOCK_PK_WALLET_ID = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.simple, +); + +const MOCK_PAYLOAD_WALLET_ID = `wallet:${MOCK_ENTROPY_ID}` as const; + +const TEST_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +const MNEMONIC_PAYLOAD = { + wallets: [ + { + id: MOCK_PAYLOAD_WALLET_ID, + type: 'mnemonic', + metadata: { name: 'My Renamed Wallet' }, + groups: [ + { + id: `${MOCK_PAYLOAD_WALLET_ID}/0`, + groupIndex: 0, + metadata: { name: 'Renamed Account 1', pinned: true, hidden: false }, + }, + { + id: `${MOCK_PAYLOAD_WALLET_ID}/1`, + groupIndex: 1, + metadata: { name: 'Renamed Account 2', pinned: false, hidden: true }, + }, + ], + }, + ], +}; + +function makeHdWalletState(): AccountTreeControllerState['accountTree']['wallets'] { + return { + [MOCK_HD_WALLET_ID]: { + id: MOCK_HD_WALLET_ID, + type: AccountWalletType.Entropy, + status: 'ready', + groups: { + [MOCK_HD_GROUP_ID_0]: { + id: MOCK_HD_GROUP_ID_0, + type: AccountGroupType.MultichainAccount, + accounts: ['account-1'], + metadata: { + name: 'Account 1', + entropy: { groupIndex: 0 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + [MOCK_HD_GROUP_ID_1]: { + id: MOCK_HD_GROUP_ID_1, + type: AccountGroupType.MultichainAccount, + accounts: ['account-2'], + metadata: { + name: 'Account 2', + entropy: { groupIndex: 1 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { name: 'Wallet 1', entropy: { id: MOCK_ENTROPY_ID } }, + }, + }; +} + +/** + * Creates an ImportContext with individual jest mocks per action. + * + * `walletsRef.current` can be mutated by tests to simulate state changes that + * happen during an import (e.g., wallet creation events updating the tree). + * + * @param options - Setup options. + * @param options.wallets - Initial wallet state (default: empty). + * @returns context, mocks (per-action jest.fn()s), and the mutable walletsRef. + */ +function setup({ + wallets = {} as AccountTreeControllerState['accountTree']['wallets'], +}: { + wallets?: AccountTreeControllerState['accountTree']['wallets']; +} = {}): { + context: ImportContext; + /* eslint-disable @typescript-eslint/naming-convention */ + mocks: { + KeyringController: { + withKeyringV2Unsafe: jest.Mock; + withKeyringV2: jest.Mock; + }; + MultichainAccountService: { + createMultichainAccountWallet: jest.Mock; + createMultichainAccountGroups: jest.Mock; + }; + setters: { + setWalletName: jest.Mock; + setGroupName: jest.Mock; + setGroupPinned: jest.Mock; + setGroupHidden: jest.Mock; + }; + }; + /* eslint-enable @typescript-eslint/naming-convention */ + walletsRef: { current: AccountTreeControllerState['accountTree']['wallets'] }; +} { + const walletsRef = { current: wallets }; + + const mocks = { + KeyringController: { + withKeyringV2Unsafe: jest.fn(), + withKeyringV2: jest.fn(), + }, + MultichainAccountService: { + createMultichainAccountWallet: jest.fn(), + createMultichainAccountGroups: jest.fn().mockResolvedValue(undefined), + }, + setters: { + setWalletName: jest.fn(), + setGroupName: jest.fn(), + setGroupPinned: jest.fn(), + setGroupHidden: jest.fn(), + }, + }; + + const messenger = { + call: jest.fn().mockImplementation((action: string, ...args: unknown[]) => { + switch (action) { + case 'KeyringController:withKeyringV2Unsafe': + return mocks.KeyringController.withKeyringV2Unsafe(...args); + case 'KeyringController:withKeyringV2': + return mocks.KeyringController.withKeyringV2(...args); + case 'MultichainAccountService:createMultichainAccountWallet': + return mocks.MultichainAccountService.createMultichainAccountWallet( + ...args, + ); + case 'MultichainAccountService:createMultichainAccountGroups': + return mocks.MultichainAccountService.createMultichainAccountGroups( + ...args, + ); + default: + return undefined; + } + }), + } as unknown as AccountTreeControllerMessenger; + + const context: ImportContext = { + getState: () => ({ + accountTree: { wallets: walletsRef.current }, + selectedAccountGroup: '', + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }), + messenger, + setWalletName: mocks.setters.setWalletName, + setGroupName: mocks.setters.setGroupName, + setGroupPinned: mocks.setters.setGroupPinned, + setGroupHidden: mocks.setters.setGroupHidden, + }; + + return { context, mocks, walletsRef }; +} + +function makeWithKeyringV2UnsafeMock(keyring: unknown): jest.Mock { + return jest + .fn() + .mockImplementation( + async (_selector: unknown, fn: (ctx: { keyring: unknown }) => unknown) => + fn({ keyring }), + ); +} + +function makeWithKeyringV2Mock( + keyring: unknown, + result: unknown = undefined, +): jest.Mock { + return jest + .fn() + .mockImplementation( + async ( + _selector: unknown, + fn: (ctx: { keyring: unknown }) => unknown, + ) => { + await fn({ keyring }); + return result; + }, + ); +} + +async function importSnapshot( + context: ImportContext, + payload: unknown, +): ReturnType { + return importState(context, await AccountTreeSnapshot.deserialize(payload)); +} + +describe('importState', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('mnemonic wallets', () => { + it('applies metadata to existing groups when the wallet already exists locally', async () => { + const { context, mocks } = setup({ wallets: makeHdWalletState() }); + mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( + { + toEntropySourceId: async () => MOCK_ENTROPY_ID, + }, + ); + + await importSnapshot(context, MNEMONIC_PAYLOAD); + + expect(mocks.setters.setWalletName).toHaveBeenCalledWith( + MOCK_HD_WALLET_ID, + 'My Renamed Wallet', + ); + expect(mocks.setters.setGroupName).toHaveBeenCalledWith( + MOCK_HD_GROUP_ID_0, + 'Renamed Account 1', + ); + expect(mocks.setters.setGroupPinned).toHaveBeenCalledWith( + MOCK_HD_GROUP_ID_0, + true, + ); + expect(mocks.setters.setGroupHidden).toHaveBeenCalledWith( + MOCK_HD_GROUP_ID_0, + false, + ); + expect(mocks.setters.setGroupName).toHaveBeenCalledWith( + MOCK_HD_GROUP_ID_1, + 'Renamed Account 2', + ); + expect(mocks.setters.setGroupPinned).toHaveBeenCalledWith( + MOCK_HD_GROUP_ID_1, + false, + ); + expect(mocks.setters.setGroupHidden).toHaveBeenCalledWith( + MOCK_HD_GROUP_ID_1, + true, + ); + }); + + it('skips non-mnemonic wallets when searching for a matching entropy source', async () => { + const pkWalletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.simple, + ); + const pkOnlyWallets: AccountTreeControllerState['accountTree']['wallets'] = + { + [pkWalletId]: { + id: pkWalletId, + type: AccountWalletType.Keyring, + status: 'ready', + groups: {}, + metadata: { + name: 'Imported', + keyring: { type: KeyringTypes.simple }, + }, + }, + }; + const { context, mocks } = setup({ wallets: pkOnlyWallets }); + + const payload = { + wallets: [ + { + id: 'wallet:entropy-only', + type: 'mnemonic', + // No mnemonic -> will early-return after not finding the wallet. + metadata: { name: 'X' }, + groups: [], + }, + ], + }; + + await importSnapshot(context, payload); + expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); + }); + + it('skips import when no local wallet matches and no mnemonic is in the payload', async () => { + const { context, mocks } = setup({ wallets: makeHdWalletState() }); + mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( + { + toEntropySourceId: async () => 'different-entropy-id', + }, + ); + + const payloadWithoutMnemonic = { + wallets: [ + { + id: 'wallet:unknown-entropy', + type: 'mnemonic', + metadata: { name: 'Unknown' }, + groups: [], + }, + ], + }; + await importSnapshot(context, payloadWithoutMnemonic); + expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); + }); + + it('throws when createMultichainAccountWallet returns an id not found in state', async () => { + const { context, mocks } = setup(); + mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( + { + toEntropySourceId: async () => 'no-match-entropy', + }, + ); + mocks.MultichainAccountService.createMultichainAccountWallet.mockResolvedValue( + { + id: 'entropy:wallet-that-does-not-exist', + }, + ); + + await expect( + importSnapshot(context, { + wallets: [ + { + id: 'wallet:no-match-entropy', + type: 'mnemonic', + value: TEST_MNEMONIC, + metadata: { name: 'Wallet' }, + groups: [], + }, + ], + }), + ).rejects.toThrow('wallet not found after creation'); + }); + + it('throws when the wallet found after creation is not a mnemonic wallet', async () => { + const { context, mocks, walletsRef } = setup(); + const fakeWalletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.simple, + ); + + mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( + { + toEntropySourceId: async () => 'no-match', + }, + ); + mocks.MultichainAccountService.createMultichainAccountWallet.mockImplementation( + async () => { + // Inject a keyring wallet (not entropy) at the returned ID. + walletsRef.current = { + [fakeWalletId]: { + id: fakeWalletId, + type: AccountWalletType.Keyring, + status: 'ready', + groups: {}, + metadata: { + name: 'Not Mnemonic', + keyring: { type: KeyringTypes.simple }, + }, + }, + }; + return { id: fakeWalletId }; + }, + ); + + await expect( + importSnapshot(context, { + wallets: [ + { + id: 'wallet:no-match', + type: 'mnemonic', + value: TEST_MNEMONIC, + metadata: { name: 'Wallet' }, + groups: [], + }, + ], + }), + ).rejects.toThrow("wallet is not of type 'mnemonic'"); + }); + + it('creates a new HD wallet when not found locally and mnemonic is provided', async () => { + const { context, mocks, walletsRef } = setup(); + + mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( + { + toEntropySourceId: async () => MOCK_ENTROPY_ID, + }, + ); + mocks.MultichainAccountService.createMultichainAccountWallet.mockImplementation( + async () => { + walletsRef.current = makeHdWalletState(); + return { id: MOCK_HD_WALLET_ID }; + }, + ); + + const payloadWithMnemonic = { + wallets: [ + { + id: 'wallet:unknown-entropy', + type: 'mnemonic', + value: TEST_MNEMONIC, + metadata: { name: 'My Renamed Wallet' }, + groups: [ + { + id: 'wallet:unknown-entropy/0', + groupIndex: 0, + metadata: { name: 'Account 1', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payloadWithMnemonic); + + expect( + mocks.MultichainAccountService.createMultichainAccountWallet, + ).toHaveBeenCalledWith(expect.objectContaining({ type: 'import' })); + expect(mocks.setters.setWalletName).toHaveBeenCalledWith( + MOCK_HD_WALLET_ID, + 'My Renamed Wallet', + ); + }); + + it('creates missing groups at the end of the payload list', async () => { + const stateWithOneGroup: AccountTreeControllerState['accountTree']['wallets'] = + { + [MOCK_HD_WALLET_ID]: { + id: MOCK_HD_WALLET_ID, + type: AccountWalletType.Entropy, + status: 'ready', + groups: { + [MOCK_HD_GROUP_ID_0]: { + id: MOCK_HD_GROUP_ID_0, + type: AccountGroupType.MultichainAccount, + accounts: ['account-1'], + metadata: { + name: 'Account 1', + entropy: { groupIndex: 0 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { name: 'Wallet 1', entropy: { id: MOCK_ENTROPY_ID } }, + }, + }; + + const { context, mocks } = setup({ wallets: stateWithOneGroup }); + mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( + { + toEntropySourceId: async () => MOCK_ENTROPY_ID, + }, + ); + + const payload = { + wallets: [ + { + id: MOCK_PAYLOAD_WALLET_ID, + type: 'mnemonic', + metadata: { name: 'Wallet 1' }, + groups: [ + { + id: `${MOCK_PAYLOAD_WALLET_ID}/0`, + groupIndex: 0, + metadata: { name: 'Account 1', pinned: false, hidden: false }, + }, + { + id: `${MOCK_PAYLOAD_WALLET_ID}/1`, + groupIndex: 1, + metadata: { name: 'Account 2', pinned: true, hidden: false }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payload); + + expect( + mocks.MultichainAccountService.createMultichainAccountGroups, + ).toHaveBeenCalledWith( + expect.objectContaining({ + entropySource: MOCK_ENTROPY_ID, + fromGroupIndex: 1, + toGroupIndex: 1, + }), + ); + }); + + it('creates missing groups in the middle of the payload list', async () => { + const group2Id = toMultichainAccountGroupId(MOCK_HD_WALLET_ID, 2); + const stateWithGap: AccountTreeControllerState['accountTree']['wallets'] = + { + [MOCK_HD_WALLET_ID]: { + id: MOCK_HD_WALLET_ID, + type: AccountWalletType.Entropy, + status: 'ready', + groups: { + [MOCK_HD_GROUP_ID_0]: { + id: MOCK_HD_GROUP_ID_0, + type: AccountGroupType.MultichainAccount, + accounts: ['account-0'], + metadata: { + name: 'Account 0', + entropy: { groupIndex: 0 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + [group2Id]: { + id: group2Id, + type: AccountGroupType.MultichainAccount, + accounts: ['account-2'], + metadata: { + name: 'Account 2', + entropy: { groupIndex: 2 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { name: 'Wallet 1', entropy: { id: MOCK_ENTROPY_ID } }, + }, + }; + + const { context, mocks } = setup({ wallets: stateWithGap }); + mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( + { + toEntropySourceId: async () => MOCK_ENTROPY_ID, + }, + ); + + const payload = { + wallets: [ + { + id: MOCK_PAYLOAD_WALLET_ID, + type: 'mnemonic', + metadata: { name: 'Wallet 1' }, + groups: [ + { + id: `${MOCK_PAYLOAD_WALLET_ID}/0`, + groupIndex: 0, + metadata: { name: 'Account 0', pinned: false, hidden: false }, + }, + { + id: `${MOCK_PAYLOAD_WALLET_ID}/1`, + groupIndex: 1, + metadata: { + name: 'Account 1 (missing)', + pinned: false, + hidden: false, + }, + }, + { + id: `${MOCK_PAYLOAD_WALLET_ID}/2`, + groupIndex: 2, + metadata: { name: 'Account 2', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payload); + + expect( + mocks.MultichainAccountService.createMultichainAccountGroups, + ).toHaveBeenCalledWith( + expect.objectContaining({ fromGroupIndex: 1, toGroupIndex: 1 }), + ); + }); + }); + + describe('private-key wallets', () => { + it('applies metadata to an existing private-key account group', async () => { + const accountId = getUUIDFromAddressOfNormalAccount(ADDR_A); + const pkGroupId = toAccountGroupId(MOCK_PK_WALLET_ID, ADDR_A); + + const pkWallets: AccountTreeControllerState['accountTree']['wallets'] = { + [MOCK_PK_WALLET_ID]: { + id: MOCK_PK_WALLET_ID, + type: AccountWalletType.Keyring, + status: 'ready', + groups: { + [pkGroupId]: { + id: pkGroupId, + type: AccountGroupType.SingleAccount, + accounts: [accountId], + metadata: { + name: 'Imported 1', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Imported Accounts', + keyring: { type: KeyringTypes.simple }, + }, + }, + }; + + const { context, mocks } = setup({ wallets: pkWallets }); + + const payload = { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: `wallet:private-key/${ADDR_A}`, + metadata: { + name: 'Renamed Imported', + pinned: true, + hidden: false, + }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payload); + + expect(mocks.setters.setGroupName).toHaveBeenCalledWith( + pkGroupId, + 'Renamed Imported', + ); + expect(mocks.setters.setGroupPinned).toHaveBeenCalledWith( + pkGroupId, + true, + ); + expect(mocks.setters.setGroupHidden).toHaveBeenCalledWith( + pkGroupId, + false, + ); + }); + + it('imports a private key when the account does not exist locally', async () => { + const newAccountId = getUUIDFromAddressOfNormalAccount(ADDR_B); + const pkGroupId = toAccountGroupId(MOCK_PK_WALLET_ID, ADDR_B); + + const { context, mocks, walletsRef } = setup(); + mocks.KeyringController.withKeyringV2 = makeWithKeyringV2Mock( + { createAccounts: jest.fn() }, + [{ id: newAccountId }], + ); + + // Simulate the wallet tree being updated after import. + mocks.KeyringController.withKeyringV2.mockImplementation( + async ( + _selector: unknown, + fn: (ctx: { keyring: unknown }) => unknown, + ) => { + walletsRef.current = { + [MOCK_PK_WALLET_ID]: { + id: MOCK_PK_WALLET_ID, + type: AccountWalletType.Keyring, + status: 'ready', + groups: { + [pkGroupId]: { + id: pkGroupId, + type: AccountGroupType.SingleAccount, + accounts: [newAccountId], + metadata: { + name: 'New Import', + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Imported Accounts', + keyring: { type: KeyringTypes.simple }, + }, + }, + }; + await fn({ keyring: { createAccounts: jest.fn() } }); + return [{ id: newAccountId }]; + }, + ); + + const payload = { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: `wallet:private-key/${ADDR_B}`, + value: { privateKey: '0xdeadbeef', encoding: 'hexadecimal' }, + metadata: { name: 'New Import', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payload); + + expect(mocks.KeyringController.withKeyringV2).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + ); + expect(mocks.setters.setGroupName).toHaveBeenCalledWith( + pkGroupId, + 'New Import', + ); + }); + + it('throws when withKeyringV2 returns an empty account list', async () => { + const { context, mocks } = setup(); + mocks.KeyringController.withKeyringV2 = makeWithKeyringV2Mock( + { createAccounts: jest.fn() }, + [], // Empty -> no account was imported. + ); + + await expect( + importSnapshot(context, { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: `wallet:private-key/${ADDR_C}`, + value: { privateKey: '0xdeadbeef', encoding: 'hexadecimal' }, + metadata: { name: 'Fail', pinned: false, hidden: false }, + }, + ], + }, + ], + }), + ).rejects.toThrow('Failed to import private key for account'); + }); + + it('skips a private-key group whose value carries a non-EVM type', async () => { + const { context, mocks } = setup(); + + const payload = { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: `wallet:private-key/${ADDR_A}`, + value: { + privateKey: '5Kb8kLf9z...', + encoding: 'base58', + type: 'bip122:p2wpkh', + }, + metadata: { + name: 'Bitcoin Account', + pinned: false, + hidden: false, + }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payload); + expect(mocks.KeyringController.withKeyringV2).not.toHaveBeenCalled(); + expect(mocks.setters.setGroupName).not.toHaveBeenCalled(); + }); + + it('does not skip a private-key group whose value type is eip155:eoa', async () => { + const { context, mocks } = setup(); + mocks.KeyringController.withKeyringV2 = makeWithKeyringV2Mock( + { createAccounts: jest.fn() }, + [], + ); + + const payload = { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: `wallet:private-key/${ADDR_A}`, + value: { + privateKey: '0xdeadbeef', + encoding: 'hexadecimal', + type: EthAccountType.Eoa, + }, + metadata: { name: 'EVM Account', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + // withKeyringV2 is called (not skipped), but returns [] so it throws. + await expect(importSnapshot(context, payload)).rejects.toThrow( + 'Failed to import private key for account', + ); + expect(mocks.KeyringController.withKeyringV2).toHaveBeenCalled(); + }); + + it('skips a private-key group that has no value and account does not exist locally', async () => { + const { context, mocks } = setup(); + + const payload = { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: `wallet:private-key/${ADDR_C}`, + // No value -> skip. + metadata: { name: 'Missing', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payload); + expect(mocks.setters.setGroupName).not.toHaveBeenCalled(); + }); + + it('skips metadata when the local group is not found after import', async () => { + const { context, mocks } = setup(); + // State stays empty -- the import succeeds but leaves no group in the tree. + mocks.KeyringController.withKeyringV2 = makeWithKeyringV2Mock( + { createAccounts: jest.fn() }, + [{ id: 'some-account-id' }], + ); + + const payload = { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: `wallet:private-key/${ADDR_C}`, + value: { privateKey: '0xdeadbeef', encoding: 'hexadecimal' }, + metadata: { name: 'Orphan', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + await importSnapshot(context, payload); + expect(mocks.setters.setGroupName).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts new file mode 100644 index 00000000000..b5e2b847be1 --- /dev/null +++ b/packages/account-tree-controller/src/state/import.ts @@ -0,0 +1,343 @@ +import { + AccountWalletType, + toAccountGroupId, + toAccountWalletId, + toMultichainAccountGroupId, +} from '@metamask/account-api'; +import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; +import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import { EthAccountType, KeyringAccount } from '@metamask/keyring-api'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { decodeMnemonicWords } from '@metamask/keyring-sdk'; + +import type { + AccountTreeControllerMessenger, + AccountTreeControllerState, +} from '../types.js'; +import type { AccountWalletEntropyObject } from '../wallet.js'; +import { isMnemonicWalletObject } from './export.js'; +import type { + AccountWalletMnemonicGroupEntry, + AccountWalletMnemonicPayload, + AccountWalletPayloadId, + AccountWalletPrivateKeyGroupEntry, +} from './payload.js'; +import { parsePayloadGroupId, toWalletPayloadId } from './payload.js'; +import type { AccountTreeSnapshot } from './snapshot.js'; + +/** Context required by {@link importState}. */ +export type ImportContext = { + getState: () => AccountTreeControllerState; + messenger: AccountTreeControllerMessenger; + setWalletName: (walletId: AccountWalletId, name: string) => void; + /** Sets a group name. Implementations must resolve name conflicts automatically. */ + setGroupName: (groupId: AccountGroupId, name: string) => void; + setGroupPinned: (groupId: AccountGroupId, pinned: boolean) => void; + setGroupHidden: (groupId: AccountGroupId, hidden: boolean) => void; +}; + +/** + * Searches the local wallet tree for an entropy wallet whose stable payload ID + * matches `payloadWalletId`. The entropy source ID is derived on-the-fly via + * `KeyringController:withKeyringV2Unsafe` rather than relying on cached metadata. + * + * @param context - Import context. + * @param payloadWalletId - Payload wallet ID to match against. + * @returns The matching local entropy wallet, or `undefined` if not found. + */ +async function findLocalWalletMnemonicFromPayloadId( + context: ImportContext, + payloadWalletId: AccountWalletPayloadId, +): Promise { + const wallets = Object.values(context.getState().accountTree.wallets); + + for (const wallet of wallets) { + if (isMnemonicWalletObject(wallet)) { + const result = await context.messenger.call( + 'KeyringController:withKeyringV2Unsafe', + { id: wallet.metadata.entropy.id }, + async ({ keyring }) => { + const hdKeyring = keyring as HdKeyring; + + return toWalletPayloadId(await hdKeyring.toEntropySourceId()); + }, + ); + + const localPayloadId = result as AccountWalletPayloadId; + if (localPayloadId === payloadWalletId) { + return wallet; + } + } + } + + return undefined; +} + +/** + * Returns the local entropy wallet for the given ID. + * + * @param context - Import context. + * @param id - Local wallet ID. + * @returns The entropy wallet object. + * @throws If the wallet is not found or is not an entropy wallet. + */ +function findLocalWalletMnemonicFromId( + context: ImportContext, + id: AccountWalletId, +): AccountWalletEntropyObject { + const localWallets = context.getState().accountTree.wallets; + const localWallet = localWallets[id]; + + if (!localWallet) { + throw new Error( + `Failed to import mnemonic wallet: wallet not found after creation`, + ); + } + if (!isMnemonicWalletObject(localWallet)) { + throw new Error( + `Failed to import mnemonic wallet: wallet is not of type 'mnemonic'`, + ); + } + return localWallet; +} + +/** + * Applies name, pinned, and hidden metadata from a payload group entry to a local group. + * + * @param context - Import context providing the metadata setters. + * @param localGroupId - Local group ID to update. + * @param payloadGroupMetadata - Metadata from the payload group entry. + */ +function setGroupMetadata( + context: ImportContext, + localGroupId: AccountGroupId, + payloadGroupMetadata: AccountWalletMnemonicGroupEntry['metadata'], +): void { + context.setGroupName(localGroupId, payloadGroupMetadata.name); + context.setGroupPinned(localGroupId, payloadGroupMetadata.pinned); + context.setGroupHidden(localGroupId, payloadGroupMetadata.hidden); +} + +/** + * Computes the contiguous ranges of group indices that are present in the payload + * but absent from the local wallet, so they can be created in batches. + * + * @param localWallet - The local entropy wallet to check existing groups against. + * @param payloadGroups - Ordered group entries from the payload. + * @returns An array of `[fromGroupIndex, toGroupIndex]` ranges to create. + */ +function getRangesFromPayloadGroups( + localWallet: AccountWalletEntropyObject, + payloadGroups: AccountWalletMnemonicGroupEntry[], +): [number, number][] { + let rangeIndex: number | undefined; + const ranges: [number, number][] = []; + + // Keep track of the last payload group so we can close the final range if needed. + let lastPayloadGroup: AccountWalletMnemonicGroupEntry | undefined; + for (const payloadGroup of payloadGroups) { + const localGroupId = toMultichainAccountGroupId( + localWallet.id, + payloadGroup.groupIndex, + ); + + if (localWallet.groups[localGroupId]) { + if (rangeIndex !== undefined) { + ranges.push([rangeIndex, payloadGroup.groupIndex - 1]); + rangeIndex = undefined; + } + continue; + } + + rangeIndex ??= payloadGroup.groupIndex; + lastPayloadGroup = payloadGroup; + } + + if (rangeIndex !== undefined && lastPayloadGroup !== undefined) { + ranges.push([rangeIndex, lastPayloadGroup.groupIndex]); + } + + return ranges; +} + +/** + * Applies a mnemonic wallet payload entry to the local state. + * + * If no local wallet with the same entropy source ID exists and a mnemonic is + * present in the payload, a new HD wallet is created via + * `MultichainAccountService:createMultichainAccountWallet`. Missing groups are + * created in batches via `MultichainAccountService:createMultichainAccountGroups`. + * Metadata (name, pinned, hidden) is applied to all groups afterward. + * + * @param context - Import context. + * @param payloadWallet - The mnemonic wallet entry from the payload. + */ +async function importMnemonicWallet( + context: ImportContext, + payloadWallet: AccountWalletMnemonicPayload, +): Promise { + // Find the local wallet with the same entropy source ID if it exists. + let localWallet = await findLocalWalletMnemonicFromPayloadId( + context, + payloadWallet.id, + ); + + if (!localWallet) { + if (!payloadWallet.value) { + // No mnemonic in payload and wallet doesn't exist locally -- nothing to do. + return; + } + + // Import the mnemonic as a new HD wallet. + const mnemonic = decodeMnemonicWords(payloadWallet.value); + const { id } = await context.messenger.call( + 'MultichainAccountService:createMultichainAccountWallet', + { type: 'import', mnemonic }, + ); + + // Event handlers fire synchronously, so the wallet is in the tree now. + localWallet = findLocalWalletMnemonicFromId(context, id); + } + + context.setWalletName(localWallet.id, payloadWallet.metadata.name); + + for (const range of getRangesFromPayloadGroups( + localWallet, + payloadWallet.groups, + )) { + await context.messenger.call( + 'MultichainAccountService:createMultichainAccountGroups', + { + entropySource: localWallet.metadata.entropy.id, + fromGroupIndex: range[0], + toGroupIndex: range[1], + }, + ); + } + + // Re-read wallet after groups creation. + localWallet = findLocalWalletMnemonicFromId(context, localWallet.id); + + for (const payloadGroup of payloadWallet.groups) { + const localGroupId = toMultichainAccountGroupId( + localWallet.id, + payloadGroup.groupIndex, + ); + + setGroupMetadata(context, localGroupId, payloadGroup.metadata); + } +} + +/** + * Applies private-key wallet group entries from the payload to the local state. + * + * For each group the account address is derived from the payload group ID. If the + * account does not yet exist locally and a private key is provided, it is imported + * via `KeyringController:withKeyringV2` using the `'private-key:import'` constructor. + * Metadata (name, pinned, hidden) is then applied to the local group. + * + * @param context - Import context. + * @param payloadGroups - Private-key group entries from the payload. + */ +async function importPrivateKeyWallet( + context: ImportContext, + payloadGroups: AccountWalletPrivateKeyGroupEntry[], +): Promise { + for (const payloadGroup of payloadGroups) { + // Only EVM EOA accounts are supported for now. Non-EVM private keys require + // Snap-based import routing (ADR-0007), which is not yet implemented. Skip the + // entire entry so payloads from future clients are accepted without crashing. + const privateKeyType = payloadGroup.value?.type; + if (privateKeyType !== undefined && privateKeyType !== EthAccountType.Eoa) { + continue; + } + + // Payload group ID format: "wallet:private-key/
" + const payloadAccountAddress = parsePayloadGroupId(payloadGroup.id).subId; + const payloadAccountId = getUUIDFromAddressOfNormalAccount( + payloadAccountAddress, + ); + + const localWalletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.simple, + ); + const localGroupId = toAccountGroupId(localWalletId, payloadAccountAddress); + + let localWallets = context.getState().accountTree.wallets; + let localWallet = localWallets[localWalletId]; + let localGroup = localWallet?.groups[localGroupId]; + + // EVM accounts have deterministic IDs, so we can re-use this to find the local group if it exists. + const hasAccount = + localGroup?.accounts.some((id) => id === payloadAccountId) ?? false; + + // If it doesn't exist, we need to import the private key. + if (!hasAccount) { + if (!payloadGroup.value) { + // No importable secret -- skip this account. + continue; + } + + const { privateKey, encoding } = payloadGroup.value; + const result = await context.messenger.call( + 'KeyringController:withKeyringV2', + { type: KeyringType.PrivateKey }, + async ({ keyring }) => { + await keyring.createAccounts({ + type: 'private-key:import', + privateKey, + encoding, + }); + }, + ); + + // There should only be 1 account in the keyring after import. + const [account] = result as KeyringAccount[]; + if (!account) { + throw new Error('Failed to import private key for account'); + } + } + + // Find the local group that contains this account. + localWallets = context.getState().accountTree.wallets; + localWallet = localWallets[localWalletId]; + localGroup = localWallet?.groups[localGroupId]; + if (!localGroup) { + continue; + } + + setGroupMetadata(context, localGroup.id, payloadGroup.metadata); + } +} + +/** + * Applies an {@link AccountTreeSnapshot} to the current controller state. + * + * The snapshot must already have been validated — typically via + * {@link AccountTreeSnapshot.deserialize}. For each retained wallet: + * + * - `'mnemonic'`: imports the mnemonic when provided and not already present, + * then applies metadata to all groups. + * - `'private-key'`: imports each retained group's key when provided and not + * already present, then applies metadata. + * + * @param context - Import context providing state, messenger, and setters. + * @param snapshot - The validated snapshot to import. + */ +export async function importState( + context: ImportContext, + snapshot: AccountTreeSnapshot, +): Promise { + const payload = snapshot.serialize(); + + for (const wallet of payload.data.wallets) { + if (wallet.type === 'mnemonic') { + await importMnemonicWallet(context, wallet); + } else { + await importPrivateKeyWallet(context, wallet.groups); + } + } +} diff --git a/packages/account-tree-controller/src/state/payload.test.ts b/packages/account-tree-controller/src/state/payload.test.ts new file mode 100644 index 00000000000..a813ce128a4 --- /dev/null +++ b/packages/account-tree-controller/src/state/payload.test.ts @@ -0,0 +1,264 @@ +import { + ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + assertValidAccountTreePayload, + migrate, + parsePayloadGroupId, + toWalletPayloadId, +} from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +const VALID_MNEMONIC_PAYLOAD = { + wallets: [ + { + id: 'wallet:entropy:mnemonic:abc123', + type: 'mnemonic', + metadata: { name: 'Wallet 1' }, + groups: [ + { + id: 'wallet:entropy:mnemonic:abc123/0', + groupIndex: 0, + metadata: { name: 'Account 1', pinned: false, hidden: false }, + }, + ], + }, + ], +}; + +const VALID_PRIVATE_KEY_PAYLOAD = { + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported' }, + groups: [ + { + id: 'wallet:private-key/0xdeadbeef', + metadata: { name: 'Imported 1', pinned: false, hidden: true }, + }, + ], + }, + ], +}; + +describe('parsePayloadGroupId', () => { + it('parses a mnemonic group ID (wallet-id/groupIndex)', () => { + const result = parsePayloadGroupId('wallet:entropy:mnemonic:abc123/0'); + expect(result.walletId).toBe('wallet:entropy:mnemonic:abc123'); + expect(result.subId).toBe('0'); + }); + + it('parses a private-key group ID (wallet:private-key/address)', () => { + const result = parsePayloadGroupId('wallet:private-key/0xdeadbeef'); + expect(result.walletId).toBe('wallet:private-key'); + expect(result.subId).toBe('0xdeadbeef'); + }); + + it('handles subId values that contain colons', () => { + const result = parsePayloadGroupId('wallet:entropy:mnemonic:uuid/0'); + expect(result.walletId).toBe('wallet:entropy:mnemonic:uuid'); + expect(result.subId).toBe('0'); + }); + + it('throws for a group ID with no slash separator', () => { + expect(() => + parsePayloadGroupId('wallet:private-key' as `wallet:${string}/${string}`), + ).toThrow('Invalid payload group ID'); + }); +}); + +describe('toWalletPayloadId', () => { + it('returns a wallet payload ID from the entropy source ID', () => { + expect(toWalletPayloadId('entropy:mnemonic:abc')).toBe( + 'wallet:entropy:mnemonic:abc', + ); + }); + + it('returns the private-key singleton ID from the literal string', () => { + expect(toWalletPayloadId('private-key')).toBe('wallet:private-key'); + }); +}); + +describe('migrate', () => { + it('throws if raw is not an object', async () => { + await expect(migrate('not an object')).rejects.toThrow( + 'Invalid AccountTreePayload', + ); + await expect(migrate(null)).rejects.toThrow('Invalid AccountTreePayload'); + await expect(migrate(42)).rejects.toThrow('Invalid AccountTreePayload'); + }); + + it('throws if the wallets field is missing', async () => { + await expect(migrate({})).rejects.toThrow('Invalid AccountTreePayload'); + await expect(migrate({ version: '1' })).rejects.toThrow( + 'Invalid AccountTreePayload', + ); + await expect(migrate({ version: 1.5 })).rejects.toThrow( + 'Invalid AccountTreePayload', + ); + }); + + it('throws if version in the versioned envelope exceeds the current migration version', async () => { + const futureVersion = ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1; + await expect( + migrate({ version: futureVersion, data: { wallets: [] } }), + ).rejects.toThrow( + `State version ${futureVersion} is newer than the latest migration version ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION}`, + ); + }); + + it('returns the inner payload for a valid v1 payload', async () => { + const raw = { wallets: [] }; + const result = await migrate(raw); + expect(result.wallets).toStrictEqual([]); + }); + + it('accepts a valid mnemonic payload', async () => { + const result = await migrate(VALID_MNEMONIC_PAYLOAD); + expect(result.wallets).toHaveLength(1); + expect(result.wallets[0]?.type).toBe('mnemonic'); + }); + + it('accepts a valid private-key payload', async () => { + const result = await migrate(VALID_PRIVATE_KEY_PAYLOAD); + expect(result.wallets).toHaveLength(1); + expect(result.wallets[0]?.type).toBe('private-key'); + }); + + it('throws for an unsupported wallet type', async () => { + await expect( + migrate({ + wallets: [ + { + id: 'wallet:ledger', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).rejects.toThrow('Invalid AccountTreePayload'); + }); + + it('throws when required wallet fields are missing', async () => { + await expect( + migrate({ + wallets: [{ type: 'mnemonic' }], + }), + ).rejects.toThrow('Invalid AccountTreePayload'); + }); + + it('redacts mnemonic secrets in validation error messages', async () => { + const secretMnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + + try { + await migrate({ + wallets: [ + { + id: 'wallet:entropy:mnemonic:abc123', + type: 'mnemonic', + value: 123, + metadata: { name: 'Wallet 1' }, + groups: [], + }, + ], + }); + throw new Error('Expected migrate to throw'); + } catch (error) { + expect(String(error)).toContain('***'); + } + + const validWithSecret = await migrate({ + wallets: [ + { + id: 'wallet:entropy:mnemonic:abc123', + type: 'mnemonic', + value: secretMnemonic, + metadata: { name: 'Wallet 1' }, + groups: [], + }, + ], + }); + expect(validWithSecret.wallets[0]?.type).toBe('mnemonic'); + }); + + it('redacts private keys in validation error messages', async () => { + const secretKey = + '4c0883a69102937d6231471b5dbb6e538eba0ef8b09f0bf4e8b8e1e4e3e3b3c2'; + + try { + await migrate({ + wallets: [ + { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: '' }, + groups: [ + { + id: 'wallet:private-key/0xabc', + value: { + privateKey: secretKey, + encoding: 'invalid-encoding', + }, + metadata: { name: 'Imported', pinned: false, hidden: false }, + }, + ], + }, + ], + }); + throw new Error('Expected migrate to throw'); + } catch (error) { + expect(String(error)).not.toContain(secretKey); + } + }); +}); + +describe('assertValidAccountTreePayload', () => { + it('does not throw for a valid payload', () => { + expect(() => assertValidAccountTreePayload({ wallets: [] })).not.toThrow(); + }); + + it('throws with "Invalid AccountTreePayload:" prefix for an invalid payload', () => { + expect(() => + assertValidAccountTreePayload({ wallets: 'not-an-array' }), + ).toThrow('Invalid AccountTreePayload:'); + }); + + it('throws when a group ID does not match the expected format', () => { + expect(() => + assertValidAccountTreePayload({ + wallets: [ + { + id: 'wallet:entropy', + type: 'mnemonic', + metadata: { name: 'Wallet' }, + groups: [ + { + id: 'no-slash-here', + groupIndex: 0, + metadata: { name: 'Account', pinned: false, hidden: false }, + }, + ], + }, + ], + }), + ).toThrow('Invalid AccountTreePayload:'); + }); +}); + +describe('AccountTreeSnapshot.deserialize validation', () => { + it('rejects payloads with unsupported wallet types', async () => { + await expect( + AccountTreeSnapshot.deserialize({ + wallets: [ + { + id: 'wallet:ledger', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).rejects.toThrow('Invalid AccountTreePayload'); + }); +}); diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts new file mode 100644 index 00000000000..829cd1bf580 --- /dev/null +++ b/packages/account-tree-controller/src/state/payload.ts @@ -0,0 +1,344 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { MigrationChain } from '@metamask/keyring-sdk'; +import { createMigrations } from '@metamask/keyring-sdk'; +import { + assert, + array, + boolean, + define, + enums, + integer, + literal, + object, + exactOptional, + sensitive, + string, + StructError, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; + +/** Stable cross-device wallet identifier. Format: `wallet:`. */ +export type AccountWalletPayloadId = `wallet:${string}`; + +/** Stable cross-device group identifier. Format: `wallet:/`. */ +export type AccountGroupPayloadId = `wallet:${string}/${string}`; + +/** + * Parsed representation of an {@link AccountGroupPayloadId}. + */ +export type ParsedPayloadGroupId = { + /** The wallet portion of the group ID. */ + walletId: AccountWalletPayloadId; + /** The group-specific sub-ID (e.g. group index for mnemonic wallets, address for private-key wallets). */ + subId: string; +}; + +const PAYLOAD_GROUP_ID_REGEX = /^(?wallet:[^/]+)\/(?.+)$/u; + +/** + * Parses a payload group ID into its wallet ID and group sub-ID components. + * + * @param groupId - The payload group ID to parse. + * @returns The parsed wallet ID and group sub-ID. + * @throws If the group ID format is invalid. + */ +export function parsePayloadGroupId( + groupId: AccountGroupPayloadId, +): ParsedPayloadGroupId { + const match = PAYLOAD_GROUP_ID_REGEX.exec(groupId); + if (!match?.groups) { + throw new Error(`Invalid payload group ID: "${groupId}"`); + } + return { + walletId: match.groups.walletId as AccountWalletPayloadId, + subId: match.groups.subId, + }; +} + +/** Wallet-level metadata carried in every payload wallet entry. */ +export type AccountWalletPayloadMetadata = { name: string }; + +/** Group-level metadata carried in every payload group entry. */ +export type AccountWalletGroupPayloadMetadata = { + name: string; + pinned: boolean; + hidden: boolean; +}; + +/** A single group entry inside an {@link AccountWalletMnemonicPayload}. */ +export type AccountWalletMnemonicGroupEntry = { + /** Stable group payload ID. Format: `/`. */ + id: AccountGroupPayloadId; + /** BIP-44 account index this group was derived at. */ + groupIndex: number; + metadata: AccountWalletGroupPayloadMetadata; +}; + +/** A single group entry inside an {@link AccountWalletPrivateKeyPayload}. */ +export type AccountWalletPrivateKeyGroupEntry = { + /** Stable group payload ID. Format: `wallet:private-key/
`. */ + id: AccountGroupPayloadId; + /** + * Private key material. Shape matches `ExportedAccount` from `@metamask/keyring-api/v2` + * so the importer knows how to decode the key without additional out-of-band information. + * Absent in metadata-only exports. + */ + value?: { + privateKey: string; + encoding: 'hexadecimal' | 'base58' | 'base32'; + /** + * Account type from `KeyringAccountType` (e.g. `'eip155:eoa'`, `'bip122:p2wpkh'`). + * Absent for EVM accounts -- import via `SimpleKeyring`. + * Present for non-EVM accounts -- routing to the BIP-44 Snap handling this type is not yet implemented. + */ + type?: KeyringAccount['type']; + }; + metadata: AccountWalletGroupPayloadMetadata; +}; + +/** Payload entry for an HD (entropy) wallet and its derived account groups. */ +export type AccountWalletMnemonicPayload = { + id: AccountWalletPayloadId; + type: 'mnemonic'; + /** BIP-39 mnemonic phrase. Absent in metadata-only exports. */ + value?: string; + metadata: AccountWalletPayloadMetadata; + groups: AccountWalletMnemonicGroupEntry[]; +}; + +/** + * Payload entry for all imported private-key accounts. + * + * All local simple-keyring wallets are merged into this single entry; + * each account is represented as a separate group entry keyed by address. + */ +export type AccountWalletPrivateKeyPayload = { + id: AccountWalletPayloadId; + type: 'private-key'; + metadata: AccountWalletPayloadMetadata; + groups: AccountWalletPrivateKeyGroupEntry[]; +}; + +/** Union of all wallet entry types that can appear in an {@link AccountTreePayload}. */ +export type AccountTreeWalletEntry = + | AccountWalletMnemonicPayload + | AccountWalletPrivateKeyPayload; + +/** Portable snapshot of the full account tree state (inner data, without version envelope). */ +export type AccountTreePayload = { + wallets: AccountTreeWalletEntry[]; +}; + +/** + * Recursively readonly view of `T` used by snapshot filtering predicate types. + * + * @typeParam T - The mutable source type to expose as deeply read-only. + */ +export type DeepReadonly = T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +/** + * Deeply read-only wallet view passed to {@link AccountTreeSnapshot.filterWallets} + * and {@link AccountTreeSnapshot.filterAllGroups} predicates. + * + * Entries are deep-cloned and deep-frozen when a snapshot is constructed, so + * callers cannot mutate wallet IDs, types, secrets, metadata, or groups. + */ +export type AccountTreeSnapshotWallet = DeepReadonly< + AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload +>; + +/** + * Deeply read-only group view passed to {@link AccountTreeSnapshot.filterGroups} + * and {@link AccountTreeSnapshot.filterAllGroups} predicates. + * + * Entries are deep-cloned and deep-frozen when a snapshot is constructed, so + * callers cannot mutate group IDs, secrets, metadata, or parent wallet references. + */ +export type AccountTreeSnapshotGroup = DeepReadonly< + AccountWalletMnemonicGroupEntry | AccountWalletPrivateKeyGroupEntry +>; + +/** + * @deprecated Use {@link AccountTreeSnapshotWallet} instead. + */ +export type AccountTreeSnapshotEntry = AccountTreeSnapshotWallet; + +/** + * Constructs an {@link AccountWalletPayloadId} from an entropy source ID. + * + * @param entropySourceId - Stable entropy source ID returned by `HdKeyring.toEntropySourceId()`. + * @returns The portable wallet payload ID. + */ +export function toWalletPayloadId( + entropySourceId: string, +): AccountWalletPayloadId { + return `wallet:${entropySourceId}`; +} + +/** Options accepted by {@link AccountTreeController.exportState}. */ +export type ExportStateOptions = { + /** When `true`, secrets (mnemonic / private keys) are included. Requires the vault to be unlocked. */ + includeSecrets?: boolean; +}; + +const AccountWalletPayloadIdStruct = define( + 'AccountWalletPayloadId', + (value) => + typeof value === 'string' && value.startsWith('wallet:') + ? true + : 'Expected a wallet payload ID starting with "wallet:"', +); + +const AccountGroupPayloadIdStruct = define( + 'AccountGroupPayloadId', + (value) => + typeof value === 'string' && PAYLOAD_GROUP_ID_REGEX.test(value) + ? true + : 'Expected a group payload ID in the form "wallet:/"', +); + +const AccountWalletPayloadMetadataStruct = object({ + name: string(), +}); + +const AccountWalletGroupPayloadMetadataStruct = object({ + name: string(), + pinned: boolean(), + hidden: boolean(), +}); + +const AccountWalletPrivateKeyValueStruct = object({ + privateKey: sensitive(string()), + encoding: enums(['hexadecimal', 'base58', 'base32']), + type: exactOptional(string()), +}); + +const AccountWalletMnemonicGroupEntryStruct = object({ + id: AccountGroupPayloadIdStruct, + groupIndex: integer(), + metadata: AccountWalletGroupPayloadMetadataStruct, +}); + +const AccountWalletPrivateKeyGroupEntryStruct = object({ + id: AccountGroupPayloadIdStruct, + value: exactOptional(AccountWalletPrivateKeyValueStruct), + metadata: AccountWalletGroupPayloadMetadataStruct, +}); + +const AccountWalletMnemonicPayloadStruct = object({ + id: AccountWalletPayloadIdStruct, + type: literal('mnemonic'), + value: exactOptional(sensitive(string())), + metadata: AccountWalletPayloadMetadataStruct, + groups: array(AccountWalletMnemonicGroupEntryStruct), +}); + +const AccountWalletPrivateKeyPayloadStruct = object({ + id: AccountWalletPayloadIdStruct, + type: literal('private-key'), + metadata: AccountWalletPayloadMetadataStruct, + groups: array(AccountWalletPrivateKeyGroupEntryStruct), +}); + +const AccountTreeWalletEntryStruct = union([ + AccountWalletMnemonicPayloadStruct, + AccountWalletPrivateKeyPayloadStruct, +]); + +/** + * Superstruct schema for an {@link AccountTreePayload} (inner data, without version envelope). + * + * Validates v1 wallet entries (`'mnemonic'` and `'private-key'` only) and + * rejects unsupported wallet types. Secret fields (`value`, `privateKey`) use + * the Superstruct `sensitive()` wrapper so validation failures redact secrets + * from error output. + */ +export const AccountTreePayloadStruct = object({ + wallets: array(AccountTreeWalletEntryStruct), +}); + +/** Inferred TypeScript type for a value matching {@link AccountTreePayloadStruct}. */ +export type AccountTreePayloadStructType = Infer< + typeof AccountTreePayloadStruct +>; + +/** + * Formats Superstruct validation failures into a single error message string. + * + * @param error - The StructError thrown during payload validation. + * @returns A comma-separated list of `[path] message` entries. + */ +const formatValidationErrorMessages = (error: StructError): string => + error + .failures() + .map(({ path, message }) => `[${path.join('.')}] ${message}`) + .join(', '); + +/** + * Asserts that `value` conforms to the v1 {@link AccountTreePayload} schema. + * + * Prefer {@link AccountTreeSnapshot.deserialize} at transport boundaries so + * validation stays paired with snapshot construction. Use this helper when you + * already hold a parsed object and need to assert its shape before further + * processing. + * + * @param value - Value to validate. + * @throws If `value` is not a valid v1 payload, including unsupported wallet types. + */ +export function assertValidAccountTreePayload( + value: unknown, +): asserts value is AccountTreePayload { + try { + assert(value, AccountTreePayloadStruct); + } catch (error) { + if (error instanceof StructError) { + throw new Error( + `Invalid AccountTreePayload: ${formatValidationErrorMessages(error)}`, + ); + } + /* istanbul ignore next */ + throw error; + } +} + +/** + * Migration chain for {@link AccountTreePayload}. + * + * Each `.add()` call appends a step; `migrations.version` equals the number of + * steps and serves as the canonical current version written by + * {@link AccountTreeSnapshot.serialize}. + * + * **v1** — validates and returns the v1 payload structure `{ wallets: [...] }`. + */ +export const migrations: MigrationChain = + createMigrations().add({ + migrate(data: Json): AccountTreePayload { + assertValidAccountTreePayload(data); + return data; + }, + }); + +/** Current version of the {@link AccountTreePayload} format, derived from the migration chain. */ +export const ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION = migrations.version; + +/** + * Validates a raw value as an `AccountTreePayload` and runs any necessary version migrations. + * + * This is the low-level entry point used by {@link AccountTreeSnapshot.deserialize}. + * Callers receiving untrusted wire data should prefer `deserialize`, which returns + * an immutable {@link AccountTreeSnapshot} ready for filtering and import. + * + * @param raw - Unknown value to validate. + * @returns A fully migrated `AccountTreePayload`. + * @throws If `raw` is not a valid payload, its version is unsupported, or any wallet type is unrecognized. + */ +export async function migrate(raw: unknown): Promise { + const { data } = await migrations.apply(raw as Json); + return data; +} diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts new file mode 100644 index 00000000000..1ab3c63a093 --- /dev/null +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -0,0 +1,391 @@ +import { IdMap } from './id-map.js'; +import type { + AccountWalletMnemonicPayload, + AccountWalletPrivateKeyPayload, +} from './payload.js'; +import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION } from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { + id: 'wallet:entropy-source-1', + type: 'mnemonic', + metadata: { name: 'Wallet 1' }, + groups: [ + { + id: 'wallet:entropy-source-1/0', + groupIndex: 0, + metadata: { name: 'Account 1', pinned: false, hidden: false }, + }, + { + id: 'wallet:entropy-source-1/1', + groupIndex: 1, + metadata: { name: 'Account 2', pinned: true, hidden: false }, + }, + ], +}; + +const MOCK_PRIVATE_KEY_WALLET: AccountWalletPrivateKeyPayload = { + id: 'wallet:private-key', + type: 'private-key', + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: 'wallet:private-key/0xdeadbeef', + metadata: { name: 'Imported 1', pinned: false, hidden: true }, + }, + ], +}; + +function buildIdMap(): IdMap { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + map.add('entropy:wallet-1/0', 'wallet:entropy-source-1/0'); + map.add('entropy:wallet-1/1', 'wallet:entropy-source-1/1'); + map.add('keyring:simple', 'wallet:private-key'); + map.add('keyring:simple/0xdeadbeef', 'wallet:private-key/0xdeadbeef'); + return map; +} + +describe('AccountTreeSnapshot', () => { + describe('immutability', () => { + it('deep-freezes entries at construction so predicates cannot mutate them', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + + expect(() => + snapshot.filterWallets((wallet) => { + (wallet.metadata as { name: string }).name = 'hacked'; + return true; + }), + ).toThrow(TypeError); + + expect(snapshot.serialize().data.wallets[0]?.metadata.name).toBe( + 'Wallet 1', + ); + }); + }); + + describe('filterWallets', () => { + it('returns a snapshot containing only matching entries', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const filtered = snapshot.filterWallets( + (wallet) => wallet.type === 'mnemonic', + ); + expect(filtered.serialize().data.wallets).toHaveLength(1); + expect(filtered.serialize().data.wallets[0]?.id).toBe( + 'wallet:entropy-source-1', + ); + }); + + it('preserves absent idMap when filtering', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const filtered = snapshot.filterWallets(() => true); + expect(filtered.toLocalId('wallet:entropy-source-1')).toBeUndefined(); + }); + + it('preserves the original idMap through wallet filtering', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filterWallets( + (wallet) => wallet.type === 'mnemonic', + ); + + expect(filtered.toLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + expect(filtered.toLocalId('wallet:entropy-source-1/0')).toBe( + 'entropy:wallet-1/0', + ); + expect(filtered.toLocalId('wallet:private-key')).toBe('keyring:simple'); + expect(filtered.toLocalId('wallet:private-key/0xdeadbeef')).toBe( + 'keyring:simple/0xdeadbeef', + ); + }); + + it('handles wallet entries whose IDs are not in the idMap', () => { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filterWallets(() => true); + expect(filtered.toLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + expect(filtered.toLocalId('wallet:private-key')).toBeUndefined(); + }); + }); + + describe('filterGroups', () => { + it('filters groups within a single wallet and leaves others unchanged', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterGroups( + 'wallet:entropy-source-1', + (group) => group.id.endsWith('/0'), + ); + + const wallets = filtered.serialize().data.wallets; + expect(wallets).toHaveLength(2); + expect(wallets[0]?.groups).toHaveLength(1); + expect(wallets[0]?.groups[0]?.id).toBe('wallet:entropy-source-1/0'); + expect(wallets[1]?.groups).toHaveLength(1); + }); + + it('removes the wallet when all groups are filtered out', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterGroups( + 'wallet:entropy-source-1', + () => false, + ); + + expect(filtered.serialize().data.wallets).toHaveLength(1); + expect(filtered.serialize().data.wallets[0]?.type).toBe('private-key'); + }); + + it('filters private-key wallet groups and preserves the idMap', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filterGroups('wallet:private-key', () => true); + + expect(filtered.serialize().data.wallets).toHaveLength(2); + expect(filtered.toLocalId('wallet:private-key/0xdeadbeef')).toBe( + 'keyring:simple/0xdeadbeef', + ); + }); + + it('preserves the idMap when filtering mnemonic wallet groups', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filterGroups( + 'wallet:entropy-source-1', + (group) => group.id.endsWith('/0'), + ); + + expect(filtered.toLocalId('wallet:entropy-source-1/0')).toBe( + 'entropy:wallet-1/0', + ); + expect(filtered.toLocalId('wallet:entropy-source-1/1')).toBe( + 'entropy:wallet-1/1', + ); + }); + + it('throws when the wallet ID is not in the snapshot', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + + expect(() => snapshot.filterGroups('wallet:missing', () => true)).toThrow( + 'wallet "wallet:missing" not found in snapshot', + ); + }); + }); + + describe('filterAllGroups', () => { + it('filters groups across all wallets and removes empty wallets', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterAllGroups((group) => + group.id.endsWith('/0'), + ); + + const wallets = filtered.serialize().data.wallets; + expect(wallets).toHaveLength(1); + expect(wallets[0]?.type).toBe('mnemonic'); + expect(wallets[0]?.groups).toHaveLength(1); + }); + + it('provides the parent wallet to the predicate', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterAllGroups( + (_group, wallet) => wallet.type === 'private-key', + ); + + expect(filtered.serialize().data.wallets).toHaveLength(1); + expect(filtered.serialize().data.wallets[0]?.type).toBe('private-key'); + }); + + it('preserves the idMap when filtering all groups', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filterAllGroups( + (_group, wallet) => wallet.type === 'mnemonic', + ); + + expect(filtered.toLocalId('wallet:entropy-source-1/0')).toBe( + 'entropy:wallet-1/0', + ); + expect(filtered.toLocalId('wallet:private-key')).toBe('keyring:simple'); + }); + }); + + describe('toLocalId', () => { + it('returns the local ID for a known payload wallet ID', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + expect(snapshot.toLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + }); + + it('returns the local ID for a known payload group ID', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + expect(snapshot.toLocalId('wallet:entropy-source-1/0')).toBe( + 'entropy:wallet-1/0', + ); + }); + + it('returns undefined when no idMap is present', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + expect(snapshot.toLocalId('wallet:entropy-source-1')).toBeUndefined(); + }); + + it('returns undefined for an unknown payload ID', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET], + new IdMap(), + ); + expect(snapshot.toLocalId('wallet:unknown')).toBeUndefined(); + }); + }); + + describe('toPayloadId', () => { + it('returns the payload ID for a known local wallet ID', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + expect(snapshot.toPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-1', + ); + }); + + it('returns the payload ID for a known local group ID', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + expect(snapshot.toPayloadId('entropy:wallet-1/0')).toBe( + 'wallet:entropy-source-1/0', + ); + }); + + it('returns undefined when no idMap is present', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + expect(snapshot.toPayloadId('entropy:wallet-1')).toBeUndefined(); + }); + + it('returns undefined for an unknown local ID', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET], + new IdMap(), + ); + expect(snapshot.toPayloadId('entropy:wallet-unknown')).toBeUndefined(); + }); + }); + + describe('serialize', () => { + it('serializes to a versioned state envelope wrapping the account tree payload', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const payload = snapshot.serialize(); + expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); + expect(payload.data.wallets).toHaveLength(2); + expect(payload.data.wallets[0]).toStrictEqual(MOCK_MNEMONIC_WALLET); + expect(payload.data.wallets[1]).toStrictEqual(MOCK_PRIVATE_KEY_WALLET); + expect(Object.isFrozen(payload.data.wallets)).toBe(true); + }); + + it('serializes an empty snapshot', () => { + const snapshot = new AccountTreeSnapshot([]); + const payload = snapshot.serialize(); + expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); + expect(payload.data.wallets).toHaveLength(0); + }); + }); + + describe('deserialize', () => { + it('deserializes a valid v1 payload into a snapshot', async () => { + const raw = { wallets: [MOCK_MNEMONIC_WALLET] }; + const snapshot = await AccountTreeSnapshot.deserialize(raw); + expect(snapshot.serialize().data.wallets).toHaveLength(1); + expect(snapshot.serialize().data.wallets[0]?.id).toBe( + 'wallet:entropy-source-1', + ); + }); + + it('returns a snapshot with no idMap (toLocalId returns undefined)', async () => { + const raw = { wallets: [MOCK_MNEMONIC_WALLET] }; + const snapshot = await AccountTreeSnapshot.deserialize(raw); + expect(snapshot.toLocalId('wallet:entropy-source-1')).toBeUndefined(); + expect(snapshot.toPayloadId('entropy:wallet-1')).toBeUndefined(); + }); + + it('throws for an invalid payload (missing wallets field)', async () => { + await expect( + AccountTreeSnapshot.deserialize({}), + ).rejects.toThrow('Invalid AccountTreePayload'); + }); + + it('throws for a future version in the versioned envelope', async () => { + await expect( + AccountTreeSnapshot.deserialize({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1, + data: { wallets: [] }, + }), + ).rejects.toThrow( + `State version ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1} is newer than the latest migration version`, + ); + }); + + it('throws for an unsupported wallet type', async () => { + await expect( + AccountTreeSnapshot.deserialize({ + wallets: [ + { + id: 'wallet:ledger', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).rejects.toThrow('Invalid AccountTreePayload'); + }); + }); +}); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts new file mode 100644 index 00000000000..a6cb8904b38 --- /dev/null +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -0,0 +1,246 @@ +import type { VersionedState } from '@metamask/keyring-sdk'; +import type { IdMap } from './id-map.js'; +import type { + AccountGroupPayloadId, + AccountTreePayload, + AccountTreeSnapshotGroup, + AccountTreeSnapshotWallet, + AccountTreeWalletEntry, + AccountWalletMnemonicGroupEntry, + AccountWalletMnemonicPayload, + AccountWalletPayloadId, + AccountWalletPrivateKeyGroupEntry, + AccountWalletPrivateKeyPayload, +} from './payload.js'; +import { migrations, migrate } from './payload.js'; + +/** + * Recursively freezes a value and its nested properties. + * + * @param value - Value to freeze. + * @returns The frozen value. + */ +function deepFreeze(value: T): T { + if (value === null || typeof value !== 'object') { + return value; + } + + Object.freeze(value); + + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + + return value; +} + +/** + * Immutable value object returned by {@link AccountTreeController.exportState}. + * + * Construct with {@link AccountTreeController.exportState}, + * {@link AccountTreeSnapshot.deserialize}, or `new AccountTreeSnapshot(...)` + * for tests and advanced use. + * + * Wallet and group entries are deep-cloned and deep-frozen once in the + * constructor. Filtering predicates receive those read-only views directly; + * each filter method returns a new snapshot that repeats the process for its + * result. + * + * An optional ID map (local ↔ payload) may be supplied when bridging between + * internal controller IDs and the stable cross-device IDs in the serialized + * payload. The map covers the original export and is preserved unchanged + * through filtering until {@link serialize}. Omit it when deterministic IDs + * make {@link toLocalId} / {@link toPayloadId} unnecessary. + */ +export class AccountTreeSnapshot { + readonly #entries: AccountTreeWalletEntry[]; + + readonly #idMap: IdMap | undefined; + + /** + * @param entries - Wallet entries in the snapshot. + * @param idMap - Optional local ↔ payload ID map from export. + */ + constructor(entries: AccountTreeWalletEntry[], idMap?: IdMap) { + this.#entries = deepFreeze(structuredClone(entries)); + this.#idMap = idMap; + } + + /** + * Returns a new snapshot containing only the wallets for which + * `predicate` returns `true`. + * + * When filtering by wallet ID, compare against stable payload IDs from + * {@link serialize} or convert local IDs with {@link toPayloadId} first. + * + * @param predicate - Function called with each deeply read-only wallet entry. + * @returns A filtered snapshot. + */ + filterWallets( + predicate: (wallet: AccountTreeSnapshotWallet) => boolean, + ): AccountTreeSnapshot { + const filteredEntries = this.#entries.filter((entry) => + predicate(entry as AccountTreeSnapshotWallet), + ); + + return new AccountTreeSnapshot(filteredEntries, this.#idMap); + } + + /** + * Filters groups within one wallet. Other wallets are left unchanged. + * + * Throws if `walletId` does not identify a wallet in the snapshot. + * Removes the wallet if no groups remain after filtering — this prevents a + * mnemonic wallet with zero selected groups from still transferring its secret. + * + * @param walletId - Stable payload wallet ID to filter groups within. + * @param predicate - Function called with each deeply read-only group entry. + * @returns A filtered snapshot. + * @throws If `walletId` is not present in the snapshot. + */ + filterGroups( + walletId: AccountWalletPayloadId, + predicate: (group: AccountTreeSnapshotGroup) => boolean, + ): AccountTreeSnapshot { + const walletIndex = this.#entries.findIndex( + (entry) => entry.id === walletId, + ); + if (walletIndex === -1) { + throw new Error( + `Cannot filter groups: wallet "${walletId}" not found in snapshot`, + ); + } + + const wallet = this.#entries[walletIndex]; + + const filteredGroups = wallet.groups.filter((group) => + predicate(group as AccountTreeSnapshotGroup), + ); + + const filteredEntries = [...this.#entries]; + if (filteredGroups.length === 0) { + filteredEntries.splice(walletIndex, 1); + } else if (wallet.type === 'mnemonic') { + filteredEntries[walletIndex] = { + ...wallet, + groups: filteredGroups as AccountWalletMnemonicGroupEntry[], + }; + } else { + filteredEntries[walletIndex] = { + ...wallet, + groups: filteredGroups as AccountWalletPrivateKeyGroupEntry[], + }; + } + + return new AccountTreeSnapshot(filteredEntries, this.#idMap); + } + + /** + * Filters groups across every wallet. + * + * The parent wallet is provided as context to the predicate. Removes any + * wallet with no remaining groups after filtering. + * + * @param predicate - Function called with each group and its parent wallet. + * @returns A filtered snapshot. + */ + filterAllGroups( + predicate: ( + group: AccountTreeSnapshotGroup, + wallet: AccountTreeSnapshotWallet, + ) => boolean, + ): AccountTreeSnapshot { + const filteredEntries: AccountTreeWalletEntry[] = []; + + for (const wallet of this.#entries) { + const filteredGroups = wallet.groups.filter((group) => + predicate( + group as AccountTreeSnapshotGroup, + wallet as AccountTreeSnapshotWallet, + ), + ); + + if (filteredGroups.length === 0) { + continue; + } + + if (wallet.type === 'mnemonic') { + filteredEntries.push({ + ...wallet, + groups: filteredGroups as AccountWalletMnemonicGroupEntry[], + }); + } else { + filteredEntries.push({ + ...wallet, + groups: filteredGroups as AccountWalletPrivateKeyGroupEntry[], + }); + } + } + + return new AccountTreeSnapshot(filteredEntries, this.#idMap); + } + + /** + * Converts a payload ID (wallet or group) to the corresponding local + * `AccountTreeController` ID. + * + * The map reflects the original export, not the wallets/groups currently + * retained in this snapshot after filtering. + * + * @param payloadId - Stable cross-device wallet or group payload ID. + * @returns The local controller ID, or `undefined` if not found or no ID map is present. + */ + toLocalId( + payloadId: AccountWalletPayloadId | AccountGroupPayloadId, + ): ReturnType { + return this.#idMap?.getLocalId(payloadId); + } + + /** + * Converts a local `AccountTreeController` ID (wallet or group) to its + * stable cross-device payload ID. + * + * The map reflects the original export, not the wallets/groups currently + * retained in this snapshot after filtering. + * + * @param localId - Local controller wallet or group ID. + * @returns The payload ID, or `undefined` if not found or no ID map is present. + */ + toPayloadId( + localId: Parameters[0], + ): ReturnType { + return this.#idMap?.getPayloadId(localId); + } + + /** + * Serializes the snapshot to a versioned state envelope wrapping the {@link AccountTreePayload}. + * + * Returns the constructor-frozen wallet tree without copying it again. + * + * @returns The versioned payload envelope. + */ + serialize(): VersionedState { + return { version: migrations.version, data: { wallets: this.#entries } }; + } + + /** + * Validates a raw value as an {@link AccountTreePayload}, running any + * necessary version migrations, and returns an immutable snapshot. + * + * This is the entry point for untrusted serialized data. Unsupported schema + * versions and wallet types fail closed with an error instead of returning a + * partial snapshot. + * + * The returned snapshot has no ID map — {@link toLocalId} / {@link toPayloadId} + * return `undefined`. Pass an {@link IdMap} to the constructor when you need + * the map. + * + * @param raw - Unknown value to parse. + * @returns A validated snapshot. + * @throws If `raw` is not a valid payload or its version is unsupported. + */ + static async deserialize(raw: unknown): Promise { + const payload = await migrate(raw); + return new AccountTreeSnapshot(payload.wallets); + } +} diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 4fff1d42f6d..e506a5d3e7b 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -14,11 +14,16 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; -import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerWithKeyringV2Action, + KeyringControllerWithKeyringV2UnsafeAction, +} from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { MultichainAccountServiceCreateMultichainAccountGroupAction, MultichainAccountServiceCreateMultichainAccountGroupsAction, + MultichainAccountServiceCreateMultichainAccountWalletAction, } from '@metamask/multichain-account-service'; import type { MultichainAccountServiceWalletStatusChangeEvent } from '@metamask/multichain-account-service'; import type { @@ -94,7 +99,10 @@ export type AllowedActions = | UserStorageController.UserStorageControllerPerformBatchSetStorageAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | MultichainAccountServiceCreateMultichainAccountGroupAction - | MultichainAccountServiceCreateMultichainAccountGroupsAction; + | MultichainAccountServiceCreateMultichainAccountGroupsAction + | MultichainAccountServiceCreateMultichainAccountWalletAction + | KeyringControllerWithKeyringV2Action + | KeyringControllerWithKeyringV2UnsafeAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index 3516c81a177..71fc3d5c48e 100644 --- a/packages/account-tree-controller/tests/mockMessenger.ts +++ b/packages/account-tree-controller/tests/mockMessenger.ts @@ -62,7 +62,11 @@ export function getAccountTreeControllerMessenger( 'UserStorageController:performBatchSetStorage', 'AuthenticationController:getSessionProfile', 'MultichainAccountService:createMultichainAccountGroup', + 'MultichainAccountService:createMultichainAccountGroups', + 'MultichainAccountService:createMultichainAccountWallet', 'KeyringController:getState', + 'KeyringController:withKeyringV2', + 'KeyringController:withKeyringV2Unsafe', 'SnapController:getSnap', ], }); diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index d5bb680fd72..5bec2e98b7b 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/eth-snap-keyring` from `^23.0.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.2` to `^12.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-sdk` from `^2.2.0` to `^3.1.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-utils` from `^3.3.1` to `^5.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) ## [39.0.6] diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index ce57cf530e7..23be721d33c 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -57,12 +57,12 @@ "dependencies": { "@ethereumjs/util": "^9.1.0", "@metamask/base-controller": "^9.1.0", - "@metamask/eth-snap-keyring": "^23.0.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/eth-snap-keyring": "^24.0.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", - "@metamask/keyring-internal-api": "^11.0.2", - "@metamask/keyring-sdk": "^2.2.0", - "@metamask/keyring-utils": "^3.3.1", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-sdk": "^3.1.0", + "@metamask/keyring-utils": "^5.0.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^35.0.1", "@metamask/superstruct": "^3.1.0", diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 7e898c3ca81..d57de92c105 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/phishing-controller` from `^17.3.0` to `^17.3.1` ([#9746](https://github.com/MetaMask/core/pull/9746)) +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.2` to `^12.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-snap-client` from `^9.2.1` to `^10.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) ## [13.1.0] diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index b00513ccc1b..cec61a63e4a 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -66,10 +66,10 @@ "@metamask/config-registry-controller": "^2.0.0", "@metamask/controller-utils": "^12.3.0", "@metamask/core-backend": "^8.1.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", - "@metamask/keyring-internal-api": "^11.0.2", - "@metamask/keyring-snap-client": "^9.2.1", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-snap-client": "^10.0.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^35.0.1", "@metamask/network-enablement-controller": "^6.0.2", diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f87e2bf8feb..0b14ff478a3 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/phishing-controller` from `^17.3.0` to `^17.3.1` ([#9746](https://github.com/MetaMask/core/pull/9746)) +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) ## [110.1.0] diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 366d790e329..250036f5f05 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -70,7 +70,7 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/core-backend": "^8.1.0", "@metamask/eth-query": "^4.0.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", @@ -104,11 +104,11 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/account-api": "^1.1.1", + "@metamask/account-api": "^2.0.0", "@metamask/auto-changelog": "^6.1.0", "@metamask/ethjs-provider-http": "^0.3.0", - "@metamask/keyring-internal-api": "^11.0.2", - "@metamask/keyring-snap-client": "^9.2.1", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-snap-client": "^10.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 4031d3c8681..f102d46b236 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/assets-controller` from `^13.0.0` to `^13.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) - Bump `@metamask/assets-controllers` from `^110.0.3` to `^110.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) ## [78.0.3] diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 15487e8ca0c..95985b5e49b 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -65,7 +65,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", "@metamask/gas-fee-controller": "^26.3.1", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/multichain-network-controller": "^3.2.2", diff --git a/packages/chain-agnostic-permission/package.json b/packages/chain-agnostic-permission/package.json index 6343b712ecb..7d3daad684a 100644 --- a/packages/chain-agnostic-permission/package.json +++ b/packages/chain-agnostic-permission/package.json @@ -62,7 +62,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/keyring-internal-api": "^11.0.2", + "@metamask/keyring-internal-api": "^12.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 0d9765451ce..dd4aa172546 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) + ## [1.6.0] ### Added @@ -20,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - STANDARD sends with empty `valueTransfers` synthesize a native token from `tx.value` - Bump `@metamask/core-backend` from `^8.0.0` to `^8.1.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) - Bump `@metamask/transaction-controller` from `^69.3.0` to `^69.4.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) ### Fixed diff --git a/packages/client-utils/package.json b/packages/client-utils/package.json index 2a34d05502f..c9a38c9bd4d 100644 --- a/packages/client-utils/package.json +++ b/packages/client-utils/package.json @@ -58,7 +58,7 @@ "@metamask/contract-metadata": "^2.4.0", "@metamask/controller-utils": "^12.3.0", "@metamask/core-backend": "^8.1.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/slip44": "^4.3.0", "@metamask/transaction-controller": "^69.4.0", "@metamask/utils": "^11.11.0", diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index 7b5f57ecc22..7e708b1ba47 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) ## [12.2.3] diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index f3e4b18e35f..d20e3747c1c 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -60,7 +60,7 @@ "@metamask/account-tree-controller": "^7.5.5", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^35.0.1", "@metamask/stake-sdk": "^3.2.1", diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index b61d42e1a37..c78e23f77ef 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.7.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^24.0.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) - Add missing dependency `@metamask/controller-utils` (`^12.3.0`) ([#8384](https://github.com/MetaMask/core/pull/8384)) -- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^11.0.2` ([#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^12.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/eth-hd-keyring` from `^14.1.1` to `^15.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/eth-simple-keyring` from `^12.0.2` to `^13.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) ## [27.1.0] diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index 72b22df1f45..17382569ef3 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -59,11 +59,11 @@ "@metamask/base-controller": "^9.1.0", "@metamask/browser-passworder": "^6.0.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/eth-hd-keyring": "^14.1.1", + "@metamask/eth-hd-keyring": "^15.0.0", "@metamask/eth-sig-util": "^8.2.0", - "@metamask/eth-simple-keyring": "^12.0.2", - "@metamask/keyring-api": "^23.7.0", - "@metamask/keyring-internal-api": "^11.0.2", + "@metamask/eth-simple-keyring": "^13.0.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-internal-api": "^12.0.0", "@metamask/messenger": "^2.0.0", "@metamask/utils": "^11.11.0", "async-mutex": "^0.5.0", @@ -78,7 +78,7 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^6.1.0", - "@metamask/keyring-utils": "^3.3.1", + "@metamask/keyring-utils": "^5.0.0", "@metamask/scure-bip39": "^2.1.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/money-account-controller/CHANGELOG.md b/packages/money-account-controller/CHANGELOG.md index 903b01ad8c9..b0a3016ef5e 100644 --- a/packages/money-account-controller/CHANGELOG.md +++ b/packages/money-account-controller/CHANGELOG.md @@ -11,12 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `eth_signTransaction` account method ([#9763](https://github.com/MetaMask/core/pull/9763)) - Money accounts were not supposed to sign transaction, the support has been removed from the keyring. -- Bump `@metamask/eth-money-keyring` from `^2.0.4` to `^3.0.1` ([#9763](https://github.com/MetaMask/core/pull/9763)) +- Bump `@metamask/eth-money-keyring` from `^2.0.4` to `^4.0.0` ([#9763](https://github.com/MetaMask/core/pull/9763), [#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) - Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.6` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231), [#9349](https://github.com/MetaMask/core/pull/9349), [#9470](https://github.com/MetaMask/core/pull/9470), [#9735](https://github.com/MetaMask/core/pull/9735)) -- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.7.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^24.0.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/eth-money-keyring` from `^2.0.4` to `^4.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) ## [0.3.3] diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 6c773fbd70f..4e0682ee2bc 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -57,15 +57,15 @@ "dependencies": { "@metamask/accounts-controller": "^39.0.6", "@metamask/base-controller": "^9.1.0", - "@metamask/eth-money-keyring": "^3.0.1", - "@metamask/keyring-api": "^23.7.0", + "@metamask/eth-money-keyring": "^4.0.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^2.0.0", "async-mutex": "^0.5.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/keyring-utils": "^3.3.1", + "@metamask/keyring-utils": "^5.0.0", "@metamask/utils": "^11.11.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index a1418444a72..88cf8616efd 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -9,13 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/account-api` from `^1.0.4` to `^1.1.1` ([#9676](https://github.com/MetaMask/core/pull/9676)) - - Moved this peer dependency as direct dependency to follow the same pattern than `@metamask/keyring-api`. -- Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676)) -- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^11.0.2` ([#9676](https://github.com/MetaMask/core/pull/9676)) -- Bump `@metamask/keyring-snap-client` from `^9.2.0` to `^9.2.1` ([#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/account-api` from `^1.0.4` to `^2.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-api` from `^23.5.0` to `^24.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^12.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-snap-client` from `^9.2.0` to `^10.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/snap-account-service` from `^2.0.0` to `^2.1.1` ([#9716](https://github.com/MetaMask/core/pull/9716), [#9736](https://github.com/MetaMask/core/pull/9736)) - Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.6` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/eth-snap-keyring` from `^23.0.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-utils` from `^3.3.1` to `^5.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) ## [13.0.0] diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index f833fc79e22..0cfc86f5fbc 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -56,16 +56,16 @@ }, "dependencies": { "@ethereumjs/util": "^9.1.0", - "@metamask/account-api": "^1.1.1", + "@metamask/account-api": "^2.0.0", "@metamask/accounts-controller": "^39.0.6", "@metamask/base-controller": "^9.1.0", - "@metamask/eth-snap-keyring": "^23.0.0", + "@metamask/eth-snap-keyring": "^24.0.0", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", - "@metamask/keyring-internal-api": "^11.0.2", - "@metamask/keyring-snap-client": "^9.2.1", - "@metamask/keyring-utils": "^3.3.1", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-snap-client": "^10.0.0", + "@metamask/keyring-utils": "^5.0.0", "@metamask/messenger": "^2.0.0", "@metamask/snap-account-service": "^2.1.1", "@metamask/snaps-controllers": "^19.0.0", @@ -79,7 +79,7 @@ "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/eth-hd-keyring": "^14.1.1", + "@metamask/eth-hd-keyring": "^15.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md index c0cc8f86ec1..0deb6df21da 100644 --- a/packages/multichain-network-controller/CHANGELOG.md +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.2` to `^12.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) ## [3.2.2] diff --git a/packages/multichain-network-controller/package.json b/packages/multichain-network-controller/package.json index 580ea050a4e..f79dffd2641 100644 --- a/packages/multichain-network-controller/package.json +++ b/packages/multichain-network-controller/package.json @@ -58,8 +58,8 @@ "@metamask/accounts-controller": "^39.0.6", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/keyring-api": "^23.7.0", - "@metamask/keyring-internal-api": "^11.0.2", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-internal-api": "^12.0.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^35.0.1", "@metamask/superstruct": "^3.1.0", diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index 52df40a2df8..a6ea163ba15 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -12,10 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.6` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231), [#9349](https://github.com/MetaMask/core/pull/9349), [#9470](https://github.com/MetaMask/core/pull/9470), [#9735](https://github.com/MetaMask/core/pull/9735)) - Bump `@metamask/polling-controller` from `^16.0.6` to `^16.0.9` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9349](https://github.com/MetaMask/core/pull/9349), [#9735](https://github.com/MetaMask/core/pull/9735)) -- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.7.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^24.0.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) -- Bump `@metamask/keyring-snap-client` from `^9.0.2` to `^9.2.1` ([#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676)) -- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^11.0.2` ([#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/keyring-snap-client` from `^9.0.2` to `^10.0.0` ([#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^12.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) ## [7.1.1] diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json index d161ad66e07..65d6d2af28a 100644 --- a/packages/multichain-transactions-controller/package.json +++ b/packages/multichain-transactions-controller/package.json @@ -57,9 +57,9 @@ "dependencies": { "@metamask/accounts-controller": "^39.0.6", "@metamask/base-controller": "^9.1.0", - "@metamask/keyring-api": "^23.7.0", - "@metamask/keyring-internal-api": "^11.0.2", - "@metamask/keyring-snap-client": "^9.2.1", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-snap-client": "^10.0.0", "@metamask/messenger": "^2.0.0", "@metamask/polling-controller": "^16.0.9", "@metamask/snaps-controllers": "^19.0.0", diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index a6678d24c31..3993ec3a07c 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) ## [6.0.2] diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index fb877fe5c98..62438235072 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -58,7 +58,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/config-registry-controller": "^2.0.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/messenger": "^2.0.0", "@metamask/multichain-network-controller": "^3.2.2", "@metamask/network-controller": "^35.0.1", diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index f8db4443f3b..94a9f3b6919 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -115,7 +115,7 @@ "@metamask/auto-changelog": "^6.1.0", "@metamask/geolocation-controller": "^1.0.0", "@metamask/keyring-controller": "^27.1.0", - "@metamask/keyring-internal-api": "^11.0.2", + "@metamask/keyring-internal-api": "^12.0.0", "@metamask/network-controller": "^35.0.1", "@metamask/profile-sync-controller": "^28.3.0", "@metamask/remote-feature-flag-controller": "^5.0.0", diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index 855084c4125..555bfb4247f 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -73,7 +73,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/keyring-internal-api": "^11.0.2", + "@metamask/keyring-internal-api": "^12.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 9b84bf63e10..4490450560c 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -127,8 +127,8 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^6.1.0", - "@metamask/keyring-api": "^23.7.0", - "@metamask/keyring-internal-api": "^11.0.2", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-internal-api": "^12.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/snap-account-service/CHANGELOG.md b/packages/snap-account-service/CHANGELOG.md index 3c1e437d0af..c187fa083b2 100644 --- a/packages/snap-account-service/CHANGELOG.md +++ b/packages/snap-account-service/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/account-api` from `^1.1.1` to `^2.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/eth-snap-keyring` from `^23.0.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-snap-client` from `^10.0.5` to `^11.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-snap-sdk` from `^9.2.1` to `^10.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) + ## [2.1.1] ### Fixed diff --git a/packages/snap-account-service/package.json b/packages/snap-account-service/package.json index c4a3e916e5d..6f627d09d4b 100644 --- a/packages/snap-account-service/package.json +++ b/packages/snap-account-service/package.json @@ -55,12 +55,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/account-api": "^1.1.1", - "@metamask/eth-snap-keyring": "^23.0.0", - "@metamask/keyring-api": "^23.7.0", + "@metamask/account-api": "^2.0.0", + "@metamask/eth-snap-keyring": "^24.0.0", + "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", - "@metamask/keyring-internal-snap-client": "^10.0.5", - "@metamask/keyring-snap-sdk": "^9.2.1", + "@metamask/keyring-internal-snap-client": "^11.0.0", + "@metamask/keyring-snap-sdk": "^10.0.0", "@metamask/messenger": "^2.0.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", @@ -69,7 +69,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", - "@metamask/keyring-utils": "^3.3.1", + "@metamask/keyring-utils": "^5.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index 1f618e28bc6..12a5b1eb233 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5724,14 +5724,14 @@ __metadata: languageName: node linkType: hard -"@metamask/account-api@npm:^1.1.1": - version: 1.1.1 - resolution: "@metamask/account-api@npm:1.1.1" +"@metamask/account-api@npm:^2.0.0": + version: 2.0.0 + resolution: "@metamask/account-api@npm:2.0.0" dependencies: - "@metamask/keyring-api": "npm:^23.6.0" - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" uuid: "npm:^9.0.1" - checksum: 10/2991dbfbbbe12437c2267c8be7f52f2eea8f6c3a021bcc21c30a07506a02894b207f29775be5e4e81a7453fc23dbafe94013fe1f48a3d6044d4527f95ae91827 + checksum: 10/c2296525b8aa4ecdd8d857b5399ae14bcc17633c86023663fe0ed74afdd90ad930253a81b7b6bbce7b12046442b75c1c265f401342f126f4a63c44376956d1d3 languageName: node linkType: hard @@ -5739,12 +5739,14 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" dependencies: - "@metamask/account-api": "npm:^1.1.1" + "@metamask/account-api": "npm:^2.0.0" "@metamask/accounts-controller": "npm:^39.0.6" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/eth-hd-keyring": "npm:^15.0.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" + "@metamask/keyring-sdk": "npm:^3.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/multichain-account-service": "npm:^13.0.0" "@metamask/profile-sync-controller": "npm:^28.3.0" @@ -5752,7 +5754,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.1.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -5780,12 +5782,12 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-snap-keyring": "npm:^23.0.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/eth-snap-keyring": "npm:^24.0.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" - "@metamask/keyring-sdk": "npm:^2.2.0" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-sdk": "npm:^3.1.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/providers": "npm:^22.1.0" @@ -5994,10 +5996,10 @@ __metadata: "@metamask/config-registry-controller": "npm:^2.0.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/core-backend": "npm:^8.1.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" - "@metamask/keyring-snap-client": "npm:^9.2.1" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-snap-client": "npm:^10.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/network-enablement-controller": "npm:^6.0.2" @@ -6039,7 +6041,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-api": "npm:^1.1.1" + "@metamask/account-api": "npm:^2.0.0" "@metamask/account-tree-controller": "npm:^7.5.5" "@metamask/accounts-controller": "npm:^39.0.6" "@metamask/approval-controller": "npm:^9.0.2" @@ -6050,10 +6052,10 @@ __metadata: "@metamask/core-backend": "npm:^8.1.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" - "@metamask/keyring-snap-client": "npm:^9.2.1" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-snap-client": "npm:^10.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^13.0.0" @@ -6267,7 +6269,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.1" "@metamask/gas-fee-controller": "npm:^26.3.1" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-network-controller": "npm:^3.2.2" @@ -6368,7 +6370,7 @@ __metadata: "@metamask/api-specs": "npm:^0.15.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" + "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/permission-controller": "npm:^13.1.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" @@ -6461,7 +6463,7 @@ __metadata: "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/core-backend": "npm:^8.1.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/slip44": "npm:^4.3.0" "@metamask/transaction-controller": "npm:^69.4.0" "@metamask/utils": "npm:^11.11.0" @@ -6806,7 +6808,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/stake-sdk": "npm:^3.2.1" @@ -7006,22 +7008,22 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-hd-keyring@npm:^14.1.1, @metamask/eth-hd-keyring@npm:^14.1.2": - version: 14.1.2 - resolution: "@metamask/eth-hd-keyring@npm:14.1.2" +"@metamask/eth-hd-keyring@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eth-hd-keyring@npm:15.0.0" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@ethereumjs/util": "npm:^9.1.0" "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/key-tree": "npm:^10.0.2" - "@metamask/keyring-api": "npm:^23.6.0" - "@metamask/keyring-sdk": "npm:^2.3.0" - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-sdk": "npm:^3.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" ethereum-cryptography: "npm:^2.2.1" - checksum: 10/077d6aacc674903d737948dfb5a3215e912e73afabad14429614ca643269150604b8ded8cbd48601f99b532c07e51c8d6b1036bad304a21a5b2ded3ea7d06c02 + checksum: 10/35bceeb450460104f996e73032cae584247461a792e733ed290cc3bd387434f86fd94e2b8f8dac792933987628d3a6febdcc0c93d9e5c349a4d9b3b0cd7d0167 languageName: node linkType: hard @@ -7114,17 +7116,17 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-money-keyring@npm:^3.0.1": - version: 3.0.1 - resolution: "@metamask/eth-money-keyring@npm:3.0.1" +"@metamask/eth-money-keyring@npm:^4.0.0": + version: 4.0.0 + resolution: "@metamask/eth-money-keyring@npm:4.0.0" dependencies: - "@metamask/eth-hd-keyring": "npm:^14.1.2" - "@metamask/keyring-api": "npm:^23.6.0" - "@metamask/keyring-sdk": "npm:^2.3.0" - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/eth-hd-keyring": "npm:^15.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-sdk": "npm:^3.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" async-mutex: "npm:^0.5.0" - checksum: 10/98307e38e51d7efcb7bcb485ef9771de8ad31fc83ec681da60112df714d1c999ec59ef7eb3709317d3a4d7edb3226370226b8db0aad9d5c1e12e0575d196d8bc + checksum: 10/d731f91fa4dc3c22eee74811c3b5c1d9dc302eb0e2698806ad7dfeefaacfe072ad80bdd24fd07caf03e1ee4d48411cfa166488070c9050370bb17f8f862dc3f5 languageName: node linkType: hard @@ -7153,44 +7155,44 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-simple-keyring@npm:^12.0.2": - version: 12.0.2 - resolution: "@metamask/eth-simple-keyring@npm:12.0.2" +"@metamask/eth-simple-keyring@npm:^13.0.0": + version: 13.0.0 + resolution: "@metamask/eth-simple-keyring@npm:13.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-sdk": "npm:^2.0.2" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-sdk": "npm:^3.0.0" "@metamask/utils": "npm:^11.11.0" ethereum-cryptography: "npm:^2.2.1" randombytes: "npm:^2.1.0" - checksum: 10/ac8a3a5871fb1b7503ae8714beaad07102ad473522f1c65cf09786a3f3498564763d96a51569ed712702f3a62dfaada4c9342def4d48e2c5a1f0985b5cd49725 + checksum: 10/aa7fd327574d12eb221756931ce51278a66b9a4412f7f64bce1dc63cc2197be04d5107ab94786fa3ab4abfe133283ac92790a66bf5f417b4b699f9d27b4f9bfa languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^23.0.0": - version: 23.0.0 - resolution: "@metamask/eth-snap-keyring@npm:23.0.0" +"@metamask/eth-snap-keyring@npm:^24.0.0": + version: 24.0.0 + resolution: "@metamask/eth-snap-keyring@npm:24.0.0" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-internal-api": "npm:^11.0.1" - "@metamask/keyring-internal-snap-client": "npm:^10.0.5" - "@metamask/keyring-sdk": "npm:^2.2.0" - "@metamask/keyring-snap-sdk": "npm:^9.2.0" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-internal-snap-client": "npm:^11.0.0" + "@metamask/keyring-sdk": "npm:^3.0.0" + "@metamask/keyring-snap-sdk": "npm:^10.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^1.1.1" "@metamask/snaps-controllers": "npm:^19.0.1" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.2.1" - "@metamask/superstruct": "npm:^3.3.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" "@types/uuid": "npm:^9.0.8" async-mutex: "npm:^0.5.0" uuid: "npm:^9.0.1" peerDependencies: - "@metamask/keyring-api": ^23.0.0 - checksum: 10/a18dfe471ea048c4b4af9f554816df6330e8c859f3b382a0d0c61a546c22af832b6815054b2483c96505d442c38d5959a3d2d945ff3f794ab02fa31b5e62aa16 + "@metamask/keyring-api": ^24.0.0 + checksum: 10/256b67b17ff03f27032e29ecf278e090acf60e4422b72318b1209ec3589484f8313377a588dbabedbe0c18899d57b2df018380d34a30c8cf56635966f6ea3f4b languageName: node linkType: hard @@ -7535,15 +7537,15 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^23.1.0, @metamask/keyring-api@npm:^23.5.0, @metamask/keyring-api@npm:^23.6.0, @metamask/keyring-api@npm:^23.7.0": - version: 23.7.0 - resolution: "@metamask/keyring-api@npm:23.7.0" +"@metamask/keyring-api@npm:^24.0.0": + version: 24.0.0 + resolution: "@metamask/keyring-api@npm:24.0.0" dependencies: - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/3c1aea064e017be0b99202a4e59b7ed9e1965aeb732b04ad5cb252dfa6b443487284f8b4253986ca325d8b45fd7977cef93f470f95f0342d377551d4b93738c3 + checksum: 10/5160a3e2b9f1f753730bc877479aa1d375ac55dc023e8e34def4f2eeb2175864b096249af12e24f19d8240569c7bb977fcc424ab3023759189e1161c23d62879 languageName: node linkType: hard @@ -7560,12 +7562,12 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-hd-keyring": "npm:^14.1.1" + "@metamask/eth-hd-keyring": "npm:^15.0.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/eth-simple-keyring": "npm:^12.0.2" - "@metamask/keyring-api": "npm:^23.7.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/eth-simple-keyring": "npm:^13.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/utils": "npm:^11.11.0" @@ -7588,101 +7590,90 @@ __metadata: languageName: unknown linkType: soft -"@metamask/keyring-internal-api@npm:^11.0.1, @metamask/keyring-internal-api@npm:^11.0.2": - version: 11.0.2 - resolution: "@metamask/keyring-internal-api@npm:11.0.2" +"@metamask/keyring-internal-api@npm:^12.0.0": + version: 12.0.0 + resolution: "@metamask/keyring-internal-api@npm:12.0.0" dependencies: - "@metamask/keyring-api": "npm:^23.6.0" - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" - checksum: 10/7a5d817b93d3f0bd54860d7747833ace48e46d079e700d98f87c76a79b4da25acc1ba0aec3fdc4ff49c36fb3dc0a3378d27c7b970cdb31c88f8caa13c8199086 + checksum: 10/7d0512ad659168924d66251c0313e29fc158527da87c28966e080cc35932c326246c304abc983541cdc4f5dd28ad1b8e7f311fb358e55af6048a7f6eab0e203c languageName: node linkType: hard -"@metamask/keyring-internal-snap-client@npm:^10.0.5": - version: 10.0.5 - resolution: "@metamask/keyring-internal-snap-client@npm:10.0.5" +"@metamask/keyring-internal-snap-client@npm:^11.0.0": + version: 11.0.0 + resolution: "@metamask/keyring-internal-snap-client@npm:11.0.0" dependencies: - "@metamask/keyring-api": "npm:^23.5.0" - "@metamask/keyring-internal-api": "npm:^11.0.1" - "@metamask/keyring-snap-client": "npm:^9.2.0" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-snap-client": "npm:^10.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^1.1.1" - checksum: 10/ae81aee3ed4ed75785d22fd57a7b169b7e540e208afb917640bd47eadbc30c52e64df2f05e3a429447951ac40da1c0a568e9d5c29a5d10b844c28ae919bf1d50 + checksum: 10/058d4fa73d8a24199106393264e5a722cc8d0eed3fbbbcb5ccd40c314220a101f373bf3077c4d1a7b789235116a524fdfa5c816d27c5abd078b652eafa02cdd3 languageName: node linkType: hard -"@metamask/keyring-sdk@npm:^2.0.2, @metamask/keyring-sdk@npm:^2.2.0, @metamask/keyring-sdk@npm:^2.3.0": - version: 2.3.0 - resolution: "@metamask/keyring-sdk@npm:2.3.0" +"@metamask/keyring-sdk@npm:^3.0.0, @metamask/keyring-sdk@npm:^3.1.0": + version: 3.1.0 + resolution: "@metamask/keyring-sdk@npm:3.1.0" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^23.6.0" - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" + "@noble/hashes": "npm:^1.8.0" async-mutex: "npm:^0.5.0" ethereum-cryptography: "npm:^2.2.1" uuid: "npm:^9.0.1" - checksum: 10/38402f3834e0e364e1b17fe30ff64c1a050549239e204260081ddd0fc9228e9632d586819c551f91793d78207fb33f81e06ec3ac24537d511e8b5f1fabe57d21 + checksum: 10/9bec3d530da5484e9b28234c1d5e62bdd402d0cec25d107152efdc617e5d818becb25049621c97cc6af821e151ce43af798cbc1c421828c28c1ed20e611d2477 languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^9.2.0, @metamask/keyring-snap-client@npm:^9.2.1": - version: 9.2.1 - resolution: "@metamask/keyring-snap-client@npm:9.2.1" +"@metamask/keyring-snap-client@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/keyring-snap-client@npm:10.0.0" dependencies: - "@metamask/keyring-api": "npm:^23.6.0" - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^24.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" "@types/uuid": "npm:^9.0.8" uuid: "npm:^9.0.1" webextension-polyfill: "npm:^0.12.0" peerDependencies: "@metamask/providers": ^19.0.0 - checksum: 10/bd4f006737598d44992e2a6283448a9cb4321fd0b5bbe59d37d8e28b1e31ad28cce997100e5af5a04c661faba1ee9f320df59ce86ef4c2a0fcbc653091b52af2 + checksum: 10/f7df23dfdba2d844885b0683cfcee3bb37a923569be80cdbb6e6426b96258343c0b2efee03890ae09325bc1545be6bfd5c7cfb02d6c46dbe9e4f008d51ab265f languageName: node linkType: hard -"@metamask/keyring-snap-sdk@npm:^9.2.0, @metamask/keyring-snap-sdk@npm:^9.2.1": - version: 9.2.1 - resolution: "@metamask/keyring-snap-sdk@npm:9.2.1" +"@metamask/keyring-snap-sdk@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/keyring-snap-sdk@npm:10.0.0" dependencies: - "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/keyring-api": ^23.0.0 + "@metamask/keyring-api": ^24.0.0 "@metamask/providers": ^19.0.0 - checksum: 10/90b01fca9cc1d055b3830e8a7d2503b40421c9ddc4e20b0db25fade73c430ac56bb482f340cd82a08a5013d76e0b8d8265c953b71f5e29be2c5e538d5cedd9e4 - languageName: node - linkType: hard - -"@metamask/keyring-utils@npm:^3.3.1": - version: 3.3.1 - resolution: "@metamask/keyring-utils@npm:3.3.1" - dependencies: - "@ethereumjs/tx": "npm:^5.4.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.11.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/d0917b2f634d9eb2f563827739fca00c1675ee90674ec49b2b68afcb604aee843d6c5be5d79e9780fa22d29f71fc876f40b2d3d0ed4cab7f5948937ddd276691 + checksum: 10/2b12b3b08d0ecb839547e2e367808797cc365a364e8a111250a056371fb86c91c5cae0bf57ebce8e80e1ffbd9f462422e217ca8f1b97753a5f4a8ff5fff2e902 languageName: node linkType: hard -"@metamask/keyring-utils@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/keyring-utils@npm:4.0.0" +"@metamask/keyring-utils@npm:^5.0.0": + version: 5.0.0 + resolution: "@metamask/keyring-utils@npm:5.0.0" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/a4299fafadd4a4f2f1a4475a7f4e6c4d268ccfa82e505cdd4cf4a1ff5cc6ca485edd7422650c498409db93d4fe32429db7bd02276ce6fee80aac5f6a64439578 + checksum: 10/261cad056370ec89c2d2b29afddfe50dfe0dca302a0f70380eb1fc96c31348bcefdd087d0e17fbe7dec39603408eea1acf8ebc36f230a8b8897fa9e231e4a2b2 languageName: node linkType: hard @@ -7886,10 +7877,10 @@ __metadata: "@metamask/accounts-controller": "npm:^39.0.6" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/eth-money-keyring": "npm:^3.0.1" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/eth-money-keyring": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" @@ -7960,19 +7951,19 @@ __metadata: resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service" dependencies: "@ethereumjs/util": "npm:^9.1.0" - "@metamask/account-api": "npm:^1.1.1" + "@metamask/account-api": "npm:^2.0.0" "@metamask/accounts-controller": "npm:^39.0.6" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-hd-keyring": "npm:^14.1.1" - "@metamask/eth-snap-keyring": "npm:^23.0.0" + "@metamask/eth-hd-keyring": "npm:^15.0.0" + "@metamask/eth-snap-keyring": "npm:^24.0.0" "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" - "@metamask/keyring-snap-client": "npm:^9.2.1" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-snap-client": "npm:^10.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/snap-account-service": "npm:^2.1.1" @@ -8042,9 +8033,9 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" + "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/superstruct": "npm:^3.1.0" @@ -8074,10 +8065,10 @@ __metadata: "@metamask/accounts-controller": "npm:^39.0.6" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" - "@metamask/keyring-snap-client": "npm:^9.2.1" + "@metamask/keyring-internal-api": "npm:^12.0.0" + "@metamask/keyring-snap-client": "npm:^10.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.9" "@metamask/snaps-controllers": "npm:^19.0.0" @@ -8205,7 +8196,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/config-registry-controller": "npm:^2.0.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/multichain-network-controller": "npm:^3.2.2" "@metamask/network-controller": "npm:^35.0.1" @@ -8406,7 +8397,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/geolocation-controller": "npm:^1.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" + "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/profile-sync-controller": "npm:^28.3.0" @@ -8564,7 +8555,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" + "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.9" "@metamask/profile-sync-controller": "npm:^28.3.0" @@ -8598,9 +8589,9 @@ __metadata: "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-api": "npm:^11.0.2" + "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/seedless-onboarding-controller": "npm:^10.1.0" @@ -9009,14 +9000,14 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" dependencies: - "@metamask/account-api": "npm:^1.1.1" + "@metamask/account-api": "npm:^2.0.0" "@metamask/auto-changelog": "npm:^6.1.0" - "@metamask/eth-snap-keyring": "npm:^23.0.0" - "@metamask/keyring-api": "npm:^23.7.0" + "@metamask/eth-snap-keyring": "npm:^24.0.0" + "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-internal-snap-client": "npm:^10.0.5" - "@metamask/keyring-snap-sdk": "npm:^9.2.1" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/keyring-internal-snap-client": "npm:^11.0.0" + "@metamask/keyring-snap-sdk": "npm:^10.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" @@ -9262,7 +9253,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1, @metamask/superstruct@npm:^3.3.0, @metamask/superstruct@npm:^3.4.1": +"@metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1, @metamask/superstruct@npm:^3.4.1": version: 3.4.1 resolution: "@metamask/superstruct@npm:3.4.1" checksum: 10/d37b5662dc9bbe0d99e06eb951167fa745829ae3abfa103423e9d8c05e8712f1451376f8479d484722e1e1c1d881732da1efddccaca8868b530a786264b903b5