From 6d56f9b7c657aee4c14d9ec5caf5532761e480f8 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 24 Jul 2026 13:13:56 +0200 Subject: [PATCH 01/38] feat(account-tree-controller): add {import,export}State actions --- ...countTreeController-method-action-types.ts | 33 ++- .../src/AccountTreeController.ts | 47 ++++ packages/account-tree-controller/src/index.ts | 16 ++ .../src/state/export.ts | 208 +++++++++++++++++ .../src/state/import.ts | 215 ++++++++++++++++++ .../src/state/payload.ts | 103 +++++++++ .../src/state/snapshot.ts | 134 +++++++++++ packages/account-tree-controller/src/types.ts | 14 +- 8 files changed, 767 insertions(+), 3 deletions(-) create mode 100644 packages/account-tree-controller/src/state/export.ts create mode 100644 packages/account-tree-controller/src/state/import.ts create mode 100644 packages/account-tree-controller/src/state/payload.ts create mode 100644 packages/account-tree-controller/src/state/snapshot.ts 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 25c3849dba5..8e6959e322d 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -199,6 +199,35 @@ 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 versioned snapshot to the current state. + * + * 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 payload - The payload 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. */ @@ -218,4 +247,6 @@ export type AccountTreeControllerMethodActions = | AccountTreeControllerSetAccountGroupHiddenAction | AccountTreeControllerClearStateAction | AccountTreeControllerSyncWithUserStorageAction - | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction; + | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction + | AccountTreeControllerExportStateAction + | AccountTreeControllerImportStateAction; diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 6fd24b9c06a..36f31b10525 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -23,6 +23,11 @@ import { import { BackupAndSyncService } from './backup-and-sync/service/index.js'; import type { BackupAndSyncContext } from './backup-and-sync/types.js'; import { createSyncMutationTracker } from './backup-and-sync/utils/index.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 { AccountTreePayload } from './state/payload.js'; import type { AccountGroupObject, AccountTypeOrderKey } from './group.js'; import { ACCOUNT_TYPE_TO_SORT_ORDER, @@ -62,6 +67,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'syncWithUserStorageAtLeastOnce', 'init', 'reinit', + 'exportState', + 'importState', ] as const; const accountTreeControllerMetadata: StateMetadata = @@ -1790,6 +1797,46 @@ 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 versioned snapshot to the current state. + * + * 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 payload - The payload to import. + * @returns A promise that resolves when the import is complete. + */ + async importState(payload: AccountTreePayload): 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), + }, + payload, + ); + } + /** * 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 854ca0a392b..a04027bc4e8 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -38,6 +38,8 @@ export type { AccountTreeControllerSyncWithUserStorageAtLeastOnceAction, AccountTreeControllerInitAction, AccountTreeControllerReinitAction, + AccountTreeControllerExportStateAction, + AccountTreeControllerImportStateAction, } from './AccountTreeController-method-action-types.js'; export type { AccountContext } from './AccountTreeController.js'; @@ -46,3 +48,17 @@ export { AccountTreeController, getDefaultAccountTreeControllerState, } from './AccountTreeController.js'; + +export type { + AccountTreePayload, + AccountWalletMnemonicPayload, + AccountWalletPrivateKeyPayload, + AccountWalletMnemonicGroupEntry, + AccountWalletPrivateKeyGroupEntry, + AccountWalletPayloadId, + AccountGroupPayloadId, + AccountTreeSnapshotEntry, + ExportStateOptions, +} from './state/payload.js'; + +export { AccountTreeSnapshot } from './state/snapshot.js'; 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..5b59582c97f --- /dev/null +++ b/packages/account-tree-controller/src/state/export.ts @@ -0,0 +1,208 @@ +import { AccountWalletType } from '@metamask/account-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; + +import type { + AccountTreeControllerMessenger, + AccountTreeControllerState, +} from '../types.js'; +import type { + AccountGroupPayloadId, + AccountWalletMnemonicGroupEntry, + AccountWalletMnemonicPayload, + AccountWalletPayloadId, + AccountWalletPrivateKeyGroupEntry, + AccountWalletPrivateKeyPayload, + ExportStateOptions, +} from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +export type ExportContext = { + getState: () => AccountTreeControllerState; + messenger: AccountTreeControllerMessenger; +}; + +// Minimal structural interface — avoids adding @metamask/eth-hd-keyring as a dep. +type HdKeyringLike = { + mnemonic: Uint8Array | null | undefined; +}; + +// Minimal structural interface for keyring v2 exportAccount. +type KeyringWithExport = { + exportAccount( + accountId: string, + options: { type: string; encoding: string }, + ): Promise<{ privateKey: string; encoding: string }>; +}; + +/** + * Builds an {@link AccountTreeSnapshot} from the current controller state. + * + * Iterates over all wallets in the tree: + * - `Entropy` (BIP-44 HD) wallets → `'mnemonic'` payload entries. + * - `Keyring` wallets with type `simple` → `'private-key'` payload entries. + * - `Snap` wallets and hardware keyrings are skipped in v1. + * + * When `options.includeSecrets` is `true` **and** the vault is unlocked, + * mnemonic phrases and private keys are included. Secret fields are silently + * omitted when the vault is locked or a keyring cannot be accessed. + * + * @param context - Export context providing state and messenger access. + * @param options - Export options. + * @returns A promise that resolves to the built snapshot. + */ +export async function exportState( + context: ExportContext, + options: ExportStateOptions = {}, +): Promise { + const { includeSecrets = false } = options; + const state = context.getState(); + + const { isUnlocked } = context.messenger.call('KeyringController:getState'); + const shouldIncludeSecrets = includeSecrets && isUnlocked; + + const localToPayload = new Map(); + const payloadToLocal = new Map(); + + function trackIds(localId: string, payloadId: string): void { + localToPayload.set(localId, payloadId); + payloadToLocal.set(payloadId, localId); + } + + const entries: Array = + []; + + // Singleton private-key payload wallet — all simple-keyring groups are merged here. + let privateKeyWallet: AccountWalletPrivateKeyPayload | undefined; + + for (const wallet of Object.values(state.accountTree.wallets)) { + if (wallet.type === AccountWalletType.Entropy) { + const entropySourceId = wallet.metadata.entropy.id; + const walletPayloadId: AccountWalletPayloadId = `wallet:${entropySourceId}`; + + trackIds(wallet.id, walletPayloadId); + + const groups: AccountWalletMnemonicGroupEntry[] = []; + for (const group of Object.values(wallet.groups)) { + const { groupIndex } = group.metadata.entropy; + const groupPayloadId: AccountGroupPayloadId = `${walletPayloadId}/${groupIndex}`; + + trackIds(group.id, groupPayloadId); + + const groupMeta = state.accountGroupsMetadata[group.id]; + groups.push({ + id: groupPayloadId, + groupIndex, + metadata: { + name: groupMeta?.name?.value ?? group.metadata.name, + pinned: groupMeta?.pinned?.value ?? group.metadata.pinned, + hidden: groupMeta?.hidden?.value ?? group.metadata.hidden, + }, + }); + } + + const walletMeta = state.accountWalletsMetadata[wallet.id]; + let mnemonicValue: string | undefined; + + if (shouldIncludeSecrets) { + try { + const mnemonicBytes = await context.messenger.call( + 'KeyringController:withKeyringV2Unsafe', + { id: entropySourceId }, + async ({ keyring }: { keyring: unknown }) => { + const hd = keyring as HdKeyringLike; + return hd.mnemonic ?? undefined; + }, + ); + if (mnemonicBytes) { + mnemonicValue = new TextDecoder().decode(mnemonicBytes); + } + } catch { + // Vault locked or keyring not found — omit secret. + } + } + + entries.push({ + id: walletPayloadId, + type: 'mnemonic', + ...(mnemonicValue !== undefined && { value: mnemonicValue }), + metadata: { name: walletMeta?.name?.value ?? wallet.metadata.name }, + groups, + }); + } else if ( + wallet.type === AccountWalletType.Keyring && + wallet.metadata.keyring.type === KeyringTypes.simple + ) { + const walletPayloadId: AccountWalletPayloadId = 'wallet:private-key'; + + if (!privateKeyWallet) { + const walletMeta = state.accountWalletsMetadata[wallet.id]; + privateKeyWallet = { + id: walletPayloadId, + type: 'private-key', + metadata: { name: walletMeta?.name?.value ?? wallet.metadata.name }, + groups: [], + }; + entries.push(privateKeyWallet); + } + + // Track this local wallet ID → singleton payload wallet ID (first wallet wins for reverse). + if (!localToPayload.has(wallet.id)) { + trackIds(wallet.id, walletPayloadId); + } + + for (const group of Object.values(wallet.groups)) { + const accountId = group.accounts[0]; + const account = context.messenger.call( + 'AccountsController:getAccount', + accountId, + ); + if (!account) { + continue; + } + + const { address } = account; + const groupPayloadId: AccountGroupPayloadId = `wallet:private-key/${address}`; + + trackIds(group.id, groupPayloadId); + + const groupMeta = state.accountGroupsMetadata[group.id]; + let privateKeyValue: AccountWalletPrivateKeyGroupEntry['value']; + + if (shouldIncludeSecrets) { + try { + const exported = await context.messenger.call( + 'KeyringController:withKeyringV2', + { address }, + async ({ keyring }: { keyring: unknown }) => { + const k = keyring as KeyringWithExport; + return k.exportAccount(accountId, { + type: 'private-key', + encoding: 'hexadecimal', + }); + }, + ); + privateKeyValue = { + privateKey: exported.privateKey, + encoding: exported.encoding as 'hexadecimal' | 'base58' | 'base32', + }; + } catch { + // Key not accessible — omit secret. + } + } + + privateKeyWallet.groups.push({ + id: groupPayloadId, + ...(privateKeyValue !== undefined && { value: privateKeyValue }), + metadata: { + name: groupMeta?.name?.value ?? group.metadata.name, + pinned: groupMeta?.pinned?.value ?? group.metadata.pinned, + hidden: groupMeta?.hidden?.value ?? group.metadata.hidden, + }, + }); + } + } + // AccountWalletType.Snap and hardware keyrings: skipped in v1. + } + + return new AccountTreeSnapshot(entries, { localToPayload, payloadToLocal }); +} 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..cc9e35cb379 --- /dev/null +++ b/packages/account-tree-controller/src/state/import.ts @@ -0,0 +1,215 @@ +import { AccountWalletType } from '@metamask/account-api'; +import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; +import { AccountImportStrategy } from '@metamask/keyring-controller'; + +import type { + AccountTreeControllerMessenger, + AccountTreeControllerState, +} from '../types.js'; +import type { AccountWalletEntropyObject } from '../wallet.js'; +import type { + AccountTreePayload, + AccountWalletMnemonicGroupEntry, + AccountWalletPrivateKeyGroupEntry, +} from './payload.js'; + +export type ImportContext = { + getState: () => AccountTreeControllerState; + messenger: AccountTreeControllerMessenger; + setWalletName: (walletId: AccountWalletId, name: string) => void; + /** Sets a group name. Implementations should resolve conflicts automatically. */ + setGroupName: (groupId: AccountGroupId, name: string) => void; + setGroupPinned: (groupId: AccountGroupId, pinned: boolean) => void; + setGroupHidden: (groupId: AccountGroupId, hidden: boolean) => void; +}; + +/** + * Finds the local entropy wallet with the given `entropySourceId` in the + * current state, or returns `undefined` if absent. + */ +function findLocalEntropyWallet( + state: AccountTreeControllerState, + entropySourceId: string, +): AccountWalletEntropyObject | undefined { + return Object.values(state.accountTree.wallets).find( + (w): w is AccountWalletEntropyObject => + w.type === AccountWalletType.Entropy && + w.metadata.entropy.id === entropySourceId, + ); +} + +/** + * Finds the local group in `wallet` whose `groupIndex` matches `payloadGroupIndex`. + */ +function findLocalGroupByIndex( + wallet: AccountWalletEntropyObject, + payloadGroupIndex: number, +): { id: AccountGroupId } | undefined { + return Object.values(wallet.groups).find( + (g) => g.metadata.entropy.groupIndex === payloadGroupIndex, + ); +} + +/** + * Applies name / pinned / hidden metadata for a single mnemonic group entry, + * if the local group exists. + */ +function applyGroupMetadata( + context: ImportContext, + localGroupId: AccountGroupId, + payloadGroupMetadata: AccountWalletMnemonicGroupEntry['metadata'], +): void { + context.setGroupName(localGroupId, payloadGroupMetadata.name); + context.setGroupPinned(localGroupId, payloadGroupMetadata.pinned); + context.setGroupHidden(localGroupId, payloadGroupMetadata.hidden); +} + +/** + * Imports a mnemonic wallet entry from the payload. + */ +async function importMnemonicWallet( + context: ImportContext, + payloadWallet: { + id: string; + value?: string; + metadata: { name: string }; + groups: AccountWalletMnemonicGroupEntry[]; + }, +): Promise { + // Strip the `wallet:` prefix to get the EntropySourceId + // e.g. "wallet:entropy:mnemonic:" → "entropy:mnemonic:" + const entropySourceId = payloadWallet.id.slice('wallet:'.length); + + let localWallet = findLocalEntropyWallet(context.getState(), entropySourceId); + + 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 mnemonicBytes = new TextEncoder().encode(payloadWallet.value); + await context.messenger.call( + 'MultichainAccountService:createMultichainAccountWallet', + { type: 'import', mnemonic: mnemonicBytes }, + ); + + // Event handlers fire synchronously, so the wallet is in the tree now. + localWallet = findLocalEntropyWallet(context.getState(), entropySourceId); + if (!localWallet) { + return; + } + } + + const localWalletId = localWallet.id; + context.setWalletName(localWalletId, payloadWallet.metadata.name); + + for (const payloadGroup of payloadWallet.groups) { + let localGroup = findLocalGroupByIndex(localWallet, payloadGroup.groupIndex); + + if (!localGroup) { + await context.messenger.call( + 'MultichainAccountService:createMultichainAccountGroup', + { entropySource: entropySourceId, groupIndex: payloadGroup.groupIndex }, + ); + + // Re-read wallet after group creation. + const updatedWallet = findLocalEntropyWallet( + context.getState(), + entropySourceId, + ); + localGroup = updatedWallet + ? findLocalGroupByIndex(updatedWallet, payloadGroup.groupIndex) + : undefined; + } + + if (localGroup) { + applyGroupMetadata(context, localGroup.id, payloadGroup.metadata); + } + } +} + +/** + * Imports a private-key wallet entry from the payload. + */ +async function importPrivateKeyWallet( + context: ImportContext, + payloadGroups: AccountWalletPrivateKeyGroupEntry[], +): Promise { + for (const payloadGroup of payloadGroups) { + // Payload group ID format: "wallet:private-key/
" + const address = payloadGroup.id.slice('wallet:private-key/'.length); + + const accounts = context.messenger.call( + 'AccountsController:listMultichainAccounts', + ); + let account = accounts.find( + (a) => a.address.toLowerCase() === address.toLowerCase(), + ); + + if (!account) { + if (!payloadGroup.value || payloadGroup.value.encoding !== 'hexadecimal') { + // No importable secret — skip this account. + continue; + } + + await context.messenger.call( + 'KeyringController:importAccountWithStrategy', + AccountImportStrategy.privateKey, + [payloadGroup.value.privateKey], + ); + + // Re-query accounts after import. + const updatedAccounts = context.messenger.call( + 'AccountsController:listMultichainAccounts', + ); + account = updatedAccounts.find( + (a) => a.address.toLowerCase() === address.toLowerCase(), + ); + } + + if (!account) { + continue; + } + + // Find the local group that contains this account. + const localGroup = Object.values(context.getState().accountTree.wallets) + .flatMap((w) => Object.values(w.groups)) + .find((g) => g.accounts.includes(account.id)); + + if (!localGroup) { + continue; + } + + context.setGroupName(localGroup.id, payloadGroup.metadata.name); + context.setGroupPinned(localGroup.id, payloadGroup.metadata.pinned); + context.setGroupHidden(localGroup.id, payloadGroup.metadata.hidden); + } +} + +/** + * Applies an {@link AccountTreePayload} to the current controller state. + * + * - For each `'mnemonic'` wallet: imports the mnemonic (if provided and not + * already present) and applies metadata to all groups. + * - For each `'private-key'` group: imports the key (if provided and not + * already present) and applies metadata. + * - Unknown wallet types are silently skipped for forward compatibility. + * + * @param context - Import context providing state, messenger, and setters. + * @param payload - The validated payload to import. + */ +export async function importState( + context: ImportContext, + payload: AccountTreePayload, +): Promise { + for (const wallet of payload.wallets) { + if (wallet.type === 'mnemonic') { + await importMnemonicWallet(context, wallet); + } else if (wallet.type === 'private-key') { + await importPrivateKeyWallet(context, wallet.groups); + } + // Unknown types: skip silently (forward-compat). + } +} 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..a6247895f6e --- /dev/null +++ b/packages/account-tree-controller/src/state/payload.ts @@ -0,0 +1,103 @@ +export type AccountWalletPayloadId = `wallet:${string}`; +export type AccountGroupPayloadId = `wallet:${string}/${string}`; + +export const ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION = 1 as const; + +export type AccountWalletPayloadMetadata = { name: string }; + +export type AccountWalletGroupPayloadMetadata = { + name: string; + pinned: boolean; + hidden: boolean; +}; + +export type AccountWalletMnemonicGroupEntry = { + id: AccountGroupPayloadId; + groupIndex: number; + metadata: AccountWalletGroupPayloadMetadata; +}; + +export type AccountWalletPrivateKeyGroupEntry = { + 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'; + }; + metadata: AccountWalletGroupPayloadMetadata; +}; + +export type AccountWalletMnemonicPayload = { + id: AccountWalletPayloadId; + type: 'mnemonic'; + /** BIP-39 mnemonic phrase. Absent in metadata-only exports. */ + value?: string; + metadata: AccountWalletPayloadMetadata; + groups: AccountWalletMnemonicGroupEntry[]; +}; + +export type AccountWalletPrivateKeyPayload = { + id: AccountWalletPayloadId; + type: 'private-key'; + metadata: AccountWalletPayloadMetadata; + groups: AccountWalletPrivateKeyGroupEntry[]; +}; + +export type AccountTreePayload = { + version: typeof ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; + wallets: Array; +}; + +/** Wallet entry type available in {@link AccountTreeSnapshot.filter} predicates. */ +export type AccountTreeSnapshotEntry = + | AccountWalletMnemonicPayload + | AccountWalletPrivateKeyPayload; + +export type ExportStateOptions = { + /** When `true`, secrets (mnemonic / private keys) are included. Requires the vault to be unlocked. */ + includeSecrets?: boolean; +}; + +type Migrator = (raw: unknown) => AccountTreePayload; + +const MIGRATORS: Record = { + // v1 is the current version — identity migration. + [ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION]: (raw) => raw as AccountTreePayload, +}; + +/** + * Validates a raw value as an `AccountTreePayload` and runs any necessary version migrations. + * + * @param raw - Unknown value to validate. + * @returns A fully migrated `AccountTreePayload`. + * @throws If `raw` is not a valid payload or `version > CURRENT_VERSION`. + */ +export function validateAndMigrate(raw: unknown): AccountTreePayload { + if (typeof raw !== 'object' || raw === null) { + throw new Error('Invalid AccountTreePayload: expected an object'); + } + + const { version } = raw as Record; + if (typeof version !== 'number' || !Number.isInteger(version)) { + throw new Error('Invalid AccountTreePayload: missing numeric version field'); + } + if (version > ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION) { + throw new Error( + `Unsupported AccountTreePayload version: ${version} (current: ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION})`, + ); + } + + let result: unknown = raw; + for (let v = version; v <= ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; v++) { + const migrator = MIGRATORS[v]; + if (migrator) { + result = migrator(result); + } + } + + return result as 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..58486b77232 --- /dev/null +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -0,0 +1,134 @@ +import type { + AccountGroupPayloadId, + AccountTreePayload, + AccountTreeSnapshotEntry, + AccountWalletPayloadId, +} from './payload.js'; +import { + ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + validateAndMigrate, +} from './payload.js'; + +type SnapshotIdMap = { + /** Maps local controller IDs (wallet or group) to their portable payload IDs. */ + localToPayload: Map; + /** Maps portable payload IDs to their local controller IDs. */ + payloadToLocal: Map; +}; + +/** + * Immutable value object returned by {@link AccountTreeController.exportState}. + * + * Holds an ID map (local ↔ payload) populated during export so callers can + * bridge between internal controller IDs and the stable cross-device IDs that + * appear in the serialized payload. The map is absent for snapshots produced + * by {@link AccountTreeSnapshot.deserialize} — `toLocalId` / `toPayloadId` + * return `undefined` in that case. + */ +export class AccountTreeSnapshot { + readonly #entries: AccountTreeSnapshotEntry[]; + + readonly #idMap: SnapshotIdMap | null; + + constructor( + entries: AccountTreeSnapshotEntry[], + idMap: SnapshotIdMap | null, + ) { + this.#entries = entries; + this.#idMap = idMap; + } + + /** + * Returns a new snapshot containing only the wallet entries for which + * `predicate` returns `true`. The ID map is pruned to match. + * + * @param predicate - Function called with each wallet entry. + * @returns A filtered snapshot. + */ + filter( + predicate: (entry: AccountTreeSnapshotEntry) => boolean, + ): AccountTreeSnapshot { + const filteredEntries = this.#entries.filter(predicate); + + if (!this.#idMap) { + return new AccountTreeSnapshot(filteredEntries, null); + } + + const localToPayload = new Map(); + const payloadToLocal = new Map(); + + for (const entry of filteredEntries) { + const localWalletId = this.#idMap.payloadToLocal.get(entry.id); + if (localWalletId !== undefined) { + localToPayload.set(localWalletId, entry.id); + payloadToLocal.set(entry.id, localWalletId); + } + for (const group of entry.groups) { + const localGroupId = this.#idMap.payloadToLocal.get(group.id); + if (localGroupId !== undefined) { + localToPayload.set(localGroupId, group.id); + payloadToLocal.set(group.id, localGroupId); + } + } + } + + return new AccountTreeSnapshot(filteredEntries, { localToPayload, payloadToLocal }); + } + + /** + * Converts a payload ID (wallet or group) to the corresponding local + * `AccountTreeController` ID. + * + * @param payloadId - Payload wallet or group ID. + * @returns The local ID, or `undefined` if not found or no ID map is present. + */ + toLocalId( + payloadId: AccountWalletPayloadId | AccountGroupPayloadId, + ): string | undefined { + return this.#idMap?.payloadToLocal.get(payloadId); + } + + /** + * Converts a local `AccountTreeController` ID (wallet or group) to its + * payload ID. + * + * @param localId - Local wallet or group ID. + * @returns The payload ID, or `undefined` if not found or no ID map is present. + */ + toPayloadId( + localId: string, + ): AccountWalletPayloadId | AccountGroupPayloadId | undefined { + return this.#idMap?.localToPayload.get(localId) as + | AccountWalletPayloadId + | AccountGroupPayloadId + | undefined; + } + + /** + * Serializes the snapshot to a versioned {@link AccountTreePayload}. + * + * @returns The versioned payload. + */ + serialize(): AccountTreePayload { + return { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: this.#entries, + }; + } + + /** + * Deserializes and validates a raw value as an `AccountTreePayload`, + * running any necessary version migrations. + * + * The returned snapshot has no ID map — `toLocalId` / `toPayloadId` return + * `undefined`. Use `AccountTreeController.exportState` when you need the map. + * + * @param raw - Unknown value to parse. + * @returns A migrated snapshot. + * @throws If `raw` is not a valid payload or its version exceeds the current version. + */ + static deserialize(raw: unknown): AccountTreeSnapshot { + const payload = validateAndMigrate(raw); + return new AccountTreeSnapshot(payload.wallets, null); + } +} diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 4fff1d42f6d..b1be4e3489f 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -14,11 +14,17 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; -import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerImportAccountWithStrategyAction, + 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 +100,11 @@ export type AllowedActions = | UserStorageController.UserStorageControllerPerformBatchSetStorageAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | MultichainAccountServiceCreateMultichainAccountGroupAction - | MultichainAccountServiceCreateMultichainAccountGroupsAction; + | MultichainAccountServiceCreateMultichainAccountGroupsAction + | MultichainAccountServiceCreateMultichainAccountWalletAction + | KeyringControllerWithKeyringV2Action + | KeyringControllerWithKeyringV2UnsafeAction + | KeyringControllerImportAccountWithStrategyAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction From 3d94ebac8db831f0efb3b15a80f3557dbd6af949 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 24 Jul 2026 13:58:32 +0200 Subject: [PATCH 02/38] refactor: add IdMap --- packages/account-tree-controller/src/index.ts | 1 + .../src/state/export.ts | 21 ++++----- .../src/state/id-map.ts | 37 ++++++++++++++++ .../src/state/snapshot.ts | 44 ++++++------------- 4 files changed, 60 insertions(+), 43 deletions(-) create mode 100644 packages/account-tree-controller/src/state/id-map.ts diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index a04027bc4e8..b3dc48415cb 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -62,3 +62,4 @@ export type { } from './state/payload.js'; export { AccountTreeSnapshot } from './state/snapshot.js'; +export { IdMap } from './state/id-map.js'; diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 5b59582c97f..1a6b0e1deb8 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -14,6 +14,7 @@ import type { AccountWalletPrivateKeyPayload, ExportStateOptions, } from './payload.js'; +import { IdMap } from './id-map.js'; import { AccountTreeSnapshot } from './snapshot.js'; export type ExportContext = { @@ -60,13 +61,7 @@ export async function exportState( const { isUnlocked } = context.messenger.call('KeyringController:getState'); const shouldIncludeSecrets = includeSecrets && isUnlocked; - const localToPayload = new Map(); - const payloadToLocal = new Map(); - - function trackIds(localId: string, payloadId: string): void { - localToPayload.set(localId, payloadId); - payloadToLocal.set(payloadId, localId); - } + const idMap = new IdMap(); const entries: Array = []; @@ -79,14 +74,14 @@ export async function exportState( const entropySourceId = wallet.metadata.entropy.id; const walletPayloadId: AccountWalletPayloadId = `wallet:${entropySourceId}`; - trackIds(wallet.id, walletPayloadId); + idMap.add(wallet.id, walletPayloadId); const groups: AccountWalletMnemonicGroupEntry[] = []; for (const group of Object.values(wallet.groups)) { const { groupIndex } = group.metadata.entropy; const groupPayloadId: AccountGroupPayloadId = `${walletPayloadId}/${groupIndex}`; - trackIds(group.id, groupPayloadId); + idMap.add(group.id, groupPayloadId); const groupMeta = state.accountGroupsMetadata[group.id]; groups.push({ @@ -146,8 +141,8 @@ export async function exportState( } // Track this local wallet ID → singleton payload wallet ID (first wallet wins for reverse). - if (!localToPayload.has(wallet.id)) { - trackIds(wallet.id, walletPayloadId); + if (!idMap.getPayloadId(wallet.id)) { + idMap.add(wallet.id, walletPayloadId); } for (const group of Object.values(wallet.groups)) { @@ -163,7 +158,7 @@ export async function exportState( const { address } = account; const groupPayloadId: AccountGroupPayloadId = `wallet:private-key/${address}`; - trackIds(group.id, groupPayloadId); + idMap.add(group.id, groupPayloadId); const groupMeta = state.accountGroupsMetadata[group.id]; let privateKeyValue: AccountWalletPrivateKeyGroupEntry['value']; @@ -204,5 +199,5 @@ export async function exportState( // AccountWalletType.Snap and hardware keyrings: skipped in v1. } - return new AccountTreeSnapshot(entries, { localToPayload, payloadToLocal }); + return new AccountTreeSnapshot(entries, idMap); } 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..e5cd6826ecb --- /dev/null +++ b/packages/account-tree-controller/src/state/id-map.ts @@ -0,0 +1,37 @@ +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. + */ +export class IdMap { + readonly #localToPayload: Map = new Map(); + + readonly #payloadToLocal: Map = new Map(); + + constructor(entries: [localId: LocalId, payloadId: PayloadId][] = []) { + for (const [localId, payloadId] of entries) { + this.add(localId, payloadId); + } + } + + add(localId: LocalId, payloadId: PayloadId): void { + this.#localToPayload.set(localId, payloadId); + this.#payloadToLocal.set(payloadId, localId); + } + + getPayloadId(localId: LocalId): PayloadId | undefined { + return this.#localToPayload.get(localId); + } + + getLocalId(payloadId: PayloadId): LocalId | undefined { + return this.#payloadToLocal.get(payloadId); + } +} diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index 58486b77232..38158d289c2 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -8,13 +8,7 @@ import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, validateAndMigrate, } from './payload.js'; - -type SnapshotIdMap = { - /** Maps local controller IDs (wallet or group) to their portable payload IDs. */ - localToPayload: Map; - /** Maps portable payload IDs to their local controller IDs. */ - payloadToLocal: Map; -}; +import { IdMap } from './id-map.js'; /** * Immutable value object returned by {@link AccountTreeController.exportState}. @@ -28,12 +22,9 @@ type SnapshotIdMap = { export class AccountTreeSnapshot { readonly #entries: AccountTreeSnapshotEntry[]; - readonly #idMap: SnapshotIdMap | null; + readonly #idMap: IdMap | null; - constructor( - entries: AccountTreeSnapshotEntry[], - idMap: SnapshotIdMap | null, - ) { + constructor(entries: AccountTreeSnapshotEntry[], idMap: IdMap | null) { this.#entries = entries; this.#idMap = idMap; } @@ -54,25 +45,21 @@ export class AccountTreeSnapshot { return new AccountTreeSnapshot(filteredEntries, null); } - const localToPayload = new Map(); - const payloadToLocal = new Map(); - + const pairs: Parameters[] = []; for (const entry of filteredEntries) { - const localWalletId = this.#idMap.payloadToLocal.get(entry.id); + const localWalletId = this.#idMap.getLocalId(entry.id); if (localWalletId !== undefined) { - localToPayload.set(localWalletId, entry.id); - payloadToLocal.set(entry.id, localWalletId); + pairs.push([localWalletId, entry.id]); } for (const group of entry.groups) { - const localGroupId = this.#idMap.payloadToLocal.get(group.id); + const localGroupId = this.#idMap.getLocalId(group.id); if (localGroupId !== undefined) { - localToPayload.set(localGroupId, group.id); - payloadToLocal.set(group.id, localGroupId); + pairs.push([localGroupId, group.id]); } } } - return new AccountTreeSnapshot(filteredEntries, { localToPayload, payloadToLocal }); + return new AccountTreeSnapshot(filteredEntries, new IdMap(pairs)); } /** @@ -84,8 +71,8 @@ export class AccountTreeSnapshot { */ toLocalId( payloadId: AccountWalletPayloadId | AccountGroupPayloadId, - ): string | undefined { - return this.#idMap?.payloadToLocal.get(payloadId); + ): ReturnType { + return this.#idMap?.getLocalId(payloadId); } /** @@ -96,12 +83,9 @@ export class AccountTreeSnapshot { * @returns The payload ID, or `undefined` if not found or no ID map is present. */ toPayloadId( - localId: string, - ): AccountWalletPayloadId | AccountGroupPayloadId | undefined { - return this.#idMap?.localToPayload.get(localId) as - | AccountWalletPayloadId - | AccountGroupPayloadId - | undefined; + localId: Parameters[0], + ): ReturnType { + return this.#idMap?.getPayloadId(localId); } /** From 7847e326aca5d1ceec272c800eccd60c00ac8fc6 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 24 Jul 2026 16:59:04 +0200 Subject: [PATCH 03/38] refactor: rework export --- packages/account-tree-controller/package.json | 2 + .../src/state/export.ts | 309 +++++++++--------- .../src/state/payload.ts | 5 +- 3 files changed, 168 insertions(+), 148 deletions(-) diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index 0f399c9b748..75ee1b12bce 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -59,6 +59,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/keyring-api": "^23.5.0", "@metamask/keyring-controller": "^27.1.0", + "@metamask/keyring-sdk": "^2.2.0", "@metamask/messenger": "^2.0.0", "@metamask/multichain-account-service": "^13.0.0", "@metamask/profile-sync-controller": "^28.3.0", @@ -73,6 +74,7 @@ "devDependencies": { "@metamask/account-api": "^1.0.4", "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-hd-keyring": "^14.1.1", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 1a6b0e1deb8..a95c94fce79 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -1,4 +1,5 @@ import { AccountWalletType } from '@metamask/account-api'; +import { encodeMnemonic } from '@metamask/keyring-sdk'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { @@ -6,34 +7,168 @@ import type { AccountTreeControllerState, } from '../types.js'; import type { - AccountGroupPayloadId, + AccountTreeWalletEntry, AccountWalletMnemonicGroupEntry, AccountWalletMnemonicPayload, - AccountWalletPayloadId, AccountWalletPrivateKeyGroupEntry, AccountWalletPrivateKeyPayload, ExportStateOptions, } from './payload.js'; import { IdMap } from './id-map.js'; import { AccountTreeSnapshot } from './snapshot.js'; +import { AccountWalletEntropyObject, AccountWalletKeyringObject, AccountWalletObject } from '../wallet.js'; +import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; export type ExportContext = { getState: () => AccountTreeControllerState; messenger: AccountTreeControllerMessenger; }; -// Minimal structural interface — avoids adding @metamask/eth-hd-keyring as a dep. -type HdKeyringLike = { - mnemonic: Uint8Array | null | undefined; -}; +function isMnemonicWalletObject(wallet: AccountWalletObject): wallet is AccountWalletEntropyObject { + return wallet.type === AccountWalletType.Entropy; +} -// Minimal structural interface for keyring v2 exportAccount. -type KeyringWithExport = { - exportAccount( - accountId: string, - options: { type: string; encoding: string }, - ): Promise<{ privateKey: string; encoding: string }>; -}; +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 ? encodeMnemonic(hdKeyring.mnemonic) : undefined, + }; + }, + ); + const { entropySourceId, mnemonic } = result as { + entropySourceId: string; + mnemonic?: number[]; + }; + + // 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 = String(mnemonic); // FIXME: This should be a string, but the encodeMnemonic function returns a number array. We need to fix this in the keyring-sdk. + } + + return wallet; +} + +function isPrivateKeyWalletObject(wallet: AccountWalletObject): wallet is AccountWalletKeyringObject { + return wallet.type === AccountWalletType.Keyring && + wallet.metadata.keyring.type === KeyringTypes.simple; +} + +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, + }; + } + + idMap.add(groupObj.id, group.id); + + wallet.groups.push(group); + } + + return wallet; +} /** * Builds an {@link AccountTreeSnapshot} from the current controller state. @@ -55,148 +190,28 @@ export async function exportState( context: ExportContext, options: ExportStateOptions = {}, ): Promise { - const { includeSecrets = false } = options; const state = context.getState(); + const includeSecrets = options.includeSecrets ?? false; const { isUnlocked } = context.messenger.call('KeyringController:getState'); - const shouldIncludeSecrets = includeSecrets && isUnlocked; + if (includeSecrets && !isUnlocked) { + throw new Error( + 'Cannot include secrets in export when vault is locked', + ); + } const idMap = new IdMap(); - - const entries: Array = - []; - - // Singleton private-key payload wallet — all simple-keyring groups are merged here. - let privateKeyWallet: AccountWalletPrivateKeyPayload | undefined; - - for (const wallet of Object.values(state.accountTree.wallets)) { - if (wallet.type === AccountWalletType.Entropy) { - const entropySourceId = wallet.metadata.entropy.id; - const walletPayloadId: AccountWalletPayloadId = `wallet:${entropySourceId}`; - - idMap.add(wallet.id, walletPayloadId); - - const groups: AccountWalletMnemonicGroupEntry[] = []; - for (const group of Object.values(wallet.groups)) { - const { groupIndex } = group.metadata.entropy; - const groupPayloadId: AccountGroupPayloadId = `${walletPayloadId}/${groupIndex}`; - - idMap.add(group.id, groupPayloadId); - - const groupMeta = state.accountGroupsMetadata[group.id]; - groups.push({ - id: groupPayloadId, - groupIndex, - metadata: { - name: groupMeta?.name?.value ?? group.metadata.name, - pinned: groupMeta?.pinned?.value ?? group.metadata.pinned, - hidden: groupMeta?.hidden?.value ?? group.metadata.hidden, - }, - }); - } - - const walletMeta = state.accountWalletsMetadata[wallet.id]; - let mnemonicValue: string | undefined; - - if (shouldIncludeSecrets) { - try { - const mnemonicBytes = await context.messenger.call( - 'KeyringController:withKeyringV2Unsafe', - { id: entropySourceId }, - async ({ keyring }: { keyring: unknown }) => { - const hd = keyring as HdKeyringLike; - return hd.mnemonic ?? undefined; - }, - ); - if (mnemonicBytes) { - mnemonicValue = new TextDecoder().decode(mnemonicBytes); - } - } catch { - // Vault locked or keyring not found — omit secret. - } - } - - entries.push({ - id: walletPayloadId, - type: 'mnemonic', - ...(mnemonicValue !== undefined && { value: mnemonicValue }), - metadata: { name: walletMeta?.name?.value ?? wallet.metadata.name }, - groups, - }); + const entries: AccountTreeWalletEntry[] = []; + for (const walletObj of Object.values(state.accountTree.wallets)) { + if (isMnemonicWalletObject(walletObj)) { + entries.push(await exportMnemonicWalletObject(context, walletObj, includeSecrets, idMap)); } else if ( - wallet.type === AccountWalletType.Keyring && - wallet.metadata.keyring.type === KeyringTypes.simple + isPrivateKeyWalletObject(walletObj) ) { - const walletPayloadId: AccountWalletPayloadId = 'wallet:private-key'; - - if (!privateKeyWallet) { - const walletMeta = state.accountWalletsMetadata[wallet.id]; - privateKeyWallet = { - id: walletPayloadId, - type: 'private-key', - metadata: { name: walletMeta?.name?.value ?? wallet.metadata.name }, - groups: [], - }; - entries.push(privateKeyWallet); - } - - // Track this local wallet ID → singleton payload wallet ID (first wallet wins for reverse). - if (!idMap.getPayloadId(wallet.id)) { - idMap.add(wallet.id, walletPayloadId); - } - - for (const group of Object.values(wallet.groups)) { - const accountId = group.accounts[0]; - const account = context.messenger.call( - 'AccountsController:getAccount', - accountId, - ); - if (!account) { - continue; - } - - const { address } = account; - const groupPayloadId: AccountGroupPayloadId = `wallet:private-key/${address}`; - - idMap.add(group.id, groupPayloadId); - - const groupMeta = state.accountGroupsMetadata[group.id]; - let privateKeyValue: AccountWalletPrivateKeyGroupEntry['value']; - - if (shouldIncludeSecrets) { - try { - const exported = await context.messenger.call( - 'KeyringController:withKeyringV2', - { address }, - async ({ keyring }: { keyring: unknown }) => { - const k = keyring as KeyringWithExport; - return k.exportAccount(accountId, { - type: 'private-key', - encoding: 'hexadecimal', - }); - }, - ); - privateKeyValue = { - privateKey: exported.privateKey, - encoding: exported.encoding as 'hexadecimal' | 'base58' | 'base32', - }; - } catch { - // Key not accessible — omit secret. - } - } - - privateKeyWallet.groups.push({ - id: groupPayloadId, - ...(privateKeyValue !== undefined && { value: privateKeyValue }), - metadata: { - name: groupMeta?.name?.value ?? group.metadata.name, - pinned: groupMeta?.pinned?.value ?? group.metadata.pinned, - hidden: groupMeta?.hidden?.value ?? group.metadata.hidden, - }, - }); - } + entries.push(await exportPrivateKeyWalletObject(context, walletObj, includeSecrets, idMap)); + } else { + // AccountWalletType.Snap and hardware keyrings: skipped for now. } - // AccountWalletType.Snap and hardware keyrings: skipped in v1. } return new AccountTreeSnapshot(entries, idMap); diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index a6247895f6e..db3abbed306 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -47,9 +47,12 @@ export type AccountWalletPrivateKeyPayload = { groups: AccountWalletPrivateKeyGroupEntry[]; }; + +export type AccountTreeWalletEntry = AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload; + export type AccountTreePayload = { version: typeof ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; - wallets: Array; + wallets: AccountTreeWalletEntry[]; }; /** Wallet entry type available in {@link AccountTreeSnapshot.filter} predicates. */ From 333492b7f7b16814a6539bb0bf1a0ee24b23f51d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 24 Jul 2026 17:00:26 +0200 Subject: [PATCH 04/38] refactor: rename to migrate --- packages/account-tree-controller/src/state/payload.ts | 2 +- packages/account-tree-controller/src/state/snapshot.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index db3abbed306..9cc5269d415 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -79,7 +79,7 @@ const MIGRATORS: Record = { * @returns A fully migrated `AccountTreePayload`. * @throws If `raw` is not a valid payload or `version > CURRENT_VERSION`. */ -export function validateAndMigrate(raw: unknown): AccountTreePayload { +export function migrate(raw: unknown): AccountTreePayload { if (typeof raw !== 'object' || raw === null) { throw new Error('Invalid AccountTreePayload: expected an object'); } diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index 38158d289c2..205ae8bbd7d 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -6,7 +6,7 @@ import type { } from './payload.js'; import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - validateAndMigrate, + migrate, } from './payload.js'; import { IdMap } from './id-map.js'; @@ -112,7 +112,7 @@ export class AccountTreeSnapshot { * @throws If `raw` is not a valid payload or its version exceeds the current version. */ static deserialize(raw: unknown): AccountTreeSnapshot { - const payload = validateAndMigrate(raw); + const payload = migrate(raw); return new AccountTreeSnapshot(payload.wallets, null); } } From 6fe6fa8634c6a40b8f6fa603407ece0af4cac080 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 24 Jul 2026 17:56:54 +0200 Subject: [PATCH 05/38] refactor: rework import --- .../src/state/export.ts | 27 +- .../src/state/import.ts | 241 +++++++++++------- .../src/state/payload.ts | 35 +++ 3 files changed, 194 insertions(+), 109 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index a95c94fce79..e277049d5db 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -1,6 +1,4 @@ -import { AccountWalletType } from '@metamask/account-api'; import { encodeMnemonic } from '@metamask/keyring-sdk'; -import { KeyringTypes } from '@metamask/keyring-controller'; import type { AccountTreeControllerMessenger, @@ -16,19 +14,27 @@ import type { } from './payload.js'; import { IdMap } from './id-map.js'; import { AccountTreeSnapshot } from './snapshot.js'; -import { AccountWalletEntropyObject, AccountWalletKeyringObject, AccountWalletObject } from '../wallet.js'; +import type { AccountWalletObject } from '../wallet.js'; +import { AccountWalletEntropyObject, AccountWalletKeyringObject } from '../wallet.js'; +import { AccountWalletType } from '@metamask/account-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; +export function isMnemonicWalletObject(wallet: AccountWalletObject): wallet is AccountWalletEntropyObject { + return wallet.type === AccountWalletType.Entropy; +} + +export function isPrivateKeyWalletObject(wallet: AccountWalletObject): wallet is AccountWalletKeyringObject { + return wallet.type === AccountWalletType.Keyring && + wallet.metadata.keyring.type === KeyringTypes.simple; +} + export type ExportContext = { getState: () => AccountTreeControllerState; messenger: AccountTreeControllerMessenger; }; -function isMnemonicWalletObject(wallet: AccountWalletObject): wallet is AccountWalletEntropyObject { - return wallet.type === AccountWalletType.Entropy; -} - async function exportMnemonicWalletObject(context: ExportContext, walletObj: AccountWalletEntropyObject, includeSecrets: boolean, idMap: IdMap): Promise { const result = await context.messenger.call( 'KeyringController:withKeyringV2Unsafe', @@ -86,17 +92,12 @@ async function exportMnemonicWalletObject(context: ExportContext, walletObj: Acc throw new Error(`Failed to export mnemonic for wallet ${wallet.id}`); } - wallet.value = String(mnemonic); // FIXME: This should be a string, but the encodeMnemonic function returns a number array. We need to fix this in the keyring-sdk. + wallet.value = JSON.stringify(mnemonic); // FIXME: This should be a string, but the encodeMnemonic function returns a number array. We need to fix this in the keyring-sdk. } return wallet; } -function isPrivateKeyWalletObject(wallet: AccountWalletObject): wallet is AccountWalletKeyringObject { - return wallet.type === AccountWalletType.Keyring && - wallet.metadata.keyring.type === KeyringTypes.simple; -} - async function exportPrivateKeyWalletObject(context: ExportContext, walletObj: AccountWalletKeyringObject, includeSecrets: boolean, idMap: IdMap): Promise { // We use a singleton wallet ID for private keys. const wallet: AccountWalletPrivateKeyPayload = { diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index cc9e35cb379..ecde66917bb 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -1,6 +1,7 @@ -import { AccountWalletType } from '@metamask/account-api'; +import { AccountWalletType, toAccountGroupId, toAccountWalletId, toMultichainAccountGroupId } from '@metamask/account-api'; +import { isMnemonicWalletObject } from './export.js'; import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; -import { AccountImportStrategy } from '@metamask/keyring-controller'; +import { KeyringTypes } from '@metamask/keyring-controller'; import type { AccountTreeControllerMessenger, @@ -10,8 +11,15 @@ import type { AccountWalletEntropyObject } from '../wallet.js'; import type { AccountTreePayload, AccountWalletMnemonicGroupEntry, + AccountWalletMnemonicPayload, + AccountWalletPayloadId, AccountWalletPrivateKeyGroupEntry, } from './payload.js'; +import { parsePayloadGroupId, toWalletPayloadId } from './payload.js'; +import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import { KeyringAccount } from '@metamask/keyring-api'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; export type ImportContext = { getState: () => AccountTreeControllerState; @@ -23,38 +31,60 @@ export type ImportContext = { setGroupHidden: (groupId: AccountGroupId, hidden: boolean) => void; }; -/** - * Finds the local entropy wallet with the given `entropySourceId` in the - * current state, or returns `undefined` if absent. - */ -function findLocalEntropyWallet( - state: AccountTreeControllerState, - entropySourceId: string, -): AccountWalletEntropyObject | undefined { - return Object.values(state.accountTree.wallets).find( - (w): w is AccountWalletEntropyObject => - w.type === AccountWalletType.Entropy && - w.metadata.entropy.id === entropySourceId, - ); +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; } -/** - * Finds the local group in `wallet` whose `groupIndex` matches `payloadGroupIndex`. - */ -function findLocalGroupByIndex( - wallet: AccountWalletEntropyObject, - payloadGroupIndex: number, -): { id: AccountGroupId } | undefined { - return Object.values(wallet.groups).find( - (g) => g.metadata.entropy.groupIndex === payloadGroupIndex, - ); +function findLocalWalletMnemonicFromId(context: ImportContext, id: AccountWalletId) { + const localWallets = context.getState().accountTree.wallets; + + if (!localWallets[id]) { + throw new Error( + `Failed to import mnemonic wallet: wallet not found after creation`, + ); + } + if (!isMnemonicWalletObject(localWallets[id])) { + throw new Error( + `Failed to import mnemonic wallet: wallet is not of type 'mnemonic'`, + ); + } + return localWallets[id]; } /** * Applies name / pinned / hidden metadata for a single mnemonic group entry, * if the local group exists. + * + * @param context + * @param localGroupId + * @param payloadGroupMetadata */ -function applyGroupMetadata( +function setGroupMetadata( context: ImportContext, localGroupId: AccountGroupId, payloadGroupMetadata: AccountWalletMnemonicGroupEntry['metadata'], @@ -66,21 +96,21 @@ function applyGroupMetadata( /** * Imports a mnemonic wallet entry from the payload. + * + * @param context + * @param payloadWallet + * @param payloadWallet.id + * @param payloadWallet.value + * @param payloadWallet.metadata + * @param payloadWallet.metadata.name + * @param payloadWallet.groups */ async function importMnemonicWallet( context: ImportContext, - payloadWallet: { - id: string; - value?: string; - metadata: { name: string }; - groups: AccountWalletMnemonicGroupEntry[]; - }, + payloadWallet: AccountWalletMnemonicPayload, ): Promise { - // Strip the `wallet:` prefix to get the EntropySourceId - // e.g. "wallet:entropy:mnemonic:" → "entropy:mnemonic:" - const entropySourceId = payloadWallet.id.slice('wallet:'.length); - - let localWallet = findLocalEntropyWallet(context.getState(), entropySourceId); + // 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) { @@ -89,49 +119,64 @@ async function importMnemonicWallet( } // Import the mnemonic as a new HD wallet. - const mnemonicBytes = new TextEncoder().encode(payloadWallet.value); - await context.messenger.call( + const mnemonic = JSON.parse(payloadWallet.value); + const { id } = await context.messenger.call( 'MultichainAccountService:createMultichainAccountWallet', - { type: 'import', mnemonic: mnemonicBytes }, + { type: 'import', mnemonic }, ); // Event handlers fire synchronously, so the wallet is in the tree now. - localWallet = findLocalEntropyWallet(context.getState(), entropySourceId); - if (!localWallet) { - return; - } + localWallet = findLocalWalletMnemonicFromId(context, id); } - const localWalletId = localWallet.id; - context.setWalletName(localWalletId, payloadWallet.metadata.name); + context.setWalletName(localWallet.id, payloadWallet.metadata.name); + // Compute range of group indices in the payload to import. + let rangeIndex: number | undefined; + const ranges: [number, number][] = []; for (const payloadGroup of payloadWallet.groups) { - let localGroup = findLocalGroupByIndex(localWallet, payloadGroup.groupIndex); + const localGroupId = toMultichainAccountGroupId(localWallet.id, payloadGroup.groupIndex); - if (!localGroup) { - await context.messenger.call( - 'MultichainAccountService:createMultichainAccountGroup', - { entropySource: entropySourceId, groupIndex: payloadGroup.groupIndex }, - ); + if (localWallet.groups[localGroupId]) { + if (rangeIndex !== undefined) { + ranges.push([rangeIndex, payloadGroup.groupIndex - 1]); + rangeIndex = undefined; + } - // Re-read wallet after group creation. - const updatedWallet = findLocalEntropyWallet( - context.getState(), - entropySourceId, - ); - localGroup = updatedWallet - ? findLocalGroupByIndex(updatedWallet, payloadGroup.groupIndex) - : undefined; + continue; } - if (localGroup) { - applyGroupMetadata(context, localGroup.id, payloadGroup.metadata); - } + rangeIndex ??= payloadGroup.groupIndex; + } + for (const range of ranges) { + 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); } } /** * Imports a private-key wallet entry from the payload. + * + * @param context + * @param payloadGroups */ async function importPrivateKeyWallet( context: ImportContext, @@ -139,52 +184,55 @@ async function importPrivateKeyWallet( ): Promise { for (const payloadGroup of payloadGroups) { // Payload group ID format: "wallet:private-key/
" - const address = payloadGroup.id.slice('wallet:private-key/'.length); + const payloadAccountAddress = parsePayloadGroupId(payloadGroup.id).subId; + const payloadAccountId = getUUIDFromAddressOfNormalAccount(payloadAccountAddress); - const accounts = context.messenger.call( - 'AccountsController:listMultichainAccounts', - ); - let account = accounts.find( - (a) => a.address.toLowerCase() === address.toLowerCase(), - ); + 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]; - if (!account) { - if (!payloadGroup.value || payloadGroup.value.encoding !== 'hexadecimal') { + // 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); + + // If it doesn't exist, we need to import the private key. + if (!hasAccount) { + if (!payloadGroup.value) { // No importable secret — skip this account. continue; } - await context.messenger.call( - 'KeyringController:importAccountWithStrategy', - AccountImportStrategy.privateKey, - [payloadGroup.value.privateKey], + 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, + }); + }, ); - // Re-query accounts after import. - const updatedAccounts = context.messenger.call( - 'AccountsController:listMultichainAccounts', - ); - account = updatedAccounts.find( - (a) => a.address.toLowerCase() === address.toLowerCase(), - ); - } - - if (!account) { - continue; + // 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. - const localGroup = Object.values(context.getState().accountTree.wallets) - .flatMap((w) => Object.values(w.groups)) - .find((g) => g.accounts.includes(account.id)); - + localWallets = context.getState().accountTree.wallets; + localWallet = localWallets[localWalletId]; + localGroup = localWallet?.groups[localGroupId]; if (!localGroup) { continue; } - context.setGroupName(localGroup.id, payloadGroup.metadata.name); - context.setGroupPinned(localGroup.id, payloadGroup.metadata.pinned); - context.setGroupHidden(localGroup.id, payloadGroup.metadata.hidden); + setGroupMetadata(context, localGroup.id, payloadGroup.metadata); } } @@ -209,7 +257,8 @@ export async function importState( await importMnemonicWallet(context, wallet); } else if (wallet.type === 'private-key') { await importPrivateKeyWallet(context, wallet.groups); + } else { + // Unknown types: skip silently (forward-compat). } - // Unknown types: skip silently (forward-compat). } -} +} \ No newline at end of file diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 9cc5269d415..4d53c2014d0 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -1,6 +1,37 @@ export type AccountWalletPayloadId = `wallet:${string}`; export type AccountGroupPayloadId = `wallet:${string}/${string}`; +/** + * Parsed payload group ID. + */ +export type ParsedPayloadGroupId = { + walletId: AccountWalletPayloadId; + 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, + }; +} + export const ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION = 1 as const; export type AccountWalletPayloadMetadata = { name: string }; @@ -60,6 +91,10 @@ export type AccountTreeSnapshotEntry = | AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload; +export function toWalletPayloadId(entropySourceId: string): AccountWalletPayloadId { + return `wallet:${entropySourceId}`; +} + export type ExportStateOptions = { /** When `true`, secrets (mnemonic / private keys) are included. Requires the vault to be unlocked. */ includeSecrets?: boolean; From 25c83a0eadd8d94a1e6e0921809a7ad8900c54d2 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 11:23:23 +0200 Subject: [PATCH 06/38] docs: add missing jsdocs --- .../src/state/export.ts | 53 ++++++++++++++--- .../src/state/id-map.ts | 25 ++++++++ .../src/state/import.ts | 59 +++++++++++++------ .../src/state/payload.ts | 38 ++++++++++-- .../src/state/snapshot.ts | 8 +-- 5 files changed, 150 insertions(+), 33 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index e277049d5db..88c40ce25fd 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -21,20 +21,48 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; +/** + * 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', @@ -98,6 +126,20 @@ async function exportMnemonicWalletObject(context: ExportContext, walletObj: Acc 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 = { @@ -175,17 +217,14 @@ async function exportPrivateKeyWalletObject(context: ExportContext, walletObj: A * Builds an {@link AccountTreeSnapshot} from the current controller state. * * Iterates over all wallets in the tree: - * - `Entropy` (BIP-44 HD) wallets → `'mnemonic'` payload entries. - * - `Keyring` wallets with type `simple` → `'private-key'` payload entries. - * - `Snap` wallets and hardware keyrings are skipped in v1. - * - * When `options.includeSecrets` is `true` **and** the vault is unlocked, - * mnemonic phrases and private keys are included. Secret fields are silently - * omitted when the vault is locked or a keyring cannot be accessed. + * - {@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, diff --git a/packages/account-tree-controller/src/state/id-map.ts b/packages/account-tree-controller/src/state/id-map.ts index e5cd6826ecb..e63d4df1956 100644 --- a/packages/account-tree-controller/src/state/id-map.ts +++ b/packages/account-tree-controller/src/state/id-map.ts @@ -10,27 +10,52 @@ 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.ts b/packages/account-tree-controller/src/state/import.ts index ecde66917bb..297ab21fa84 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -21,16 +21,26 @@ import { KeyringType } from '@metamask/keyring-api/v2'; import { KeyringAccount } from '@metamask/keyring-api'; import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; +/** Context required by {@link importState}. */ export type ImportContext = { getState: () => AccountTreeControllerState; messenger: AccountTreeControllerMessenger; setWalletName: (walletId: AccountWalletId, name: string) => void; - /** Sets a group name. Implementations should resolve conflicts automatically. */ + /** 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, @@ -60,7 +70,15 @@ async function findLocalWalletMnemonicFromPayloadId( return undefined; } -function findLocalWalletMnemonicFromId(context: ImportContext, id: AccountWalletId) { +/** + * 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; if (!localWallets[id]) { @@ -77,12 +95,11 @@ function findLocalWalletMnemonicFromId(context: ImportContext, id: AccountWallet } /** - * Applies name / pinned / hidden metadata for a single mnemonic group entry, - * if the local group exists. + * Applies name, pinned, and hidden metadata from a payload group entry to a local group. * - * @param context - * @param localGroupId - * @param payloadGroupMetadata + * @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, @@ -95,15 +112,16 @@ function setGroupMetadata( } /** - * Imports a mnemonic wallet entry from the payload. + * Applies a mnemonic wallet payload entry to the local state. * - * @param context - * @param payloadWallet - * @param payloadWallet.id - * @param payloadWallet.value - * @param payloadWallet.metadata - * @param payloadWallet.metadata.name - * @param payloadWallet.groups + * 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, @@ -173,10 +191,15 @@ async function importMnemonicWallet( } /** - * Imports a private-key wallet entry from the payload. + * 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 - * @param payloadGroups + * @param context - Import context. + * @param payloadGroups - Private-key group entries from the payload. */ async function importPrivateKeyWallet( context: ImportContext, diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 4d53c2014d0..9c3befacdc0 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -1,11 +1,16 @@ +/** 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 payload group ID. + * 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; }; @@ -32,23 +37,31 @@ export function parsePayloadGroupId( }; } +/** Current version of the {@link AccountTreePayload} format. */ export const ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION = 1 as const; +/** 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` @@ -62,6 +75,7 @@ export type AccountWalletPrivateKeyGroupEntry = { metadata: AccountWalletGroupPayloadMetadata; }; +/** Payload entry for an HD (entropy) wallet and its derived account groups. */ export type AccountWalletMnemonicPayload = { id: AccountWalletPayloadId; type: 'mnemonic'; @@ -71,6 +85,12 @@ export type AccountWalletMnemonicPayload = { 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'; @@ -78,23 +98,33 @@ export type AccountWalletPrivateKeyPayload = { groups: AccountWalletPrivateKeyGroupEntry[]; }; +/** Union of all wallet entry types that can appear in an {@link AccountTreePayload}. */ +export type AccountTreeWalletEntry = + | AccountWalletMnemonicPayload + | AccountWalletPrivateKeyPayload; -export type AccountTreeWalletEntry = AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload; - +/** Versioned, portable snapshot of the full account tree state. */ export type AccountTreePayload = { version: typeof ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; wallets: AccountTreeWalletEntry[]; }; -/** Wallet entry type available in {@link AccountTreeSnapshot.filter} predicates. */ +/** Wallet entry type exposed to {@link AccountTreeSnapshot.filter} predicates. */ export type AccountTreeSnapshotEntry = | AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload; +/** + * 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; diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index 205ae8bbd7d..a42a8588b88 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -66,8 +66,8 @@ export class AccountTreeSnapshot { * Converts a payload ID (wallet or group) to the corresponding local * `AccountTreeController` ID. * - * @param payloadId - Payload wallet or group ID. - * @returns The local ID, or `undefined` if not found or no ID map is present. + * @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, @@ -77,9 +77,9 @@ export class AccountTreeSnapshot { /** * Converts a local `AccountTreeController` ID (wallet or group) to its - * payload ID. + * stable cross-device payload ID. * - * @param localId - Local wallet or group ID. + * @param localId - Local controller wallet or group ID. * @returns The payload ID, or `undefined` if not found or no ID map is present. */ toPayloadId( From 5d7b2f79d91a2b9cd44d7cbb1df2da4bfcb0ac79 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 11:23:39 +0200 Subject: [PATCH 07/38] chore: lint --- .../src/state/export.ts | 88 ++++++++++----- .../src/state/import.ts | 106 +++++++++++------- .../src/state/payload.ts | 11 +- .../src/state/snapshot.ts | 7 +- 4 files changed, 135 insertions(+), 77 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 88c40ce25fd..17ec2838ad8 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -1,9 +1,19 @@ +import { AccountWalletType } from '@metamask/account-api'; +import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; +import { KeyringTypes } from '@metamask/keyring-controller'; import { encodeMnemonic } 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, @@ -12,14 +22,7 @@ import type { AccountWalletPrivateKeyPayload, ExportStateOptions, } from './payload.js'; -import { IdMap } from './id-map.js'; import { AccountTreeSnapshot } from './snapshot.js'; -import type { AccountWalletObject } from '../wallet.js'; -import { AccountWalletEntropyObject, AccountWalletKeyringObject } from '../wallet.js'; -import { AccountWalletType } from '@metamask/account-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; -import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; /** * Returns `true` if `wallet` is an HD entropy wallet ({@link AccountWalletEntropyObject}). @@ -27,7 +30,9 @@ import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; * @param wallet - The wallet object to test. * @returns Type predicate narrowing to {@link AccountWalletEntropyObject}. */ -export function isMnemonicWalletObject(wallet: AccountWalletObject): wallet is AccountWalletEntropyObject { +export function isMnemonicWalletObject( + wallet: AccountWalletObject, +): wallet is AccountWalletEntropyObject { return wallet.type === AccountWalletType.Entropy; } @@ -38,9 +43,13 @@ export function isMnemonicWalletObject(wallet: AccountWalletObject): wallet is A * @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; +export function isPrivateKeyWalletObject( + wallet: AccountWalletObject, +): wallet is AccountWalletKeyringObject { + return ( + wallet.type === AccountWalletType.Keyring && + wallet.metadata.keyring.type === KeyringTypes.simple + ); } /** Context required by {@link exportState}. */ @@ -63,20 +72,30 @@ export type ExportContext = { * @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 { +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; + 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 ? encodeMnemonic(hdKeyring.mnemonic) : undefined, + mnemonic: includeMnemonic + ? encodeMnemonic(hdKeyring.mnemonic) + : undefined, }; }, ); @@ -140,7 +159,12 @@ async function exportMnemonicWalletObject(context: ExportContext, walletObj: Acc * @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 { +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', @@ -173,7 +197,9 @@ async function exportPrivateKeyWalletObject(context: ExportContext, walletObj: A { address }, async ({ keyring }) => { if (!keyring.exportAccount) { - throw new Error(`Keyring for account ${accountId} does not support exportAccount`); + throw new Error( + `Keyring for account ${accountId} does not support exportAccount`, + ); } return keyring.exportAccount(accountId, { @@ -197,7 +223,9 @@ async function exportPrivateKeyWalletObject(context: ExportContext, walletObj: A if (includeSecrets) { if (!exported) { - throw new Error(`Failed to export private key for account ${accountId}`); + throw new Error( + `Failed to export private key for account ${accountId}`, + ); } group.value = { privateKey: exported.privateKey, @@ -235,20 +263,30 @@ export async function exportState( 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', - ); + throw new Error('Cannot include secrets in export when vault is locked'); } const idMap = new IdMap(); const entries: AccountTreeWalletEntry[] = []; for (const walletObj of Object.values(state.accountTree.wallets)) { if (isMnemonicWalletObject(walletObj)) { - entries.push(await exportMnemonicWalletObject(context, walletObj, includeSecrets, idMap)); - } else if ( - isPrivateKeyWalletObject(walletObj) - ) { - entries.push(await exportPrivateKeyWalletObject(context, walletObj, includeSecrets, idMap)); + entries.push( + await exportMnemonicWalletObject( + context, + walletObj, + includeSecrets, + idMap, + ), + ); + } else if (isPrivateKeyWalletObject(walletObj)) { + entries.push( + await exportPrivateKeyWalletObject( + context, + walletObj, + includeSecrets, + idMap, + ), + ); } else { // AccountWalletType.Snap and hardware keyrings: skipped for now. } diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index 297ab21fa84..02141d5fce3 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -1,6 +1,14 @@ -import { AccountWalletType, toAccountGroupId, toAccountWalletId, toMultichainAccountGroupId } from '@metamask/account-api'; -import { isMnemonicWalletObject } from './export.js'; +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 { KeyringAccount } from '@metamask/keyring-api'; +import { KeyringType } from '@metamask/keyring-api/v2'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { @@ -8,6 +16,7 @@ import type { AccountTreeControllerState, } from '../types.js'; import type { AccountWalletEntropyObject } from '../wallet.js'; +import { isMnemonicWalletObject } from './export.js'; import type { AccountTreePayload, AccountWalletMnemonicGroupEntry, @@ -16,10 +25,6 @@ import type { AccountWalletPrivateKeyGroupEntry, } from './payload.js'; import { parsePayloadGroupId, toWalletPayloadId } from './payload.js'; -import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; -import { KeyringType } from '@metamask/keyring-api/v2'; -import { KeyringAccount } from '@metamask/keyring-api'; -import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; /** Context required by {@link importState}. */ export type ImportContext = { @@ -49,21 +54,20 @@ async function findLocalWalletMnemonicFromPayloadId( 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; - 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()); - }, - ); + return toWalletPayloadId(await hdKeyring.toEntropySourceId()); + }, + ); - const localPayloadId = result as AccountWalletPayloadId; - if (localPayloadId === payloadWalletId) { - return wallet; - } + const localPayloadId = result as AccountWalletPayloadId; + if (localPayloadId === payloadWalletId) { + return wallet; + } } } @@ -78,20 +82,23 @@ async function findLocalWalletMnemonicFromPayloadId( * @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; +function findLocalWalletMnemonicFromId( + context: ImportContext, + id: AccountWalletId, +): AccountWalletEntropyObject { + const localWallets = context.getState().accountTree.wallets; - if (!localWallets[id]) { - throw new Error( - `Failed to import mnemonic wallet: wallet not found after creation`, - ); - } - if (!isMnemonicWalletObject(localWallets[id])) { - throw new Error( - `Failed to import mnemonic wallet: wallet is not of type 'mnemonic'`, - ); - } - return localWallets[id]; + if (!localWallets[id]) { + throw new Error( + `Failed to import mnemonic wallet: wallet not found after creation`, + ); + } + if (!isMnemonicWalletObject(localWallets[id])) { + throw new Error( + `Failed to import mnemonic wallet: wallet is not of type 'mnemonic'`, + ); + } + return localWallets[id]; } /** @@ -128,7 +135,10 @@ async function importMnemonicWallet( payloadWallet: AccountWalletMnemonicPayload, ): Promise { // Find the local wallet with the same entropy source ID if it exists. - let localWallet = await findLocalWalletMnemonicFromPayloadId(context, payloadWallet.id); + let localWallet = await findLocalWalletMnemonicFromPayloadId( + context, + payloadWallet.id, + ); if (!localWallet) { if (!payloadWallet.value) { @@ -153,7 +163,10 @@ async function importMnemonicWallet( let rangeIndex: number | undefined; const ranges: [number, number][] = []; for (const payloadGroup of payloadWallet.groups) { - const localGroupId = toMultichainAccountGroupId(localWallet.id, payloadGroup.groupIndex); + const localGroupId = toMultichainAccountGroupId( + localWallet.id, + payloadGroup.groupIndex, + ); if (localWallet.groups[localGroupId]) { if (rangeIndex !== undefined) { @@ -178,13 +191,13 @@ async function importMnemonicWallet( } // Re-read wallet after groups creation. - localWallet = findLocalWalletMnemonicFromId( - context, - localWallet.id, - ); + localWallet = findLocalWalletMnemonicFromId(context, localWallet.id); for (const payloadGroup of payloadWallet.groups) { - const localGroupId = toMultichainAccountGroupId(localWallet.id, payloadGroup.groupIndex); + const localGroupId = toMultichainAccountGroupId( + localWallet.id, + payloadGroup.groupIndex, + ); setGroupMetadata(context, localGroupId, payloadGroup.metadata); } @@ -208,9 +221,14 @@ async function importPrivateKeyWallet( for (const payloadGroup of payloadGroups) { // Payload group ID format: "wallet:private-key/
" const payloadAccountAddress = parsePayloadGroupId(payloadGroup.id).subId; - const payloadAccountId = getUUIDFromAddressOfNormalAccount(payloadAccountAddress); + const payloadAccountId = getUUIDFromAddressOfNormalAccount( + payloadAccountAddress, + ); - const localWalletId = toAccountWalletId(AccountWalletType.Keyring, KeyringTypes.simple); + const localWalletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.simple, + ); const localGroupId = toAccountGroupId(localWalletId, payloadAccountAddress); let localWallets = context.getState().accountTree.wallets; @@ -218,7 +236,9 @@ async function importPrivateKeyWallet( 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); + const hasAccount = localGroup.accounts.some( + (id) => id === payloadAccountId, + ); // If it doesn't exist, we need to import the private key. if (!hasAccount) { @@ -284,4 +304,4 @@ export async function importState( // Unknown types: skip silently (forward-compat). } } -} \ No newline at end of file +} diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 9c3befacdc0..e7e2abbebfe 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -14,8 +14,7 @@ export type ParsedPayloadGroupId = { subId: string; }; -const PAYLOAD_GROUP_ID_REGEX = - /^(?wallet:[^/]+)\/(?.+)$/u; +const PAYLOAD_GROUP_ID_REGEX = /^(?wallet:[^/]+)\/(?.+)$/u; /** * Parses a payload group ID into its wallet ID and group sub-ID components. @@ -120,7 +119,9 @@ export type AccountTreeSnapshotEntry = * @param entropySourceId - Stable entropy source ID returned by `HdKeyring.toEntropySourceId()`. * @returns The portable wallet payload ID. */ -export function toWalletPayloadId(entropySourceId: string): AccountWalletPayloadId { +export function toWalletPayloadId( + entropySourceId: string, +): AccountWalletPayloadId { return `wallet:${entropySourceId}`; } @@ -151,7 +152,9 @@ export function migrate(raw: unknown): AccountTreePayload { const { version } = raw as Record; if (typeof version !== 'number' || !Number.isInteger(version)) { - throw new Error('Invalid AccountTreePayload: missing numeric version field'); + throw new Error( + 'Invalid AccountTreePayload: missing numeric version field', + ); } if (version > ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION) { throw new Error( diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index a42a8588b88..5920e4721fc 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -1,14 +1,11 @@ +import { IdMap } from './id-map.js'; import type { AccountGroupPayloadId, AccountTreePayload, AccountTreeSnapshotEntry, AccountWalletPayloadId, } from './payload.js'; -import { - ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - migrate, -} from './payload.js'; -import { IdMap } from './id-map.js'; +import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, migrate } from './payload.js'; /** * Immutable value object returned by {@link AccountTreeController.exportState}. From b20dc4002a8541192a43c8a09baf085f61ff59de Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 12:05:01 +0200 Subject: [PATCH 08/38] test: add tests --- .../src/AccountTreeController.test.ts | 157 ++++ .../src/AccountTreeController.ts | 14 +- .../src/state/export.test.ts | 524 +++++++++++ .../src/state/id-map.test.ts | 97 +++ .../src/state/import.test.ts | 824 ++++++++++++++++++ .../src/state/import.ts | 11 +- .../src/state/payload.test.ts | 96 ++ .../src/state/snapshot.test.ts | 235 +++++ .../tests/mockMessenger.ts | 4 + 9 files changed, 1953 insertions(+), 9 deletions(-) create mode 100644 packages/account-tree-controller/src/state/export.test.ts create mode 100644 packages/account-tree-controller/src/state/id-map.test.ts create mode 100644 packages/account-tree-controller/src/state/import.test.ts create mode 100644 packages/account-tree-controller/src/state/payload.test.ts create mode 100644 packages/account-tree-controller/src/state/snapshot.test.ts diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index ce9a457dd3b..1b599ce8f0a 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -6077,4 +6077,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.wallets).toHaveLength(1); + const exportedWallet = payload.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(payload); + + // 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.wallets[0] as { value?: string }).value).toBeUndefined(); + + // Reimport is a no-op for metadata when nothing changed. + await expect(controller.importState(payload)).resolves.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 36f31b10525..b70518256f2 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -23,11 +23,6 @@ import { import { BackupAndSyncService } from './backup-and-sync/service/index.js'; import type { BackupAndSyncContext } from './backup-and-sync/types.js'; import { createSyncMutationTracker } from './backup-and-sync/utils/index.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 { AccountTreePayload } from './state/payload.js'; import type { AccountGroupObject, AccountTypeOrderKey } from './group.js'; import { ACCOUNT_TYPE_TO_SORT_ORDER, @@ -40,6 +35,11 @@ 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 { AccountTreePayload } from './state/payload.js'; +import type { AccountTreeSnapshot } from './state/snapshot.js'; import type { AccountTreeControllerConfig, AccountTreeControllerInternalBackupAndSyncConfig, @@ -1806,7 +1806,9 @@ export class AccountTreeController extends BaseController< * @param options - Export options. * @returns A promise resolving to an `AccountTreeSnapshot`. */ - async exportState(options?: ExportStateOptions): Promise { + async exportState( + options?: ExportStateOptions, + ): Promise { return exportState( { getState: () => this.state, messenger: this.messenger }, options, 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..c545efdef81 --- /dev/null +++ b/packages/account-tree-controller/src/state/export.test.ts @@ -0,0 +1,524 @@ +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.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, +} = {}) { + 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 }; +} + +/** Returns a mock withKeyringV2Unsafe implementation for an HD keyring. */ +function makeHdKeyringHandler( + entropySourceId: string, + mnemonic: Uint8Array | null = null, +) { + return jest + .fn() + .mockImplementation( + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => + callback({ + keyring: { + toEntropySourceId: async () => entropySourceId, + mnemonic, + }, + }), + ); +} + +/** Returns a mock withKeyringV2 implementation for a private-key keyring. */ +function makePrivateKeyExportHandler( + result: { privateKey: string; encoding: string } | undefined, +) { + 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 }); + await expect(exportState(context)).resolves.toBeDefined(); + }); + }); + + describe('with no wallets', () => { + it('returns an empty snapshot', async () => { + const { context } = setup(); + const snapshot = await exportState(context); + expect(snapshot.serialize().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().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().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().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.wallets).toHaveLength(1); + const wallet = payload.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().wallets[0]?.groups[0] as { + value?: { privateKey: string; encoding: string }; + }; + + expect(group.value?.privateKey).toBe('0xdeadbeef'); + expect(group.value?.encoding).toBe('hexadecimal'); + }); + + 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().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().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, + ); + }); + }); +}); 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/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts new file mode 100644 index 00000000000..96816627feb --- /dev/null +++ b/packages/account-tree-controller/src/state/import.test.ts @@ -0,0 +1,824 @@ +import { + AccountWalletType, + toAccountGroupId, + toAccountWalletId, + toMultichainAccountGroupId, + toMultichainAccountWalletId, +} from '@metamask/account-api'; +import { AccountGroupType } from '@metamask/account-api'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; +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 type { AccountTreePayload } from './payload.js'; +import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION } from './payload.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 MNEMONIC_PAYLOAD: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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.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'], +} = {}) { + 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 }; +} + +/** Returns a withKeyringV2Unsafe mock that calls `callback({ keyring })`. */ +function makeWithKeyringV2UnsafeMock(keyring: unknown) { + return jest + .fn() + .mockImplementation( + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => callback({ keyring }), + ); +} + +/** Returns a withKeyringV2 mock that calls `callback({ keyring })` and returns `result`. */ +function makeWithKeyringV2Mock(keyring: unknown, result: unknown = undefined) { + return jest + .fn() + .mockImplementation( + async ( + _selector: unknown, + callback: (ctx: { keyring: unknown }) => unknown, + ) => { + await callback({ keyring }); + return result; + }, + ); +} + +describe('importState', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('unknown wallet types', () => { + it('silently skips wallet entries with unrecognised types', async () => { + const { context, mocks } = setup(); + const payload: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + // @ts-expect-error -- deliberate unknown type for forward-compat test + { + id: 'wallet:future', + type: 'future-type', + metadata: { name: 'X' }, + groups: [], + }, + ], + }; + await expect(importState(context, payload)).resolves.toBeUndefined(); + expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); + }); + }); + + 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 importState(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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:entropy-only', + type: 'mnemonic', + // No mnemonic → will early-return after not finding the wallet. + metadata: { name: 'X' }, + groups: [], + }, + ], + }; + + await importState(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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:unknown-entropy', + type: 'mnemonic', + metadata: { name: 'Unknown' }, + groups: [], + }, + ], + }; + await importState(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( + importState(context, { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:no-match-entropy', + type: 'mnemonic', + value: JSON.stringify([1, 2, 3]), + 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( + importState(context, { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:no-match', + type: 'mnemonic', + value: JSON.stringify([1, 2, 3]), + 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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:unknown-entropy', + type: 'mnemonic', + value: JSON.stringify([1, 2, 3]), + metadata: { name: 'My Renamed Wallet' }, + groups: [ + { + id: 'wallet:unknown-entropy/0', + groupIndex: 0, + metadata: { name: 'Account 1', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + await importState(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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 importState(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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 importState(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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 importState(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, + callback: (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 callback({ keyring: { createAccounts: jest.fn() } }); + return [{ id: newAccountId }]; + }, + ); + + const payload: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 importState(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( + importState(context, { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 that has no value and account does not exist locally', async () => { + const { context, mocks } = setup(); + + const payload: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 importState(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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 importState(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 index 02141d5fce3..30bd81781c6 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -179,6 +179,12 @@ async function importMnemonicWallet( rangeIndex ??= payloadGroup.groupIndex; } + // Close any open range that runs to the end of the payload groups. + const lastPayloadGroup = + payloadWallet.groups[payloadWallet.groups.length - 1]; + if (rangeIndex !== undefined && lastPayloadGroup !== undefined) { + ranges.push([rangeIndex, lastPayloadGroup.groupIndex]); + } for (const range of ranges) { await context.messenger.call( 'MultichainAccountService:createMultichainAccountGroups', @@ -236,9 +242,8 @@ async function importPrivateKeyWallet( 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, - ); + const hasAccount = + localGroup?.accounts.some((id) => id === payloadAccountId) ?? false; // If it doesn't exist, we need to import the private key. if (!hasAccount) { 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..08ea8dca451 --- /dev/null +++ b/packages/account-tree-controller/src/state/payload.test.ts @@ -0,0 +1,96 @@ +import { + ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + migrate, + parsePayloadGroupId, + toWalletPayloadId, +} from './payload.js'; + +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', () => { + expect(() => migrate('not an object')).toThrow( + 'Invalid AccountTreePayload: expected an object', + ); + expect(() => migrate(null)).toThrow( + 'Invalid AccountTreePayload: expected an object', + ); + expect(() => migrate(42)).toThrow( + 'Invalid AccountTreePayload: expected an object', + ); + }); + + it('throws if version field is missing or not a number', () => { + expect(() => migrate({})).toThrow( + 'Invalid AccountTreePayload: missing numeric version field', + ); + expect(() => migrate({ version: '1' })).toThrow( + 'Invalid AccountTreePayload: missing numeric version field', + ); + expect(() => migrate({ version: 1.5 })).toThrow( + 'Invalid AccountTreePayload: missing numeric version field', + ); + }); + + it('throws if version exceeds CURRENT_VERSION', () => { + expect(() => + migrate({ version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1 }), + ).toThrow( + `Unsupported AccountTreePayload version: ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1}`, + ); + }); + + it('returns the payload unchanged for the current version', () => { + const raw = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [], + }; + const result = migrate(raw); + expect(result).toBe(raw); + expect(result.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); + expect(result.wallets).toStrictEqual([]); + }); + + it('skips migration steps that have no registered migrator', () => { + // Version 0 has no migrator entry; the loop still runs but skips it. + const raw = { version: 0, wallets: [] }; + // Should not throw, even though there is no v0 migrator. + expect(() => migrate(raw)).not.toThrow(); + }); +}); 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..2435b5cce8e --- /dev/null +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -0,0 +1,235 @@ +import { IdMap } from './id-map.js'; +import type { + AccountTreePayload, + 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('filter', () => { + it('returns a snapshot containing only matching entries', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + null, + ); + const filtered = snapshot.filter((e) => e.type === 'mnemonic'); + expect(filtered.serialize().wallets).toHaveLength(1); + expect(filtered.serialize().wallets[0]?.id).toBe( + 'wallet:entropy-source-1', + ); + }); + + it('preserves null idMap when filtering', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + null, + ); + const filtered = snapshot.filter(() => true); + expect(filtered.toLocalId('wallet:entropy-source-1')).toBeUndefined(); + }); + + it('prunes the idMap to only include entries for kept wallets', () => { + const map = buildIdMap(); + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filter((e) => e.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', + ); + // Private key wallet entries should not be in the filtered map. + expect(filtered.toLocalId('wallet:private-key')).toBeUndefined(); + expect( + filtered.toLocalId('wallet:private-key/0xdeadbeef'), + ).toBeUndefined(); + }); + + it('handles wallet entries whose IDs are not in the idMap', () => { + const map = new IdMap(); + // Only add one of the two wallets to the map. + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filter(() => true); + expect(filtered.toLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + // Private key wallet was not in the map — it should still be absent. + expect(filtered.toLocalId('wallet:private-key')).toBeUndefined(); + }); + }); + + 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], null); + 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], null); + 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 AccountTreePayload', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + null, + ); + const payload = snapshot.serialize(); + expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); + expect(payload.wallets).toHaveLength(2); + expect(payload.wallets[0]).toBe(MOCK_MNEMONIC_WALLET); + expect(payload.wallets[1]).toBe(MOCK_PRIVATE_KEY_WALLET); + }); + + it('serializes an empty snapshot', () => { + const snapshot = new AccountTreeSnapshot([], null); + const payload = snapshot.serialize(); + expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); + expect(payload.wallets).toHaveLength(0); + }); + }); + + describe('deserialize', () => { + it('deserializes a valid v1 payload into a snapshot', () => { + const raw: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [MOCK_MNEMONIC_WALLET], + }; + const snapshot = AccountTreeSnapshot.deserialize(raw); + expect(snapshot.serialize().wallets).toHaveLength(1); + expect(snapshot.serialize().wallets[0]?.id).toBe( + 'wallet:entropy-source-1', + ); + }); + + it('returns a snapshot with no idMap (toLocalId returns undefined)', () => { + const raw: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [MOCK_MNEMONIC_WALLET], + }; + const snapshot = AccountTreeSnapshot.deserialize(raw); + expect(snapshot.toLocalId('wallet:entropy-source-1')).toBeUndefined(); + expect(snapshot.toPayloadId('entropy:wallet-1')).toBeUndefined(); + }); + + it('throws for an invalid payload (no version)', () => { + expect(() => AccountTreeSnapshot.deserialize({ wallets: [] })).toThrow( + 'Invalid AccountTreePayload', + ); + }); + + it('throws for a future version', () => { + expect(() => + AccountTreeSnapshot.deserialize({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1, + wallets: [], + }), + ).toThrow('Unsupported AccountTreePayload version'); + }); + }); +}); 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', ], }); From 7b756799b8a082a0cf2e3a2f1a28cf4b8d70a393 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 12:14:31 +0200 Subject: [PATCH 09/38] refactor: refactor ranges --- .../src/state/import.ts | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index 30bd81781c6..6a9490e28da 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -118,6 +118,48 @@ function setGroupMetadata( 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. * @@ -159,33 +201,10 @@ async function importMnemonicWallet( context.setWalletName(localWallet.id, payloadWallet.metadata.name); - // Compute range of group indices in the payload to import. - let rangeIndex: number | undefined; - const ranges: [number, number][] = []; - for (const payloadGroup of payloadWallet.groups) { - 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; - } - // Close any open range that runs to the end of the payload groups. - const lastPayloadGroup = - payloadWallet.groups[payloadWallet.groups.length - 1]; - if (rangeIndex !== undefined && lastPayloadGroup !== undefined) { - ranges.push([rangeIndex, lastPayloadGroup.groupIndex]); - } - for (const range of ranges) { + for (const range of getRangesFromPayloadGroups( + localWallet, + payloadWallet.groups, + )) { await context.messenger.call( 'MultichainAccountService:createMultichainAccountGroups', { From 2fb79753ed718dab570347f6c634e2869dc8b669 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 13:22:20 +0200 Subject: [PATCH 10/38] fix: add explicit value.type for private keys --- .../src/state/export.ts | 2 + .../src/state/import.test.ts | 67 +++++++++++++++++++ .../src/state/import.ts | 13 +++- .../src/state/payload.ts | 8 +++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 17ec2838ad8..3884b625258 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -1,5 +1,6 @@ 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 { encodeMnemonic } from '@metamask/keyring-sdk'; @@ -230,6 +231,7 @@ async function exportPrivateKeyWalletObject( group.value = { privateKey: exported.privateKey, encoding: exported.encoding, + type: EthAccountType.Eoa, }; } diff --git a/packages/account-tree-controller/src/state/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts index 96816627feb..73aa80236be 100644 --- a/packages/account-tree-controller/src/state/import.test.ts +++ b/packages/account-tree-controller/src/state/import.test.ts @@ -7,6 +7,7 @@ import { } 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 { @@ -766,6 +767,72 @@ describe('importState', () => { ).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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 importState(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: AccountTreePayload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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(importState(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(); diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index 6a9490e28da..0f296ad3dc0 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -7,7 +7,7 @@ import { import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; -import { KeyringAccount } from '@metamask/keyring-api'; +import { EthAccountType, KeyringAccount } from '@metamask/keyring-api'; import { KeyringType } from '@metamask/keyring-api/v2'; import { KeyringTypes } from '@metamask/keyring-controller'; @@ -244,6 +244,17 @@ async function importPrivateKeyWallet( 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( diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index e7e2abbebfe..08903f82717 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -1,3 +1,5 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + /** Stable cross-device wallet identifier. Format: `wallet:`. */ export type AccountWalletPayloadId = `wallet:${string}`; @@ -70,6 +72,12 @@ export type AccountWalletPrivateKeyGroupEntry = { 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; }; From 32bc80e2452088b687b719446825e6de7d3eee23 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 13:23:31 +0200 Subject: [PATCH 11/38] chore: cosmetic --- packages/account-tree-controller/src/state/export.test.ts | 7 ++++--- packages/account-tree-controller/src/state/export.ts | 4 ++-- packages/account-tree-controller/src/state/import.test.ts | 8 ++++---- packages/account-tree-controller/src/state/import.ts | 4 ++-- packages/account-tree-controller/src/state/payload.ts | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.test.ts b/packages/account-tree-controller/src/state/export.test.ts index c545efdef81..ef8b4bc66b2 100644 --- a/packages/account-tree-controller/src/state/export.test.ts +++ b/packages/account-tree-controller/src/state/export.test.ts @@ -263,7 +263,7 @@ describe('exportState', () => { 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. + // encodeMnemonic uses Uint16Array internally -- must be even-length. mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( 'stable-entropy-id', new Uint8Array([1, 2, 3, 4]), @@ -294,7 +294,7 @@ describe('exportState', () => { 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. + // mnemonic: null -> includeMnemonic will be false -> throws after export. mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( 'stable-entropy-id', null, @@ -422,11 +422,12 @@ describe('exportState', () => { const snapshot = await exportState(context, { includeSecrets: true }); const group = snapshot.serialize().wallets[0]?.groups[0] as { - value?: { privateKey: string; encoding: string }; + 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 () => { diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 3884b625258..387806b6acc 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -247,8 +247,8 @@ async function exportPrivateKeyWalletObject( * 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. + * - {@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. diff --git a/packages/account-tree-controller/src/state/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts index 73aa80236be..332f1cda6cb 100644 --- a/packages/account-tree-controller/src/state/import.test.ts +++ b/packages/account-tree-controller/src/state/import.test.ts @@ -285,7 +285,7 @@ describe('importState', () => { { id: 'wallet:entropy-only', type: 'mnemonic', - // No mnemonic → will early-return after not finding the wallet. + // No mnemonic -> will early-return after not finding the wallet. metadata: { name: 'X' }, groups: [], }, @@ -743,7 +743,7 @@ describe('importState', () => { const { context, mocks } = setup(); mocks.KeyringController.withKeyringV2 = makeWithKeyringV2Mock( { createAccounts: jest.fn() }, - [], // Empty → no account was imported. + [], // Empty -> no account was imported. ); await expect( @@ -846,7 +846,7 @@ describe('importState', () => { groups: [ { id: `wallet:private-key/${ADDR_C}`, - // No value → skip. + // No value -> skip. metadata: { name: 'Missing', pinned: false, hidden: false }, }, ], @@ -860,7 +860,7 @@ describe('importState', () => { 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. + // State stays empty -- the import succeeds but leaves no group in the tree. mocks.KeyringController.withKeyringV2 = makeWithKeyringV2Mock( { createAccounts: jest.fn() }, [{ id: 'some-account-id' }], diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index 0f296ad3dc0..8cbe23ea90d 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -184,7 +184,7 @@ async function importMnemonicWallet( if (!localWallet) { if (!payloadWallet.value) { - // No mnemonic in payload and wallet doesn't exist locally — nothing to do. + // No mnemonic in payload and wallet doesn't exist locally -- nothing to do. return; } @@ -278,7 +278,7 @@ async function importPrivateKeyWallet( // If it doesn't exist, we need to import the private key. if (!hasAccount) { if (!payloadGroup.value) { - // No importable secret — skip this account. + // No importable secret -- skip this account. continue; } diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 08903f82717..761b5dd6200 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -142,7 +142,7 @@ export type ExportStateOptions = { type Migrator = (raw: unknown) => AccountTreePayload; const MIGRATORS: Record = { - // v1 is the current version — identity migration. + // v1 is the current version -- identity migration. [ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION]: (raw) => raw as AccountTreePayload, }; From 802dc77ff3d0533d30f2f1b5443128f0b3429b9d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 13:44:41 +0200 Subject: [PATCH 12/38] chore: use preview builds --- package.json | 2 ++ yarn.lock | 65 ++++++++++++++++++++++++++++++++-------------------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 25f86f528de..31b5ed8c068 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,8 @@ "yargs": "^17.7.2" }, "resolutions": { + "@metamask/eth-hd-keyring": "npm:@metamask-previews/eth-hd-keyring@14.1.2-58658de", + "@metamask/keyring-sdk": "npm:@metamask-previews/keyring-sdk@2.3.0-58658de", "@nktkas/hyperliquid@npm:^0.33.1": "patch:@nktkas/hyperliquid@npm%3A0.33.1#~/.yarn/patches/@nktkas-hyperliquid-npm-0.33.1-6a541fdd1d.patch", "elliptic@6.5.4": "^6.5.7", "fast-xml-parser@^4.3.4": "^4.4.1", diff --git a/yarn.lock b/yarn.lock index 8b0f0e77cc5..6a0e5bc3739 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5519,8 +5519,10 @@ __metadata: "@metamask/accounts-controller": "npm:^39.0.5" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/eth-hd-keyring": "npm:^14.1.1" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/keyring-controller": "npm:^27.1.0" + "@metamask/keyring-sdk": "npm:^2.2.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/multichain-account-service": "npm:^13.0.0" "@metamask/profile-sync-controller": "npm:^28.3.0" @@ -6771,22 +6773,22 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-hd-keyring@npm:^14.1.1": - version: 14.1.1 - resolution: "@metamask/eth-hd-keyring@npm:14.1.1" +"@metamask/eth-hd-keyring@npm:@metamask-previews/eth-hd-keyring@14.1.2-58658de": + version: 14.1.2-58658de + resolution: "@metamask-previews/eth-hd-keyring@npm:14.1.2-58658de" 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.1.0" - "@metamask/keyring-sdk": "npm:^2.0.2" - "@metamask/keyring-utils": "npm:^3.2.0" + "@metamask/keyring-api": "npm:23.7.0" + "@metamask/keyring-sdk": "npm:2.3.0" + "@metamask/keyring-utils": "npm:4.0.0" "@metamask/scure-bip39": "npm:^2.1.1" - "@metamask/superstruct": "npm:^3.1.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" ethereum-cryptography: "npm:^2.2.1" - checksum: 10/f711742682a77990310272013523595822e29bfb61980828d1460ab8af888d7c344bb50f04d2611d801d63438e31532d3d66698c595b4fd3ad512b02056b7234 + checksum: 10/0f177df3c09f400bec21d55fa575dd3f578f88100131cc8e47fd3544a2a12a4cb8d98189d40e0e85a4e4520c52bde257bb1e92ee5a0a1e30ce37efcd5288031b languageName: node linkType: hard @@ -7299,15 +7301,15 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^23.1.0, @metamask/keyring-api@npm:^23.2.0, @metamask/keyring-api@npm:^23.5.0": - version: 23.5.0 - resolution: "@metamask/keyring-api@npm:23.5.0" +"@metamask/keyring-api@npm:23.7.0, @metamask/keyring-api@npm:^23.1.0, @metamask/keyring-api@npm:^23.5.0": + version: 23.7.0 + resolution: "@metamask/keyring-api@npm:23.7.0" dependencies: - "@metamask/keyring-utils": "npm:^3.3.1" - "@metamask/superstruct": "npm:^3.3.0" + "@metamask/keyring-utils": "npm:^4.0.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/2d8a4b378f5bad77284feb58bc7f3b64d541751527c693e43f7e4d860333bc55593e27fc5264fc908d719912b374f77dabec3f5e491bb49f72501fd2f5c43e68 + checksum: 10/3c1aea064e017be0b99202a4e59b7ed9e1965aeb732b04ad5cb252dfa6b443487284f8b4253986ca325d8b45fd7977cef93f470f95f0342d377551d4b93738c3 languageName: node linkType: hard @@ -7376,21 +7378,22 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-sdk@npm:^2.0.2, @metamask/keyring-sdk@npm:^2.2.0": - version: 2.2.0 - resolution: "@metamask/keyring-sdk@npm:2.2.0" +"@metamask/keyring-sdk@npm:@metamask-previews/keyring-sdk@2.3.0-58658de": + version: 2.3.0-58658de + resolution: "@metamask-previews/keyring-sdk@npm:2.3.0-58658de" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^23.2.0" - "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/keyring-api": "npm:23.7.0" + "@metamask/keyring-utils": "npm:4.0.0" "@metamask/scure-bip39": "npm:^2.1.1" - "@metamask/superstruct": "npm:^3.1.0" + "@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/5cb7f2496f0fc95e85eb97932b69da9c02c232e2310b5f390bfc7c56996bf8516d15db54260e68288f6d6f5604bfacc19b4b82b9b28b7f794a3ab5ee9a1f10d1 + checksum: 10/3f8488fdf7c9800c3a7087054ac571ab67b2b8184b9cfb2f19925cd88416854df86fccb63fdcdcb878b961ec456386a382ebec2bddd342f40d453e5f36147760 languageName: node linkType: hard @@ -7426,6 +7429,18 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-utils@npm:4.0.0, @metamask/keyring-utils@npm:^4.0.0": + version: 4.0.0 + resolution: "@metamask/keyring-utils@npm:4.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 + languageName: node + linkType: hard + "@metamask/keyring-utils@npm:^3.2.0, @metamask/keyring-utils@npm:^3.3.1": version: 3.3.1 resolution: "@metamask/keyring-utils@npm:3.3.1" @@ -8988,10 +9003,10 @@ __metadata: languageName: unknown linkType: soft -"@metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1, @metamask/superstruct@npm:^3.3.0": - version: 3.3.0 - resolution: "@metamask/superstruct@npm:3.3.0" - checksum: 10/664d5e330484a86420bc004b1c7f8301e1501cce712f611fe657176b2979edbd7cc6f4c77c8b1610486a685ebbd0b72dacf4bd6cf143d6b52038069d2f4e84ab +"@metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1, @metamask/superstruct@npm:^3.3.0, @metamask/superstruct@npm:^3.4.1": + version: 3.4.1 + resolution: "@metamask/superstruct@npm:3.4.1" + checksum: 10/d37b5662dc9bbe0d99e06eb951167fa745829ae3abfa103423e9d8c05e8712f1451376f8479d484722e1e1c1d881732da1efddccaca8868b530a786264b903b5 languageName: node linkType: hard From 4024258ff7bfd418f2131979eefd050ff2619682 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 14:05:08 +0200 Subject: [PATCH 13/38] chore: lint --- .../src/AccountTreeController.test.ts | 2 +- .../src/state/export.test.ts | 27 +++++++-- .../src/state/import.test.ts | 57 ++++++++++++++----- .../src/state/import.ts | 5 +- .../src/state/payload.ts | 4 +- .../src/state/snapshot.test.ts | 4 +- 6 files changed, 69 insertions(+), 30 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 1b599ce8f0a..79dbd054ff6 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -6206,7 +6206,7 @@ describe('AccountTreeController', () => { expect((payload.wallets[0] as { value?: string }).value).toBeUndefined(); // Reimport is a no-op for metadata when nothing changed. - await expect(controller.importState(payload)).resolves.toBeUndefined(); + expect(await controller.importState(payload)).toBeUndefined(); }); it('throws when exporting with includeSecrets: true and the vault is locked', async () => { diff --git a/packages/account-tree-controller/src/state/export.test.ts b/packages/account-tree-controller/src/state/export.test.ts index ef8b4bc66b2..0bd4a817098 100644 --- a/packages/account-tree-controller/src/state/export.test.ts +++ b/packages/account-tree-controller/src/state/export.test.ts @@ -85,6 +85,7 @@ const MOCK_PK_WALLET_STATE: AccountTreeControllerState['accountTree']['wallets'] * 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. @@ -92,7 +93,23 @@ const MOCK_PK_WALLET_STATE: AccountTreeControllerState['accountTree']['wallets'] 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: [] }), @@ -138,11 +155,10 @@ function setup({ return { context, mocks, messenger }; } -/** Returns a mock withKeyringV2Unsafe implementation for an HD keyring. */ function makeHdKeyringHandler( entropySourceId: string, mnemonic: Uint8Array | null = null, -) { +): jest.Mock { return jest .fn() .mockImplementation( @@ -159,10 +175,9 @@ function makeHdKeyringHandler( ); } -/** Returns a mock withKeyringV2 implementation for a private-key keyring. */ function makePrivateKeyExportHandler( result: { privateKey: string; encoding: string } | undefined, -) { +): jest.Mock { return jest .fn() .mockImplementation( @@ -248,7 +263,7 @@ describe('exportState', () => { it('does not throw when includeSecrets is false and vault is locked', async () => { const { context } = setup({ isUnlocked: false }); - await expect(exportState(context)).resolves.toBeDefined(); + expect(await exportState(context)).toBeDefined(); }); }); diff --git a/packages/account-tree-controller/src/state/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts index 332f1cda6cb..ad5482f9335 100644 --- a/packages/account-tree-controller/src/state/import.test.ts +++ b/packages/account-tree-controller/src/state/import.test.ts @@ -101,12 +101,36 @@ function makeHdWalletState(): AccountTreeControllerState['accountTree']['wallets * `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 = { @@ -166,28 +190,27 @@ function setup({ return { context, mocks, walletsRef }; } -/** Returns a withKeyringV2Unsafe mock that calls `callback({ keyring })`. */ -function makeWithKeyringV2UnsafeMock(keyring: unknown) { +function makeWithKeyringV2UnsafeMock(keyring: unknown): jest.Mock { return jest .fn() .mockImplementation( - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => callback({ keyring }), + async (_selector: unknown, fn: (ctx: { keyring: unknown }) => unknown) => + fn({ keyring }), ); } -/** Returns a withKeyringV2 mock that calls `callback({ keyring })` and returns `result`. */ -function makeWithKeyringV2Mock(keyring: unknown, result: unknown = undefined) { +function makeWithKeyringV2Mock( + keyring: unknown, + result: unknown = undefined, +): jest.Mock { return jest .fn() .mockImplementation( async ( _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, + fn: (ctx: { keyring: unknown }) => unknown, ) => { - await callback({ keyring }); + await fn({ keyring }); return result; }, ); @@ -213,7 +236,7 @@ describe('importState', () => { }, ], }; - await expect(importState(context, payload)).resolves.toBeUndefined(); + expect(await importState(context, payload)).toBeUndefined(); expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); }); }); @@ -678,7 +701,7 @@ describe('importState', () => { mocks.KeyringController.withKeyringV2.mockImplementation( async ( _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, + fn: (ctx: { keyring: unknown }) => unknown, ) => { walletsRef.current = { [MOCK_PK_WALLET_ID]: { @@ -704,7 +727,7 @@ describe('importState', () => { }, }, }; - await callback({ keyring: { createAccounts: jest.fn() } }); + await fn({ keyring: { createAccounts: jest.fn() } }); return [{ id: newAccountId }]; }, ); @@ -785,7 +808,11 @@ describe('importState', () => { encoding: 'base58', type: 'bip122:p2wpkh', }, - metadata: { name: 'Bitcoin Account', pinned: false, hidden: false }, + metadata: { + name: 'Bitcoin Account', + pinned: false, + hidden: false, + }, }, ], }, diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index 8cbe23ea90d..c380ca12397 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -248,10 +248,7 @@ async function importPrivateKeyWallet( // 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 - ) { + if (privateKeyType !== undefined && privateKeyType !== EthAccountType.Eoa) { continue; } diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 761b5dd6200..9071301be11 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -171,8 +171,8 @@ export function migrate(raw: unknown): AccountTreePayload { } let result: unknown = raw; - for (let v = version; v <= ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; v++) { - const migrator = MIGRATORS[v]; + for (let ver = version; ver <= ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; ver++) { + const migrator = MIGRATORS[ver]; if (migrator) { result = migrator(result); } diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index 2435b5cce8e..61ef9518813 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -54,7 +54,7 @@ describe('AccountTreeSnapshot', () => { [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], null, ); - const filtered = snapshot.filter((e) => e.type === 'mnemonic'); + const filtered = snapshot.filter((entry) => entry.type === 'mnemonic'); expect(filtered.serialize().wallets).toHaveLength(1); expect(filtered.serialize().wallets[0]?.id).toBe( 'wallet:entropy-source-1', @@ -77,7 +77,7 @@ describe('AccountTreeSnapshot', () => { map, ); - const filtered = snapshot.filter((e) => e.type === 'mnemonic'); + const filtered = snapshot.filter((entry) => entry.type === 'mnemonic'); expect(filtered.toLocalId('wallet:entropy-source-1')).toBe( 'entropy:wallet-1', From 5a09f5d7b8b76a131faaba3e6432542fff4bbace Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 15:48:39 +0200 Subject: [PATCH 14/38] fix: fix typing error --- packages/account-tree-controller/src/state/import.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index c380ca12397..a9c8357bf01 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -87,18 +87,19 @@ function findLocalWalletMnemonicFromId( id: AccountWalletId, ): AccountWalletEntropyObject { const localWallets = context.getState().accountTree.wallets; + const localWallet = localWallets[id]; - if (!localWallets[id]) { + if (!localWallet) { throw new Error( `Failed to import mnemonic wallet: wallet not found after creation`, ); } - if (!isMnemonicWalletObject(localWallets[id])) { + if (!isMnemonicWalletObject(localWallet)) { throw new Error( `Failed to import mnemonic wallet: wallet is not of type 'mnemonic'`, ); } - return localWallets[id]; + return localWallet; } /** From 421b167935f0b37d7ade8b12cb4a0430a11b8e3c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 27 Jul 2026 15:59:46 +0200 Subject: [PATCH 15/38] chore: changelog --- packages/account-tree-controller/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index b5b8c2f3704..eef9988e14d 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [7.5.5] ### Changed From e5cab2e8b0fd9733cbb77898cdacdcae1407565e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 28 Jul 2026 17:42:53 +0200 Subject: [PATCH 16/38] fix: remove unused :importAccountWithStrategy --- packages/account-tree-controller/src/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index b1be4e3489f..ccaae9ebbf3 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -16,7 +16,6 @@ import type { import type { TraceCallback } from '@metamask/controller-utils'; import type { KeyringControllerGetStateAction, - KeyringControllerImportAccountWithStrategyAction, KeyringControllerWithKeyringV2Action, KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; @@ -104,7 +103,6 @@ export type AllowedActions = | MultichainAccountServiceCreateMultichainAccountWalletAction | KeyringControllerWithKeyringV2Action | KeyringControllerWithKeyringV2UnsafeAction - | KeyringControllerImportAccountWithStrategyAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction From 3084246a66af83a190de63fed9c2665a8aab8231 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 30 Jul 2026 10:59:59 +0200 Subject: [PATCH 17/38] fix: fix mnemonic encoding --- package.json | 2 +- packages/account-tree-controller/package.json | 2 +- .../src/state/export.ts | 8 ++-- .../src/state/import.ts | 3 +- packages/account-tree-controller/src/types.ts | 2 +- packages/accounts-controller/package.json | 2 +- yarn.lock | 40 +++++++++++++++---- 7 files changed, 42 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 31b5ed8c068..ad10f60a4db 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ }, "resolutions": { "@metamask/eth-hd-keyring": "npm:@metamask-previews/eth-hd-keyring@14.1.2-58658de", - "@metamask/keyring-sdk": "npm:@metamask-previews/keyring-sdk@2.3.0-58658de", + "@metamask/keyring-sdk": "npm:@metamask-previews/keyring-sdk@3.0.0-914f87e", "@nktkas/hyperliquid@npm:^0.33.1": "patch:@nktkas/hyperliquid@npm%3A0.33.1#~/.yarn/patches/@nktkas-hyperliquid-npm-0.33.1-6a541fdd1d.patch", "elliptic@6.5.4": "^6.5.7", "fast-xml-parser@^4.3.4": "^4.4.1", diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index 75ee1b12bce..b1a81f45b9c 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -59,7 +59,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/keyring-api": "^23.5.0", "@metamask/keyring-controller": "^27.1.0", - "@metamask/keyring-sdk": "^2.2.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", diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 387806b6acc..bc74a9f93ff 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -3,7 +3,7 @@ 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 { encodeMnemonic } from '@metamask/keyring-sdk'; +import { encodeMnemonic, encodeMnemonicWords } from '@metamask/keyring-sdk'; import type { AccountTreeControllerMessenger, @@ -95,14 +95,14 @@ async function exportMnemonicWalletObject( entropySourceId: await hdKeyring.toEntropySourceId(), // No need to include the mnemonic here if we're not exporting secrets. mnemonic: includeMnemonic - ? encodeMnemonic(hdKeyring.mnemonic) + ? hdKeyring.mnemonic : undefined, }; }, ); const { entropySourceId, mnemonic } = result as { entropySourceId: string; - mnemonic?: number[]; + mnemonic?: Uint8Array; }; // We use the stable entropy source ID as the payload wallet ID, rather than the local wallet ID, to @@ -140,7 +140,7 @@ async function exportMnemonicWalletObject( throw new Error(`Failed to export mnemonic for wallet ${wallet.id}`); } - wallet.value = JSON.stringify(mnemonic); // FIXME: This should be a string, but the encodeMnemonic function returns a number array. We need to fix this in the keyring-sdk. + wallet.value = encodeMnemonicWords(mnemonic); } return wallet; diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index a9c8357bf01..7b246fb2032 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -10,6 +10,7 @@ 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, @@ -190,7 +191,7 @@ async function importMnemonicWallet( } // Import the mnemonic as a new HD wallet. - const mnemonic = JSON.parse(payloadWallet.value); + const mnemonic = decodeMnemonicWords(payloadWallet.value); const { id } = await context.messenger.call( 'MultichainAccountService:createMultichainAccountWallet', { type: 'import', mnemonic }, diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index ccaae9ebbf3..e506a5d3e7b 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -102,7 +102,7 @@ export type AllowedActions = | MultichainAccountServiceCreateMultichainAccountGroupsAction | MultichainAccountServiceCreateMultichainAccountWalletAction | KeyringControllerWithKeyringV2Action - | KeyringControllerWithKeyringV2UnsafeAction + | KeyringControllerWithKeyringV2UnsafeAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index be25902f274..c190c8b0659 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -61,7 +61,7 @@ "@metamask/keyring-api": "^23.5.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/keyring-internal-api": "^11.0.1", - "@metamask/keyring-sdk": "^2.2.0", + "@metamask/keyring-sdk": "^3.0.0", "@metamask/keyring-utils": "^3.3.1", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^34.0.0", diff --git a/yarn.lock b/yarn.lock index 6a0e5bc3739..0e8aaded5af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5522,7 +5522,7 @@ __metadata: "@metamask/eth-hd-keyring": "npm:^14.1.1" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/keyring-sdk": "npm:^2.2.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" @@ -5562,7 +5562,7 @@ __metadata: "@metamask/keyring-api": "npm:^23.5.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/keyring-internal-api": "npm:^11.0.1" - "@metamask/keyring-sdk": "npm:^2.2.0" + "@metamask/keyring-sdk": "npm:^3.0.0" "@metamask/keyring-utils": "npm:^3.3.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" @@ -7313,6 +7313,18 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-api@npm:24.0.0": + version: 24.0.0 + resolution: "@metamask/keyring-api@npm:24.0.0" + dependencies: + "@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/5160a3e2b9f1f753730bc877479aa1d375ac55dc023e8e34def4f2eeb2175864b096249af12e24f19d8240569c7bb977fcc424ab3023759189e1161c23d62879 + languageName: node + linkType: hard + "@metamask/keyring-controller@npm:^27.1.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" @@ -7378,14 +7390,14 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-sdk@npm:@metamask-previews/keyring-sdk@2.3.0-58658de": - version: 2.3.0-58658de - resolution: "@metamask-previews/keyring-sdk@npm:2.3.0-58658de" +"@metamask/keyring-sdk@npm:@metamask-previews/keyring-sdk@3.0.0-914f87e": + version: 3.0.0-914f87e + resolution: "@metamask-previews/keyring-sdk@npm:3.0.0-914f87e" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:23.7.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" @@ -7393,7 +7405,7 @@ __metadata: async-mutex: "npm:^0.5.0" ethereum-cryptography: "npm:^2.2.1" uuid: "npm:^9.0.1" - checksum: 10/3f8488fdf7c9800c3a7087054ac571ab67b2b8184b9cfb2f19925cd88416854df86fccb63fdcdcb878b961ec456386a382ebec2bddd342f40d453e5f36147760 + checksum: 10/d3fc9cc2e97e8bb21d7f56cb56df499157c55d79b4bc76064aabf20e15d405ad42fbed519cd28c03e3d4f0ba5b0c9e8de5aa5fa5f3fcf4a3d8d4d189b6424616 languageName: node linkType: hard @@ -7441,6 +7453,18 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-utils@npm:5.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/261cad056370ec89c2d2b29afddfe50dfe0dca302a0f70380eb1fc96c31348bcefdd087d0e17fbe7dec39603408eea1acf8ebc36f230a8b8897fa9e231e4a2b2 + languageName: node + linkType: hard + "@metamask/keyring-utils@npm:^3.2.0, @metamask/keyring-utils@npm:^3.3.1": version: 3.3.1 resolution: "@metamask/keyring-utils@npm:3.3.1" From 30ec0677c8f43354f123ecd00a40690b77b23aa7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 15:01:06 +0200 Subject: [PATCH 18/38] feat: add payload schema validation --- packages/account-tree-controller/package.json | 2 +- .../src/state/payload.test.ts | 170 ++++++++++++++- .../src/state/payload.ts | 205 +++++++++++++++++- 3 files changed, 362 insertions(+), 15 deletions(-) diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index 6f8f22e7502..e0260863374 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -66,7 +66,7 @@ "@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" diff --git a/packages/account-tree-controller/src/state/payload.test.ts b/packages/account-tree-controller/src/state/payload.test.ts index 08ea8dca451..32c500052e4 100644 --- a/packages/account-tree-controller/src/state/payload.test.ts +++ b/packages/account-tree-controller/src/state/payload.test.ts @@ -4,6 +4,42 @@ import { parsePayloadGroupId, toWalletPayloadId, } from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +const VALID_MNEMONIC_PAYLOAD = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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)', () => { @@ -76,7 +112,13 @@ describe('migrate', () => { ); }); - it('returns the payload unchanged for the current version', () => { + it('throws if version is below CURRENT_VERSION', () => { + expect(() => migrate({ version: 0, wallets: [] })).toThrow( + 'Unsupported AccountTreePayload version: 0', + ); + }); + + it('returns the payload unchanged for a valid current-version payload', () => { const raw = { version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [], @@ -87,10 +129,126 @@ describe('migrate', () => { expect(result.wallets).toStrictEqual([]); }); - it('skips migration steps that have no registered migrator', () => { - // Version 0 has no migrator entry; the loop still runs but skips it. - const raw = { version: 0, wallets: [] }; - // Should not throw, even though there is no v0 migrator. - expect(() => migrate(raw)).not.toThrow(); + it('accepts a valid mnemonic payload', () => { + const result = migrate(VALID_MNEMONIC_PAYLOAD); + expect(result.wallets).toHaveLength(1); + expect(result.wallets[0]?.type).toBe('mnemonic'); + }); + + it('accepts a valid private-key payload', () => { + const result = 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', () => { + expect(() => + migrate({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:ledger', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).toThrow('Invalid AccountTreePayload'); + }); + + it('throws when required wallet fields are missing', () => { + expect(() => + migrate({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [{ type: 'mnemonic' }], + }), + ).toThrow('Invalid AccountTreePayload'); + }); + + it('redacts mnemonic secrets in validation error messages', () => { + const secretMnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + + try { + migrate({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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 = migrate({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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', () => { + const secretKey = + '4c0883a69102937d6231471b5dbb6e538eba0ef8b09f0bf4e8b8e1e4e3e3b3c2'; + + try { + migrate({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + 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('AccountTreeSnapshot.deserialize validation', () => { + it('rejects payloads with unsupported wallet types', () => { + expect(() => + AccountTreeSnapshot.deserialize({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:ledger', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).toThrow('Invalid AccountTreePayload'); }); }); diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 9071301be11..0f0d4db0a14 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -1,4 +1,20 @@ import type { KeyringAccount } from '@metamask/keyring-api'; +import { + assert, + array, + boolean, + define, + enums, + integer, + literal, + object, + optional, + sensitive, + string, + StructError, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; /** Stable cross-device wallet identifier. Format: `wallet:`. */ export type AccountWalletPayloadId = `wallet:${string}`; @@ -116,10 +132,43 @@ export type AccountTreePayload = { wallets: AccountTreeWalletEntry[]; }; -/** Wallet entry type exposed to {@link AccountTreeSnapshot.filter} predicates. */ -export type AccountTreeSnapshotEntry = - | AccountWalletMnemonicPayload - | AccountWalletPrivateKeyPayload; +/** + * 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. + * + * Values are runtime-frozen before the predicate runs, 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. + * + * Values are runtime-frozen before the predicate runs, 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. @@ -139,19 +188,154 @@ export type ExportStateOptions = { includeSecrets?: boolean; }; +const AccountWalletPayloadIdSchema = define( + 'AccountWalletPayloadId', + (value) => + typeof value === 'string' && value.startsWith('wallet:') + ? true + : 'Expected a wallet payload ID starting with "wallet:"', +); + +const AccountGroupPayloadIdSchema = define( + 'AccountGroupPayloadId', + (value) => + typeof value === 'string' && PAYLOAD_GROUP_ID_REGEX.test(value) + ? true + : 'Expected a group payload ID in the form "wallet:/"', +); + +const AccountWalletPayloadMetadataSchema = object({ + name: string(), +}); + +const AccountWalletGroupPayloadMetadataSchema = object({ + name: string(), + pinned: boolean(), + hidden: boolean(), +}); + +const AccountWalletPrivateKeyValueSchema = object({ + privateKey: sensitive(string()), + encoding: enums(['hexadecimal', 'base58', 'base32']), + type: optional(string()), +}); + +const AccountWalletMnemonicGroupEntrySchema = object({ + id: AccountGroupPayloadIdSchema, + groupIndex: integer(), + metadata: AccountWalletGroupPayloadMetadataSchema, +}); + +const AccountWalletPrivateKeyGroupEntrySchema = object({ + id: AccountGroupPayloadIdSchema, + value: optional(AccountWalletPrivateKeyValueSchema), + metadata: AccountWalletGroupPayloadMetadataSchema, +}); + +const AccountWalletMnemonicPayloadSchema = object({ + id: AccountWalletPayloadIdSchema, + type: literal('mnemonic'), + value: optional(sensitive(string())), + metadata: AccountWalletPayloadMetadataSchema, + groups: array(AccountWalletMnemonicGroupEntrySchema), +}); + +const AccountWalletPrivateKeyPayloadSchema = object({ + id: AccountWalletPayloadIdSchema, + type: literal('private-key'), + metadata: AccountWalletPayloadMetadataSchema, + groups: array(AccountWalletPrivateKeyGroupEntrySchema), +}); + +const AccountTreeWalletEntrySchema = union([ + AccountWalletMnemonicPayloadSchema, + AccountWalletPrivateKeyPayloadSchema, +]); + +/** + * Superstruct schema for a versioned {@link AccountTreePayload}. + * + * 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 AccountTreePayloadSchema = object({ + version: literal(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION), + wallets: array(AccountTreeWalletEntrySchema), +}); + +/** Inferred TypeScript type for a value matching {@link AccountTreePayloadSchema}. */ +export type AccountTreePayloadSchemaType = Infer< + typeof AccountTreePayloadSchema +>; + +/** + * 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, AccountTreePayloadSchema); + } catch (error) { + if (error instanceof StructError) { + throw new Error( + `Invalid AccountTreePayload: ${formatValidationErrorMessages(error)}`, + ); + } + /* istanbul ignore next */ + throw error; + } +} + type Migrator = (raw: unknown) => AccountTreePayload; +/** + * Validates a raw value as a v1 {@link AccountTreePayload}. + * + * @param raw - Unknown value to validate. + * @returns The validated payload. + */ +const migrateV1 = (raw: unknown): AccountTreePayload => { + assertValidAccountTreePayload(raw); + return raw; +}; + const MIGRATORS: Record = { - // v1 is the current version -- identity migration. - [ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION]: (raw) => raw as AccountTreePayload, + [ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION]: migrateV1, }; /** * 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 or `version > CURRENT_VERSION`. + * @throws If `raw` is not a valid payload, its version is unsupported, or any wallet type is unrecognized. */ export function migrate(raw: unknown): AccountTreePayload { if (typeof raw !== 'object' || raw === null) { @@ -169,6 +353,11 @@ export function migrate(raw: unknown): AccountTreePayload { `Unsupported AccountTreePayload version: ${version} (current: ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION})`, ); } + if (version < ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION) { + throw new Error( + `Unsupported AccountTreePayload version: ${version} (current: ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION})`, + ); + } let result: unknown = raw; for (let ver = version; ver <= ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; ver++) { @@ -178,5 +367,5 @@ export function migrate(raw: unknown): AccountTreePayload { } } - return result as AccountTreePayload; + return result; } From a30fdb98d273a02bb5d81f9a69037392a24501ec Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 15:04:13 +0200 Subject: [PATCH 19/38] refactor: rework filtering --- .../src/state/snapshot.test.ts | 194 ++++++++++++-- .../src/state/snapshot.ts | 242 ++++++++++++++++-- 2 files changed, 386 insertions(+), 50 deletions(-) diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index 61ef9518813..fe26f54e129 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -5,7 +5,10 @@ import type { AccountWalletPrivateKeyPayload, } from './payload.js'; import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION } from './payload.js'; -import { AccountTreeSnapshot } from './snapshot.js'; +import { + AccountTreeSnapshot, + createAccountTreeSnapshot, +} from './snapshot.js'; const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { id: 'wallet:entropy-source-1', @@ -48,13 +51,15 @@ function buildIdMap(): IdMap { } describe('AccountTreeSnapshot', () => { - describe('filter', () => { + describe('filterWallets', () => { it('returns a snapshot containing only matching entries', () => { - const snapshot = new AccountTreeSnapshot( + const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], null, ); - const filtered = snapshot.filter((entry) => entry.type === 'mnemonic'); + const filtered = snapshot.filterWallets( + (wallet) => wallet.type === 'mnemonic', + ); expect(filtered.serialize().wallets).toHaveLength(1); expect(filtered.serialize().wallets[0]?.id).toBe( 'wallet:entropy-source-1', @@ -62,22 +67,24 @@ describe('AccountTreeSnapshot', () => { }); it('preserves null idMap when filtering', () => { - const snapshot = new AccountTreeSnapshot( + const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], null, ); - const filtered = snapshot.filter(() => true); + const filtered = snapshot.filterWallets(() => true); expect(filtered.toLocalId('wallet:entropy-source-1')).toBeUndefined(); }); it('prunes the idMap to only include entries for kept wallets', () => { const map = buildIdMap(); - const snapshot = new AccountTreeSnapshot( + const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], map, ); - const filtered = snapshot.filter((entry) => entry.type === 'mnemonic'); + const filtered = snapshot.filterWallets( + (wallet) => wallet.type === 'mnemonic', + ); expect(filtered.toLocalId('wallet:entropy-source-1')).toBe( 'entropy:wallet-1', @@ -85,7 +92,6 @@ describe('AccountTreeSnapshot', () => { expect(filtered.toLocalId('wallet:entropy-source-1/0')).toBe( 'entropy:wallet-1/0', ); - // Private key wallet entries should not be in the filtered map. expect(filtered.toLocalId('wallet:private-key')).toBeUndefined(); expect( filtered.toLocalId('wallet:private-key/0xdeadbeef'), @@ -94,19 +100,145 @@ describe('AccountTreeSnapshot', () => { it('handles wallet entries whose IDs are not in the idMap', () => { const map = new IdMap(); - // Only add one of the two wallets to the map. map.add('entropy:wallet-1', 'wallet:entropy-source-1'); - const snapshot = new AccountTreeSnapshot( + const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], map, ); - const filtered = snapshot.filter(() => true); + const filtered = snapshot.filterWallets(() => true); expect(filtered.toLocalId('wallet:entropy-source-1')).toBe( 'entropy:wallet-1', ); - // Private key wallet was not in the map — it should still be absent. + expect(filtered.toLocalId('wallet:private-key')).toBeUndefined(); + }); + }); + + describe('filterGroups', () => { + it('filters groups within a single wallet and leaves others unchanged', () => { + const snapshot = createAccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + null, + ); + + const filtered = snapshot.filterGroups( + 'wallet:entropy-source-1', + (group) => group.id.endsWith('/0'), + ); + + const wallets = filtered.serialize().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 = createAccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + null, + ); + + const filtered = snapshot.filterGroups( + 'wallet:entropy-source-1', + () => false, + ); + + expect(filtered.serialize().wallets).toHaveLength(1); + expect(filtered.serialize().wallets[0]?.type).toBe('private-key'); + }); + + it('filters private-key wallet groups and prunes the idMap', () => { + const map = buildIdMap(); + const snapshot = createAccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filterGroups( + 'wallet:private-key', + () => true, + ); + + expect(filtered.serialize().wallets).toHaveLength(2); + expect(filtered.toLocalId('wallet:private-key/0xdeadbeef')).toBe( + 'keyring:simple/0xdeadbeef', + ); + }); + + it('filters mnemonic wallet groups and prunes the idMap', () => { + const map = buildIdMap(); + const snapshot = createAccountTreeSnapshot( + [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')).toBeUndefined(); + }); + + it('throws when the wallet ID is not in the snapshot', () => { + const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); + + 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 = createAccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + null, + ); + + const filtered = snapshot.filterAllGroups((group) => + group.id.endsWith('/0'), + ); + + const wallets = filtered.serialize().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 = createAccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + null, + ); + + const filtered = snapshot.filterAllGroups( + (_group, wallet) => wallet.type === 'private-key', + ); + + expect(filtered.serialize().wallets).toHaveLength(1); + expect(filtered.serialize().wallets[0]?.type).toBe('private-key'); + }); + + it('prunes the idMap when filtering all groups', () => { + const map = buildIdMap(); + const snapshot = createAccountTreeSnapshot( + [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')).toBeUndefined(); }); }); @@ -114,7 +246,7 @@ describe('AccountTreeSnapshot', () => { describe('toLocalId', () => { it('returns the local ID for a known payload wallet ID', () => { const map = buildIdMap(); - const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); expect(snapshot.toLocalId('wallet:entropy-source-1')).toBe( 'entropy:wallet-1', ); @@ -122,19 +254,19 @@ describe('AccountTreeSnapshot', () => { it('returns the local ID for a known payload group ID', () => { const map = buildIdMap(); - const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + const snapshot = createAccountTreeSnapshot([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], null); + const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); expect(snapshot.toLocalId('wallet:entropy-source-1')).toBeUndefined(); }); it('returns undefined for an unknown payload ID', () => { - const snapshot = new AccountTreeSnapshot( + const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET], new IdMap(), ); @@ -145,7 +277,7 @@ describe('AccountTreeSnapshot', () => { describe('toPayloadId', () => { it('returns the payload ID for a known local wallet ID', () => { const map = buildIdMap(); - const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); expect(snapshot.toPayloadId('entropy:wallet-1')).toBe( 'wallet:entropy-source-1', ); @@ -153,19 +285,19 @@ describe('AccountTreeSnapshot', () => { it('returns the payload ID for a known local group ID', () => { const map = buildIdMap(); - const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + const snapshot = createAccountTreeSnapshot([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], null); + const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); expect(snapshot.toPayloadId('entropy:wallet-1')).toBeUndefined(); }); it('returns undefined for an unknown local ID', () => { - const snapshot = new AccountTreeSnapshot( + const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET], new IdMap(), ); @@ -175,7 +307,7 @@ describe('AccountTreeSnapshot', () => { describe('serialize', () => { it('serializes to a versioned AccountTreePayload', () => { - const snapshot = new AccountTreeSnapshot( + const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], null, ); @@ -187,7 +319,7 @@ describe('AccountTreeSnapshot', () => { }); it('serializes an empty snapshot', () => { - const snapshot = new AccountTreeSnapshot([], null); + const snapshot = createAccountTreeSnapshot([], null); const payload = snapshot.serialize(); expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); expect(payload.wallets).toHaveLength(0); @@ -231,5 +363,21 @@ describe('AccountTreeSnapshot', () => { }), ).toThrow('Unsupported AccountTreePayload version'); }); + + it('throws for an unsupported wallet type', () => { + expect(() => + AccountTreeSnapshot.deserialize({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:ledger', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).toThrow('Invalid AccountTreePayload'); + }); }); }); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index 5920e4721fc..f4da1f7eeef 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -2,61 +2,244 @@ import { IdMap } from './id-map.js'; import type { AccountGroupPayloadId, AccountTreePayload, - AccountTreeSnapshotEntry, + AccountTreeSnapshotGroup, + AccountTreeSnapshotWallet, + AccountTreeWalletEntry, + AccountWalletMnemonicGroupEntry, + AccountWalletMnemonicPayload, AccountWalletPayloadId, + AccountWalletPrivateKeyGroupEntry, + AccountWalletPrivateKeyPayload, } from './payload.js'; import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, migrate } from './payload.js'; +/** + * Creates an {@link AccountTreeSnapshot}. Package-internal factory used by export + * and tests; callers outside this package should use + * {@link AccountTreeController.exportState} or {@link AccountTreeSnapshot.deserialize}. + * + * @param entries - Wallet entries in the snapshot. + * @param idMap - Optional local ↔ payload ID map populated during export. + * @returns A new snapshot. + */ +export function createAccountTreeSnapshot( + entries: AccountTreeWalletEntry[], + idMap: IdMap | null, +): AccountTreeSnapshot { + return new AccountTreeSnapshot(entries, idMap); +} + +/** + * Deep-freezes a value for use in immutable snapshot filtering predicates. + * + * @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; +} + +/** + * Builds ID-map pairs for the wallets and groups present in `entries`. + * + * @param entries - Wallet entries to map. + * @param idMap - Source ID map. + * @returns Pairs for a pruned {@link IdMap}. + */ +function collectIdMapPairs( + entries: AccountTreeWalletEntry[], + idMap: IdMap, +): Parameters[] { + const pairs: Parameters[] = []; + + for (const entry of entries) { + const localWalletId = idMap.getLocalId(entry.id); + if (localWalletId !== undefined) { + pairs.push([localWalletId, entry.id]); + } + for (const group of entry.groups) { + const localGroupId = idMap.getLocalId(group.id); + if (localGroupId !== undefined) { + pairs.push([localGroupId, group.id]); + } + } + } + + return pairs; +} + /** * Immutable value object returned by {@link AccountTreeController.exportState}. * + * Snapshots can only be constructed by {@link AccountTreeController.exportState}, + * {@link AccountTreeSnapshot.deserialize}, or the package-internal + * {@link createAccountTreeSnapshot} factory. + * * Holds an ID map (local ↔ payload) populated during export so callers can * bridge between internal controller IDs and the stable cross-device IDs that * appear in the serialized payload. The map is absent for snapshots produced - * by {@link AccountTreeSnapshot.deserialize} — `toLocalId` / `toPayloadId` - * return `undefined` in that case. + * by {@link AccountTreeSnapshot.deserialize} — {@link toLocalId} / + * {@link toPayloadId} return `undefined` in that case. */ export class AccountTreeSnapshot { - readonly #entries: AccountTreeSnapshotEntry[]; + readonly #entries: AccountTreeWalletEntry[]; readonly #idMap: IdMap | null; - constructor(entries: AccountTreeSnapshotEntry[], idMap: IdMap | null) { + private constructor( + entries: AccountTreeWalletEntry[], + idMap: IdMap | null, + ) { this.#entries = entries; this.#idMap = idMap; } /** - * Returns a new snapshot containing only the wallet entries for which + * Returns a new snapshot containing only the wallets for which * `predicate` returns `true`. The ID map is pruned to match. * - * @param predicate - Function called with each wallet entry. + * 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(deepFreeze(structuredClone(entry)) as AccountTreeSnapshotWallet), + ); + + if (!this.#idMap) { + return new AccountTreeSnapshot(filteredEntries, null); + } + + return new AccountTreeSnapshot( + filteredEntries, + new IdMap(collectIdMapPairs(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. */ - filter( - predicate: (entry: AccountTreeSnapshotEntry) => boolean, + filterGroups( + walletId: AccountWalletPayloadId, + predicate: (group: AccountTreeSnapshotGroup) => boolean, ): AccountTreeSnapshot { - const filteredEntries = this.#entries.filter(predicate); + 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] as AccountTreeWalletEntry; + + const filteredGroups = wallet.groups.filter((group) => + predicate(deepFreeze(structuredClone(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[], + }; + } if (!this.#idMap) { return new AccountTreeSnapshot(filteredEntries, null); } - const pairs: Parameters[] = []; - for (const entry of filteredEntries) { - const localWalletId = this.#idMap.getLocalId(entry.id); - if (localWalletId !== undefined) { - pairs.push([localWalletId, entry.id]); + return new AccountTreeSnapshot( + filteredEntries, + new IdMap(collectIdMapPairs(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 frozenWallet = deepFreeze( + structuredClone(wallet), + ) as AccountTreeSnapshotWallet; + const filteredGroups = wallet.groups.filter((group) => + predicate( + deepFreeze(structuredClone(group)) as AccountTreeSnapshotGroup, + frozenWallet, + ), + ); + + if (filteredGroups.length === 0) { + continue; } - for (const group of entry.groups) { - const localGroupId = this.#idMap.getLocalId(group.id); - if (localGroupId !== undefined) { - pairs.push([localGroupId, group.id]); - } + + if (wallet.type === 'mnemonic') { + filteredEntries.push({ + ...wallet, + groups: filteredGroups as AccountWalletMnemonicGroupEntry[], + }); + } else { + filteredEntries.push({ + ...wallet, + groups: filteredGroups as AccountWalletPrivateKeyGroupEntry[], + }); } } - return new AccountTreeSnapshot(filteredEntries, new IdMap(pairs)); + if (!this.#idMap) { + return new AccountTreeSnapshot(filteredEntries, null); + } + + return new AccountTreeSnapshot( + filteredEntries, + new IdMap(collectIdMapPairs(filteredEntries, this.#idMap)), + ); } /** @@ -98,15 +281,20 @@ export class AccountTreeSnapshot { } /** - * Deserializes and validates a raw value as an `AccountTreePayload`, - * running any necessary version migrations. + * 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 — `toLocalId` / `toPayloadId` return - * `undefined`. Use `AccountTreeController.exportState` when you need the map. + * The returned snapshot has no ID map — {@link toLocalId} / {@link toPayloadId} + * return `undefined`. Use {@link AccountTreeController.exportState} when you + * need the map. * * @param raw - Unknown value to parse. - * @returns A migrated snapshot. - * @throws If `raw` is not a valid payload or its version exceeds the current version. + * @returns A validated snapshot. + * @throws If `raw` is not a valid payload or its version is unsupported. */ static deserialize(raw: unknown): AccountTreeSnapshot { const payload = migrate(raw); From 9c5ae42b24689548dd89a7ca53887e4359549962 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 15:07:41 +0200 Subject: [PATCH 20/38] refactor: use snapshot in importState --- ...countTreeController-method-action-types.ts | 12 ++-- .../src/AccountTreeController.test.ts | 4 +- .../src/AccountTreeController.ts | 15 +++-- .../src/state/import.test.ts | 67 ++++++++----------- .../src/state/import.ts | 26 +++---- 5 files changed, 62 insertions(+), 62 deletions(-) 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 8e6959e322d..907aeb02289 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -214,13 +214,15 @@ export type AccountTreeControllerExportStateAction = { }; /** - * Applies a versioned snapshot to the current state. + * Applies a validated snapshot to the current state. * - * 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. + * 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 payload - The payload to import. + * @param snapshot - The validated snapshot to import. * @returns A promise that resolves when the import is complete. */ export type AccountTreeControllerImportStateAction = { diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 79dbd054ff6..e1ec68ac68b 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -6150,7 +6150,7 @@ describe('AccountTreeController', () => { // --- 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(payload); + await controller.importState(snapshot); // After import, original metadata should be restored. expect( @@ -6206,7 +6206,7 @@ describe('AccountTreeController', () => { expect((payload.wallets[0] as { value?: string }).value).toBeUndefined(); // Reimport is a no-op for metadata when nothing changed. - expect(await controller.importState(payload)).toBeUndefined(); + expect(await controller.importState(snapshot)).toBeUndefined(); }); it('throws when exporting with includeSecrets: true and the vault is locked', async () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index b70518256f2..cf34c383b0b 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -38,7 +38,6 @@ 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 { AccountTreePayload } from './state/payload.js'; import type { AccountTreeSnapshot } from './state/snapshot.js'; import type { AccountTreeControllerConfig, @@ -1816,16 +1815,22 @@ export class AccountTreeController extends BaseController< } /** - * Applies a versioned snapshot to the current state. + * 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 payload - The payload to import. + * @param snapshot - The validated snapshot to import. * @returns A promise that resolves when the import is complete. */ - async importState(payload: AccountTreePayload): Promise { + async importState(snapshot: AccountTreeSnapshot): Promise { return importState( { getState: () => this.state, @@ -1835,7 +1840,7 @@ export class AccountTreeController extends BaseController< setGroupPinned: (id, pinned) => this.setAccountGroupPinned(id, pinned), setGroupHidden: (id, hidden) => this.setAccountGroupHidden(id, hidden), }, - payload, + snapshot, ); } diff --git a/packages/account-tree-controller/src/state/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts index ad5482f9335..a6f59e62ef1 100644 --- a/packages/account-tree-controller/src/state/import.test.ts +++ b/packages/account-tree-controller/src/state/import.test.ts @@ -18,6 +18,7 @@ import type { ImportContext } from './import.js'; import { importState } from './import.js'; import type { AccountTreePayload } from './payload.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'; @@ -35,6 +36,9 @@ const MOCK_PK_WALLET_ID = toAccountWalletId( 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: AccountTreePayload = { version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ @@ -216,31 +220,18 @@ function makeWithKeyringV2Mock( ); } +function importSnapshot( + context: ImportContext, + payload: AccountTreePayload, +): ReturnType { + return importState(context, AccountTreeSnapshot.deserialize(payload)); +} + describe('importState', () => { beforeEach(() => { jest.resetAllMocks(); }); - describe('unknown wallet types', () => { - it('silently skips wallet entries with unrecognised types', async () => { - const { context, mocks } = setup(); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - wallets: [ - // @ts-expect-error -- deliberate unknown type for forward-compat test - { - id: 'wallet:future', - type: 'future-type', - metadata: { name: 'X' }, - groups: [], - }, - ], - }; - expect(await importState(context, payload)).toBeUndefined(); - expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); - }); - }); - describe('mnemonic wallets', () => { it('applies metadata to existing groups when the wallet already exists locally', async () => { const { context, mocks } = setup({ wallets: makeHdWalletState() }); @@ -250,7 +241,7 @@ describe('importState', () => { }, ); - await importState(context, MNEMONIC_PAYLOAD); + await importSnapshot(context, MNEMONIC_PAYLOAD); expect(mocks.setters.setWalletName).toHaveBeenCalledWith( MOCK_HD_WALLET_ID, @@ -315,7 +306,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + await importSnapshot(context, payload); expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); }); @@ -338,7 +329,7 @@ describe('importState', () => { }, ], }; - await importState(context, payloadWithoutMnemonic); + await importSnapshot(context, payloadWithoutMnemonic); expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); }); @@ -356,13 +347,13 @@ describe('importState', () => { ); await expect( - importState(context, { + importSnapshot(context, { version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:no-match-entropy', type: 'mnemonic', - value: JSON.stringify([1, 2, 3]), + value: TEST_MNEMONIC, metadata: { name: 'Wallet' }, groups: [], }, @@ -403,13 +394,13 @@ describe('importState', () => { ); await expect( - importState(context, { + importSnapshot(context, { version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:no-match', type: 'mnemonic', - value: JSON.stringify([1, 2, 3]), + value: TEST_MNEMONIC, metadata: { name: 'Wallet' }, groups: [], }, @@ -439,7 +430,7 @@ describe('importState', () => { { id: 'wallet:unknown-entropy', type: 'mnemonic', - value: JSON.stringify([1, 2, 3]), + value: TEST_MNEMONIC, metadata: { name: 'My Renamed Wallet' }, groups: [ { @@ -452,7 +443,7 @@ describe('importState', () => { ], }; - await importState(context, payloadWithMnemonic); + await importSnapshot(context, payloadWithMnemonic); expect( mocks.MultichainAccountService.createMultichainAccountWallet, @@ -518,7 +509,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + await importSnapshot(context, payload); expect( mocks.MultichainAccountService.createMultichainAccountGroups, @@ -608,7 +599,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + await importSnapshot(context, payload); expect( mocks.MultichainAccountService.createMultichainAccountGroups, @@ -671,7 +662,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + await importSnapshot(context, payload); expect(mocks.setters.setGroupName).toHaveBeenCalledWith( pkGroupId, @@ -750,7 +741,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + await importSnapshot(context, payload); expect(mocks.KeyringController.withKeyringV2).toHaveBeenCalledWith( expect.anything(), @@ -770,7 +761,7 @@ describe('importState', () => { ); await expect( - importState(context, { + importSnapshot(context, { version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { @@ -819,7 +810,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + await importSnapshot(context, payload); expect(mocks.KeyringController.withKeyringV2).not.toHaveBeenCalled(); expect(mocks.setters.setGroupName).not.toHaveBeenCalled(); }); @@ -854,7 +845,7 @@ describe('importState', () => { }; // withKeyringV2 is called (not skipped), but returns [] so it throws. - await expect(importState(context, payload)).rejects.toThrow( + await expect(importSnapshot(context, payload)).rejects.toThrow( 'Failed to import private key for account', ); expect(mocks.KeyringController.withKeyringV2).toHaveBeenCalled(); @@ -881,7 +872,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + await importSnapshot(context, payload); expect(mocks.setters.setGroupName).not.toHaveBeenCalled(); }); @@ -911,7 +902,7 @@ describe('importState', () => { ], }; - await importState(context, payload); + 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 index 7b246fb2032..3b41aaa4912 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -19,13 +19,13 @@ import type { import type { AccountWalletEntropyObject } from '../wallet.js'; import { isMnemonicWalletObject } from './export.js'; import type { - AccountTreePayload, 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 = { @@ -314,28 +314,30 @@ async function importPrivateKeyWallet( } /** - * Applies an {@link AccountTreePayload} to the current controller state. + * Applies an {@link AccountTreeSnapshot} to the current controller state. * - * - For each `'mnemonic'` wallet: imports the mnemonic (if provided and not - * already present) and applies metadata to all groups. - * - For each `'private-key'` group: imports the key (if provided and not - * already present) and applies metadata. - * - Unknown wallet types are silently skipped for forward compatibility. + * 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 payload - The validated payload to import. + * @param snapshot - The validated snapshot to import. */ export async function importState( context: ImportContext, - payload: AccountTreePayload, + snapshot: AccountTreeSnapshot, ): Promise { + const payload = snapshot.serialize(); + for (const wallet of payload.wallets) { if (wallet.type === 'mnemonic') { await importMnemonicWallet(context, wallet); - } else if (wallet.type === 'private-key') { - await importPrivateKeyWallet(context, wallet.groups); } else { - // Unknown types: skip silently (forward-compat). + await importPrivateKeyWallet(context, wallet.groups); } } } From 9e4f36053532032dab9bc1568734aef3ef92cde9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 15:18:23 +0200 Subject: [PATCH 21/38] fix: merge all private-key wallets on export --- .../src/state/export.test.ts | 53 +++++++++++++++++++ .../src/state/export.ts | 28 ++++++---- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.test.ts b/packages/account-tree-controller/src/state/export.test.ts index 0bd4a817098..603e12373de 100644 --- a/packages/account-tree-controller/src/state/export.test.ts +++ b/packages/account-tree-controller/src/state/export.test.ts @@ -536,5 +536,58 @@ describe('exportState', () => { 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.wallets).toHaveLength(1); + expect(payload.wallets[0]?.type).toBe('private-key'); + expect(payload.wallets[0]?.groups).toHaveLength(2); + expect(payload.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 index bc74a9f93ff..9d2666dd9bf 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -23,7 +23,7 @@ import type { AccountWalletPrivateKeyPayload, ExportStateOptions, } from './payload.js'; -import { AccountTreeSnapshot } from './snapshot.js'; +import { AccountTreeSnapshot, createAccountTreeSnapshot } from './snapshot.js'; /** * Returns `true` if `wallet` is an HD entropy wallet ({@link AccountWalletEntropyObject}). @@ -270,6 +270,8 @@ export async function exportState( 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( @@ -281,18 +283,26 @@ export async function exportState( ), ); } else if (isPrivateKeyWalletObject(walletObj)) { - entries.push( - await exportPrivateKeyWalletObject( - context, - walletObj, - includeSecrets, - idMap, - ), + const exported = await exportPrivateKeyWalletObject( + context, + walletObj, + includeSecrets, + idMap, ); + + if (!privateKeyWallet) { + privateKeyWallet = exported; + } else { + privateKeyWallet.groups.push(...exported.groups); + } } else { // AccountWalletType.Snap and hardware keyrings: skipped for now. } } - return new AccountTreeSnapshot(entries, idMap); + if (privateKeyWallet) { + entries.push(privateKeyWallet); + } + + return createAccountTreeSnapshot(entries, idMap); } From e1248df3b614eeb7cfabba8b75c4cbd6de4964d9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 15:19:01 +0200 Subject: [PATCH 22/38] fix: fix missing exports --- packages/account-tree-controller/src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index b3dc48415cb..e833ae0012b 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -51,15 +51,23 @@ export { export type { AccountTreePayload, + AccountTreePayloadSchemaType, AccountWalletMnemonicPayload, AccountWalletPrivateKeyPayload, AccountWalletMnemonicGroupEntry, AccountWalletPrivateKeyGroupEntry, AccountWalletPayloadId, AccountGroupPayloadId, - AccountTreeSnapshotEntry, + AccountTreeSnapshotWallet, + AccountTreeSnapshotGroup, ExportStateOptions, } from './state/payload.js'; +export { + AccountTreePayloadSchema, + assertValidAccountTreePayload, + migrate, +} from './state/payload.js'; + export { AccountTreeSnapshot } from './state/snapshot.js'; export { IdMap } from './state/id-map.js'; From b9b5818594f97bea30a478ee4543a6906b00cc92 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 18:16:33 +0200 Subject: [PATCH 23/38] refactor: remove extra freeze --- .../src/state/payload.ts | 8 ++--- .../src/state/snapshot.test.ts | 20 +++++++++-- .../src/state/snapshot.ts | 36 +++++++++++++------ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 0f0d4db0a14..81221f9a480 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -147,8 +147,8 @@ export type DeepReadonly = T extends readonly (infer Item)[] * Deeply read-only wallet view passed to {@link AccountTreeSnapshot.filterWallets} * and {@link AccountTreeSnapshot.filterAllGroups} predicates. * - * Values are runtime-frozen before the predicate runs, so callers cannot mutate - * wallet IDs, types, secrets, metadata, or groups. + * 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 @@ -158,8 +158,8 @@ export type AccountTreeSnapshotWallet = DeepReadonly< * Deeply read-only group view passed to {@link AccountTreeSnapshot.filterGroups} * and {@link AccountTreeSnapshot.filterAllGroups} predicates. * - * Values are runtime-frozen before the predicate runs, so callers cannot mutate - * group IDs, secrets, metadata, or parent wallet references. + * 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 diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index fe26f54e129..a2a5a12bd77 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -51,6 +51,21 @@ function buildIdMap(): IdMap { } describe('AccountTreeSnapshot', () => { + describe('immutability', () => { + it('deep-freezes entries at construction so predicates cannot mutate them', () => { + const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); + + expect(() => + snapshot.filterWallets((wallet) => { + (wallet.metadata as { name: string }).name = 'hacked'; + return true; + }), + ).toThrow(TypeError); + + expect(snapshot.serialize().wallets[0]?.metadata.name).toBe('Wallet 1'); + }); + }); + describe('filterWallets', () => { it('returns a snapshot containing only matching entries', () => { const snapshot = createAccountTreeSnapshot( @@ -314,8 +329,9 @@ describe('AccountTreeSnapshot', () => { const payload = snapshot.serialize(); expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); expect(payload.wallets).toHaveLength(2); - expect(payload.wallets[0]).toBe(MOCK_MNEMONIC_WALLET); - expect(payload.wallets[1]).toBe(MOCK_PRIVATE_KEY_WALLET); + expect(payload.wallets[0]).toStrictEqual(MOCK_MNEMONIC_WALLET); + expect(payload.wallets[1]).toStrictEqual(MOCK_PRIVATE_KEY_WALLET); + expect(Object.isFrozen(payload.wallets)).toBe(true); }); it('serializes an empty snapshot', () => { diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index f4da1f7eeef..a1178cbd081 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -30,7 +30,19 @@ export function createAccountTreeSnapshot( } /** - * Deep-freezes a value for use in immutable snapshot filtering predicates. + * Deep-clones and deep-freezes wallet entries for immutable snapshot storage. + * + * @param entries - Mutable wallet entries to copy and freeze. + * @returns A deep-frozen copy of `entries`. + */ +function cloneAndFreezeEntries( + entries: AccountTreeWalletEntry[], +): AccountTreeWalletEntry[] { + return deepFreeze(structuredClone(entries)); +} + +/** + * Recursively freezes a value and its nested properties. * * @param value - Value to freeze. * @returns The frozen value. @@ -85,6 +97,11 @@ function collectIdMapPairs( * {@link AccountTreeSnapshot.deserialize}, or the package-internal * {@link createAccountTreeSnapshot} factory. * + * 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. + * * Holds an ID map (local ↔ payload) populated during export so callers can * bridge between internal controller IDs and the stable cross-device IDs that * appear in the serialized payload. The map is absent for snapshots produced @@ -100,7 +117,7 @@ export class AccountTreeSnapshot { entries: AccountTreeWalletEntry[], idMap: IdMap | null, ) { - this.#entries = entries; + this.#entries = cloneAndFreezeEntries(entries); this.#idMap = idMap; } @@ -118,7 +135,7 @@ export class AccountTreeSnapshot { predicate: (wallet: AccountTreeSnapshotWallet) => boolean, ): AccountTreeSnapshot { const filteredEntries = this.#entries.filter((entry) => - predicate(deepFreeze(structuredClone(entry)) as AccountTreeSnapshotWallet), + predicate(entry as AccountTreeSnapshotWallet), ); if (!this.#idMap) { @@ -156,10 +173,10 @@ export class AccountTreeSnapshot { ); } - const wallet = this.#entries[walletIndex] as AccountTreeWalletEntry; + const wallet = this.#entries[walletIndex]; const filteredGroups = wallet.groups.filter((group) => - predicate(deepFreeze(structuredClone(group)) as AccountTreeSnapshotGroup), + predicate(group as AccountTreeSnapshotGroup), ); const filteredEntries = [...this.#entries]; @@ -205,13 +222,10 @@ export class AccountTreeSnapshot { const filteredEntries: AccountTreeWalletEntry[] = []; for (const wallet of this.#entries) { - const frozenWallet = deepFreeze( - structuredClone(wallet), - ) as AccountTreeSnapshotWallet; const filteredGroups = wallet.groups.filter((group) => predicate( - deepFreeze(structuredClone(group)) as AccountTreeSnapshotGroup, - frozenWallet, + group as AccountTreeSnapshotGroup, + wallet as AccountTreeSnapshotWallet, ), ); @@ -271,6 +285,8 @@ export class AccountTreeSnapshot { /** * Serializes the snapshot to a versioned {@link AccountTreePayload}. * + * Returns the constructor-frozen wallet tree without copying it again. + * * @returns The versioned payload. */ serialize(): AccountTreePayload { From 5f5ff91ffe7bd13e78d9dc111a16b7106ff450ad Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 18:17:54 +0200 Subject: [PATCH 24/38] refactor: optional -> exactOptional --- packages/account-tree-controller/src/state/payload.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 81221f9a480..0522413ed3c 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -8,7 +8,7 @@ import { integer, literal, object, - optional, + exactOptional, sensitive, string, StructError, @@ -217,7 +217,7 @@ const AccountWalletGroupPayloadMetadataSchema = object({ const AccountWalletPrivateKeyValueSchema = object({ privateKey: sensitive(string()), encoding: enums(['hexadecimal', 'base58', 'base32']), - type: optional(string()), + type: exactOptional(string()), }); const AccountWalletMnemonicGroupEntrySchema = object({ @@ -228,14 +228,14 @@ const AccountWalletMnemonicGroupEntrySchema = object({ const AccountWalletPrivateKeyGroupEntrySchema = object({ id: AccountGroupPayloadIdSchema, - value: optional(AccountWalletPrivateKeyValueSchema), + value: exactOptional(AccountWalletPrivateKeyValueSchema), metadata: AccountWalletGroupPayloadMetadataSchema, }); const AccountWalletMnemonicPayloadSchema = object({ id: AccountWalletPayloadIdSchema, type: literal('mnemonic'), - value: optional(sensitive(string())), + value: exactOptional(sensitive(string())), metadata: AccountWalletPayloadMetadataSchema, groups: array(AccountWalletMnemonicGroupEntrySchema), }); From 705045fae160950e16f869eaf9555ec2c79fc146 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 18:41:28 +0200 Subject: [PATCH 25/38] refactor: simplify and re-use 1 single IdMap --- .../src/state/snapshot.test.ts | 22 +++--- .../src/state/snapshot.ts | 71 ++++--------------- 2 files changed, 26 insertions(+), 67 deletions(-) diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index a2a5a12bd77..718d434c54f 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -90,7 +90,7 @@ describe('AccountTreeSnapshot', () => { expect(filtered.toLocalId('wallet:entropy-source-1')).toBeUndefined(); }); - it('prunes the idMap to only include entries for kept wallets', () => { + it('preserves the original idMap through wallet filtering', () => { const map = buildIdMap(); const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], @@ -107,10 +107,10 @@ describe('AccountTreeSnapshot', () => { expect(filtered.toLocalId('wallet:entropy-source-1/0')).toBe( 'entropy:wallet-1/0', ); - expect(filtered.toLocalId('wallet:private-key')).toBeUndefined(); - expect( - filtered.toLocalId('wallet:private-key/0xdeadbeef'), - ).toBeUndefined(); + 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', () => { @@ -164,7 +164,7 @@ describe('AccountTreeSnapshot', () => { expect(filtered.serialize().wallets[0]?.type).toBe('private-key'); }); - it('filters private-key wallet groups and prunes the idMap', () => { + it('filters private-key wallet groups and preserves the idMap', () => { const map = buildIdMap(); const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], @@ -182,7 +182,7 @@ describe('AccountTreeSnapshot', () => { ); }); - it('filters mnemonic wallet groups and prunes the idMap', () => { + it('preserves the idMap when filtering mnemonic wallet groups', () => { const map = buildIdMap(); const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], @@ -197,7 +197,9 @@ describe('AccountTreeSnapshot', () => { expect(filtered.toLocalId('wallet:entropy-source-1/0')).toBe( 'entropy:wallet-1/0', ); - expect(filtered.toLocalId('wallet:entropy-source-1/1')).toBeUndefined(); + expect(filtered.toLocalId('wallet:entropy-source-1/1')).toBe( + 'entropy:wallet-1/1', + ); }); it('throws when the wallet ID is not in the snapshot', () => { @@ -240,7 +242,7 @@ describe('AccountTreeSnapshot', () => { expect(filtered.serialize().wallets[0]?.type).toBe('private-key'); }); - it('prunes the idMap when filtering all groups', () => { + it('preserves the idMap when filtering all groups', () => { const map = buildIdMap(); const snapshot = createAccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], @@ -254,7 +256,7 @@ describe('AccountTreeSnapshot', () => { expect(filtered.toLocalId('wallet:entropy-source-1/0')).toBe( 'entropy:wallet-1/0', ); - expect(filtered.toLocalId('wallet:private-key')).toBeUndefined(); + expect(filtered.toLocalId('wallet:private-key')).toBe('keyring:simple'); }); }); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index a1178cbd081..6659fb321b1 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -61,35 +61,6 @@ function deepFreeze(value: T): T { return value; } -/** - * Builds ID-map pairs for the wallets and groups present in `entries`. - * - * @param entries - Wallet entries to map. - * @param idMap - Source ID map. - * @returns Pairs for a pruned {@link IdMap}. - */ -function collectIdMapPairs( - entries: AccountTreeWalletEntry[], - idMap: IdMap, -): Parameters[] { - const pairs: Parameters[] = []; - - for (const entry of entries) { - const localWalletId = idMap.getLocalId(entry.id); - if (localWalletId !== undefined) { - pairs.push([localWalletId, entry.id]); - } - for (const group of entry.groups) { - const localGroupId = idMap.getLocalId(group.id); - if (localGroupId !== undefined) { - pairs.push([localGroupId, group.id]); - } - } - } - - return pairs; -} - /** * Immutable value object returned by {@link AccountTreeController.exportState}. * @@ -104,9 +75,10 @@ function collectIdMapPairs( * * Holds an ID map (local ↔ payload) populated during export so callers can * bridge between internal controller IDs and the stable cross-device IDs that - * appear in the serialized payload. The map is absent for snapshots produced - * by {@link AccountTreeSnapshot.deserialize} — {@link toLocalId} / - * {@link toPayloadId} return `undefined` in that case. + * appear in the serialized payload. The map covers the original export and is + * preserved unchanged through filtering until {@link serialize}. It is absent + * for snapshots produced by {@link AccountTreeSnapshot.deserialize} — + * {@link toLocalId} / {@link toPayloadId} return `undefined` in that case. */ export class AccountTreeSnapshot { readonly #entries: AccountTreeWalletEntry[]; @@ -123,7 +95,7 @@ export class AccountTreeSnapshot { /** * Returns a new snapshot containing only the wallets for which - * `predicate` returns `true`. The ID map is pruned to match. + * `predicate` returns `true`. * * When filtering by wallet ID, compare against stable payload IDs from * {@link serialize} or convert local IDs with {@link toPayloadId} first. @@ -138,14 +110,7 @@ export class AccountTreeSnapshot { predicate(entry as AccountTreeSnapshotWallet), ); - if (!this.#idMap) { - return new AccountTreeSnapshot(filteredEntries, null); - } - - return new AccountTreeSnapshot( - filteredEntries, - new IdMap(collectIdMapPairs(filteredEntries, this.#idMap)), - ); + return new AccountTreeSnapshot(filteredEntries, this.#idMap); } /** @@ -194,14 +159,7 @@ export class AccountTreeSnapshot { }; } - if (!this.#idMap) { - return new AccountTreeSnapshot(filteredEntries, null); - } - - return new AccountTreeSnapshot( - filteredEntries, - new IdMap(collectIdMapPairs(filteredEntries, this.#idMap)), - ); + return new AccountTreeSnapshot(filteredEntries, this.#idMap); } /** @@ -246,20 +204,16 @@ export class AccountTreeSnapshot { } } - if (!this.#idMap) { - return new AccountTreeSnapshot(filteredEntries, null); - } - - return new AccountTreeSnapshot( - filteredEntries, - new IdMap(collectIdMapPairs(filteredEntries, this.#idMap)), - ); + 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. */ @@ -273,6 +227,9 @@ export class AccountTreeSnapshot { * 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. */ From eadc490b641cfc0142c652ee990ebc60c65fe5e0 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 18:43:46 +0200 Subject: [PATCH 26/38] refactor: inline cloneAndFreezeEntries --- .../account-tree-controller/src/state/snapshot.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index 6659fb321b1..c0e355f6080 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -29,18 +29,6 @@ export function createAccountTreeSnapshot( return new AccountTreeSnapshot(entries, idMap); } -/** - * Deep-clones and deep-freezes wallet entries for immutable snapshot storage. - * - * @param entries - Mutable wallet entries to copy and freeze. - * @returns A deep-frozen copy of `entries`. - */ -function cloneAndFreezeEntries( - entries: AccountTreeWalletEntry[], -): AccountTreeWalletEntry[] { - return deepFreeze(structuredClone(entries)); -} - /** * Recursively freezes a value and its nested properties. * @@ -89,7 +77,7 @@ export class AccountTreeSnapshot { entries: AccountTreeWalletEntry[], idMap: IdMap | null, ) { - this.#entries = cloneAndFreezeEntries(entries); + this.#entries = deepFreeze(structuredClone(entries)); this.#idMap = idMap; } From b8cacf3e64a0b56d0a9dfa6551dba74e0f8746bd Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 31 Jul 2026 18:50:50 +0200 Subject: [PATCH 27/38] refactor: remove createAccountTreeSnapshot --- .../src/state/export.ts | 4 +- .../src/state/snapshot.test.ts | 60 ++++++++----------- .../src/state/snapshot.ts | 52 ++++++---------- 3 files changed, 45 insertions(+), 71 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 9d2666dd9bf..7bd6f510aeb 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -23,7 +23,7 @@ import type { AccountWalletPrivateKeyPayload, ExportStateOptions, } from './payload.js'; -import { AccountTreeSnapshot, createAccountTreeSnapshot } from './snapshot.js'; +import { AccountTreeSnapshot } from './snapshot.js'; /** * Returns `true` if `wallet` is an HD entropy wallet ({@link AccountWalletEntropyObject}). @@ -304,5 +304,5 @@ export async function exportState( entries.push(privateKeyWallet); } - return createAccountTreeSnapshot(entries, idMap); + return new AccountTreeSnapshot(entries, idMap); } diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index 718d434c54f..5b678462245 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -5,10 +5,7 @@ import type { AccountWalletPrivateKeyPayload, } from './payload.js'; import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION } from './payload.js'; -import { - AccountTreeSnapshot, - createAccountTreeSnapshot, -} from './snapshot.js'; +import { AccountTreeSnapshot } from './snapshot.js'; const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { id: 'wallet:entropy-source-1', @@ -53,7 +50,7 @@ function buildIdMap(): IdMap { describe('AccountTreeSnapshot', () => { describe('immutability', () => { it('deep-freezes entries at construction so predicates cannot mutate them', () => { - const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); expect(() => snapshot.filterWallets((wallet) => { @@ -68,9 +65,8 @@ describe('AccountTreeSnapshot', () => { describe('filterWallets', () => { it('returns a snapshot containing only matching entries', () => { - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - null, ); const filtered = snapshot.filterWallets( (wallet) => wallet.type === 'mnemonic', @@ -81,10 +77,9 @@ describe('AccountTreeSnapshot', () => { ); }); - it('preserves null idMap when filtering', () => { - const snapshot = createAccountTreeSnapshot( + it('preserves absent idMap when filtering', () => { + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - null, ); const filtered = snapshot.filterWallets(() => true); expect(filtered.toLocalId('wallet:entropy-source-1')).toBeUndefined(); @@ -92,7 +87,7 @@ describe('AccountTreeSnapshot', () => { it('preserves the original idMap through wallet filtering', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], map, ); @@ -117,7 +112,7 @@ describe('AccountTreeSnapshot', () => { const map = new IdMap(); map.add('entropy:wallet-1', 'wallet:entropy-source-1'); - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], map, ); @@ -132,9 +127,8 @@ describe('AccountTreeSnapshot', () => { describe('filterGroups', () => { it('filters groups within a single wallet and leaves others unchanged', () => { - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - null, ); const filtered = snapshot.filterGroups( @@ -150,9 +144,8 @@ describe('AccountTreeSnapshot', () => { }); it('removes the wallet when all groups are filtered out', () => { - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - null, ); const filtered = snapshot.filterGroups( @@ -166,7 +159,7 @@ describe('AccountTreeSnapshot', () => { it('filters private-key wallet groups and preserves the idMap', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], map, ); @@ -184,7 +177,7 @@ describe('AccountTreeSnapshot', () => { it('preserves the idMap when filtering mnemonic wallet groups', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], map, ); @@ -203,7 +196,7 @@ describe('AccountTreeSnapshot', () => { }); it('throws when the wallet ID is not in the snapshot', () => { - const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); expect(() => snapshot.filterGroups('wallet:missing', () => true), @@ -213,9 +206,8 @@ describe('AccountTreeSnapshot', () => { describe('filterAllGroups', () => { it('filters groups across all wallets and removes empty wallets', () => { - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - null, ); const filtered = snapshot.filterAllGroups((group) => @@ -229,9 +221,8 @@ describe('AccountTreeSnapshot', () => { }); it('provides the parent wallet to the predicate', () => { - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - null, ); const filtered = snapshot.filterAllGroups( @@ -244,7 +235,7 @@ describe('AccountTreeSnapshot', () => { it('preserves the idMap when filtering all groups', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], map, ); @@ -263,7 +254,7 @@ describe('AccountTreeSnapshot', () => { describe('toLocalId', () => { it('returns the local ID for a known payload wallet ID', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); expect(snapshot.toLocalId('wallet:entropy-source-1')).toBe( 'entropy:wallet-1', ); @@ -271,19 +262,19 @@ describe('AccountTreeSnapshot', () => { it('returns the local ID for a known payload group ID', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + 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 = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); + 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 = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET], new IdMap(), ); @@ -294,7 +285,7 @@ describe('AccountTreeSnapshot', () => { describe('toPayloadId', () => { it('returns the payload ID for a known local wallet ID', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); expect(snapshot.toPayloadId('entropy:wallet-1')).toBe( 'wallet:entropy-source-1', ); @@ -302,19 +293,19 @@ describe('AccountTreeSnapshot', () => { it('returns the payload ID for a known local group ID', () => { const map = buildIdMap(); - const snapshot = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], map); + 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 = createAccountTreeSnapshot([MOCK_MNEMONIC_WALLET], null); + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); expect(snapshot.toPayloadId('entropy:wallet-1')).toBeUndefined(); }); it('returns undefined for an unknown local ID', () => { - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET], new IdMap(), ); @@ -324,9 +315,8 @@ describe('AccountTreeSnapshot', () => { describe('serialize', () => { it('serializes to a versioned AccountTreePayload', () => { - const snapshot = createAccountTreeSnapshot( + const snapshot = new AccountTreeSnapshot( [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - null, ); const payload = snapshot.serialize(); expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); @@ -337,7 +327,7 @@ describe('AccountTreeSnapshot', () => { }); it('serializes an empty snapshot', () => { - const snapshot = createAccountTreeSnapshot([], null); + const snapshot = new AccountTreeSnapshot([]); const payload = snapshot.serialize(); expect(payload.version).toBe(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION); expect(payload.wallets).toHaveLength(0); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index c0e355f6080..ef7d5c98abe 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -1,4 +1,3 @@ -import { IdMap } from './id-map.js'; import type { AccountGroupPayloadId, AccountTreePayload, @@ -12,22 +11,7 @@ import type { AccountWalletPrivateKeyPayload, } from './payload.js'; import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, migrate } from './payload.js'; - -/** - * Creates an {@link AccountTreeSnapshot}. Package-internal factory used by export - * and tests; callers outside this package should use - * {@link AccountTreeController.exportState} or {@link AccountTreeSnapshot.deserialize}. - * - * @param entries - Wallet entries in the snapshot. - * @param idMap - Optional local ↔ payload ID map populated during export. - * @returns A new snapshot. - */ -export function createAccountTreeSnapshot( - entries: AccountTreeWalletEntry[], - idMap: IdMap | null, -): AccountTreeSnapshot { - return new AccountTreeSnapshot(entries, idMap); -} +import type { IdMap } from './id-map.js'; /** * Recursively freezes a value and its nested properties. @@ -52,31 +36,31 @@ function deepFreeze(value: T): T { /** * Immutable value object returned by {@link AccountTreeController.exportState}. * - * Snapshots can only be constructed by {@link AccountTreeController.exportState}, - * {@link AccountTreeSnapshot.deserialize}, or the package-internal - * {@link createAccountTreeSnapshot} factory. + * 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. * - * Holds an ID map (local ↔ payload) populated during export so callers can - * bridge between internal controller IDs and the stable cross-device IDs that - * appear in the serialized payload. The map covers the original export and is - * preserved unchanged through filtering until {@link serialize}. It is absent - * for snapshots produced by {@link AccountTreeSnapshot.deserialize} — - * {@link toLocalId} / {@link toPayloadId} return `undefined` in that case. + * 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 | null; + readonly #idMap: IdMap | undefined; - private constructor( - entries: AccountTreeWalletEntry[], - idMap: IdMap | null, - ) { + /** + * @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; } @@ -250,8 +234,8 @@ export class AccountTreeSnapshot { * partial snapshot. * * The returned snapshot has no ID map — {@link toLocalId} / {@link toPayloadId} - * return `undefined`. Use {@link AccountTreeController.exportState} when you - * need the map. + * return `undefined`. Pass an {@link IdMap} to the constructor when you need + * the map. * * @param raw - Unknown value to parse. * @returns A validated snapshot. @@ -259,6 +243,6 @@ export class AccountTreeSnapshot { */ static deserialize(raw: unknown): AccountTreeSnapshot { const payload = migrate(raw); - return new AccountTreeSnapshot(payload.wallets, null); + return new AccountTreeSnapshot(payload.wallets); } } From c7d34f54be36259c35e31913c5bae0e0aec228b3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 10:32:30 +0200 Subject: [PATCH 28/38] refactor: *Schema -> *Struct --- .../src/state/export.ts | 8 +-- .../src/state/payload.ts | 58 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index 7bd6f510aeb..aad5d9aa801 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -3,7 +3,7 @@ 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 { encodeMnemonic, encodeMnemonicWords } from '@metamask/keyring-sdk'; +import { encodeMnemonicWords } from '@metamask/keyring-sdk'; import type { AccountTreeControllerMessenger, @@ -290,10 +290,10 @@ export async function exportState( idMap, ); - if (!privateKeyWallet) { - privateKeyWallet = exported; - } else { + if (privateKeyWallet) { privateKeyWallet.groups.push(...exported.groups); + } else { + privateKeyWallet = exported; } } else { // AccountWalletType.Snap and hardware keyrings: skipped for now. diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 0522413ed3c..c5ec689de26 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -188,7 +188,7 @@ export type ExportStateOptions = { includeSecrets?: boolean; }; -const AccountWalletPayloadIdSchema = define( +const AccountWalletPayloadIdStruct = define( 'AccountWalletPayloadId', (value) => typeof value === 'string' && value.startsWith('wallet:') @@ -196,7 +196,7 @@ const AccountWalletPayloadIdSchema = define( : 'Expected a wallet payload ID starting with "wallet:"', ); -const AccountGroupPayloadIdSchema = define( +const AccountGroupPayloadIdStruct = define( 'AccountGroupPayloadId', (value) => typeof value === 'string' && PAYLOAD_GROUP_ID_REGEX.test(value) @@ -204,52 +204,52 @@ const AccountGroupPayloadIdSchema = define( : 'Expected a group payload ID in the form "wallet:/"', ); -const AccountWalletPayloadMetadataSchema = object({ +const AccountWalletPayloadMetadataStruct = object({ name: string(), }); -const AccountWalletGroupPayloadMetadataSchema = object({ +const AccountWalletGroupPayloadMetadataStruct = object({ name: string(), pinned: boolean(), hidden: boolean(), }); -const AccountWalletPrivateKeyValueSchema = object({ +const AccountWalletPrivateKeyValueStruct = object({ privateKey: sensitive(string()), encoding: enums(['hexadecimal', 'base58', 'base32']), type: exactOptional(string()), }); -const AccountWalletMnemonicGroupEntrySchema = object({ - id: AccountGroupPayloadIdSchema, +const AccountWalletMnemonicGroupEntryStruct = object({ + id: AccountGroupPayloadIdStruct, groupIndex: integer(), - metadata: AccountWalletGroupPayloadMetadataSchema, + metadata: AccountWalletGroupPayloadMetadataStruct, }); -const AccountWalletPrivateKeyGroupEntrySchema = object({ - id: AccountGroupPayloadIdSchema, - value: exactOptional(AccountWalletPrivateKeyValueSchema), - metadata: AccountWalletGroupPayloadMetadataSchema, +const AccountWalletPrivateKeyGroupEntryStruct = object({ + id: AccountGroupPayloadIdStruct, + value: exactOptional(AccountWalletPrivateKeyValueStruct), + metadata: AccountWalletGroupPayloadMetadataStruct, }); -const AccountWalletMnemonicPayloadSchema = object({ - id: AccountWalletPayloadIdSchema, +const AccountWalletMnemonicPayloadStruct = object({ + id: AccountWalletPayloadIdStruct, type: literal('mnemonic'), value: exactOptional(sensitive(string())), - metadata: AccountWalletPayloadMetadataSchema, - groups: array(AccountWalletMnemonicGroupEntrySchema), + metadata: AccountWalletPayloadMetadataStruct, + groups: array(AccountWalletMnemonicGroupEntryStruct), }); -const AccountWalletPrivateKeyPayloadSchema = object({ - id: AccountWalletPayloadIdSchema, +const AccountWalletPrivateKeyPayloadStruct = object({ + id: AccountWalletPayloadIdStruct, type: literal('private-key'), - metadata: AccountWalletPayloadMetadataSchema, - groups: array(AccountWalletPrivateKeyGroupEntrySchema), + metadata: AccountWalletPayloadMetadataStruct, + groups: array(AccountWalletPrivateKeyGroupEntryStruct), }); -const AccountTreeWalletEntrySchema = union([ - AccountWalletMnemonicPayloadSchema, - AccountWalletPrivateKeyPayloadSchema, +const AccountTreeWalletEntryStruct = union([ + AccountWalletMnemonicPayloadStruct, + AccountWalletPrivateKeyPayloadStruct, ]); /** @@ -260,14 +260,14 @@ const AccountTreeWalletEntrySchema = union([ * the Superstruct `sensitive()` wrapper so validation failures redact secrets * from error output. */ -export const AccountTreePayloadSchema = object({ +export const AccountTreePayloadStruct = object({ version: literal(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION), - wallets: array(AccountTreeWalletEntrySchema), + wallets: array(AccountTreeWalletEntryStruct), }); -/** Inferred TypeScript type for a value matching {@link AccountTreePayloadSchema}. */ -export type AccountTreePayloadSchemaType = Infer< - typeof AccountTreePayloadSchema +/** Inferred TypeScript type for a value matching {@link AccountTreePayloadStruct}. */ +export type AccountTreePayloadStructType = Infer< + typeof AccountTreePayloadStruct >; /** @@ -297,7 +297,7 @@ export function assertValidAccountTreePayload( value: unknown, ): asserts value is AccountTreePayload { try { - assert(value, AccountTreePayloadSchema); + assert(value, AccountTreePayloadStruct); } catch (error) { if (error instanceof StructError) { throw new Error( From 6a43ecbce8861c034cf8c166713df09922c036fd Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 10:32:49 +0200 Subject: [PATCH 29/38] chore: lint --- .../src/state/export.test.ts | 3 +- .../src/state/export.ts | 4 +- .../src/state/snapshot.test.ts | 60 ++++++++++--------- .../src/state/snapshot.ts | 2 +- 4 files changed, 36 insertions(+), 33 deletions(-) diff --git a/packages/account-tree-controller/src/state/export.test.ts b/packages/account-tree-controller/src/state/export.test.ts index 603e12373de..99d506bbf88 100644 --- a/packages/account-tree-controller/src/state/export.test.ts +++ b/packages/account-tree-controller/src/state/export.test.ts @@ -538,7 +538,8 @@ describe('exportState', () => { }); 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 secondPkWalletId = + 'keyring:simple:legacy' as typeof MOCK_PK_WALLET_ID; const secondPkGroupId = toAccountGroupId(secondPkWalletId, '0xdef'); const wallets: AccountTreeControllerState['accountTree']['wallets'] = { diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts index aad5d9aa801..55bcfd8e700 100644 --- a/packages/account-tree-controller/src/state/export.ts +++ b/packages/account-tree-controller/src/state/export.ts @@ -94,9 +94,7 @@ async function exportMnemonicWalletObject( // 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, + mnemonic: includeMnemonic ? hdKeyring.mnemonic : undefined, }; }, ); diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index 5b678462245..c68391e2c67 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -65,9 +65,10 @@ describe('AccountTreeSnapshot', () => { describe('filterWallets', () => { it('returns a snapshot containing only matching entries', () => { - const snapshot = new AccountTreeSnapshot( - [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - ); + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); const filtered = snapshot.filterWallets( (wallet) => wallet.type === 'mnemonic', ); @@ -78,9 +79,10 @@ describe('AccountTreeSnapshot', () => { }); it('preserves absent idMap when filtering', () => { - const snapshot = new AccountTreeSnapshot( - [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - ); + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); const filtered = snapshot.filterWallets(() => true); expect(filtered.toLocalId('wallet:entropy-source-1')).toBeUndefined(); }); @@ -127,9 +129,10 @@ describe('AccountTreeSnapshot', () => { 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 snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); const filtered = snapshot.filterGroups( 'wallet:entropy-source-1', @@ -144,9 +147,10 @@ describe('AccountTreeSnapshot', () => { }); it('removes the wallet when all groups are filtered out', () => { - const snapshot = new AccountTreeSnapshot( - [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - ); + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); const filtered = snapshot.filterGroups( 'wallet:entropy-source-1', @@ -164,10 +168,7 @@ describe('AccountTreeSnapshot', () => { map, ); - const filtered = snapshot.filterGroups( - 'wallet:private-key', - () => true, - ); + const filtered = snapshot.filterGroups('wallet:private-key', () => true); expect(filtered.serialize().wallets).toHaveLength(2); expect(filtered.toLocalId('wallet:private-key/0xdeadbeef')).toBe( @@ -198,17 +199,18 @@ describe('AccountTreeSnapshot', () => { 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'); + 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 snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); const filtered = snapshot.filterAllGroups((group) => group.id.endsWith('/0'), @@ -221,9 +223,10 @@ describe('AccountTreeSnapshot', () => { }); it('provides the parent wallet to the predicate', () => { - const snapshot = new AccountTreeSnapshot( - [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - ); + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); const filtered = snapshot.filterAllGroups( (_group, wallet) => wallet.type === 'private-key', @@ -315,9 +318,10 @@ describe('AccountTreeSnapshot', () => { describe('serialize', () => { it('serializes to a versioned AccountTreePayload', () => { - const snapshot = new AccountTreeSnapshot( - [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], - ); + 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.wallets).toHaveLength(2); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index ef7d5c98abe..e2eab45e087 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -1,3 +1,4 @@ +import type { IdMap } from './id-map.js'; import type { AccountGroupPayloadId, AccountTreePayload, @@ -11,7 +12,6 @@ import type { AccountWalletPrivateKeyPayload, } from './payload.js'; import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, migrate } from './payload.js'; -import type { IdMap } from './id-map.js'; /** * Recursively freezes a value and its nested properties. From daf62c91fc2ee3e2ddbdc682d3f77782c4682ae6 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 11:02:03 +0200 Subject: [PATCH 30/38] chore: bump accounts deps --- packages/account-tree-controller/package.json | 4 +- packages/accounts-controller/package.json | 10 +- packages/assets-controller/package.json | 6 +- packages/assets-controllers/package.json | 8 +- packages/bridge-controller/package.json | 2 +- .../chain-agnostic-permission/package.json | 2 +- packages/client-utils/package.json | 2 +- packages/earn-controller/package.json | 2 +- packages/keyring-controller/package.json | 10 +- .../money-account-controller/package.json | 6 +- .../multichain-account-service/package.json | 14 +- .../package.json | 4 +- .../package.json | 6 +- .../package.json | 2 +- packages/perps-controller/package.json | 2 +- .../profile-metrics-controller/package.json | 2 +- packages/profile-sync-controller/package.json | 4 +- packages/snap-account-service/package.json | 12 +- yarn.lock | 276 +++++++++--------- 19 files changed, 182 insertions(+), 192 deletions(-) diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index feeb2ede01e..5746a6b39ae 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -57,7 +57,7 @@ "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/messenger": "^2.0.0", "@metamask/multichain-account-service": "^13.0.0", @@ -71,7 +71,7 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@metamask/account-api": "^1.1.1", + "@metamask/account-api": "^2.0.0", "@metamask/auto-changelog": "^6.1.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index f52586f6270..a61ff89084e 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.0.0", + "@metamask/keyring-utils": "^5.0.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^35.0.0", "@metamask/superstruct": "^3.1.0", diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index fbdad51afce..6ecca3a39b3 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.0", "@metamask/network-enablement-controller": "^6.0.2", diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 00be51675ec..1bd56f880a2 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/package.json b/packages/bridge-controller/package.json index a9fb589f296..e74e157b83e 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/package.json b/packages/client-utils/package.json index 38193c06d84..67d1d123353 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/package.json b/packages/earn-controller/package.json index 53221bd6ef7..b4dd51bfe88 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.0", "@metamask/stake-sdk": "^3.2.1", 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/package.json b/packages/money-account-controller/package.json index 30c4606fedd..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": "^2.0.4", - "@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/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/package.json b/packages/multichain-network-controller/package.json index cb8f9f66877..c3371963cd5 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.0", "@metamask/superstruct": "^3.1.0", 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/package.json b/packages/network-enablement-controller/package.json index d4f18d0843c..4a28d80cc00 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.0", diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 5917d050a32..9750fbed08e 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.0", "@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 598d7605a85..4fe7981887a 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -126,8 +126,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/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 7634a83cf68..f2670386849 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,11 +5739,11 @@ __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/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/multichain-account-service": "npm:^13.0.0" @@ -5780,12 +5780,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.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.0" "@metamask/providers": "npm:^22.1.0" @@ -5994,10 +5994,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.0" "@metamask/network-enablement-controller": "npm:^6.0.2" @@ -6039,7 +6039,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 +6050,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 +6267,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 +6368,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 +6461,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" @@ -6804,7 +6804,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.0" "@metamask/stake-sdk": "npm:^3.2.1" @@ -7004,22 +7004,22 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-hd-keyring@npm:^14.1.1": - version: 14.1.1 - resolution: "@metamask/eth-hd-keyring@npm:14.1.1" +"@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.1.0" - "@metamask/keyring-sdk": "npm:^2.0.2" - "@metamask/keyring-utils": "npm:^3.2.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.1.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" ethereum-cryptography: "npm:^2.2.1" - checksum: 10/f711742682a77990310272013523595822e29bfb61980828d1460ab8af888d7c344bb50f04d2611d801d63438e31532d3d66698c595b4fd3ad512b02056b7234 + checksum: 10/35bceeb450460104f996e73032cae584247461a792e733ed290cc3bd387434f86fd94e2b8f8dac792933987628d3a6febdcc0c93d9e5c349a4d9b3b0cd7d0167 languageName: node linkType: hard @@ -7112,16 +7112,17 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-money-keyring@npm:^2.0.4": - version: 2.0.4 - resolution: "@metamask/eth-money-keyring@npm:2.0.4" +"@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.1" - "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/keyring-utils": "npm:^3.2.0" - "@metamask/superstruct": "npm:^3.1.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/2e3941355be1750c433cf8106235df1b0785becc47c0c87dfabbe7ed43006fa5d303bca70a1d5a308c0d0b281b75468440ab5c8883d70e12fa3923a5ccba3dd1 + checksum: 10/d731f91fa4dc3c22eee74811c3b5c1d9dc302eb0e2698806ad7dfeefaacfe072ad80bdd24fd07caf03e1ee4d48411cfa166488070c9050370bb17f8f862dc3f5 languageName: node linkType: hard @@ -7150,44 +7151,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 @@ -7532,15 +7533,15 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^23.1.0, @metamask/keyring-api@npm:^23.2.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 @@ -7557,12 +7558,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" @@ -7585,101 +7586,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": - version: 2.2.0 - resolution: "@metamask/keyring-sdk@npm:2.2.0" +"@metamask/keyring-sdk@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/keyring-sdk@npm:3.0.0" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^23.2.0" - "@metamask/keyring-utils": "npm:^3.3.1" + "@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.1.0" + "@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/5cb7f2496f0fc95e85eb97932b69da9c02c232e2310b5f390bfc7c56996bf8516d15db54260e68288f6d6f5604bfacc19b4b82b9b28b7f794a3ab5ee9a1f10d1 + checksum: 10/987f03ef5b4cb0afd1f4c88e69baf89ab028bbb85ae04f12dcf1611a1dc839d705bfd59b50fc2166f413f42f29d58b287fdb7f27931add8280e76d43b23ddb95 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.2.0, @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 @@ -7883,10 +7873,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:^2.0.4" - "@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" @@ -7954,19 +7944,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" @@ -8036,9 +8026,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.0" "@metamask/superstruct": "npm:^3.1.0" @@ -8068,10 +8058,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" @@ -8199,7 +8189,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.0" @@ -8400,7 +8390,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.0" "@metamask/profile-sync-controller": "npm:^28.3.0" @@ -8558,7 +8548,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" @@ -8592,9 +8582,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/snaps-controllers": "npm:^19.0.0" @@ -9002,14 +8992,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" @@ -9255,7 +9245,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 From cf0e708672bd8869b8713969c5f5d96819eaefd9 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 11:06:38 +0200 Subject: [PATCH 31/38] chore: changelogs --- packages/account-tree-controller/CHANGELOG.md | 2 +- packages/accounts-controller/CHANGELOG.md | 8 ++++++++ packages/assets-controller/CHANGELOG.md | 3 +++ packages/assets-controllers/CHANGELOG.md | 1 + packages/bridge-controller/CHANGELOG.md | 1 + packages/client-utils/CHANGELOG.md | 1 + packages/earn-controller/CHANGELOG.md | 4 ++++ packages/keyring-controller/CHANGELOG.md | 6 ++++-- packages/money-account-controller/CHANGELOG.md | 3 ++- packages/multichain-account-service/CHANGELOG.md | 11 ++++++----- packages/multichain-network-controller/CHANGELOG.md | 5 +++++ .../multichain-transactions-controller/CHANGELOG.md | 6 +++--- packages/network-enablement-controller/CHANGELOG.md | 4 ++++ packages/snap-account-service/CHANGELOG.md | 8 ++++++++ 14 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 4880b05e01a..119f4cfd033 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 657785b0e50..5764fea4dd5 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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.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)) + ## [39.0.6] ### Changed diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 9aa1bbe851f..8308828313d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - 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-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 7541bc350d4..65caa7b1b57 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - 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/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 4d600a7cebe..ebfbfc8f711 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 500ea36ffa3..5e98674b3e5 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) ## [1.5.0] diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index 40f55cf182a..b94b66076a3 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/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)) + ## [12.2.3] ### Changed 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/money-account-controller/CHANGELOG.md b/packages/money-account-controller/CHANGELOG.md index 1bb04576484..973c0c51e87 100644 --- a/packages/money-account-controller/CHANGELOG.md +++ b/packages/money-account-controller/CHANGELOG.md @@ -12,8 +12,9 @@ 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/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/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-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md index 54ae77fcdf9..a3e3ff14d9c 100644 --- a/packages/multichain-network-controller/CHANGELOG.md +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -7,6 +7,11 @@ 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)) +- Bump `@metamask/keyring-internal-api` from `^11.0.2` to `^12.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) + ## [3.2.2] ### Changed 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/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index 97e44153c37..5d8fadde22f 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/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)) + ## [6.0.2] ### Changed 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 From de126e568aa335a4c143889b217bd3932e68c3b7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 11:24:21 +0200 Subject: [PATCH 32/38] fix: remove eth_signTransaction from MoneyAccount(s) --- .../src/MoneyAccountController.test.ts | 7 +------ .../money-account-controller/src/MoneyAccountController.ts | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/money-account-controller/src/MoneyAccountController.test.ts b/packages/money-account-controller/src/MoneyAccountController.test.ts index a4974f0612c..60b56cb69a2 100644 --- a/packages/money-account-controller/src/MoneyAccountController.test.ts +++ b/packages/money-account-controller/src/MoneyAccountController.test.ts @@ -41,7 +41,6 @@ const MOCK_MONEY_ACCOUNT: MoneyAccount = { exportable: false, }, methods: [ - 'eth_signTransaction', 'personal_sign', 'eth_signTypedData_v1', 'eth_signTypedData_v3', @@ -64,7 +63,6 @@ const MOCK_MONEY_ACCOUNT_2: MoneyAccount = { exportable: false, }, methods: [ - 'eth_signTransaction', 'personal_sign', 'eth_signTypedData_v1', 'eth_signTypedData_v3', @@ -295,10 +293,7 @@ describe('MoneyAccountController', () => { derivationPath: "m/44'/4392018'/0'/0", }, }, - methods: expect.arrayContaining([ - 'personal_sign', - 'eth_signTransaction', - ]), + methods: expect.arrayContaining(['personal_sign']), }); expect(typeof account.id).toBe('string'); }); diff --git a/packages/money-account-controller/src/MoneyAccountController.ts b/packages/money-account-controller/src/MoneyAccountController.ts index ae1fd3fc155..5e6d259adfa 100644 --- a/packages/money-account-controller/src/MoneyAccountController.ts +++ b/packages/money-account-controller/src/MoneyAccountController.ts @@ -210,7 +210,6 @@ export class MoneyAccountController extends BaseController< exportable: false, }, methods: [ - EthMethod.SignTransaction, EthMethod.PersonalSign, EthMethod.SignTypedDataV1, EthMethod.SignTypedDataV3, From 7597e66f0c91322f8c626778e48f7eb6ef34be50 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 11:40:51 +0200 Subject: [PATCH 33/38] fix: fix remaining renames --- packages/account-tree-controller/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index e833ae0012b..5352005052c 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -51,7 +51,7 @@ export { export type { AccountTreePayload, - AccountTreePayloadSchemaType, + AccountTreePayloadStructType, AccountWalletMnemonicPayload, AccountWalletPrivateKeyPayload, AccountWalletMnemonicGroupEntry, @@ -64,7 +64,7 @@ export type { } from './state/payload.js'; export { - AccountTreePayloadSchema, + AccountTreePayloadStruct, assertValidAccountTreePayload, migrate, } from './state/payload.js'; From 3acb5c550cb8ca4117b5649b70870feddacd7453 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 15:44:40 +0200 Subject: [PATCH 34/38] refactor: re-use migration framework from keyring-sdk --- .../src/AccountTreeController.test.ts | 6 +- packages/account-tree-controller/src/index.ts | 3 + .../src/state/export.test.ts | 26 ++-- .../src/state/import.test.ts | 46 ++---- .../src/state/import.ts | 2 +- .../src/state/payload.test.ts | 134 ++++++++++-------- .../src/state/payload.ts | 78 ++++------ .../src/state/snapshot.test.ts | 82 +++++------ .../src/state/snapshot.ts | 18 ++- 9 files changed, 184 insertions(+), 211 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index e1ec68ac68b..ae6ebc01ea3 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -6121,8 +6121,8 @@ describe('AccountTreeController', () => { const snapshot = await controller.exportState(); const payload = snapshot.serialize(); - expect(payload.wallets).toHaveLength(1); - const exportedWallet = payload.wallets[0]; + 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'); @@ -6203,7 +6203,7 @@ describe('AccountTreeController', () => { const payload = snapshot.serialize(); // Exported mnemonic wallet has no secret value. - expect((payload.wallets[0] as { value?: string }).value).toBeUndefined(); + 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(); diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 5352005052c..7eca7d47475 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -67,7 +67,10 @@ 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 index 99d506bbf88..70d9934812e 100644 --- a/packages/account-tree-controller/src/state/export.test.ts +++ b/packages/account-tree-controller/src/state/export.test.ts @@ -271,7 +271,7 @@ describe('exportState', () => { it('returns an empty snapshot', async () => { const { context } = setup(); const snapshot = await exportState(context); - expect(snapshot.serialize().wallets).toHaveLength(0); + expect(snapshot.serialize().data.wallets).toHaveLength(0); }); }); @@ -285,7 +285,7 @@ describe('exportState', () => { ); const snapshot = await exportState(context); - const wallet = snapshot.serialize().wallets[0]; + const wallet = snapshot.serialize().data.wallets[0]; expect(wallet?.id).toBe('wallet:stable-entropy-id'); expect(wallet?.type).toBe('mnemonic'); @@ -301,7 +301,7 @@ describe('exportState', () => { ); const snapshot = await exportState(context, { includeSecrets: true }); - const wallet = snapshot.serialize().wallets[0] as { value?: string }; + const wallet = snapshot.serialize().data.wallets[0] as { value?: string }; expect(wallet.value).toBeDefined(); expect(typeof wallet.value).toBe('string'); @@ -400,7 +400,7 @@ describe('exportState', () => { const { context } = setup({ wallets: mixedWallets }); const snapshot = await exportState(context); - expect(snapshot.serialize().wallets).toHaveLength(0); + expect(snapshot.serialize().data.wallets).toHaveLength(0); }); }); @@ -415,8 +415,8 @@ describe('exportState', () => { const snapshot = await exportState(context); const payload = snapshot.serialize(); - expect(payload.wallets).toHaveLength(1); - const wallet = payload.wallets[0]; + 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); @@ -436,7 +436,7 @@ describe('exportState', () => { }); const snapshot = await exportState(context, { includeSecrets: true }); - const group = snapshot.serialize().wallets[0]?.groups[0] as { + const group = snapshot.serialize().data.wallets[0]?.groups[0] as { value?: { privateKey: string; encoding: string; type: string }; }; @@ -482,7 +482,7 @@ describe('exportState', () => { mocks.AccountsController.getAccount.mockReturnValue(undefined); const snapshot = await exportState(context); - expect(snapshot.serialize().wallets[0]?.groups).toHaveLength(0); + expect(snapshot.serialize().data.wallets[0]?.groups).toHaveLength(0); }); it('skips groups with no accounts', async () => { @@ -519,7 +519,7 @@ describe('exportState', () => { const { context } = setup({ wallets }); const snapshot = await exportState(context); - expect(snapshot.serialize().wallets[0]?.groups).toHaveLength(0); + expect(snapshot.serialize().data.wallets[0]?.groups).toHaveLength(0); }); it('populates the idMap with private-key wallet and group pairs', async () => { @@ -582,10 +582,10 @@ describe('exportState', () => { const snapshot = await exportState(context); const payload = snapshot.serialize(); - expect(payload.wallets).toHaveLength(1); - expect(payload.wallets[0]?.type).toBe('private-key'); - expect(payload.wallets[0]?.groups).toHaveLength(2); - expect(payload.wallets[0]?.groups.map((group) => group.id)).toEqual([ + 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/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts index a6f59e62ef1..8fd7ed18301 100644 --- a/packages/account-tree-controller/src/state/import.test.ts +++ b/packages/account-tree-controller/src/state/import.test.ts @@ -16,7 +16,6 @@ import type { } from '../types.js'; import type { ImportContext } from './import.js'; import { importState } from './import.js'; -import type { AccountTreePayload } from './payload.js'; import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION } from './payload.js'; import { AccountTreeSnapshot } from './snapshot.js'; @@ -39,8 +38,7 @@ 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: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, +const MNEMONIC_PAYLOAD = { wallets: [ { id: MOCK_PAYLOAD_WALLET_ID, @@ -220,11 +218,11 @@ function makeWithKeyringV2Mock( ); } -function importSnapshot( +async function importSnapshot( context: ImportContext, - payload: AccountTreePayload, + payload: unknown, ): ReturnType { - return importState(context, AccountTreeSnapshot.deserialize(payload)); + return importState(context, await AccountTreeSnapshot.deserialize(payload)); } describe('importState', () => { @@ -293,8 +291,7 @@ describe('importState', () => { }; const { context, mocks } = setup({ wallets: pkOnlyWallets }); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: 'wallet:entropy-only', @@ -318,8 +315,7 @@ describe('importState', () => { }, ); - const payloadWithoutMnemonic: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payloadWithoutMnemonic = { wallets: [ { id: 'wallet:unknown-entropy', @@ -348,7 +344,6 @@ describe('importState', () => { await expect( importSnapshot(context, { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:no-match-entropy', @@ -395,7 +390,6 @@ describe('importState', () => { await expect( importSnapshot(context, { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:no-match', @@ -424,8 +418,7 @@ describe('importState', () => { }, ); - const payloadWithMnemonic: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payloadWithMnemonic = { wallets: [ { id: 'wallet:unknown-entropy', @@ -486,8 +479,7 @@ describe('importState', () => { }, ); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: MOCK_PAYLOAD_WALLET_ID, @@ -567,8 +559,7 @@ describe('importState', () => { }, ); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: MOCK_PAYLOAD_WALLET_ID, @@ -641,8 +632,7 @@ describe('importState', () => { const { context, mocks } = setup({ wallets: pkWallets }); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: 'wallet:private-key', @@ -723,8 +713,7 @@ describe('importState', () => { }, ); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: 'wallet:private-key', @@ -762,7 +751,6 @@ describe('importState', () => { await expect( importSnapshot(context, { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:private-key', @@ -784,8 +772,7 @@ describe('importState', () => { it('skips a private-key group whose value carries a non-EVM type', async () => { const { context, mocks } = setup(); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: 'wallet:private-key', @@ -822,8 +809,7 @@ describe('importState', () => { [], ); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: 'wallet:private-key', @@ -854,8 +840,7 @@ describe('importState', () => { it('skips a private-key group that has no value and account does not exist locally', async () => { const { context, mocks } = setup(); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: 'wallet:private-key', @@ -884,8 +869,7 @@ describe('importState', () => { [{ id: 'some-account-id' }], ); - const payload: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const payload = { wallets: [ { id: 'wallet:private-key', diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index 3b41aaa4912..b5e2b847be1 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -333,7 +333,7 @@ export async function importState( ): Promise { const payload = snapshot.serialize(); - for (const wallet of payload.wallets) { + for (const wallet of payload.data.wallets) { if (wallet.type === 'mnemonic') { await importMnemonicWallet(context, wallet); } else { diff --git a/packages/account-tree-controller/src/state/payload.test.ts b/packages/account-tree-controller/src/state/payload.test.ts index 32c500052e4..a813ce128a4 100644 --- a/packages/account-tree-controller/src/state/payload.test.ts +++ b/packages/account-tree-controller/src/state/payload.test.ts @@ -1,5 +1,6 @@ import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + assertValidAccountTreePayload, migrate, parsePayloadGroupId, toWalletPayloadId, @@ -7,7 +8,6 @@ import { import { AccountTreeSnapshot } from './snapshot.js'; const VALID_MNEMONIC_PAYLOAD = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:entropy:mnemonic:abc123', @@ -25,7 +25,6 @@ const VALID_MNEMONIC_PAYLOAD = { }; const VALID_PRIVATE_KEY_PAYLOAD = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:private-key', @@ -80,71 +79,54 @@ describe('toWalletPayloadId', () => { }); describe('migrate', () => { - it('throws if raw is not an object', () => { - expect(() => migrate('not an object')).toThrow( - 'Invalid AccountTreePayload: expected an object', - ); - expect(() => migrate(null)).toThrow( - 'Invalid AccountTreePayload: expected an object', - ); - expect(() => migrate(42)).toThrow( - 'Invalid AccountTreePayload: expected an object', + 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 version field is missing or not a number', () => { - expect(() => migrate({})).toThrow( - 'Invalid AccountTreePayload: missing numeric version field', - ); - expect(() => migrate({ version: '1' })).toThrow( - 'Invalid AccountTreePayload: missing numeric version field', - ); - expect(() => migrate({ version: 1.5 })).toThrow( - 'Invalid AccountTreePayload: missing numeric version field', + 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', ); - }); - - it('throws if version exceeds CURRENT_VERSION', () => { - expect(() => - migrate({ version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1 }), - ).toThrow( - `Unsupported AccountTreePayload version: ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1}`, + await expect(migrate({ version: 1.5 })).rejects.toThrow( + 'Invalid AccountTreePayload', ); }); - it('throws if version is below CURRENT_VERSION', () => { - expect(() => migrate({ version: 0, wallets: [] })).toThrow( - 'Unsupported AccountTreePayload version: 0', + 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 payload unchanged for a valid current-version payload', () => { - const raw = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - wallets: [], - }; - const result = migrate(raw); - expect(result).toBe(raw); - expect(result.version).toBe(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', () => { - const result = migrate(VALID_MNEMONIC_PAYLOAD); + 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', () => { - const result = migrate(VALID_PRIVATE_KEY_PAYLOAD); + 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', () => { - expect(() => + it('throws for an unsupported wallet type', async () => { + await expect( migrate({ - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:ledger', @@ -154,25 +136,23 @@ describe('migrate', () => { }, ], }), - ).toThrow('Invalid AccountTreePayload'); + ).rejects.toThrow('Invalid AccountTreePayload'); }); - it('throws when required wallet fields are missing', () => { - expect(() => + it('throws when required wallet fields are missing', async () => { + await expect( migrate({ - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [{ type: 'mnemonic' }], }), - ).toThrow('Invalid AccountTreePayload'); + ).rejects.toThrow('Invalid AccountTreePayload'); }); - it('redacts mnemonic secrets in validation error messages', () => { + it('redacts mnemonic secrets in validation error messages', async () => { const secretMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; try { - migrate({ - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + await migrate({ wallets: [ { id: 'wallet:entropy:mnemonic:abc123', @@ -188,8 +168,7 @@ describe('migrate', () => { expect(String(error)).toContain('***'); } - const validWithSecret = migrate({ - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + const validWithSecret = await migrate({ wallets: [ { id: 'wallet:entropy:mnemonic:abc123', @@ -203,13 +182,12 @@ describe('migrate', () => { expect(validWithSecret.wallets[0]?.type).toBe('mnemonic'); }); - it('redacts private keys in validation error messages', () => { + it('redacts private keys in validation error messages', async () => { const secretKey = '4c0883a69102937d6231471b5dbb6e538eba0ef8b09f0bf4e8b8e1e4e3e3b3c2'; try { - migrate({ - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + await migrate({ wallets: [ { id: 'wallet:private-key', @@ -235,11 +213,43 @@ describe('migrate', () => { }); }); -describe('AccountTreeSnapshot.deserialize validation', () => { - it('rejects payloads with unsupported wallet types', () => { +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({ - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:ledger', @@ -249,6 +259,6 @@ describe('AccountTreeSnapshot.deserialize validation', () => { }, ], }), - ).toThrow('Invalid AccountTreePayload'); + ).rejects.toThrow('Invalid AccountTreePayload'); }); }); diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index c5ec689de26..e5369044556 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -1,4 +1,6 @@ import type { KeyringAccount } from '@metamask/keyring-api'; +import type { MigrationChain } from '@metamask/keyring-sdk'; +import { createMigrations } from '@metamask/keyring-sdk'; import { assert, array, @@ -15,6 +17,7 @@ import { 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}`; @@ -54,9 +57,6 @@ export function parsePayloadGroupId( }; } -/** Current version of the {@link AccountTreePayload} format. */ -export const ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION = 1 as const; - /** Wallet-level metadata carried in every payload wallet entry. */ export type AccountWalletPayloadMetadata = { name: string }; @@ -126,9 +126,8 @@ export type AccountTreeWalletEntry = | AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload; -/** Versioned, portable snapshot of the full account tree state. */ +/** Portable snapshot of the full account tree state (inner data, without version envelope). */ export type AccountTreePayload = { - version: typeof ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; wallets: AccountTreeWalletEntry[]; }; @@ -253,7 +252,7 @@ const AccountTreeWalletEntryStruct = union([ ]); /** - * Superstruct schema for a versioned {@link AccountTreePayload}. + * 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 @@ -261,7 +260,6 @@ const AccountTreeWalletEntryStruct = union([ * from error output. */ export const AccountTreePayloadStruct = object({ - version: literal(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION), wallets: array(AccountTreeWalletEntryStruct), }); @@ -309,22 +307,25 @@ export function assertValidAccountTreePayload( } } -type Migrator = (raw: unknown) => AccountTreePayload; - /** - * Validates a raw value as a v1 {@link AccountTreePayload}. + * Migration chain for {@link AccountTreePayload}. * - * @param raw - Unknown value to validate. - * @returns The validated payload. + * 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: [...] }`. */ -const migrateV1 = (raw: unknown): AccountTreePayload => { - assertValidAccountTreePayload(raw); - return raw; -}; +export const migrations: MigrationChain = + createMigrations().add({ + migrate(data: Json): AccountTreePayload { + assertValidAccountTreePayload(data); + return data; + }, + }); -const MIGRATORS: Record = { - [ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION]: migrateV1, -}; +/** 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. @@ -337,35 +338,16 @@ const MIGRATORS: Record = { * @returns A fully migrated `AccountTreePayload`. * @throws If `raw` is not a valid payload, its version is unsupported, or any wallet type is unrecognized. */ -export function migrate(raw: unknown): AccountTreePayload { - if (typeof raw !== 'object' || raw === null) { - throw new Error('Invalid AccountTreePayload: expected an object'); - } - - const { version } = raw as Record; - if (typeof version !== 'number' || !Number.isInteger(version)) { - throw new Error( - 'Invalid AccountTreePayload: missing numeric version field', - ); - } - if (version > ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION) { - throw new Error( - `Unsupported AccountTreePayload version: ${version} (current: ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION})`, - ); - } - if (version < ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION) { - throw new Error( - `Unsupported AccountTreePayload version: ${version} (current: ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION})`, - ); - } - - let result: unknown = raw; - for (let ver = version; ver <= ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION; ver++) { - const migrator = MIGRATORS[ver]; - if (migrator) { - result = migrator(result); +export async function migrate(raw: unknown): Promise { + try { + const { data } = await migrations.apply(raw as Json); + return data; + } catch (error) { + if (error instanceof StructError) { + throw new Error( + `Invalid AccountTreePayload: ${formatValidationErrorMessages(error)}`, + ); } + throw error; } - - return result; } diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index c68391e2c67..1ab3c63a093 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -1,6 +1,5 @@ import { IdMap } from './id-map.js'; import type { - AccountTreePayload, AccountWalletMnemonicPayload, AccountWalletPrivateKeyPayload, } from './payload.js'; @@ -59,7 +58,9 @@ describe('AccountTreeSnapshot', () => { }), ).toThrow(TypeError); - expect(snapshot.serialize().wallets[0]?.metadata.name).toBe('Wallet 1'); + expect(snapshot.serialize().data.wallets[0]?.metadata.name).toBe( + 'Wallet 1', + ); }); }); @@ -72,8 +73,8 @@ describe('AccountTreeSnapshot', () => { const filtered = snapshot.filterWallets( (wallet) => wallet.type === 'mnemonic', ); - expect(filtered.serialize().wallets).toHaveLength(1); - expect(filtered.serialize().wallets[0]?.id).toBe( + expect(filtered.serialize().data.wallets).toHaveLength(1); + expect(filtered.serialize().data.wallets[0]?.id).toBe( 'wallet:entropy-source-1', ); }); @@ -139,7 +140,7 @@ describe('AccountTreeSnapshot', () => { (group) => group.id.endsWith('/0'), ); - const wallets = filtered.serialize().wallets; + 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'); @@ -157,8 +158,8 @@ describe('AccountTreeSnapshot', () => { () => false, ); - expect(filtered.serialize().wallets).toHaveLength(1); - expect(filtered.serialize().wallets[0]?.type).toBe('private-key'); + 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', () => { @@ -170,7 +171,7 @@ describe('AccountTreeSnapshot', () => { const filtered = snapshot.filterGroups('wallet:private-key', () => true); - expect(filtered.serialize().wallets).toHaveLength(2); + expect(filtered.serialize().data.wallets).toHaveLength(2); expect(filtered.toLocalId('wallet:private-key/0xdeadbeef')).toBe( 'keyring:simple/0xdeadbeef', ); @@ -216,7 +217,7 @@ describe('AccountTreeSnapshot', () => { group.id.endsWith('/0'), ); - const wallets = filtered.serialize().wallets; + const wallets = filtered.serialize().data.wallets; expect(wallets).toHaveLength(1); expect(wallets[0]?.type).toBe('mnemonic'); expect(wallets[0]?.groups).toHaveLength(1); @@ -232,8 +233,8 @@ describe('AccountTreeSnapshot', () => { (_group, wallet) => wallet.type === 'private-key', ); - expect(filtered.serialize().wallets).toHaveLength(1); - expect(filtered.serialize().wallets[0]?.type).toBe('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', () => { @@ -317,69 +318,64 @@ describe('AccountTreeSnapshot', () => { }); describe('serialize', () => { - it('serializes to a versioned AccountTreePayload', () => { + 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.wallets).toHaveLength(2); - expect(payload.wallets[0]).toStrictEqual(MOCK_MNEMONIC_WALLET); - expect(payload.wallets[1]).toStrictEqual(MOCK_PRIVATE_KEY_WALLET); - expect(Object.isFrozen(payload.wallets)).toBe(true); + 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.wallets).toHaveLength(0); + expect(payload.data.wallets).toHaveLength(0); }); }); describe('deserialize', () => { - it('deserializes a valid v1 payload into a snapshot', () => { - const raw: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - wallets: [MOCK_MNEMONIC_WALLET], - }; - const snapshot = AccountTreeSnapshot.deserialize(raw); - expect(snapshot.serialize().wallets).toHaveLength(1); - expect(snapshot.serialize().wallets[0]?.id).toBe( + 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)', () => { - const raw: AccountTreePayload = { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - wallets: [MOCK_MNEMONIC_WALLET], - }; - const snapshot = AccountTreeSnapshot.deserialize(raw); + 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 (no version)', () => { - expect(() => AccountTreeSnapshot.deserialize({ wallets: [] })).toThrow( - 'Invalid AccountTreePayload', - ); + it('throws for an invalid payload (missing wallets field)', async () => { + await expect( + AccountTreeSnapshot.deserialize({}), + ).rejects.toThrow('Invalid AccountTreePayload'); }); - it('throws for a future version', () => { - expect(() => + it('throws for a future version in the versioned envelope', async () => { + await expect( AccountTreeSnapshot.deserialize({ version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1, - wallets: [], + data: { wallets: [] }, }), - ).toThrow('Unsupported AccountTreePayload version'); + ).rejects.toThrow( + `State version ${ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1} is newer than the latest migration version`, + ); }); - it('throws for an unsupported wallet type', () => { - expect(() => + it('throws for an unsupported wallet type', async () => { + await expect( AccountTreeSnapshot.deserialize({ - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets: [ { id: 'wallet:ledger', @@ -389,7 +385,7 @@ describe('AccountTreeSnapshot', () => { }, ], }), - ).toThrow('Invalid AccountTreePayload'); + ).rejects.toThrow('Invalid AccountTreePayload'); }); }); }); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index e2eab45e087..a6cb8904b38 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -1,3 +1,4 @@ +import type { VersionedState } from '@metamask/keyring-sdk'; import type { IdMap } from './id-map.js'; import type { AccountGroupPayloadId, @@ -11,7 +12,7 @@ import type { AccountWalletPrivateKeyGroupEntry, AccountWalletPrivateKeyPayload, } from './payload.js'; -import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, migrate } from './payload.js'; +import { migrations, migrate } from './payload.js'; /** * Recursively freezes a value and its nested properties. @@ -212,17 +213,14 @@ export class AccountTreeSnapshot { } /** - * Serializes the snapshot to a versioned {@link AccountTreePayload}. + * 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. + * @returns The versioned payload envelope. */ - serialize(): AccountTreePayload { - return { - version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - wallets: this.#entries, - }; + serialize(): VersionedState { + return { version: migrations.version, data: { wallets: this.#entries } }; } /** @@ -241,8 +239,8 @@ export class AccountTreeSnapshot { * @returns A validated snapshot. * @throws If `raw` is not a valid payload or its version is unsupported. */ - static deserialize(raw: unknown): AccountTreeSnapshot { - const payload = migrate(raw); + static async deserialize(raw: unknown): Promise { + const payload = await migrate(raw); return new AccountTreeSnapshot(payload.wallets); } } From f5c9cc6930d751b40d8f0fbabbfe83fe1e7caa84 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 3 Aug 2026 15:51:55 +0200 Subject: [PATCH 35/38] refactor: remove unreachable try/catch --- .../account-tree-controller/src/state/payload.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index e5369044556..829cd1bf580 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -339,15 +339,6 @@ export const ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION = migrations.version; * @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 { - try { - const { data } = await migrations.apply(raw as Json); - return data; - } catch (error) { - if (error instanceof StructError) { - throw new Error( - `Invalid AccountTreePayload: ${formatValidationErrorMessages(error)}`, - ); - } - throw error; - } + const { data } = await migrations.apply(raw as Json); + return data; } From f028530421eda127e446d4cf2f048cbdadb9a7f3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 4 Aug 2026 13:42:52 +0200 Subject: [PATCH 36/38] chore: use keyring-sdk 3.1.0 --- packages/accounts-controller/CHANGELOG.md | 2 +- packages/accounts-controller/package.json | 2 +- yarn.lock | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 429e3f36c4a..5bec2e98b7b 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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.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)) diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index 8847fc85afc..23be721d33c 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -61,7 +61,7 @@ "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/keyring-internal-api": "^12.0.0", - "@metamask/keyring-sdk": "^3.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", diff --git a/yarn.lock b/yarn.lock index 8fcc7e35d53..8b9c8841d12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5784,7 +5784,7 @@ __metadata: "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/keyring-internal-api": "npm:^12.0.0" - "@metamask/keyring-sdk": "npm:^3.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" @@ -7612,9 +7612,9 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-sdk@npm:^3.0.0": - version: 3.0.0 - resolution: "@metamask/keyring-sdk@npm:3.0.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" @@ -7627,7 +7627,7 @@ __metadata: async-mutex: "npm:^0.5.0" ethereum-cryptography: "npm:^2.2.1" uuid: "npm:^9.0.1" - checksum: 10/987f03ef5b4cb0afd1f4c88e69baf89ab028bbb85ae04f12dcf1611a1dc839d705bfd59b50fc2166f413f42f29d58b287fdb7f27931add8280e76d43b23ddb95 + checksum: 10/9bec3d530da5484e9b28234c1d5e62bdd402d0cec25d107152efdc617e5d818becb25049621c97cc6af821e151ce43af798cbc1c421828c28c1ed20e611d2477 languageName: node linkType: hard From 8af6b3ae781c75429c88ef463bc7732e6bb20e25 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 4 Aug 2026 13:43:19 +0200 Subject: [PATCH 37/38] chore: changelogs --- packages/client-utils/CHANGELOG.md | 4 ++++ packages/money-account-controller/CHANGELOG.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 7c6108f59e3..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 diff --git a/packages/money-account-controller/CHANGELOG.md b/packages/money-account-controller/CHANGELOG.md index 33897d9cddc..b0a3016ef5e 100644 --- a/packages/money-account-controller/CHANGELOG.md +++ b/packages/money-account-controller/CHANGELOG.md @@ -11,7 +11,7 @@ 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)) From f0a86dfc3a0922e22404dadefd22d05e2ddd5abc Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 4 Aug 2026 17:07:24 +0200 Subject: [PATCH 38/38] chore: remove use of preview builds + bump eth-hd-keyring --- package.json | 2 - packages/account-tree-controller/package.json | 2 +- yarn.lock | 56 ++++++------------- 3 files changed, 17 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index c36ec007eee..486e49e6aa1 100644 --- a/package.json +++ b/package.json @@ -116,8 +116,6 @@ "yargs": "^17.7.2" }, "resolutions": { - "@metamask/eth-hd-keyring": "npm:@metamask-previews/eth-hd-keyring@14.1.2-58658de", - "@metamask/keyring-sdk": "npm:@metamask-previews/keyring-sdk@3.0.0-914f87e", "@nktkas/hyperliquid@npm:^0.33.1": "patch:@nktkas/hyperliquid@npm%3A0.33.1#~/.yarn/patches/@nktkas-hyperliquid-npm-0.33.1-6a541fdd1d.patch", "elliptic@6.5.4": "^6.5.7", "fast-xml-parser@^4.3.4": "^4.4.1", diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index bb3c70ecb81..aa05f8760c5 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -74,7 +74,7 @@ "devDependencies": { "@metamask/account-api": "^2.0.0", "@metamask/auto-changelog": "^6.1.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/yarn.lock b/yarn.lock index cf2e5c9cfbf..12a5b1eb233 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5743,7 +5743,7 @@ __metadata: "@metamask/accounts-controller": "npm:^39.0.6" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/eth-hd-keyring": "npm:^14.1.1" + "@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" @@ -7008,22 +7008,22 @@ __metadata: languageName: unknown linkType: soft -"@metamask/eth-hd-keyring@npm:@metamask-previews/eth-hd-keyring@14.1.2-58658de": - version: 14.1.2-58658de - resolution: "@metamask-previews/eth-hd-keyring@npm:14.1.2-58658de" +"@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.7.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/0f177df3c09f400bec21d55fa575dd3f578f88100131cc8e47fd3544a2a12a4cb8d98189d40e0e85a4e4520c52bde257bb1e92ee5a0a1e30ce37efcd5288031b + checksum: 10/35bceeb450460104f996e73032cae584247461a792e733ed290cc3bd387434f86fd94e2b8f8dac792933987628d3a6febdcc0c93d9e5c349a4d9b3b0cd7d0167 languageName: node linkType: hard @@ -7537,19 +7537,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:23.7.0": - version: 23.7.0 - resolution: "@metamask/keyring-api@npm:23.7.0" - dependencies: - "@metamask/keyring-utils": "npm:^4.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/3c1aea064e017be0b99202a4e59b7ed9e1965aeb732b04ad5cb252dfa6b443487284f8b4253986ca325d8b45fd7977cef93f470f95f0342d377551d4b93738c3 - languageName: node - linkType: hard - -"@metamask/keyring-api@npm:24.0.0, @metamask/keyring-api@npm:^24.0.0": +"@metamask/keyring-api@npm:^24.0.0": version: 24.0.0 resolution: "@metamask/keyring-api@npm:24.0.0" dependencies: @@ -7626,14 +7614,14 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-sdk@npm:@metamask-previews/keyring-sdk@3.0.0-914f87e": - version: 3.0.0-914f87e - resolution: "@metamask-previews/keyring-sdk@npm:3.0.0-914f87e" +"@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:24.0.0" - "@metamask/keyring-utils": "npm:5.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" @@ -7641,7 +7629,7 @@ __metadata: async-mutex: "npm:^0.5.0" ethereum-cryptography: "npm:^2.2.1" uuid: "npm:^9.0.1" - checksum: 10/d3fc9cc2e97e8bb21d7f56cb56df499157c55d79b4bc76064aabf20e15d405ad42fbed519cd28c03e3d4f0ba5b0c9e8de5aa5fa5f3fcf4a3d8d4d189b6424616 + checksum: 10/9bec3d530da5484e9b28234c1d5e62bdd402d0cec25d107152efdc617e5d818becb25049621c97cc6af821e151ce43af798cbc1c421828c28c1ed20e611d2477 languageName: node linkType: hard @@ -7677,19 +7665,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-utils@npm:4.0.0, @metamask/keyring-utils@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/keyring-utils@npm:4.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 - languageName: node - linkType: hard - -"@metamask/keyring-utils@npm:5.0.0, @metamask/keyring-utils@npm:^5.0.0": +"@metamask/keyring-utils@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/keyring-utils@npm:5.0.0" dependencies: