From 3e5569f08351895449824eed9b88e3091246fb51 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 4 Sep 2026 13:52:57 +0200 Subject: [PATCH 1/8] feat(account-tree-controller): add strip* helpers on state snapshot + make metadata optional --- .../src/AccountTreeController.test.ts | 20 +++ .../src/AccountTreeController.ts | 17 ++ .../src/state/import.ts | 12 +- .../src/state/payload.ts | 16 +- .../src/state/snapshot.test.ts | 145 ++++++++++++++++++ .../src/state/snapshot.ts | 105 +++++++++++++ 6 files changed, 303 insertions(+), 12 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index f8b8fbf4ec1..4cd47cb77a0 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4819,6 +4819,26 @@ describe('AccountTreeController', () => { await messenger.call('AccountTreeController:importState', mockSnapshot); expect(spy).toHaveBeenCalledWith(mockSnapshot); }); + + it('throws when a snapshot with no primary wallet is imported after onboarding is complete', async () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + const noPrimaryWalletSnapshot = { + getPrimaryWallet: () => undefined, + serialize: () => ({ version: 1, wallets: [] }), + } as Parameters[0]; + + await expect( + controller.importState(noPrimaryWalletSnapshot), + ).rejects.toThrow( + 'AccountTreeSnapshot has no primary wallet', + ); + }); }); describe('Event Emissions', () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 2734a3eb37a..9826a5194b9 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1927,6 +1927,12 @@ export class AccountTreeController extends BaseController< * @returns A promise that resolves when the import is complete. */ async importState(snapshot: AccountTreeSnapshot): Promise { + if (this.#hasCompletedOnboarding() && snapshot.getPrimaryWallet() === undefined) { + throw new Error( + 'AccountTreeSnapshot has no primary wallet', + ); + } + return importState( { getState: () => this.state, @@ -1943,6 +1949,17 @@ export class AccountTreeController extends BaseController< ); } + /** + * Checks if the onboarding process has been completed (i.e., if there are any mnemonic wallets). + * + * @returns `true` if the onboarding process has been completed, `false` otherwise. + */ + #hasCompletedOnboarding(): boolean { + return Object.values(this.state.accountTree.wallets).some( + (wallet) => wallet.type === AccountWalletType.Entropy, + ); + } + /** * Creates an backup and sync context for sync operations. * Used by the backup and sync service. diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts index c1562ffa724..da1b00a2a69 100644 --- a/packages/account-tree-controller/src/state/import.ts +++ b/packages/account-tree-controller/src/state/import.ts @@ -168,9 +168,11 @@ function setGroupMetadata( localGroupId: AccountGroupId, payloadGroupMetadata: AccountWalletMnemonicGroupEntry['metadata'], ): void { - context.setAccountGroupName(localGroupId, payloadGroupMetadata.name); - context.setAccountGroupPinned(localGroupId, payloadGroupMetadata.pinned); - context.setAccountGroupHidden(localGroupId, payloadGroupMetadata.hidden); + if (payloadGroupMetadata) { + context.setAccountGroupName(localGroupId, payloadGroupMetadata.name); + context.setAccountGroupPinned(localGroupId, payloadGroupMetadata.pinned); + context.setAccountGroupHidden(localGroupId, payloadGroupMetadata.hidden); + } } /** @@ -254,7 +256,9 @@ async function importMnemonicWallet( localWallet = findLocalWalletMnemonicFromId(context, id); } - context.setWalletName(localWallet.id, payloadWallet.metadata.name); + if (payloadWallet.metadata) { + context.setWalletName(localWallet.id, payloadWallet.metadata.name); + } // Apply metadata to groups that are already present locally before attempting // to create missing ones. If createMultichainAccountGroups throws partway diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 3b1311edeab..da3e2dda14d 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -89,7 +89,7 @@ export type AccountWalletMnemonicGroupEntry = { id: AccountGroupPayloadId; /** BIP-44 account index this group was derived at. */ groupIndex: number; - metadata: AccountWalletGroupPayloadMetadata; + metadata?: AccountWalletGroupPayloadMetadata; }; /** @@ -123,7 +123,7 @@ export type AccountWalletPrivateKeyGroupEntry = { */ type?: KeyringAccount['type']; }; - metadata: AccountWalletGroupPayloadMetadata; + metadata?: AccountWalletGroupPayloadMetadata; }; /** Payload entry for an HD (entropy) wallet and its derived account groups. */ @@ -132,7 +132,7 @@ export type AccountWalletMnemonicPayload = { type: typeof AccountWalletPayloadType.Mnemonic; /** BIP-39 mnemonic phrase encoded as bytes. Absent in metadata-only exports. */ value?: EncodedBytes; - metadata: AccountWalletPayloadMetadata; + metadata?: AccountWalletPayloadMetadata; groups: AccountWalletMnemonicGroupEntry[]; }; @@ -145,7 +145,7 @@ export type AccountWalletMnemonicPayload = { export type AccountWalletPrivateKeyPayload = { id: AccountWalletPayloadId; type: typeof AccountWalletPayloadType.PrivateKey; - metadata: AccountWalletPayloadMetadata; + metadata?: AccountWalletPayloadMetadata; groups: AccountWalletPrivateKeyGroupEntry[]; }; @@ -257,13 +257,13 @@ const AccountWalletPrivateKeyValueStruct = object({ const AccountWalletMnemonicGroupEntryStruct = object({ id: AccountGroupPayloadIdStruct, groupIndex: integer(), - metadata: AccountWalletGroupPayloadMetadataStruct, + metadata: exactOptional(AccountWalletGroupPayloadMetadataStruct), }); const AccountWalletPrivateKeyGroupEntryStruct = object({ id: AccountGroupPayloadIdStruct, value: exactOptional(AccountWalletPrivateKeyValueStruct), - metadata: AccountWalletGroupPayloadMetadataStruct, + metadata: exactOptional(AccountWalletGroupPayloadMetadataStruct), }); // The `groups` array in a mnemonic wallet payload must have contiguous group indices starting at 0. @@ -289,14 +289,14 @@ const AccountWalletMnemonicPayloadStruct = object({ id: AccountWalletPayloadIdStruct, type: literal(AccountWalletPayloadType.Mnemonic), value: exactOptional(sensitive(BytesStruct)), - metadata: AccountWalletPayloadMetadataStruct, + metadata: exactOptional(AccountWalletPayloadMetadataStruct), groups: AccountWalletMnemonicGroupsStruct, }); const AccountWalletPrivateKeyPayloadStruct = object({ id: AccountWalletPayloadIdStruct, type: literal(AccountWalletPayloadType.PrivateKey), - metadata: AccountWalletPayloadMetadataStruct, + metadata: exactOptional(AccountWalletPayloadMetadataStruct), groups: array(AccountWalletPrivateKeyGroupEntryStruct), }); diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index 00018b068e4..a85d79406dd 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -6,6 +6,7 @@ import type { import { ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, AccountWalletPayloadType, + AccountWalletPrivateKeyEncoding, toGroupPayloadId, toWalletPayloadId, } from './payload.js'; @@ -16,9 +17,12 @@ const MOCK_PRIVATE_KEY_PAYLOAD_ID = toWalletPayloadId( AccountWalletPayloadType.PrivateKey, ); +const MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID = toWalletPayloadId('entropy-source-2'); + const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { id: MOCK_MNEMONIC_PAYLOAD_ID, type: AccountWalletPayloadType.Mnemonic, + value: [1, 2, 3, 4], metadata: { name: 'Wallet 1' }, groups: [ { @@ -34,6 +38,20 @@ const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { ], }; +const MOCK_SECONDARY_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { + id: MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID, + type: AccountWalletPayloadType.Mnemonic, + value: [5, 6, 7, 8], + metadata: { name: 'Wallet 2' }, + groups: [ + { + id: toGroupPayloadId(MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID, 0), + groupIndex: 0, + metadata: { name: 'Account 3', pinned: false, hidden: false }, + }, + ], +}; + const MOCK_PRIVATE_KEY_WALLET: AccountWalletPrivateKeyPayload = { id: MOCK_PRIVATE_KEY_PAYLOAD_ID, type: AccountWalletPayloadType.PrivateKey, @@ -41,6 +59,7 @@ const MOCK_PRIVATE_KEY_WALLET: AccountWalletPrivateKeyPayload = { groups: [ { id: toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xdeadbeef'), + value: { privateKey: [0xde, 0xad, 0xbe, 0xef], encoding: AccountWalletPrivateKeyEncoding.Hexadecimal }, metadata: { name: 'Imported 1', pinned: false, hidden: true }, }, ], @@ -412,4 +431,130 @@ describe('AccountTreeSnapshot', () => { ).rejects.toThrow('Invalid AccountTreePayload'); }); }); + + describe('stripSecrets', () => { + it('removes value from mnemonic wallets', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + const stripped = snapshot.stripSecrets(); + expect(stripped.serialize().wallets[0]).not.toHaveProperty('value'); + }); + + it('removes value from private-key group entries', () => { + const snapshot = new AccountTreeSnapshot([MOCK_PRIVATE_KEY_WALLET]); + const stripped = snapshot.stripSecrets(); + expect(stripped.serialize().wallets[0]?.groups[0]).not.toHaveProperty( + 'value', + ); + }); + + it('preserves wallet and group metadata', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const stripped = snapshot.stripSecrets(); + const { wallets } = stripped.serialize(); + expect(wallets[0]?.metadata.name).toBe('Wallet 1'); + expect(wallets[0]?.groups[0]?.metadata.name).toBe('Account 1'); + expect(wallets[1]?.metadata.name).toBe('Imported Accounts'); + expect(wallets[1]?.groups[0]?.metadata.name).toBe('Imported 1'); + }); + + }); + + describe('stripPrimaryWallet', () => { + it('removes the first mnemonic wallet', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_SECONDARY_MNEMONIC_WALLET, + ]); + const stripped = snapshot.stripPrimaryWallet(); + const { wallets } = stripped.serialize(); + expect(wallets).toHaveLength(1); + expect(wallets[0]?.id).toBe(MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID); + }); + + it('keeps private-key wallets intact', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const stripped = snapshot.stripPrimaryWallet(); + const { wallets } = stripped.serialize(); + expect(wallets).toHaveLength(1); + expect(wallets[0]?.type).toBe(AccountWalletPayloadType.PrivateKey); + }); + + it('returns an empty snapshot when there is only one mnemonic wallet', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + expect(snapshot.stripPrimaryWallet().serialize().wallets).toHaveLength(0); + }); + }); + + describe('stripMetadata', () => { + it('removes wallet metadata', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + const stripped = snapshot.stripMetadata(); + expect(stripped.serialize().wallets[0]).not.toHaveProperty('metadata'); + }); + + it('removes group metadata', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + const stripped = snapshot.stripMetadata(); + expect(stripped.serialize().wallets[0]?.groups[0]).not.toHaveProperty( + 'metadata', + ); + }); + + it('preserves secret values', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const stripped = snapshot.stripMetadata(); + const { wallets } = stripped.serialize(); + expect( + (wallets[0] as typeof MOCK_MNEMONIC_WALLET).value, + ).toStrictEqual(MOCK_MNEMONIC_WALLET.value); + expect( + (wallets[1] as typeof MOCK_PRIVATE_KEY_WALLET).groups[0]?.value, + ).toStrictEqual(MOCK_PRIVATE_KEY_WALLET.groups[0]?.value); + }); + + }); + + describe('getPrimaryWallet', () => { + it('returns the first mnemonic wallet', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_SECONDARY_MNEMONIC_WALLET, + ]); + expect(snapshot.getPrimaryWallet()?.id).toBe(MOCK_MNEMONIC_PAYLOAD_ID); + }); + + it('returns undefined when there are no mnemonic wallets', () => { + const snapshot = new AccountTreeSnapshot([MOCK_PRIVATE_KEY_WALLET]); + expect(snapshot.getPrimaryWallet()).toBeUndefined(); + }); + + it('returns undefined on an empty snapshot', () => { + const snapshot = new AccountTreeSnapshot([]); + expect(snapshot.getPrimaryWallet()).toBeUndefined(); + }); + + it('returns undefined after stripPrimaryWallet removes the primary wallet', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + expect(snapshot.stripPrimaryWallet().getPrimaryWallet()).toBeUndefined(); + }); + + it('returns the secondary mnemonic wallet as primary after strip', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_SECONDARY_MNEMONIC_WALLET, + ]); + expect(snapshot.stripPrimaryWallet().getPrimaryWallet()?.id).toBe( + MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID, + ); + }); + }); }); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index bb9c68663c3..b60bdc0aea0 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -6,8 +6,10 @@ import type { AccountTreeSnapshotWallet, AccountTreeWalletEntry, AccountWalletMnemonicGroupEntry, + AccountWalletMnemonicPayload, AccountWalletPayloadId, AccountWalletPrivateKeyGroupEntry, + AccountWalletPrivateKeyPayload, } from './payload.js'; import { AccountWalletPayloadType, @@ -48,6 +50,25 @@ export class AccountTreeSnapshot { this.#idMap = idMap; } + /** + * Returns the primary (first mnemonic) wallet entry, or `undefined` if none + * is present in the snapshot. + * + * Use this to detect whether the primary SRP is included before passing the + * snapshot to {@link AccountTreeController.importState}. When `undefined`, + * the controller will reject the snapshot if mnemonic wallets already exist. + */ + getPrimaryWallet(): + | (AccountTreeSnapshotWallet & { type: AccountWalletPayloadType.Mnemonic }) + | undefined { + const entry = this.#entries.find( + (wallet) => wallet.type === AccountWalletPayloadType.Mnemonic, + ); + return entry as + | (AccountTreeSnapshotWallet & { type: AccountWalletPayloadType.Mnemonic }) + | undefined; + } + /** * Returns a new snapshot containing only the wallets for which * `predicate` returns `true`. @@ -171,6 +192,90 @@ export class AccountTreeSnapshot { return new AccountTreeSnapshot(filteredEntries, this.#idMap); } + /** + * Returns a new snapshot with all secret material removed — mnemonic + * {@link AccountWalletMnemonicPayload.value | values} and private-key group + * {@link AccountWalletPrivateKeyGroupEntry.value | values} are omitted. + * Wallet and group metadata (names, pin, hidden) are preserved. + * + * Use this to produce a **metadata-only** view for Phase C of the QR sync + * provisioning flow, where secrets are already in the vault and only layout + * information needs to be applied. + * + * @returns A secrets-stripped snapshot. + */ + stripSecrets(): AccountTreeSnapshot { + const entries = this.#entries.map((wallet): AccountTreeWalletEntry => { + if (wallet.type === AccountWalletPayloadType.Mnemonic) { + const { value: _value, ...rest } = wallet; + return rest as AccountWalletMnemonicPayload; + } + return { + ...wallet, + groups: wallet.groups.map( + ({ value: _value, ...group }): AccountWalletPrivateKeyGroupEntry => + group as AccountWalletPrivateKeyGroupEntry, + ), + } as AccountWalletPrivateKeyPayload; + }); + return new AccountTreeSnapshot(entries, this.#idMap); + } + + /** + * Returns a new snapshot with the primary (first mnemonic) wallet removed. + * + * The primary wallet is identified positionally — the first + * {@link AccountWalletPayloadType.Mnemonic} entry in the wallet list. All + * remaining wallets (secondary mnemonics, private-key wallets) are preserved. + * + * Removing the primary wallet makes the snapshot safe to pass to + * {@link AccountTreeController.importState} during initial onboarding, where + * the primary SRP has already been imported manually and only secondary + * secrets need to be added. The controller detects that no primary wallet is + * present via {@link getPrimaryWallet} and rejects the import post-onboarding. + * + * @returns A new snapshot without the primary wallet. + */ + stripPrimaryWallet(): AccountTreeSnapshot { + let primaryRemoved = false; + const entries = this.#entries.filter((wallet) => { + if ( + wallet.type === AccountWalletPayloadType.Mnemonic && + !primaryRemoved + ) { + primaryRemoved = true; + return false; + } + return true; + }); + return new AccountTreeSnapshot(entries, this.#idMap); + } + + /** + * Returns a new snapshot with all metadata reset to defaults — wallet names + * are cleared and group metadata (`name`, `pinned`, `hidden`) is reset. + * Secret values are preserved. + * + * Use this alongside {@link stripPrimaryWallet} for Phase B of the QR sync + * provisioning flow, where only secondary secrets need to be imported and + * metadata will be applied later in Phase C. + * + * @returns A metadata-stripped snapshot. + */ + stripMetadata(): AccountTreeSnapshot { + const entries = this.#entries.map((wallet): AccountTreeWalletEntry => { + const { metadata: _walletMetadata, ...walletRest } = wallet; + return { + ...walletRest, + groups: wallet.groups.map((group) => { + const { metadata: _groupMetadata, ...groupRest } = group; + return groupRest as typeof group; + }), + } as AccountTreeWalletEntry; + }); + return new AccountTreeSnapshot(entries, this.#idMap); + } + /** * Converts a payload ID (wallet or group) to the corresponding local * `AccountTreeController` ID. From 03ee99dd14380ed8a6485fdbf0ae8c8415d2b1f4 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 4 Sep 2026 15:28:22 +0200 Subject: [PATCH 2/8] chore: lint --- .../src/AccountTreeController.test.ts | 4 +--- .../src/AccountTreeController.ts | 9 +++++---- .../src/state/snapshot.test.ts | 16 +++++++++------- .../src/state/snapshot.ts | 4 +++- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 4cd47cb77a0..2dd0743cb82 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4835,9 +4835,7 @@ describe('AccountTreeController', () => { await expect( controller.importState(noPrimaryWalletSnapshot), - ).rejects.toThrow( - 'AccountTreeSnapshot has no primary wallet', - ); + ).rejects.toThrow('AccountTreeSnapshot has no primary wallet'); }); }); diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 9826a5194b9..1c118c49ba9 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1927,10 +1927,11 @@ export class AccountTreeController extends BaseController< * @returns A promise that resolves when the import is complete. */ async importState(snapshot: AccountTreeSnapshot): Promise { - if (this.#hasCompletedOnboarding() && snapshot.getPrimaryWallet() === undefined) { - throw new Error( - 'AccountTreeSnapshot has no primary wallet', - ); + if ( + this.#hasCompletedOnboarding() && + snapshot.getPrimaryWallet() === undefined + ) { + throw new Error('AccountTreeSnapshot has no primary wallet'); } return importState( diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index a85d79406dd..d485e7096d0 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -17,7 +17,8 @@ const MOCK_PRIVATE_KEY_PAYLOAD_ID = toWalletPayloadId( AccountWalletPayloadType.PrivateKey, ); -const MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID = toWalletPayloadId('entropy-source-2'); +const MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID = + toWalletPayloadId('entropy-source-2'); const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { id: MOCK_MNEMONIC_PAYLOAD_ID, @@ -59,7 +60,10 @@ const MOCK_PRIVATE_KEY_WALLET: AccountWalletPrivateKeyPayload = { groups: [ { id: toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xdeadbeef'), - value: { privateKey: [0xde, 0xad, 0xbe, 0xef], encoding: AccountWalletPrivateKeyEncoding.Hexadecimal }, + value: { + privateKey: [0xde, 0xad, 0xbe, 0xef], + encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, + }, metadata: { name: 'Imported 1', pinned: false, hidden: true }, }, ], @@ -459,7 +463,6 @@ describe('AccountTreeSnapshot', () => { expect(wallets[1]?.metadata.name).toBe('Imported Accounts'); expect(wallets[1]?.groups[0]?.metadata.name).toBe('Imported 1'); }); - }); describe('stripPrimaryWallet', () => { @@ -513,14 +516,13 @@ describe('AccountTreeSnapshot', () => { ]); const stripped = snapshot.stripMetadata(); const { wallets } = stripped.serialize(); - expect( - (wallets[0] as typeof MOCK_MNEMONIC_WALLET).value, - ).toStrictEqual(MOCK_MNEMONIC_WALLET.value); + expect((wallets[0] as typeof MOCK_MNEMONIC_WALLET).value).toStrictEqual( + MOCK_MNEMONIC_WALLET.value, + ); expect( (wallets[1] as typeof MOCK_PRIVATE_KEY_WALLET).groups[0]?.value, ).toStrictEqual(MOCK_PRIVATE_KEY_WALLET.groups[0]?.value); }); - }); describe('getPrimaryWallet', () => { diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index b60bdc0aea0..93fea9117da 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -65,7 +65,9 @@ export class AccountTreeSnapshot { (wallet) => wallet.type === AccountWalletPayloadType.Mnemonic, ); return entry as - | (AccountTreeSnapshotWallet & { type: AccountWalletPayloadType.Mnemonic }) + | (AccountTreeSnapshotWallet & { + type: AccountWalletPayloadType.Mnemonic; + }) | undefined; } From 7dd72ded9aef41d2829dbe327534bb0eaef46d8c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 4 Sep 2026 15:53:06 +0200 Subject: [PATCH 3/8] fix: properly propagate stripped primary wallet --- .../src/AccountTreeController.test.ts | 2 +- .../src/AccountTreeController.ts | 9 ++-- .../src/state/snapshot.test.ts | 32 ++++++++------ .../src/state/snapshot.ts | 44 ++++++++++--------- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 2dd0743cb82..9e8ca183bf3 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4829,7 +4829,7 @@ describe('AccountTreeController', () => { controller.init(); const noPrimaryWalletSnapshot = { - getPrimaryWallet: () => undefined, + hasPrimaryWallet: () => false, serialize: () => ({ version: 1, wallets: [] }), } as Parameters[0]; diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 1c118c49ba9..717eb1cff32 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1927,11 +1927,10 @@ export class AccountTreeController extends BaseController< * @returns A promise that resolves when the import is complete. */ async importState(snapshot: AccountTreeSnapshot): Promise { - if ( - this.#hasCompletedOnboarding() && - snapshot.getPrimaryWallet() === undefined - ) { - throw new Error('AccountTreeSnapshot has no primary wallet'); + if (!snapshot.hasPrimaryWallet() && this.#hasCompletedOnboarding()) { + throw new Error( + 'AccountTreeSnapshot has no primary wallet (derived with stripPrimaryWallet()) and can only be applied before onboarding is complete (no existing mnemonic wallets)', + ); } return importState( diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index d485e7096d0..ac2fd6b0d90 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -525,38 +525,44 @@ describe('AccountTreeSnapshot', () => { }); }); - describe('getPrimaryWallet', () => { - it('returns the first mnemonic wallet', () => { + describe('hasPrimaryWallet', () => { + it('is true when a mnemonic wallet is present', () => { const snapshot = new AccountTreeSnapshot([ MOCK_MNEMONIC_WALLET, MOCK_SECONDARY_MNEMONIC_WALLET, ]); - expect(snapshot.getPrimaryWallet()?.id).toBe(MOCK_MNEMONIC_PAYLOAD_ID); + expect(snapshot.hasPrimaryWallet()).toBe(true); }); - it('returns undefined when there are no mnemonic wallets', () => { + it('is false when there are no mnemonic wallets', () => { const snapshot = new AccountTreeSnapshot([MOCK_PRIVATE_KEY_WALLET]); - expect(snapshot.getPrimaryWallet()).toBeUndefined(); + expect(snapshot.hasPrimaryWallet()).toBe(false); }); - it('returns undefined on an empty snapshot', () => { + it('is false on an empty snapshot', () => { const snapshot = new AccountTreeSnapshot([]); - expect(snapshot.getPrimaryWallet()).toBeUndefined(); + expect(snapshot.hasPrimaryWallet()).toBe(false); }); - it('returns undefined after stripPrimaryWallet removes the primary wallet', () => { + it('is false after stripPrimaryWallet', () => { const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); - expect(snapshot.stripPrimaryWallet().getPrimaryWallet()).toBeUndefined(); + expect(snapshot.stripPrimaryWallet().hasPrimaryWallet()).toBe(false); }); - it('returns the secondary mnemonic wallet as primary after strip', () => { + it('is false after stripPrimaryWallet even when secondary mnemonics remain', () => { const snapshot = new AccountTreeSnapshot([ MOCK_MNEMONIC_WALLET, MOCK_SECONDARY_MNEMONIC_WALLET, ]); - expect(snapshot.stripPrimaryWallet().getPrimaryWallet()?.id).toBe( - MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID, - ); + expect(snapshot.stripPrimaryWallet().hasPrimaryWallet()).toBe(false); + }); + + it('is false after stripPrimaryWallet chained with other strip methods', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_SECONDARY_MNEMONIC_WALLET, + ]); + expect(snapshot.stripPrimaryWallet().stripMetadata().hasPrimaryWallet()).toBe(false); }); }); }); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index 93fea9117da..f7368f4f742 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -41,34 +41,36 @@ export class AccountTreeSnapshot { readonly #idMap: IdMap | undefined; + // Set to true by stripPrimaryWallet(). getPrimaryWallet() returns undefined + // regardless of remaining entries so the guard in importState fires correctly + // even when secondary mnemonic wallets are still present in the snapshot. + readonly #primaryWalletStripped: boolean; + /** * @param entries - Wallet entries in the snapshot. * @param idMap - Optional local ↔ payload ID map from export. + * @param primaryWalletStripped - Internal flag; set by {@link stripPrimaryWallet}. */ - constructor(entries: AccountTreeWalletEntry[], idMap?: IdMap) { + constructor(entries: AccountTreeWalletEntry[], idMap?: IdMap, primaryWalletStripped = false) { this.#entries = deepFreeze(structuredClone(entries)); this.#idMap = idMap; + this.#primaryWalletStripped = primaryWalletStripped; } /** - * Returns the primary (first mnemonic) wallet entry, or `undefined` if none - * is present in the snapshot. + * Whether the primary (first mnemonic) wallet is present in this snapshot. * - * Use this to detect whether the primary SRP is included before passing the - * snapshot to {@link AccountTreeController.importState}. When `undefined`, - * the controller will reject the snapshot if mnemonic wallets already exist. + * `false` when the snapshot has no mnemonic wallets, or when it was derived + * via {@link stripPrimaryWallet}. {@link AccountTreeController.importState} + * rejects a snapshot where this is `false` if mnemonic wallets already exist. */ - getPrimaryWallet(): - | (AccountTreeSnapshotWallet & { type: AccountWalletPayloadType.Mnemonic }) - | undefined { - const entry = this.#entries.find( + hasPrimaryWallet(): boolean { + if (this.#primaryWalletStripped) { + return false; + } + return this.#entries.some( (wallet) => wallet.type === AccountWalletPayloadType.Mnemonic, ); - return entry as - | (AccountTreeSnapshotWallet & { - type: AccountWalletPayloadType.Mnemonic; - }) - | undefined; } /** @@ -88,7 +90,7 @@ export class AccountTreeSnapshot { predicate(entry as AccountTreeSnapshotWallet), ); - return new AccountTreeSnapshot(filteredEntries, this.#idMap); + return new AccountTreeSnapshot(filteredEntries, this.#idMap, this.#primaryWalletStripped); } /** @@ -143,7 +145,7 @@ export class AccountTreeSnapshot { }; } - return new AccountTreeSnapshot(filteredEntries, this.#idMap); + return new AccountTreeSnapshot(filteredEntries, this.#idMap, this.#primaryWalletStripped); } /** @@ -191,7 +193,7 @@ export class AccountTreeSnapshot { } } - return new AccountTreeSnapshot(filteredEntries, this.#idMap); + return new AccountTreeSnapshot(filteredEntries, this.#idMap, this.#primaryWalletStripped); } /** @@ -220,7 +222,7 @@ export class AccountTreeSnapshot { ), } as AccountWalletPrivateKeyPayload; }); - return new AccountTreeSnapshot(entries, this.#idMap); + return new AccountTreeSnapshot(entries, this.#idMap, this.#primaryWalletStripped); } /** @@ -250,7 +252,7 @@ export class AccountTreeSnapshot { } return true; }); - return new AccountTreeSnapshot(entries, this.#idMap); + return new AccountTreeSnapshot(entries, this.#idMap, true); } /** @@ -275,7 +277,7 @@ export class AccountTreeSnapshot { }), } as AccountTreeWalletEntry; }); - return new AccountTreeSnapshot(entries, this.#idMap); + return new AccountTreeSnapshot(entries, this.#idMap, this.#primaryWalletStripped); } /** From 5bb6dba48a9a91f04d9ae132b32d016c535731bb Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 7 Sep 2026 11:19:51 +0200 Subject: [PATCH 4/8] refactor: remove hasPrimaryWallet logic entirely --- .../src/AccountTreeController.test.ts | 18 ---- .../src/AccountTreeController.ts | 17 ---- .../src/state/snapshot.test.ts | 70 ---------------- .../src/state/snapshot.ts | 83 ++++--------------- 4 files changed, 14 insertions(+), 174 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 9e8ca183bf3..f8b8fbf4ec1 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4819,24 +4819,6 @@ describe('AccountTreeController', () => { await messenger.call('AccountTreeController:importState', mockSnapshot); expect(spy).toHaveBeenCalledWith(mockSnapshot); }); - - it('throws when a snapshot with no primary wallet is imported after onboarding is complete', async () => { - const { controller } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - const noPrimaryWalletSnapshot = { - hasPrimaryWallet: () => false, - serialize: () => ({ version: 1, wallets: [] }), - } as Parameters[0]; - - await expect( - controller.importState(noPrimaryWalletSnapshot), - ).rejects.toThrow('AccountTreeSnapshot has no primary wallet'); - }); }); describe('Event Emissions', () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 717eb1cff32..2734a3eb37a 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1927,12 +1927,6 @@ export class AccountTreeController extends BaseController< * @returns A promise that resolves when the import is complete. */ async importState(snapshot: AccountTreeSnapshot): Promise { - if (!snapshot.hasPrimaryWallet() && this.#hasCompletedOnboarding()) { - throw new Error( - 'AccountTreeSnapshot has no primary wallet (derived with stripPrimaryWallet()) and can only be applied before onboarding is complete (no existing mnemonic wallets)', - ); - } - return importState( { getState: () => this.state, @@ -1949,17 +1943,6 @@ export class AccountTreeController extends BaseController< ); } - /** - * Checks if the onboarding process has been completed (i.e., if there are any mnemonic wallets). - * - * @returns `true` if the onboarding process has been completed, `false` otherwise. - */ - #hasCompletedOnboarding(): boolean { - return Object.values(this.state.accountTree.wallets).some( - (wallet) => wallet.type === AccountWalletType.Entropy, - ); - } - /** * Creates an backup and sync context for sync operations. * Used by the backup and sync service. diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index ac2fd6b0d90..69ef61f16bc 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -465,35 +465,6 @@ describe('AccountTreeSnapshot', () => { }); }); - describe('stripPrimaryWallet', () => { - it('removes the first mnemonic wallet', () => { - const snapshot = new AccountTreeSnapshot([ - MOCK_MNEMONIC_WALLET, - MOCK_SECONDARY_MNEMONIC_WALLET, - ]); - const stripped = snapshot.stripPrimaryWallet(); - const { wallets } = stripped.serialize(); - expect(wallets).toHaveLength(1); - expect(wallets[0]?.id).toBe(MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID); - }); - - it('keeps private-key wallets intact', () => { - const snapshot = new AccountTreeSnapshot([ - MOCK_MNEMONIC_WALLET, - MOCK_PRIVATE_KEY_WALLET, - ]); - const stripped = snapshot.stripPrimaryWallet(); - const { wallets } = stripped.serialize(); - expect(wallets).toHaveLength(1); - expect(wallets[0]?.type).toBe(AccountWalletPayloadType.PrivateKey); - }); - - it('returns an empty snapshot when there is only one mnemonic wallet', () => { - const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); - expect(snapshot.stripPrimaryWallet().serialize().wallets).toHaveLength(0); - }); - }); - describe('stripMetadata', () => { it('removes wallet metadata', () => { const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); @@ -524,45 +495,4 @@ describe('AccountTreeSnapshot', () => { ).toStrictEqual(MOCK_PRIVATE_KEY_WALLET.groups[0]?.value); }); }); - - describe('hasPrimaryWallet', () => { - it('is true when a mnemonic wallet is present', () => { - const snapshot = new AccountTreeSnapshot([ - MOCK_MNEMONIC_WALLET, - MOCK_SECONDARY_MNEMONIC_WALLET, - ]); - expect(snapshot.hasPrimaryWallet()).toBe(true); - }); - - it('is false when there are no mnemonic wallets', () => { - const snapshot = new AccountTreeSnapshot([MOCK_PRIVATE_KEY_WALLET]); - expect(snapshot.hasPrimaryWallet()).toBe(false); - }); - - it('is false on an empty snapshot', () => { - const snapshot = new AccountTreeSnapshot([]); - expect(snapshot.hasPrimaryWallet()).toBe(false); - }); - - it('is false after stripPrimaryWallet', () => { - const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); - expect(snapshot.stripPrimaryWallet().hasPrimaryWallet()).toBe(false); - }); - - it('is false after stripPrimaryWallet even when secondary mnemonics remain', () => { - const snapshot = new AccountTreeSnapshot([ - MOCK_MNEMONIC_WALLET, - MOCK_SECONDARY_MNEMONIC_WALLET, - ]); - expect(snapshot.stripPrimaryWallet().hasPrimaryWallet()).toBe(false); - }); - - it('is false after stripPrimaryWallet chained with other strip methods', () => { - const snapshot = new AccountTreeSnapshot([ - MOCK_MNEMONIC_WALLET, - MOCK_SECONDARY_MNEMONIC_WALLET, - ]); - expect(snapshot.stripPrimaryWallet().stripMetadata().hasPrimaryWallet()).toBe(false); - }); - }); }); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index f7368f4f742..bbba8ad636d 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -41,36 +41,13 @@ export class AccountTreeSnapshot { readonly #idMap: IdMap | undefined; - // Set to true by stripPrimaryWallet(). getPrimaryWallet() returns undefined - // regardless of remaining entries so the guard in importState fires correctly - // even when secondary mnemonic wallets are still present in the snapshot. - readonly #primaryWalletStripped: boolean; - /** * @param entries - Wallet entries in the snapshot. * @param idMap - Optional local ↔ payload ID map from export. - * @param primaryWalletStripped - Internal flag; set by {@link stripPrimaryWallet}. */ - constructor(entries: AccountTreeWalletEntry[], idMap?: IdMap, primaryWalletStripped = false) { + constructor(entries: AccountTreeWalletEntry[], idMap?: IdMap) { this.#entries = deepFreeze(structuredClone(entries)); this.#idMap = idMap; - this.#primaryWalletStripped = primaryWalletStripped; - } - - /** - * Whether the primary (first mnemonic) wallet is present in this snapshot. - * - * `false` when the snapshot has no mnemonic wallets, or when it was derived - * via {@link stripPrimaryWallet}. {@link AccountTreeController.importState} - * rejects a snapshot where this is `false` if mnemonic wallets already exist. - */ - hasPrimaryWallet(): boolean { - if (this.#primaryWalletStripped) { - return false; - } - return this.#entries.some( - (wallet) => wallet.type === AccountWalletPayloadType.Mnemonic, - ); } /** @@ -90,14 +67,14 @@ export class AccountTreeSnapshot { predicate(entry as AccountTreeSnapshotWallet), ); - return new AccountTreeSnapshot(filteredEntries, this.#idMap, this.#primaryWalletStripped); + return new AccountTreeSnapshot(filteredEntries, this.#idMap); } /** * Filters groups within one wallet. Other wallets are left unchanged. * * Throws if `walletId` does not identify a wallet in the snapshot. - * Removes the wallet if no groups remain after filtering — this prevents a + * Removes the wallet if no groups remain after filtering - this prevents a * mnemonic wallet with zero selected groups from still transferring its secret. * * **Mnemonic wallets:** group indices must remain contiguous starting at 0 @@ -145,7 +122,7 @@ export class AccountTreeSnapshot { }; } - return new AccountTreeSnapshot(filteredEntries, this.#idMap, this.#primaryWalletStripped); + return new AccountTreeSnapshot(filteredEntries, this.#idMap); } /** @@ -193,18 +170,17 @@ export class AccountTreeSnapshot { } } - return new AccountTreeSnapshot(filteredEntries, this.#idMap, this.#primaryWalletStripped); + return new AccountTreeSnapshot(filteredEntries, this.#idMap); } /** - * Returns a new snapshot with all secret material removed — mnemonic + * Returns a new snapshot with all secret material removed - mnemonic * {@link AccountWalletMnemonicPayload.value | values} and private-key group * {@link AccountWalletPrivateKeyGroupEntry.value | values} are omitted. * Wallet and group metadata (names, pin, hidden) are preserved. * - * Use this to produce a **metadata-only** view for Phase C of the QR sync - * provisioning flow, where secrets are already in the vault and only layout - * information needs to be applied. + * Useful when only metadata (names, layout) needs to be applied and secrets + * are already present in the vault. * * @returns A secrets-stripped snapshot. */ @@ -222,47 +198,16 @@ export class AccountTreeSnapshot { ), } as AccountWalletPrivateKeyPayload; }); - return new AccountTreeSnapshot(entries, this.#idMap, this.#primaryWalletStripped); - } - - /** - * Returns a new snapshot with the primary (first mnemonic) wallet removed. - * - * The primary wallet is identified positionally — the first - * {@link AccountWalletPayloadType.Mnemonic} entry in the wallet list. All - * remaining wallets (secondary mnemonics, private-key wallets) are preserved. - * - * Removing the primary wallet makes the snapshot safe to pass to - * {@link AccountTreeController.importState} during initial onboarding, where - * the primary SRP has already been imported manually and only secondary - * secrets need to be added. The controller detects that no primary wallet is - * present via {@link getPrimaryWallet} and rejects the import post-onboarding. - * - * @returns A new snapshot without the primary wallet. - */ - stripPrimaryWallet(): AccountTreeSnapshot { - let primaryRemoved = false; - const entries = this.#entries.filter((wallet) => { - if ( - wallet.type === AccountWalletPayloadType.Mnemonic && - !primaryRemoved - ) { - primaryRemoved = true; - return false; - } - return true; - }); - return new AccountTreeSnapshot(entries, this.#idMap, true); + return new AccountTreeSnapshot(entries, this.#idMap); } /** - * Returns a new snapshot with all metadata reset to defaults — wallet names + * Returns a new snapshot with all metadata reset to defaults - wallet names * are cleared and group metadata (`name`, `pinned`, `hidden`) is reset. * Secret values are preserved. * - * Use this alongside {@link stripPrimaryWallet} for Phase B of the QR sync - * provisioning flow, where only secondary secrets need to be imported and - * metadata will be applied later in Phase C. + * Useful when importing secrets into a vault in a separate step from applying + * metadata - the metadata can be re-applied later from the original snapshot. * * @returns A metadata-stripped snapshot. */ @@ -277,7 +222,7 @@ export class AccountTreeSnapshot { }), } as AccountTreeWalletEntry; }); - return new AccountTreeSnapshot(entries, this.#idMap, this.#primaryWalletStripped); + return new AccountTreeSnapshot(entries, this.#idMap); } /** @@ -335,7 +280,7 @@ export class AccountTreeSnapshot { * versions and wallet types fail closed with an error instead of returning a * partial snapshot. * - * The returned snapshot has no ID map — {@link toLocalId} / {@link toPayloadId} + * The returned snapshot has no ID map - {@link toLocalId} / {@link toPayloadId} * return `undefined`. Pass an {@link IdMap} to the constructor when you need * the map. * From d94d69352c8f1a3d2a2fedb105a27fd03718e9b3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 7 Sep 2026 11:52:37 +0200 Subject: [PATCH 5/8] chore: lint --- .../src/state/snapshot.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/account-tree-controller/src/state/snapshot.test.ts b/packages/account-tree-controller/src/state/snapshot.test.ts index 69ef61f16bc..4be90809147 100644 --- a/packages/account-tree-controller/src/state/snapshot.test.ts +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -17,9 +17,6 @@ const MOCK_PRIVATE_KEY_PAYLOAD_ID = toWalletPayloadId( AccountWalletPayloadType.PrivateKey, ); -const MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID = - toWalletPayloadId('entropy-source-2'); - const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { id: MOCK_MNEMONIC_PAYLOAD_ID, type: AccountWalletPayloadType.Mnemonic, @@ -39,20 +36,6 @@ const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { ], }; -const MOCK_SECONDARY_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { - id: MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID, - type: AccountWalletPayloadType.Mnemonic, - value: [5, 6, 7, 8], - metadata: { name: 'Wallet 2' }, - groups: [ - { - id: toGroupPayloadId(MOCK_SECONDARY_MNEMONIC_PAYLOAD_ID, 0), - groupIndex: 0, - metadata: { name: 'Account 3', pinned: false, hidden: false }, - }, - ], -}; - const MOCK_PRIVATE_KEY_WALLET: AccountWalletPrivateKeyPayload = { id: MOCK_PRIVATE_KEY_PAYLOAD_ID, type: AccountWalletPayloadType.PrivateKey, From 1dc89c8285a520846eba787836ff74cc65444093 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 7 Sep 2026 12:23:48 +0200 Subject: [PATCH 6/8] chore: changelog --- packages/account-tree-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 8d570dd02ea..7e3c21b4ea9 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountTreeSnapshot:strip{Metadata,Secrets}` support ([#10112](https://github.com/MetaMask/core/pull/10112)) + - This can be used to mutate the snapshot before calling `:importState` and ease custom flow integration where secrets and metadata would be imported in 2-steps. + ## [8.1.0] ### Added From d35ccf6ec9415a959305452897b54592543d56fb Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 7 Sep 2026 17:01:18 +0200 Subject: [PATCH 7/8] refactor: remove unneeded type casts --- packages/account-tree-controller/src/state/snapshot.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts index bbba8ad636d..a86aa41dc67 100644 --- a/packages/account-tree-controller/src/state/snapshot.ts +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -6,10 +6,8 @@ import type { AccountTreeSnapshotWallet, AccountTreeWalletEntry, AccountWalletMnemonicGroupEntry, - AccountWalletMnemonicPayload, AccountWalletPayloadId, AccountWalletPrivateKeyGroupEntry, - AccountWalletPrivateKeyPayload, } from './payload.js'; import { AccountWalletPayloadType, @@ -188,15 +186,15 @@ export class AccountTreeSnapshot { const entries = this.#entries.map((wallet): AccountTreeWalletEntry => { if (wallet.type === AccountWalletPayloadType.Mnemonic) { const { value: _value, ...rest } = wallet; - return rest as AccountWalletMnemonicPayload; + return rest; } return { ...wallet, groups: wallet.groups.map( ({ value: _value, ...group }): AccountWalletPrivateKeyGroupEntry => - group as AccountWalletPrivateKeyGroupEntry, + group, ), - } as AccountWalletPrivateKeyPayload; + }; }); return new AccountTreeSnapshot(entries, this.#idMap); } @@ -220,7 +218,7 @@ export class AccountTreeSnapshot { const { metadata: _groupMetadata, ...groupRest } = group; return groupRest as typeof group; }), - } as AccountTreeWalletEntry; + } as AccountTreeWalletEntry; // Looks like the compiler is not able to infer this correctly, but we just remove the `metadata` field out of any entry. }); return new AccountTreeSnapshot(entries, this.#idMap); } From d8fceeabc9507814039310f0d6a0669c609c6bf3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 7 Sep 2026 17:32:39 +0200 Subject: [PATCH 8/8] chore: changelog --- packages/account-tree-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 7e3c21b4ea9..db2224a4148 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `AccountTreeSnapshot:strip{Metadata,Secrets}` support ([#10112](https://github.com/MetaMask/core/pull/10112)) - This can be used to mutate the snapshot before calling `:importState` and ease custom flow integration where secrets and metadata would be imported in 2-steps. +### Changed + +- **BREAKING:** `metadata` is now optional on `AccountWalletPayloadMetadata`, `AccountWalletGroupPayloadMetadata`, and all wallet/group payload entry types ([#10112](https://github.com/MetaMask/core/pull/10112)) + - Consumers reading `metadata` from a snapshot or payload must now guard against `undefined` (e.g. after calling `AccountTreeSnapshot.stripMetadata()`). + ## [8.1.0] ### Added