Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6d56f9b
feat(account-tree-controller): add {import,export}State actions
ccharly Jul 24, 2026
3d94eba
refactor: add IdMap
ccharly Jul 24, 2026
7847e32
refactor: rework export
ccharly Jul 24, 2026
333492b
refactor: rename to migrate
ccharly Jul 24, 2026
6fe6fa8
refactor: rework import
ccharly Jul 24, 2026
25c83a0
docs: add missing jsdocs
ccharly Jul 27, 2026
5d7b2f7
chore: lint
ccharly Jul 27, 2026
b20dc40
test: add tests
ccharly Jul 27, 2026
7b75679
refactor: refactor ranges
ccharly Jul 27, 2026
2fb7975
fix: add explicit value.type for private keys
ccharly Jul 27, 2026
32bc80e
chore: cosmetic
ccharly Jul 27, 2026
802dc77
chore: use preview builds
ccharly Jul 27, 2026
4024258
chore: lint
ccharly Jul 27, 2026
5a09f5d
fix: fix typing error
ccharly Jul 27, 2026
421b167
chore: changelog
ccharly Jul 27, 2026
e5cab2e
fix: remove unused :importAccountWithStrategy
ccharly Jul 28, 2026
3084246
fix: fix mnemonic encoding
ccharly Jul 30, 2026
b46bba0
Merge branch 'main' into cc/feat/account-tree-export-import
ccharly Jul 31, 2026
30ec067
feat: add payload schema validation
ccharly Jul 31, 2026
a30fdb9
refactor: rework filtering
ccharly Jul 31, 2026
9c5ae42
refactor: use snapshot in importState
ccharly Jul 31, 2026
9e4f360
fix: merge all private-key wallets on export
ccharly Jul 31, 2026
e1248df
fix: fix missing exports
ccharly Jul 31, 2026
b9b5818
refactor: remove extra freeze
ccharly Jul 31, 2026
5f5ff91
refactor: optional -> exactOptional
ccharly Jul 31, 2026
705045f
refactor: simplify and re-use 1 single IdMap
ccharly Jul 31, 2026
eadc490
refactor: inline cloneAndFreezeEntries
ccharly Jul 31, 2026
b8cacf3
refactor: remove createAccountTreeSnapshot
ccharly Jul 31, 2026
c7d34f5
refactor: *Schema -> *Struct
ccharly Aug 3, 2026
6a43ecb
chore: lint
ccharly Aug 3, 2026
daf62c9
chore: bump accounts deps
ccharly Aug 3, 2026
cf0e708
chore: changelogs
ccharly Aug 3, 2026
de126e5
fix: remove eth_signTransaction from MoneyAccount(s)
ccharly Aug 3, 2026
c47cd03
Merge branch 'cc/chore/bump-accounts-deps' into cc/feat/account-tree-…
ccharly Aug 3, 2026
7597e66
fix: fix remaining renames
ccharly Aug 3, 2026
3acb5c5
refactor: re-use migration framework from keyring-sdk
ccharly Aug 3, 2026
f5c9cc6
refactor: remove unreachable try/catch
ccharly Aug 3, 2026
8987780
Merge branch 'main' into cc/chore/bump-accounts-deps
ccharly Aug 4, 2026
f028530
chore: use keyring-sdk 3.1.0
ccharly Aug 4, 2026
8af6b3a
chore: changelogs
ccharly Aug 4, 2026
376ce82
Merge branch 'cc/chore/bump-accounts-deps' into cc/feat/account-tree-…
ccharly Aug 4, 2026
f0a86df
chore: remove use of preview builds + bump eth-hd-keyring
ccharly Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/account-tree-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `{import,export}State` actions ([#9663](https://github.com/MetaMask/core/pull/9663))
- Those methods/actions can be used to export a proper snapshot of the account-tree (including secrets or not).
- The payload is versionned and will auto-migrate its payload if needed on the receiving end.
- Currently, wallet and group IDs are not the same as the local ones, mostly because local IDs are not stable and cannot be used in a cross-client context.
- Add `getAccountFromSelectedAccountGroup` and `setSelectedAccountGroupByAccountId` methods ([#9739](https://github.com/MetaMask/core/pull/9739))

### Changed

- Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676))
- Bump `@metamask/keyring-api` from `^23.5.0` to `^24.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754))
- Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.6` ([#9735](https://github.com/MetaMask/core/pull/9735))

## [7.5.5]
Expand Down
8 changes: 5 additions & 3 deletions packages/account-tree-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,24 @@
"dependencies": {
"@metamask/accounts-controller": "^39.0.6",
"@metamask/base-controller": "^9.1.0",
"@metamask/keyring-api": "^23.7.0",
"@metamask/keyring-api": "^24.0.0",
"@metamask/keyring-controller": "^27.1.0",
"@metamask/keyring-sdk": "^3.0.0",
"@metamask/messenger": "^2.0.0",
"@metamask/multichain-account-service": "^13.0.0",
"@metamask/profile-sync-controller": "^28.3.0",
"@metamask/snaps-controllers": "^19.0.0",
"@metamask/snaps-sdk": "^11.0.0",
"@metamask/snaps-utils": "^12.1.2",
"@metamask/superstruct": "^3.1.0",
"@metamask/superstruct": "^3.4.1",
"@metamask/utils": "^11.11.0",
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"devDependencies": {
"@metamask/account-api": "^1.1.1",
"@metamask/account-api": "^2.0.0",
"@metamask/auto-changelog": "^6.1.0",
"@metamask/eth-hd-keyring": "^15.0.0",
"@metamask/providers": "^22.1.0",
"@ts-bridge/cli": "^0.6.4",
"@types/jest": "^30.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,37 @@ export type AccountTreeControllerSyncWithUserStorageAtLeastOnceAction = {
handler: AccountTreeController['syncWithUserStorageAtLeastOnce'];
};

/**
* Produces a versioned snapshot of the current wallet and group state.
*
* When `options.includeSecrets` is `true` and the vault is unlocked,
* mnemonic phrases and private keys are included in the snapshot.
*
* @param options - Export options.
* @returns A promise resolving to an `AccountTreeSnapshot`.
*/
export type AccountTreeControllerExportStateAction = {
type: `AccountTreeController:exportState`;
handler: AccountTreeController['exportState'];
};

/**
* Applies a validated snapshot to the current state.
*
* Accepts an `AccountTreeSnapshot` only — untrusted wire data must be parsed
* with `AccountTreeSnapshot.deserialize` first. New mnemonic wallets are
* imported via `MultichainAccountService` and new private-key accounts via
* `KeyringController`. Metadata (name, pinned, hidden) is applied to all
* existing and newly created wallets / groups.
*
* @param snapshot - The validated snapshot to import.
* @returns A promise that resolves when the import is complete.
*/
export type AccountTreeControllerImportStateAction = {
type: `AccountTreeController:importState`;
handler: AccountTreeController['importState'];
};

/**
* Union of all AccountTreeController action types.
*/
Expand All @@ -254,4 +285,6 @@ export type AccountTreeControllerMethodActions =
| AccountTreeControllerSetAccountGroupHiddenAction
| AccountTreeControllerClearStateAction
| AccountTreeControllerSyncWithUserStorageAction
| AccountTreeControllerSyncWithUserStorageAtLeastOnceAction;
| AccountTreeControllerSyncWithUserStorageAtLeastOnceAction
| AccountTreeControllerExportStateAction
| AccountTreeControllerImportStateAction;
157 changes: 157 additions & 0 deletions packages/account-tree-controller/src/AccountTreeController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6389,4 +6389,161 @@ describe('AccountTreeController', () => {
});
});
});

describe('exportState / importState round-trip', () => {
it('preserves wallet and group metadata across a metadata-only export/import cycle', async () => {
const { controller, messenger } = setup({
accounts: [MOCK_HD_ACCOUNT_1],
keyrings: [MOCK_HD_KEYRING_1],
});

controller.init();

const walletId = toMultichainAccountWalletId(
MOCK_HD_KEYRING_1.metadata.id,
);
const groupId = toMultichainAccountGroupId(
walletId,
MOCK_HD_ACCOUNT_1.options.entropy.groupIndex,
);

// Set custom metadata before export.
controller.setAccountWalletName(walletId, 'My Custom Wallet');
controller.setAccountGroupName(groupId, 'My Custom Account');
controller.setAccountGroupPinned(groupId, true);
controller.setAccountGroupHidden(groupId, false);

// Register handlers that export needs but the default setup() doesn't provide.
// withKeyringV2Unsafe: returns the entropy source ID derived from the keyring.
messenger.registerActionHandler(
'KeyringController:withKeyringV2Unsafe',
async (
_selector: unknown,
callback: (ctx: { keyring: unknown }) => unknown,
) =>
callback({
keyring: {
toEntropySourceId: async () => MOCK_HD_KEYRING_1.metadata.id,
mnemonic: null,
},
}),
);

// --- EXPORT ---
const snapshot = await controller.exportState();
const payload = snapshot.serialize();

expect(payload.data.wallets).toHaveLength(1);
const exportedWallet = payload.data.wallets[0];
expect(exportedWallet.type).toBe('mnemonic');
expect(exportedWallet.metadata.name).toBe('My Custom Wallet');
expect(exportedWallet.groups[0]?.metadata.name).toBe('My Custom Account');
expect(exportedWallet.groups[0]?.metadata.pinned).toBe(true);
expect(exportedWallet.groups[0]?.metadata.hidden).toBe(false);

// The snapshot's idMap bridges local IDs ↔ payload IDs.
expect(snapshot.toPayloadId(walletId)).toBe(
`wallet:${MOCK_HD_KEYRING_1.metadata.id}`,
);
expect(
snapshot.toLocalId(`wallet:${MOCK_HD_KEYRING_1.metadata.id}`),
).toBe(walletId);

// Mutate metadata so the import can restore it.
controller.setAccountWalletName(walletId, 'Overwritten Wallet Name');
controller.setAccountGroupName(groupId, 'Overwritten Account Name');
controller.setAccountGroupPinned(groupId, false);
controller.setAccountGroupHidden(groupId, true);

expect(
controller.state.accountTree.wallets[walletId]?.metadata.name,
).toBe('Overwritten Wallet Name');

// --- IMPORT ---
// withKeyringV2Unsafe is called again during import to find the matching wallet.
// It's already registered; the existing handler stays in place.
await controller.importState(snapshot);

// After import, original metadata should be restored.
expect(
controller.state.accountTree.wallets[walletId]?.metadata.name,
).toBe('My Custom Wallet');
expect(
controller.state.accountTree.wallets[walletId]?.groups[groupId]
?.metadata.name,
).toBe('My Custom Account');
expect(
controller.state.accountTree.wallets[walletId]?.groups[groupId]
?.metadata.pinned,
).toBe(true);
expect(
controller.state.accountTree.wallets[walletId]?.groups[groupId]
?.metadata.hidden,
).toBe(false);
});

it('round-trips a snapshot with includeSecrets: false and vault locked', async () => {
const { controller, messenger, mocks } = setup({
accounts: [MOCK_HD_ACCOUNT_1],
keyrings: [MOCK_HD_KEYRING_1],
});

controller.init();

// Override KeyringController:getState to report a locked vault.
mocks.KeyringController.getState.mockReturnValue({
isUnlocked: false,
keyrings: mocks.KeyringController.keyrings,
});

messenger.registerActionHandler(
'KeyringController:withKeyringV2Unsafe',
async (
_selector: unknown,
callback: (ctx: { keyring: unknown }) => unknown,
) =>
callback({
keyring: {
toEntropySourceId: async () => MOCK_HD_KEYRING_1.metadata.id,
mnemonic: null,
},
}),
);

// Export without secrets is allowed even when the vault is locked.
const snapshot = await controller.exportState({ includeSecrets: false });
const payload = snapshot.serialize();

// Exported mnemonic wallet has no secret value.
expect((payload.data.wallets[0] as { value?: string }).value).toBeUndefined();

// Reimport is a no-op for metadata when nothing changed.
expect(await controller.importState(snapshot)).toBeUndefined();
});

it('throws when exporting with includeSecrets: true and the vault is locked', async () => {
const { controller, messenger, mocks } = setup({
accounts: [MOCK_HD_ACCOUNT_1],
keyrings: [MOCK_HD_KEYRING_1],
});

controller.init();

mocks.KeyringController.getState.mockReturnValue({
isUnlocked: false,
keyrings: mocks.KeyringController.keyrings,
});

messenger.registerActionHandler(
'KeyringController:withKeyringV2Unsafe',
async () => undefined,
);

await expect(
controller.exportState({ includeSecrets: true }),
).rejects.toThrow(
'Cannot include secrets in export when vault is locked',
);
});
});
});
54 changes: 54 additions & 0 deletions packages/account-tree-controller/src/AccountTreeController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import type { Rule } from './rule.js';
import { EntropyRule } from './rules/entropy.js';
import { KeyringRule } from './rules/keyring.js';
import { SnapRule } from './rules/snap.js';
import { exportState } from './state/export.js';
import { importState } from './state/import.js';
import type { ExportStateOptions } from './state/payload.js';
import type { AccountTreeSnapshot } from './state/snapshot.js';
import type {
AccountTreeControllerConfig,
AccountTreeControllerInternalBackupAndSyncConfig,
Expand Down Expand Up @@ -65,6 +69,8 @@ const MESSENGER_EXPOSED_METHODS = [
'syncWithUserStorageAtLeastOnce',
'init',
'reinit',
'exportState',
'importState',
] as const;

const accountTreeControllerMetadata: StateMetadata<AccountTreeControllerState> =
Expand Down Expand Up @@ -1855,6 +1861,54 @@ export class AccountTreeController extends BaseController<
return this.#backupAndSyncService.performFullSyncAtLeastOnce();
}

/**
* Produces a versioned snapshot of the current wallet and group state.
*
* When `options.includeSecrets` is `true` and the vault is unlocked,
* mnemonic phrases and private keys are included in the snapshot.
*
* @param options - Export options.
* @returns A promise resolving to an `AccountTreeSnapshot`.
*/
async exportState(
options?: ExportStateOptions,
): Promise<AccountTreeSnapshot> {
return exportState(
{ getState: () => this.state, messenger: this.messenger },
options,
);
}

/**
* Applies a validated snapshot to the current state.
*
* Accepts an {@link AccountTreeSnapshot} only — untrusted wire data must be
* parsed with {@link AccountTreeSnapshot.deserialize} first. Callers may
* filter the snapshot with {@link AccountTreeSnapshot.filterWallets},
* {@link AccountTreeSnapshot.filterGroups}, or
* {@link AccountTreeSnapshot.filterAllGroups} before importing.
*
* New mnemonic wallets are imported via `MultichainAccountService` and new
* private-key accounts via `KeyringController`. Metadata (name, pinned,
* hidden) is applied to all existing and newly created wallets / groups.
*
* @param snapshot - The validated snapshot to import.
* @returns A promise that resolves when the import is complete.
*/
async importState(snapshot: AccountTreeSnapshot): Promise<void> {
return importState(
{
getState: () => this.state,
messenger: this.messenger,
setWalletName: (id, name) => this.setAccountWalletName(id, name),
setGroupName: (id, name) => this.setAccountGroupName(id, name, true),
setGroupPinned: (id, pinned) => this.setAccountGroupPinned(id, pinned),
setGroupHidden: (id, hidden) => this.setAccountGroupHidden(id, hidden),
},
snapshot,
);
}

