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 0000000000..fe7e445bc5 --- /dev/null +++ b/packages/account-tree-controller/src/state/id-map.test.ts @@ -0,0 +1,104 @@ +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('throws if the same local ID is registered twice', () => { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + expect(() => + map.add('entropy:wallet-1', 'wallet:entropy-source-2'), + ).toThrow('Local ID already registered: entropy:wallet-1'); + }); + + it('throws if the same payload ID is registered twice', () => { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + expect(() => + map.add('entropy:wallet-2', 'wallet:entropy-source-1'), + ).toThrow('Payload ID already registered: wallet:entropy-source-1'); + }); + + it('handles wallet and group IDs in the same map', () => { + const map = new IdMap(); + map.add('entropy:wallet-1', 'wallet:entropy-source-1'); + map.add('entropy:wallet-1/0', 'wallet:entropy-source-1/0'); + expect(map.getPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-1', + ); + expect(map.getPayloadId('entropy:wallet-1/0')).toBe( + 'wallet:entropy-source-1/0', + ); + }); + }); + + describe('getPayloadId', () => { + it('returns the payload ID for a known local wallet ID', () => { + const map = new IdMap([['entropy:wallet-1', 'wallet:entropy-source-1']]); + expect(map.getPayloadId('entropy:wallet-1')).toBe( + 'wallet:entropy-source-1', + ); + }); + + it('returns undefined for an unknown local ID', () => { + const map = new IdMap(); + expect(map.getPayloadId('entropy:wallet-unknown')).toBeUndefined(); + }); + }); + + describe('getLocalId', () => { + it('returns the local ID for a known payload wallet ID', () => { + const map = new IdMap([['entropy:wallet-1', 'wallet:entropy-source-1']]); + expect(map.getLocalId('wallet:entropy-source-1')).toBe( + 'entropy:wallet-1', + ); + }); + + it('returns undefined for an unknown payload ID', () => { + const map = new IdMap(); + expect(map.getLocalId('wallet:entropy-source-unknown')).toBeUndefined(); + }); + + it('returns the local ID for a group payload ID', () => { + const map = new IdMap([ + ['entropy:wallet-1/0', 'wallet:entropy-source-1/0'], + ]); + expect(map.getLocalId('wallet:entropy-source-1/0')).toBe( + 'entropy:wallet-1/0', + ); + }); + }); +}); diff --git a/packages/account-tree-controller/src/state/id-map.ts b/packages/account-tree-controller/src/state/id-map.ts new file mode 100644 index 0000000000..fe314ba7e2 --- /dev/null +++ b/packages/account-tree-controller/src/state/id-map.ts @@ -0,0 +1,74 @@ +import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; +import { assert } from '@metamask/utils'; + +import type { + AccountGroupPayloadId, + AccountWalletPayloadId, +} from './payload.js'; + +type LocalId = AccountWalletId | AccountGroupId; +type PayloadId = AccountWalletPayloadId | AccountGroupPayloadId; + +/** + * Bidirectional map between local controller IDs and portable payload IDs. + * + * Populated during {@link exportState} so that callers can bridge between + * device-local wallet/group IDs and the stable cross-device IDs that appear + * in a serialized {@link AccountTreePayload}. + */ +export class IdMap { + readonly #localToPayload: Map = new Map(); + + readonly #payloadToLocal: Map = new Map(); + + /** + * @param entries - Optional seed pairs `[localId, payloadId]` to pre-populate the map. + */ + constructor(entries: [localId: LocalId, payloadId: PayloadId][] = []) { + for (const [localId, payloadId] of entries) { + this.add(localId, payloadId); + } + } + + /** + * Registers a local↔payload ID pair. + * + * @param localId - Local controller wallet or group ID. + * @param payloadId - Corresponding portable payload ID. + */ + add(localId: LocalId, payloadId: PayloadId): void { + // Each local ID maps to exactly one payload ID and vice versa: wallet and + // group IDs are unique by construction, so a duplicate signals a bug in the + // caller (e.g. exportState visiting the same node twice). + assert( + !this.#localToPayload.has(localId), + `Local ID already registered: ${localId}`, + ); + assert( + !this.#payloadToLocal.has(payloadId), + `Payload ID already registered: ${payloadId}`, + ); + 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/payload.test.ts b/packages/account-tree-controller/src/state/payload.test.ts new file mode 100644 index 0000000000..f37435ff40 --- /dev/null +++ b/packages/account-tree-controller/src/state/payload.test.ts @@ -0,0 +1,380 @@ +import { assert } from '@metamask/superstruct'; + +import { + ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + AccountTreePayloadStruct, + AccountWalletPayloadType, + assertAccountTreePayload, + parsePayloadGroupId, + toGroupPayloadId, + toWalletPayloadId, +} from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +const MOCK_BAD_SECRET = 8675309; + +function thrownMessage(fn: () => void): string { + try { + fn(); + } catch (error) { + return (error as Error).message; + } + throw new Error('Expected function to throw'); +} + +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('assertAccountTreePayload', () => { + it('does not throw for a valid payload', () => { + expect(() => + assertAccountTreePayload({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [], + }), + ).not.toThrow(); + }); + + it('throws with "Invalid AccountTreePayload:" prefix for an invalid payload', () => { + expect(() => + assertAccountTreePayload({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: 'not-an-array', + }), + ).toThrow('Invalid AccountTreePayload:'); + }); + + it('throws when the version field is missing', () => { + expect(() => assertAccountTreePayload({ wallets: [] })).toThrow( + 'Invalid AccountTreePayload:', + ); + }); + + it('throws when the version is not the current version', () => { + expect(() => + assertAccountTreePayload({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION + 1, + wallets: [], + }), + ).toThrow('Invalid AccountTreePayload:'); + }); + + it('throws when mnemonic wallet groups do not start at index 0', () => { + const walletId = toWalletPayloadId('entropy-source-1'); + expect(() => + assertAccountTreePayload({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: walletId, + type: AccountWalletPayloadType.Mnemonic, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(walletId, 1), + groupIndex: 1, + metadata: { name: 'Account 1', pinned: false, hidden: false }, + }, + ], + }, + ], + }), + ).toThrow('Invalid AccountTreePayload:'); + }); + + it('throws when mnemonic wallet groups are non-contiguous', () => { + expect(() => + assertAccountTreePayload({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: toWalletPayloadId('entropy-source-1'), + type: AccountWalletPayloadType.Mnemonic, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(toWalletPayloadId('entropy-source-1'), 0), + groupIndex: 0, + metadata: { name: 'Account 0', pinned: false, hidden: false }, + }, + // Group 1 is intentionally missing — non-contiguous. + { + id: toGroupPayloadId(toWalletPayloadId('entropy-source-1'), 2), + groupIndex: 2, + metadata: { name: 'Account 2', pinned: false, hidden: false }, + }, + ], + }, + ], + }), + ).toThrow('Invalid AccountTreePayload:'); + }); + + describe('mnemonic value field redaction — nested path and value', () => { + const walletId = toWalletPayloadId('entropy-source-1'); + const payload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: walletId, + type: AccountWalletPayloadType.Mnemonic, + value: MOCK_BAD_SECRET, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(walletId, 0), + groupIndex: 0, + metadata: { name: 'Account 0', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + it('includes the field path in the error message (with formatValidationErrorMessages)', () => { + expect(() => assertAccountTreePayload(payload)).toThrow( + '[wallets.0.value]', + ); + }); + + it('does not leak the value in the error message (with formatValidationErrorMessages)', () => { + expect( + thrownMessage(() => assertAccountTreePayload(payload)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + + it('does not leak the value in the error message (with superstruct.sensitive)', () => { + expect( + thrownMessage(() => assert(payload, AccountTreePayloadStruct)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + }); + + describe('private key field redaction — nested path and value', () => { + const walletId = toWalletPayloadId('private-key'); + const payload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: walletId, + type: AccountWalletPayloadType.PrivateKey, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(walletId, '0xdeadbeef'), + value: { privateKey: MOCK_BAD_SECRET, encoding: 'hex' }, + metadata: { name: 'Account', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + it('includes the nested field path in the error message (with formatValidationErrorMessages)', () => { + expect(() => assertAccountTreePayload(payload)).toThrow( + '[wallets.0.groups.0.value.privateKey]', + ); + }); + + it('does not leak the value in the error message (with formatValidationErrorMessages)', () => { + expect( + thrownMessage(() => assertAccountTreePayload(payload)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + + it('does not leak the value in the error message (with superstruct.sensitive)', () => { + expect( + thrownMessage(() => assert(payload, AccountTreePayloadStruct)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + }); + + describe('multiple sensitive fields failing simultaneously', () => { + const mnemonicWalletId = toWalletPayloadId('entropy-source-1'); + const privateKeyWalletId = toWalletPayloadId('private-key'); + const payload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: mnemonicWalletId, + type: AccountWalletPayloadType.Mnemonic, + value: MOCK_BAD_SECRET, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(mnemonicWalletId, 0), + groupIndex: 0, + metadata: { name: 'Account 0', pinned: false, hidden: false }, + }, + ], + }, + { + id: privateKeyWalletId, + type: AccountWalletPayloadType.PrivateKey, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(privateKeyWalletId, '0xdeadbeef'), + value: { privateKey: MOCK_BAD_SECRET, encoding: 'hex' }, + metadata: { name: 'Account', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + it('does not leak either value in the error message (with formatValidationErrorMessages)', () => { + expect( + thrownMessage(() => assertAccountTreePayload(payload)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + + it('does not leak either value in the error message (with superstruct.sensitive)', () => { + expect( + thrownMessage(() => assert(payload, AccountTreePayloadStruct)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + }); + + describe('mnemonic value field redaction', () => { + const walletId = toWalletPayloadId('entropy-source-1'); + const payload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: walletId, + type: AccountWalletPayloadType.Mnemonic, + value: MOCK_BAD_SECRET, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(walletId, 0), + groupIndex: 0, + metadata: { name: 'Account 0', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + it('does not leak the value (with formatValidationErrorMessages)', () => { + expect( + thrownMessage(() => assertAccountTreePayload(payload)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + + it('does not leak the value (with superstruct.sensitive)', () => { + expect( + thrownMessage(() => assert(payload, AccountTreePayloadStruct)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + }); + + describe('private key field redaction', () => { + const walletId = toWalletPayloadId('private-key'); + const payload = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: walletId, + type: AccountWalletPayloadType.PrivateKey, + metadata: { name: 'Wallet' }, + groups: [ + { + id: toGroupPayloadId(walletId, '0xdeadbeef'), + value: { privateKey: MOCK_BAD_SECRET, encoding: 'hex' }, + metadata: { name: 'Account', pinned: false, hidden: false }, + }, + ], + }, + ], + }; + + it('does not leak the value (with formatValidationErrorMessages)', () => { + expect( + thrownMessage(() => assertAccountTreePayload(payload)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + + it('does not leak the value (with superstruct.sensitive)', () => { + expect( + thrownMessage(() => assert(payload, AccountTreePayloadStruct)), + ).not.toContain(String(MOCK_BAD_SECRET)); + }); + }); + + it('throws when a group ID does not match the expected format', () => { + expect(() => + assertAccountTreePayload({ + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [ + { + id: 'wallet:entropy', + type: AccountWalletPayloadType.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', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).rejects.toThrow('Invalid AccountTreePayload'); + }); +}); diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts new file mode 100644 index 0000000000..1086d80306 --- /dev/null +++ b/packages/account-tree-controller/src/state/payload.ts @@ -0,0 +1,352 @@ +import { KeyringAccountTypeStruct } from '@metamask/keyring-api'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import { + array, + assert, + boolean, + define, + enums, + integer, + literal, + object, + exactOptional, + refine, + sensitive, + string, + StructError, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +import type { DeepReadonly, EncodedBytes } from './utils.js'; +import { BytesStruct, formatValidationErrorMessages } from './utils.js'; + +/** Stable cross-device wallet identifier. Format: `wallet:`. */ +export type AccountWalletPayloadId = `wallet:${string}`; + +/** Stable cross-device group identifier. Format: `wallet:/`. */ +export type AccountGroupPayloadId = `${AccountWalletPayloadId}/${string}`; + +/** + * Parsed representation of an {@link AccountGroupPayloadId}. + */ +export type ParsedPayloadGroupId = { + /** The wallet portion of the group ID. */ + walletId: AccountWalletPayloadId; + /** The group-specific sub-ID (e.g. group index for mnemonic wallets, address for private-key wallets). */ + subId: string; +}; + +const PAYLOAD_GROUP_ID_REGEX = /^(?wallet:[^/]+)\/(?.+)$/u; + +/** + * Parses a payload group ID into its wallet ID and group sub-ID components. + * + * @param groupId - The payload group ID to parse. + * @returns The parsed wallet ID and group sub-ID. + * @throws If the group ID format is invalid. + */ +export function parsePayloadGroupId( + groupId: AccountGroupPayloadId, +): ParsedPayloadGroupId { + const match = PAYLOAD_GROUP_ID_REGEX.exec(groupId); + if (!match?.groups) { + throw new Error(`Invalid payload group ID: "${groupId}"`); + } + return { + walletId: match.groups.walletId as AccountWalletPayloadId, + subId: match.groups.subId, + }; +} + +/** + * Wallet type discriminants used in serialized {@link AccountTreePayload} entries. + * + * Use these constants instead of raw string literals so callers get autocomplete + * and a single source of truth for the discriminant values. + */ +export const AccountWalletPayloadType = { + Mnemonic: 'mnemonic', + PrivateKey: 'private-key', +} as const; + +export type AccountWalletPayloadType = + (typeof AccountWalletPayloadType)[keyof typeof AccountWalletPayloadType]; + +/** 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; +}; + +/** + * Encoding formats for exported private key material. + */ +export const AccountWalletPrivateKeyEncoding = { + Hexadecimal: 'hexadecimal', + Base58: 'base58', + Base32: 'base32', +} as const; + +export type AccountWalletPrivateKeyEncoding = + (typeof AccountWalletPrivateKeyEncoding)[keyof typeof AccountWalletPrivateKeyEncoding]; + +/** A single group entry inside an {@link AccountWalletPrivateKeyPayload}. */ +export type AccountWalletPrivateKeyGroupEntry = { + /** Stable group payload ID. Format: `wallet:private-key/
`. */ + id: AccountGroupPayloadId; + /** + * Private key material. Shape matches `ExportedAccount` from `@metamask/keyring-api/v2` + * so the importer knows how to decode the key without additional out-of-band information. + * Absent in metadata-only exports. + */ + value?: { + privateKey: EncodedBytes; + encoding: AccountWalletPrivateKeyEncoding; + /** + * Account type from `KeyringAccountType` (e.g. `'eip155:eoa'`, `'bip122:p2wpkh'`). + * Absent for EVM accounts -- import via `SimpleKeyring`. + * Present for non-EVM accounts -- routing to the BIP-44 Snap handling this type is not yet implemented. + */ + type?: KeyringAccount['type']; + }; + metadata: AccountWalletGroupPayloadMetadata; +}; + +/** Payload entry for an HD (entropy) wallet and its derived account groups. */ +export type AccountWalletMnemonicPayload = { + id: AccountWalletPayloadId; + type: typeof AccountWalletPayloadType.Mnemonic; + /** BIP-39 mnemonic phrase encoded as bytes. Absent in metadata-only exports. */ + value?: EncodedBytes; + metadata: AccountWalletPayloadMetadata; + groups: AccountWalletMnemonicGroupEntry[]; +}; + +/** + * Payload entry for all imported private-key accounts. + * + * All local simple-keyring wallets are merged into this single entry; + * each account is represented as a separate group entry keyed by address. + */ +export type AccountWalletPrivateKeyPayload = { + id: AccountWalletPayloadId; + type: typeof AccountWalletPayloadType.PrivateKey; + metadata: AccountWalletPayloadMetadata; + groups: AccountWalletPrivateKeyGroupEntry[]; +}; + +/** Union of all wallet entry types that can appear in an {@link AccountTreePayload}. */ +export type AccountTreeWalletEntry = + | AccountWalletMnemonicPayload + | AccountWalletPrivateKeyPayload; + +/** Portable snapshot of the full account tree state (flat versioned format). */ +export type AccountTreePayload = { + version: number; + wallets: AccountTreeWalletEntry[]; +}; + +/** + * Deeply read-only wallet view passed to {@link AccountTreeSnapshot.filterWallets} + * and {@link AccountTreeSnapshot.filterAllGroups} predicates. + * + * Entries are deep-cloned and deep-frozen when a snapshot is constructed, so + * callers cannot mutate wallet IDs, types, secrets, metadata, or groups. + */ +export type AccountTreeSnapshotWallet = DeepReadonly< + AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload +>; + +/** + * Deeply read-only group view passed to {@link AccountTreeSnapshot.filterGroups} + * and {@link AccountTreeSnapshot.filterAllGroups} predicates. + * + * Entries are deep-cloned and deep-frozen when a snapshot is constructed, so + * callers cannot mutate group IDs, secrets, metadata, or parent wallet references. + */ +export type AccountTreeSnapshotGroup = DeepReadonly< + AccountWalletMnemonicGroupEntry | AccountWalletPrivateKeyGroupEntry +>; + +/** + * 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}`; +} + +/** + * Constructs an {@link AccountGroupPayloadId} from a wallet payload ID and a sub-ID. + * + * @param walletId - The wallet payload ID this group belongs to. + * @param subId - The group-specific sub-ID (e.g. group index for mnemonic wallets, address for private-key wallets). + * @returns The portable group payload ID. + */ +export function toGroupPayloadId( + walletId: AccountWalletPayloadId, + subId: string | number, +): AccountGroupPayloadId { + return `${walletId}/${subId}`; +} + +/** Options accepted by {@link AccountTreeController.exportState}. */ +export type ExportStateOptions = { + /** When `true`, secrets (mnemonic / private keys) are included. Requires the vault to be unlocked. */ + includeSecrets?: boolean; +}; + +const AccountWalletPayloadIdStruct = define( + 'AccountWalletPayloadId', + (value) => + typeof value === 'string' && value.startsWith('wallet:') + ? true + : 'Expected a wallet payload ID starting with "wallet:"', +); + +const AccountGroupPayloadIdStruct = define( + 'AccountGroupPayloadId', + (value) => + typeof value === 'string' && PAYLOAD_GROUP_ID_REGEX.test(value) + ? true + : 'Expected a group payload ID in the form "wallet:/"', +); + +const AccountWalletPayloadMetadataStruct = object({ + name: string(), +}); + +const AccountWalletGroupPayloadMetadataStruct = object({ + name: string(), + pinned: boolean(), + hidden: boolean(), +}); + +const AccountWalletPrivateKeyValueStruct = object({ + privateKey: sensitive(BytesStruct), + encoding: enums(Object.values(AccountWalletPrivateKeyEncoding)), + type: exactOptional(KeyringAccountTypeStruct), +}); + +const AccountWalletMnemonicGroupEntryStruct = object({ + id: AccountGroupPayloadIdStruct, + groupIndex: integer(), + metadata: AccountWalletGroupPayloadMetadataStruct, +}); + +const AccountWalletPrivateKeyGroupEntryStruct = object({ + id: AccountGroupPayloadIdStruct, + value: exactOptional(AccountWalletPrivateKeyValueStruct), + metadata: AccountWalletGroupPayloadMetadataStruct, +}); + +// The `groups` array in a mnemonic wallet payload must have contiguous group indices starting at 0. +const AccountWalletMnemonicGroupsStruct = refine( + array(AccountWalletMnemonicGroupEntryStruct), + 'contiguous-group-indices', + (groups) => { + let prev: AccountWalletMnemonicGroupEntry | undefined; + for (const curr of groups) { + if (prev === undefined && curr.groupIndex !== 0) { + return `group indices must start at 0; got ${curr.groupIndex}`; + } + if (prev !== undefined && curr.groupIndex !== prev.groupIndex + 1) { + return `group indices must be contiguous and sorted; found gap between index ${prev.groupIndex} and ${curr.groupIndex}`; + } + prev = curr; + } + return true; + }, +); + +const AccountWalletMnemonicPayloadStruct = object({ + id: AccountWalletPayloadIdStruct, + type: literal(AccountWalletPayloadType.Mnemonic), + value: exactOptional(sensitive(BytesStruct)), + metadata: AccountWalletPayloadMetadataStruct, + groups: AccountWalletMnemonicGroupsStruct, +}); + +const AccountWalletPrivateKeyPayloadStruct = object({ + id: AccountWalletPayloadIdStruct, + type: literal(AccountWalletPayloadType.PrivateKey), + metadata: AccountWalletPayloadMetadataStruct, + groups: array(AccountWalletPrivateKeyGroupEntryStruct), +}); + +const AccountTreeWalletEntryStruct = union([ + AccountWalletMnemonicPayloadStruct, + AccountWalletPrivateKeyPayloadStruct, +]); + +/** Current version of the {@link AccountTreePayload} format. */ +export const ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION = 1; + +/** + * Superstruct schema for a fully versioned {@link AccountTreePayload}. + * + * Pins `version` to exactly {@link ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION} so + * payloads from newer clients (v2+) are rejected rather than silently + * mis-handled as v1. Once a migration framework is wired up in + * {@link AccountTreeSnapshot.deserialize}, older versions will be up-migrated + * before this struct is checked, and newer versions will require a new struct. + * + * Secret fields (`value`, `privateKey`) use the Superstruct `sensitive()` + * wrapper so validation failures redact secrets from error output. + */ +export const AccountTreePayloadStruct = object({ + version: literal(ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION), + wallets: array(AccountTreeWalletEntryStruct), +}); + +/** Inferred TypeScript type for a value matching {@link AccountTreePayloadStruct}. */ +export type AccountTreePayloadStructType = Infer< + typeof AccountTreePayloadStruct +>; + +/** + * 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 assertAccountTreePayload( + value: unknown, +): asserts value is AccountTreePayload { + try { + // AccountTreePayloadStruct pins version to ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + // so unknown future versions are rejected here rather than silently mis-handled. + assert(value, AccountTreePayloadStruct); + } catch (error) { + if (error instanceof StructError) { + throw new Error( + `Invalid AccountTreePayload: ${formatValidationErrorMessages(error)}`, + ); + } + /* istanbul ignore next */ + throw error; + } +} 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 0000000000..00018b068e --- /dev/null +++ b/packages/account-tree-controller/src/state/snapshot.test.ts @@ -0,0 +1,415 @@ +import { IdMap } from './id-map.js'; +import type { + AccountWalletMnemonicPayload, + AccountWalletPrivateKeyPayload, +} from './payload.js'; +import { + ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + AccountWalletPayloadType, + toGroupPayloadId, + toWalletPayloadId, +} from './payload.js'; +import { AccountTreeSnapshot } from './snapshot.js'; + +const MOCK_MNEMONIC_PAYLOAD_ID = toWalletPayloadId('entropy-source-1'); +const MOCK_PRIVATE_KEY_PAYLOAD_ID = toWalletPayloadId( + AccountWalletPayloadType.PrivateKey, +); + +const MOCK_MNEMONIC_WALLET: AccountWalletMnemonicPayload = { + id: MOCK_MNEMONIC_PAYLOAD_ID, + type: AccountWalletPayloadType.Mnemonic, + metadata: { name: 'Wallet 1' }, + groups: [ + { + id: toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0), + groupIndex: 0, + metadata: { name: 'Account 1', pinned: false, hidden: false }, + }, + { + id: toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 1), + groupIndex: 1, + metadata: { name: 'Account 2', pinned: true, hidden: false }, + }, + ], +}; + +const MOCK_PRIVATE_KEY_WALLET: AccountWalletPrivateKeyPayload = { + id: MOCK_PRIVATE_KEY_PAYLOAD_ID, + type: AccountWalletPayloadType.PrivateKey, + metadata: { name: 'Imported Accounts' }, + groups: [ + { + id: toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xdeadbeef'), + metadata: { name: 'Imported 1', pinned: false, hidden: true }, + }, + ], +}; + +const MOCK_ID_MAP = new IdMap([ + ['entropy:wallet-1', MOCK_MNEMONIC_PAYLOAD_ID], + ['entropy:wallet-1/0', toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0)], + ['entropy:wallet-1/1', toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 1)], + ['keyring:simple', MOCK_PRIVATE_KEY_PAYLOAD_ID], + [ + 'keyring:simple/0xdeadbeef', + toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xdeadbeef'), + ], +]); + +describe('AccountTreeSnapshot', () => { + describe('immutability', () => { + it('deep-freezes entries at construction so predicates cannot mutate them', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + + expect(() => + snapshot.filterWallets((wallet) => { + (wallet.metadata as { name: string }).name = 'hacked'; + return true; + }), + ).toThrow(TypeError); + + expect(snapshot.serialize().wallets[0]?.metadata.name).toBe('Wallet 1'); + }); + }); + + describe('filterWallets', () => { + it('returns a snapshot containing only matching entries', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const filtered = snapshot.filterWallets( + (wallet) => wallet.type === AccountWalletPayloadType.Mnemonic, + ); + expect(filtered.serialize().wallets).toHaveLength(1); + expect(filtered.serialize().wallets[0]?.id).toBe( + MOCK_MNEMONIC_PAYLOAD_ID, + ); + }); + + it('preserves absent idMap when filtering', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + const filtered = snapshot.filterWallets(() => true); + expect(filtered.toLocalId(MOCK_MNEMONIC_PAYLOAD_ID)).toBeUndefined(); + }); + + it('preserves the original idMap through wallet filtering', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + MOCK_ID_MAP, + ); + + const filtered = snapshot.filterWallets( + (wallet) => wallet.type === AccountWalletPayloadType.Mnemonic, + ); + + expect(filtered.toLocalId(MOCK_MNEMONIC_PAYLOAD_ID)).toBe( + 'entropy:wallet-1', + ); + expect( + filtered.toLocalId(toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0)), + ).toBe('entropy:wallet-1/0'); + expect(filtered.toLocalId(MOCK_PRIVATE_KEY_PAYLOAD_ID)).toBe( + 'keyring:simple', + ); + expect( + filtered.toLocalId( + toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xdeadbeef'), + ), + ).toBe('keyring:simple/0xdeadbeef'); + }); + + it('handles wallet entries whose IDs are not in the idMap', () => { + const map = new IdMap([['entropy:wallet-1', MOCK_MNEMONIC_PAYLOAD_ID]]); + + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + map, + ); + + const filtered = snapshot.filterWallets(() => true); + expect(filtered.toLocalId(MOCK_MNEMONIC_PAYLOAD_ID)).toBe( + 'entropy:wallet-1', + ); + expect(filtered.toLocalId(MOCK_PRIVATE_KEY_PAYLOAD_ID)).toBeUndefined(); + }); + }); + + describe('filterGroups', () => { + it('filters groups within a single wallet and leaves others unchanged', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterGroups( + MOCK_MNEMONIC_PAYLOAD_ID, + (group) => group.id.endsWith('/0'), + ); + + const { wallets } = filtered.serialize(); + expect(wallets).toHaveLength(2); + expect(wallets[0]?.groups).toHaveLength(1); + expect(wallets[0]?.groups[0]?.id).toBe( + toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0), + ); + expect(wallets[1]?.groups).toHaveLength(1); + }); + + it('removes the wallet when all groups are filtered out', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterGroups( + MOCK_MNEMONIC_PAYLOAD_ID, + () => false, + ); + + expect(filtered.serialize().wallets).toHaveLength(1); + expect(filtered.serialize().wallets[0]?.type).toBe( + AccountWalletPayloadType.PrivateKey, + ); + }); + + it('filters private-key wallet groups and preserves the idMap', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + MOCK_ID_MAP, + ); + + const filtered = snapshot.filterGroups( + MOCK_PRIVATE_KEY_PAYLOAD_ID, + () => true, + ); + + expect(filtered.serialize().wallets).toHaveLength(2); + expect( + filtered.toLocalId( + toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xdeadbeef'), + ), + ).toBe('keyring:simple/0xdeadbeef'); + }); + + it('preserves the idMap when filtering mnemonic wallet groups', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + MOCK_ID_MAP, + ); + + const filtered = snapshot.filterGroups( + MOCK_MNEMONIC_PAYLOAD_ID, + (group) => group.id.endsWith('/0'), + ); + + expect( + filtered.toLocalId(toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0)), + ).toBe('entropy:wallet-1/0'); + expect( + filtered.toLocalId(toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 1)), + ).toBe('entropy:wallet-1/1'); + }); + + it('throws when the wallet ID is not in the snapshot', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + + expect(() => snapshot.filterGroups('wallet:missing', () => true)).toThrow( + 'wallet "wallet:missing" not found in snapshot', + ); + }); + }); + + describe('filterAllGroups', () => { + it('filters groups across all wallets and removes empty wallets', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterAllGroups((group) => + group.id.endsWith('/0'), + ); + + const { wallets } = filtered.serialize(); + expect(wallets).toHaveLength(1); + expect(wallets[0]?.type).toBe(AccountWalletPayloadType.Mnemonic); + expect(wallets[0]?.groups).toHaveLength(1); + }); + + it('provides the parent wallet to the predicate', () => { + const snapshot = new AccountTreeSnapshot([ + MOCK_MNEMONIC_WALLET, + MOCK_PRIVATE_KEY_WALLET, + ]); + + const filtered = snapshot.filterAllGroups( + (_group, wallet) => wallet.type === AccountWalletPayloadType.PrivateKey, + ); + + expect(filtered.serialize().wallets).toHaveLength(1); + expect(filtered.serialize().wallets[0]?.type).toBe( + AccountWalletPayloadType.PrivateKey, + ); + }); + + it('preserves the idMap when filtering all groups', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET, MOCK_PRIVATE_KEY_WALLET], + MOCK_ID_MAP, + ); + + const filtered = snapshot.filterAllGroups( + (_group, wallet) => wallet.type === AccountWalletPayloadType.Mnemonic, + ); + + expect( + filtered.toLocalId(toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0)), + ).toBe('entropy:wallet-1/0'); + expect(filtered.toLocalId(MOCK_PRIVATE_KEY_PAYLOAD_ID)).toBe( + 'keyring:simple', + ); + }); + }); + + describe('toLocalId', () => { + it('returns the local ID for a known payload wallet ID', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET], + MOCK_ID_MAP, + ); + expect(snapshot.toLocalId(MOCK_MNEMONIC_PAYLOAD_ID)).toBe( + 'entropy:wallet-1', + ); + }); + + it('returns the local ID for a known payload group ID', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET], + MOCK_ID_MAP, + ); + expect( + snapshot.toLocalId(toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0)), + ).toBe('entropy:wallet-1/0'); + }); + + it('returns undefined when no idMap is present', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + expect(snapshot.toLocalId(MOCK_MNEMONIC_PAYLOAD_ID)).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 snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET], + MOCK_ID_MAP, + ); + expect(snapshot.toPayloadId('entropy:wallet-1')).toBe( + MOCK_MNEMONIC_PAYLOAD_ID, + ); + }); + + it('returns the payload ID for a known local group ID', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET], + MOCK_ID_MAP, + ); + expect(snapshot.toPayloadId('entropy:wallet-1/0')).toBe( + toGroupPayloadId(MOCK_MNEMONIC_PAYLOAD_ID, 0), + ); + }); + + it('returns undefined when no idMap is present', () => { + const snapshot = new AccountTreeSnapshot([MOCK_MNEMONIC_WALLET]); + expect(snapshot.toPayloadId('entropy:wallet-1')).toBeUndefined(); + }); + + it('returns undefined for an unknown local ID', () => { + const snapshot = new AccountTreeSnapshot( + [MOCK_MNEMONIC_WALLET], + new IdMap(), + ); + expect(snapshot.toPayloadId('entropy:wallet-unknown')).toBeUndefined(); + }); + }); + + describe('serialize', () => { + it('serializes to a flat versioned state with version inlined alongside wallet entries', () => { + 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); + }); + + 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); + }); + }); + + describe('deserialize', () => { + it('deserializes a valid v1 payload into a snapshot', async () => { + const raw = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [MOCK_MNEMONIC_WALLET], + }; + const snapshot = await AccountTreeSnapshot.deserialize(raw); + expect(snapshot.serialize().wallets).toHaveLength(1); + expect(snapshot.serialize().wallets[0]?.id).toBe( + MOCK_MNEMONIC_PAYLOAD_ID, + ); + }); + + it('returns a snapshot with no idMap (toLocalId returns undefined)', async () => { + const raw = { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: [MOCK_MNEMONIC_WALLET], + }; + const snapshot = await AccountTreeSnapshot.deserialize(raw); + expect(snapshot.toLocalId(MOCK_MNEMONIC_PAYLOAD_ID)).toBeUndefined(); + expect(snapshot.toPayloadId('entropy:wallet-1')).toBeUndefined(); + }); + + it('throws for an invalid payload (missing wallets field)', async () => { + await expect(AccountTreeSnapshot.deserialize({})).rejects.toThrow( + 'Invalid AccountTreePayload', + ); + }); + + it('throws for an unsupported wallet type', async () => { + await expect( + AccountTreeSnapshot.deserialize({ + wallets: [ + { + id: 'wallet:ledger', + type: 'ledger', + metadata: { name: '' }, + groups: [], + }, + ], + }), + ).rejects.toThrow('Invalid AccountTreePayload'); + }); + }); +}); diff --git a/packages/account-tree-controller/src/state/snapshot.ts b/packages/account-tree-controller/src/state/snapshot.ts new file mode 100644 index 0000000000..bb9c68663c --- /dev/null +++ b/packages/account-tree-controller/src/state/snapshot.ts @@ -0,0 +1,242 @@ +import type { IdMap } from './id-map.js'; +import type { + AccountGroupPayloadId, + AccountTreePayload, + AccountTreeSnapshotGroup, + AccountTreeSnapshotWallet, + AccountTreeWalletEntry, + AccountWalletMnemonicGroupEntry, + AccountWalletPayloadId, + AccountWalletPrivateKeyGroupEntry, +} from './payload.js'; +import { + AccountWalletPayloadType, + assertAccountTreePayload, + ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, +} from './payload.js'; +import { deepFreeze } from './utils.js'; + +/** + * Immutable value object returned by {@link AccountTreeController.exportState}. + * + * Construct with {@link AccountTreeController.exportState}, + * {@link AccountTreeSnapshot.deserialize}, or `new AccountTreeSnapshot(...)` + * for tests and advanced use. + * + * Wallet and group entries are deep-cloned and deep-frozen once in the + * constructor. Filtering predicates receive those read-only views directly; + * each filter method returns a new snapshot that repeats the process for its + * result. + * + * An optional ID map (local ↔ payload) may be supplied when bridging between + * internal controller IDs and the stable cross-device IDs in the serialized + * payload. The map covers the original export and is preserved unchanged + * through filtering until {@link serialize}. Omit it when deterministic IDs + * make {@link toLocalId} / {@link toPayloadId} unnecessary. + */ +export class AccountTreeSnapshot { + readonly #entries: AccountTreeWalletEntry[]; + + readonly #idMap: IdMap | undefined; + + /** + * @param entries - Wallet entries in the snapshot. + * @param idMap - Optional local ↔ payload ID map from export. + */ + constructor(entries: AccountTreeWalletEntry[], idMap?: IdMap) { + this.#entries = deepFreeze(structuredClone(entries)); + this.#idMap = idMap; + } + + /** + * Returns a new snapshot containing only the wallets for which + * `predicate` returns `true`. + * + * When filtering by wallet ID, compare against stable payload IDs from + * {@link serialize} or convert local IDs with {@link toPayloadId} first. + * + * @param predicate - Function called with each deeply read-only wallet entry. + * @returns A filtered snapshot. + */ + filterWallets( + predicate: (wallet: AccountTreeSnapshotWallet) => boolean, + ): AccountTreeSnapshot { + const filteredEntries = this.#entries.filter((entry) => + predicate(entry as AccountTreeSnapshotWallet), + ); + + return new AccountTreeSnapshot(filteredEntries, this.#idMap); + } + + /** + * Filters groups within one wallet. Other wallets are left unchanged. + * + * Throws if `walletId` does not identify a wallet in the snapshot. + * Removes the wallet if no groups remain after filtering — this prevents a + * mnemonic wallet with zero selected groups from still transferring its secret. + * + * **Mnemonic wallets:** group indices must remain contiguous starting at 0 + * after filtering, because the payload schema enforces this invariant. + * Predicates that produce gaps (e.g. keeping only index 1, or 0 and 2) will + * cause {@link AccountTreeSnapshot.deserialize} to reject the payload on the + * receiving end. + * + * @param walletId - Stable payload wallet ID to filter groups within. + * @param predicate - Function called with each deeply read-only group entry. + * @returns A filtered snapshot. + * @throws If `walletId` is not present in the snapshot. + */ + filterGroups( + walletId: AccountWalletPayloadId, + predicate: (group: AccountTreeSnapshotGroup) => boolean, + ): AccountTreeSnapshot { + const walletIndex = this.#entries.findIndex( + (entry) => entry.id === walletId, + ); + if (walletIndex === -1) { + throw new Error( + `Cannot filter groups: wallet "${walletId}" not found in snapshot`, + ); + } + + const wallet = this.#entries[walletIndex]; + + const filteredGroups = wallet.groups.filter((group) => + predicate(group as AccountTreeSnapshotGroup), + ); + + const filteredEntries = [...this.#entries]; + if (filteredGroups.length === 0) { + filteredEntries.splice(walletIndex, 1); + } else if (wallet.type === AccountWalletPayloadType.Mnemonic) { + filteredEntries[walletIndex] = { + ...wallet, + groups: filteredGroups as AccountWalletMnemonicGroupEntry[], + }; + } else { + filteredEntries[walletIndex] = { + ...wallet, + groups: filteredGroups as AccountWalletPrivateKeyGroupEntry[], + }; + } + + return new AccountTreeSnapshot(filteredEntries, this.#idMap); + } + + /** + * Filters groups across every wallet. + * + * The parent wallet is provided as context to the predicate. Removes any + * wallet with no remaining groups after filtering. + * + * **Mnemonic wallets:** see {@link filterGroups} for the contiguous-index + * constraint that applies here as well. + * + * @param predicate - Function called with each group and its parent wallet. + * @returns A filtered snapshot. + */ + filterAllGroups( + predicate: ( + group: AccountTreeSnapshotGroup, + wallet: AccountTreeSnapshotWallet, + ) => boolean, + ): AccountTreeSnapshot { + const filteredEntries: AccountTreeWalletEntry[] = []; + + for (const wallet of this.#entries) { + const filteredGroups = wallet.groups.filter((group) => + predicate( + group as AccountTreeSnapshotGroup, + wallet as AccountTreeSnapshotWallet, + ), + ); + + if (filteredGroups.length === 0) { + continue; + } + + if (wallet.type === AccountWalletPayloadType.Mnemonic) { + filteredEntries.push({ + ...wallet, + groups: filteredGroups as AccountWalletMnemonicGroupEntry[], + }); + } else { + filteredEntries.push({ + ...wallet, + groups: filteredGroups as AccountWalletPrivateKeyGroupEntry[], + }); + } + } + + return new AccountTreeSnapshot(filteredEntries, this.#idMap); + } + + /** + * Converts a payload ID (wallet or group) to the corresponding local + * `AccountTreeController` ID. + * + * The map reflects the original export, not the wallets/groups currently + * retained in this snapshot after filtering. + * + * @param payloadId - Stable cross-device wallet or group payload ID. + * @returns The local controller ID, or `undefined` if not found or no ID map is present. + */ + toLocalId( + payloadId: AccountWalletPayloadId | AccountGroupPayloadId, + ): ReturnType { + return this.#idMap?.getLocalId(payloadId); + } + + /** + * Converts a local `AccountTreeController` ID (wallet or group) to its + * stable cross-device payload ID. + * + * The map reflects the original export, not the wallets/groups currently + * retained in this snapshot after filtering. + * + * @param localId - Local controller wallet or group ID. + * @returns The payload ID, or `undefined` if not found or no ID map is present. + */ + toPayloadId( + localId: Parameters[0], + ): ReturnType { + return this.#idMap?.getPayloadId(localId); + } + + /** + * Serializes the snapshot to a flat {@link AccountTreePayload} with `version` inlined + * alongside the wallet entries. + * + * Returns the constructor-frozen wallet tree without copying it again. + * + * @returns The versioned flat payload. + */ + serialize(): AccountTreePayload { + return { + version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, + wallets: this.#entries, + }; + } + + /** + * Validates a raw value as an {@link AccountTreePayload}, running any + * necessary version migrations, and returns an immutable snapshot. + * + * This is the entry point for untrusted serialized data. Unsupported schema + * versions and wallet types fail closed with an error instead of returning a + * partial snapshot. + * + * The returned snapshot has no ID map — {@link toLocalId} / {@link toPayloadId} + * return `undefined`. Pass an {@link IdMap} to the constructor when you need + * the map. + * + * @param raw - Unknown value to parse. + * @returns A validated snapshot. + * @throws If `raw` is not a valid payload or its version is unsupported. + */ + static async deserialize(raw: unknown): Promise { + // TODO: Use migration framework here. + assertAccountTreePayload(raw); + return new AccountTreeSnapshot(raw.wallets); + } +} diff --git a/packages/account-tree-controller/src/state/utils.test.ts b/packages/account-tree-controller/src/state/utils.test.ts new file mode 100644 index 0000000000..15575f4a87 --- /dev/null +++ b/packages/account-tree-controller/src/state/utils.test.ts @@ -0,0 +1,201 @@ +import { + assert, + object, + sensitive, + string, + StructError, +} from '@metamask/superstruct'; + +import { + BytesStruct, + decodeBytes, + deepFreeze, + encodeBytes, + formatValidationErrorMessages, +} from './utils.js'; + +describe('BytesStruct', () => { + it('accepts a valid byte array', () => { + expect(() => assert([0, 128, 255], BytesStruct)).not.toThrow(); + }); + + it('accepts an empty array', () => { + expect(() => assert([], BytesStruct)).not.toThrow(); + }); + + it('rejects a value below 0', () => { + expect(() => assert([-1, 0], BytesStruct)).toThrow( + 'each byte must be in [0, 255]', + ); + }); + + it('rejects a value above 255', () => { + expect(() => assert([0, 256], BytesStruct)).toThrow( + 'each byte must be in [0, 255]', + ); + }); +}); + +describe('encodeBytes', () => { + it('converts a Uint8Array to a number[]', () => { + expect(encodeBytes(new Uint8Array([0, 128, 255]))).toStrictEqual([ + 0, 128, 255, + ]); + }); + + it('returns an empty array for an empty Uint8Array', () => { + expect(encodeBytes(new Uint8Array([]))).toStrictEqual([]); + }); +}); + +describe('decodeBytes', () => { + it('converts a number[] back to a Uint8Array', () => { + expect(decodeBytes([0, 128, 255])).toStrictEqual( + new Uint8Array([0, 128, 255]), + ); + }); + + it('returns an empty Uint8Array for an empty array', () => { + expect(decodeBytes([])).toStrictEqual(new Uint8Array([])); + }); + + it('round-trips with encodeBytes', () => { + const original = new Uint8Array([1, 2, 3, 254, 255]); + expect(decodeBytes(encodeBytes(original))).toStrictEqual(original); + }); +}); + +describe('deepFreeze', () => { + it('returns primitives unchanged', () => { + expect(deepFreeze(42)).toBe(42); + expect(deepFreeze('hello')).toBe('hello'); + expect(deepFreeze(true)).toBe(true); + }); + + it('returns null unchanged', () => { + expect(deepFreeze(null)).toBeNull(); + }); + + it('freezes a flat object', () => { + const obj = { a: 1, b: 2 }; + deepFreeze(obj); + expect(Object.isFrozen(obj)).toBe(true); + }); + + it('freezes nested objects', () => { + const obj = { a: { b: { c: 3 } } }; + deepFreeze(obj); + expect(Object.isFrozen(obj)).toBe(true); + expect(Object.isFrozen(obj.a)).toBe(true); + expect(Object.isFrozen(obj.a.b)).toBe(true); + }); + + it('freezes arrays', () => { + const arr = [1, 2, 3]; + deepFreeze(arr); + expect(Object.isFrozen(arr)).toBe(true); + }); + + it('freezes objects nested inside arrays', () => { + const arr = [{ x: 1 }, { x: 2 }]; + deepFreeze(arr); + expect(Object.isFrozen(arr[0])).toBe(true); + expect(Object.isFrozen(arr[1])).toBe(true); + }); + + it('returns the same reference', () => { + const obj = { a: 1 }; + expect(deepFreeze(obj)).toBe(obj); + }); + + it('prevents mutation of frozen objects in strict mode', () => { + const obj = deepFreeze({ a: { b: 1 } }); + expect(() => { + (obj.a as { b: number }).b = 99; + }).toThrow(TypeError); + }); +}); + +describe('formatValidationErrorMessages', () => { + function makeStructError(value: unknown): StructError { + const schema = object({ name: string() }); + let caught: StructError | undefined; + try { + schema.assert(value); + } catch (error) { + if (error instanceof StructError) { + caught = error; + } + } + if (!caught) { + throw new Error('Expected a StructError'); + } + return caught; + } + + it('formats a single root-level failure', () => { + const error = makeStructError(null); + const result = formatValidationErrorMessages(error); + expect(result).toContain(''); + expect(result).toContain('expected:'); + }); + + it('formats a nested field failure with a dotted path', () => { + const error = makeStructError({ name: 42 }); + const result = formatValidationErrorMessages(error); + expect(result).toContain('[name]'); + expect(result).toContain('expected: string'); + }); + + it('joins multiple failures with a comma', () => { + const schema = object({ a: string(), b: string() }); + let caught: StructError | undefined; + try { + schema.assert({ a: 1, b: 2 }); + } catch (error) { + if (error instanceof StructError) { + caught = error; + } + } + expect(caught).toBeDefined(); + const result = formatValidationErrorMessages(caught as StructError); + expect(result.split(', ').length).toBeGreaterThan(1); + }); + + it('uses type/refinement and never failure.message', () => { + // sensitive() redacts the value to *** in failure.message — if + // formatValidationErrorMessages were to use message instead of type, *** would + // appear in the output. Asserting it does not pins the mechanism. + const schema = object({ secret: sensitive(string()) }); + let caught: StructError | undefined; + try { + schema.assert({ secret: 12345 }); + } catch (error) { + if (error instanceof StructError) { + caught = error; + } + } + expect(caught).toBeDefined(); + const result = formatValidationErrorMessages(caught as StructError); + expect(result).toContain('expected: string'); + expect(result).not.toContain('***'); + }); + + it('sensitive() redacts the actual value in failure.message', () => { + // This pins the superstruct behaviour we rely on: sensitive() must produce + // *** in failure.message so that any code path using message is also safe. + const schema = object({ secret: sensitive(string()) }); + let caught: StructError | undefined; + try { + schema.assert({ secret: 12345 }); + } catch (error) { + if (error instanceof StructError) { + caught = error; + } + } + expect(caught).toBeDefined(); + const failure = (caught as StructError).failures()[0]; + expect(failure?.message).toContain('***'); + expect(failure?.message).not.toContain('12345'); + }); +}); diff --git a/packages/account-tree-controller/src/state/utils.ts b/packages/account-tree-controller/src/state/utils.ts new file mode 100644 index 0000000000..d147c183de --- /dev/null +++ b/packages/account-tree-controller/src/state/utils.ts @@ -0,0 +1,90 @@ +import type { Struct } from '@metamask/superstruct'; +import { array, integer, refine, StructError } from '@metamask/superstruct'; + +/** + * A JSON-compatible representation of a `Uint8Array` as an array of integers + * in [0, 255]. Use {@link encodeBytes} and {@link decodeBytes} to convert. + */ +export type EncodedBytes = number[]; + +/** + * Superstruct struct that validates an {@link EncodedBytes} value. + */ +export const BytesStruct: Struct = refine( + array(integer()), + 'bytes', + (value) => { + const invalid = value.find((b) => b < 0 || b > 255); + return invalid === undefined + ? true + : `each byte must be in [0, 255]; got ${invalid}`; + }, +); + +/** + * Encodes a `Uint8Array` as a JSON-compatible {@link EncodedBytes}. + * + * @param bytes - The bytes to encode. + * @returns An array of integers in [0, 255]. + */ +export function encodeBytes(bytes: Uint8Array): EncodedBytes { + return Array.from(bytes); +} + +/** + * Decodes an {@link EncodedBytes} produced by {@link encodeBytes} back into a `Uint8Array`. + * The caller is responsible for zeroing the result when the data is no longer needed. + * + * @param encoded - The encoded byte array. + * @returns The decoded `Uint8Array`. + */ +export function decodeBytes(encoded: EncodedBytes): Uint8Array { + return new Uint8Array(encoded); +} + +/** + * Recursively readonly view of `Value` used by snapshot filtering predicate types. + * + * @typeParam T - The mutable source type to expose as deeply read-only. + */ +export type DeepReadonly = Value extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : Value extends object + ? { readonly [Key in keyof Value]: DeepReadonly } + : Value; + +/** + * Recursively freezes a value and its nested properties. + * + * @param value - Value to freeze. + * @returns The frozen value. + */ +export function deepFreeze(value: Value): Value { + if (value === null || typeof value !== 'object') { + return value; + } + + Object.freeze(value); + + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + + return value; +} + +/** + * Formats Superstruct validation failures into a single error message string. + * + * @param error - The StructError thrown during validation. + * @returns A comma-separated list of `[path] expected: ` entries. + */ +export function formatValidationErrorMessages(error: StructError): string { + return error + .failures() + .map(({ path, type, refinement }) => { + const location = path.length > 0 ? path.join('.') : ''; + return `[${location}] expected: ${refinement ?? type}`; + }) + .join(', '); +}