/**
* Creates an backup and sync context for sync operations.
* Used by the backup and sync service.
Expand Down
28 changes: 28 additions & 0 deletions packages/account-tree-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export type {
AccountTreeControllerSyncWithUserStorageAtLeastOnceAction,
AccountTreeControllerInitAction,
AccountTreeControllerReinitAction,
AccountTreeControllerExportStateAction,
AccountTreeControllerImportStateAction,
} from './AccountTreeController-method-action-types.js';

export type { AccountContext } from './AccountTreeController.js';
Expand All @@ -48,3 +50,29 @@ export {
AccountTreeController,
getDefaultAccountTreeControllerState,
} from './AccountTreeController.js';

export type {
AccountTreePayload,
AccountTreePayloadStructType,
AccountWalletMnemonicPayload,
AccountWalletPrivateKeyPayload,
AccountWalletMnemonicGroupEntry,
AccountWalletPrivateKeyGroupEntry,
AccountWalletPayloadId,
AccountGroupPayloadId,
AccountTreeSnapshotWallet,
AccountTreeSnapshotGroup,
ExportStateOptions,
} from './state/payload.js';

export {
AccountTreePayloadStruct,
assertValidAccountTreePayload,
migrate,
migrations,
} from './state/payload.js';

export type { VersionedState } from '@metamask/keyring-sdk';

export { AccountTreeSnapshot } from './state/snapshot.js';
export { IdMap } from './state/id-map.js';
Loading
Loading