From 99421bfe4b0f08e5435ef1358bbaad196de70bb8 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 31 Aug 2026 13:24:45 +0200 Subject: [PATCH 1/3] feat: Add BaseController validation utilities --- packages/base-controller/package.json | 1 + .../base-controller/src/BaseController.ts | 48 ++++++++++++++++++- yarn.lock | 1 + 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/base-controller/package.json b/packages/base-controller/package.json index 2d08ff61bd..a1b3dec620 100644 --- a/packages/base-controller/package.json +++ b/packages/base-controller/package.json @@ -53,6 +53,7 @@ }, "dependencies": { "@metamask/messenger": "^2.0.0", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", "immer": "^9.0.6" }, diff --git a/packages/base-controller/src/BaseController.ts b/packages/base-controller/src/BaseController.ts index 2cc4047e45..9709911d9e 100644 --- a/packages/base-controller/src/BaseController.ts +++ b/packages/base-controller/src/BaseController.ts @@ -5,7 +5,8 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; -import type { Json, PublicInterface } from '@metamask/utils'; +import { Struct, validate } from '@metamask/superstruct'; +import { assert, type Json, type PublicInterface } from '@metamask/utils'; import { enablePatches, produceWithPatches, applyPatches, freeze } from 'immer'; import type { Draft, Patch } from 'immer'; @@ -229,6 +230,8 @@ export class BaseController< public readonly metadata: StateMetadata; + public readonly struct?: Struct; + /** * Creates a BaseController instance. * @@ -244,6 +247,7 @@ export class BaseController< metadata, name, state, + struct, }: { messenger: ControllerActions< ControllerName, @@ -264,6 +268,7 @@ export class BaseController< metadata: StateMetadata; name: ControllerName; state: ControllerState; + struct?: Struct; }) { // The parameter type validates that the expected actions/events are present // We don't have a way to validate the type property because the type is invariant @@ -281,6 +286,7 @@ export class BaseController< // `Immutable` does not handle recursive types such as our `Json` type. this.#internalState = freeze(state, true); this.metadata = metadata; + this.struct = struct; this.#messenger.registerActionHandler(`${name}:getState`, () => this.state); @@ -446,3 +452,43 @@ export function deriveStateFromMetadata< } }, {} as never); } + +/** + * Validate the state of a controller against its struct. Returning the optionally coerced state if valid and otherwise throwing. + * + * Note that if the `mode` is lenient, validation errors are logged and not thrown. + * + * @param name - The name of the controller. + * @param state - The state of the controller. + * @param struct - The struct used to validate the controller. + * @param mode - The validation mode. + * @param captureException - A utility function for reporting an error to Sentry. + * @returns The validated controller state. + */ +export function validateControllerState< + ControllerState extends StateConstraint, +>( + name: string, + state: unknown, + struct: Struct, + mode: 'strict' | 'lenient', + captureException?: (error: Error) => void, +): ControllerState { + const [validationError, result] = validate(state, struct); + + if (mode === 'strict' && validationError) { + throw validationError; + } else if (mode === 'lenient' && validationError) { + const error = new Error( + `Validation of "${name}" state failed, but did not block: ${validationError.message}`, + ); + // @ts-expect-error Current target does not support causes. + error.cause = validationError; + + captureException?.(error); + console.warn(error); + return state as ControllerState; + } + + return result as ControllerState; +} diff --git a/yarn.lock b/yarn.lock index 9acaac50d8..a7fb52054e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6193,6 +6193,7 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" From aac92358934b17a86312e2554a737dc9d76b4fed Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 31 Aug 2026 14:58:11 +0200 Subject: [PATCH 2/3] Define struct statically --- .../src/BaseController.test.ts | 39 ++++++++++++++++++- .../base-controller/src/BaseController.ts | 15 +++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/base-controller/src/BaseController.test.ts b/packages/base-controller/src/BaseController.test.ts index e2b7614a3a..33323db65c 100644 --- a/packages/base-controller/src/BaseController.test.ts +++ b/packages/base-controller/src/BaseController.test.ts @@ -1,6 +1,7 @@ /* eslint-disable jest/no-export */ import type { MockAnyNamespace } from '@metamask/messenger'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import { number, object, string, Struct } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import type { Draft, Patch } from 'immer'; @@ -12,7 +13,11 @@ import type { ControllerStateChangedEvent, StatePropertyMetadata, } from './BaseController.js'; -import { BaseController, deriveStateFromMetadata } from './BaseController.js'; +import { + BaseController, + deriveStateFromMetadata, + validateControllerState, +} from './BaseController.js'; export const countControllerName = 'CountController'; @@ -1191,3 +1196,35 @@ describe('deriveStateFromMetadata', () => { }); }); }); + +describe('validateControllerState', () => { + type FooControllerState = { + foo: string; + bar: number; + }; + + const FooControllerStateStruct = object({ + foo: string(), + bar: number(), + }); + + class FooController extends BaseController< + 'FooController', + FooControllerState, + any + > { + static readonly struct: Struct = + FooControllerStateStruct; + } + + it('throws for invalid state in strict mode', () => { + expect(() => + validateControllerState( + 'FooController', + FooController, + { foo: 'foo', bar: 'bar' }, + 'strict', + ), + ).toThrow('At path: bar -- Expected a number, but received: \"bar\"'); + }); +}); diff --git a/packages/base-controller/src/BaseController.ts b/packages/base-controller/src/BaseController.ts index 9709911d9e..2bb2eef26a 100644 --- a/packages/base-controller/src/BaseController.ts +++ b/packages/base-controller/src/BaseController.ts @@ -6,7 +6,7 @@ import type { MessengerEvents, } from '@metamask/messenger'; import { Struct, validate } from '@metamask/superstruct'; -import { assert, type Json, type PublicInterface } from '@metamask/utils'; +import type { Json, PublicInterface } from '@metamask/utils'; import { enablePatches, produceWithPatches, applyPatches, freeze } from 'immer'; import type { Draft, Patch } from 'immer'; @@ -230,8 +230,6 @@ export class BaseController< public readonly metadata: StateMetadata; - public readonly struct?: Struct; - /** * Creates a BaseController instance. * @@ -286,7 +284,6 @@ export class BaseController< // `Immutable` does not handle recursive types such as our `Json` type. this.#internalState = freeze(state, true); this.metadata = metadata; - this.struct = struct; this.#messenger.registerActionHandler(`${name}:getState`, () => this.state); @@ -453,14 +450,18 @@ export function deriveStateFromMetadata< }, {} as never); } +type ValidatableController = { + struct: Struct; +}; + /** * Validate the state of a controller against its struct. Returning the optionally coerced state if valid and otherwise throwing. * * Note that if the `mode` is lenient, validation errors are logged and not thrown. * * @param name - The name of the controller. + * @param controller - The static controller. * @param state - The state of the controller. - * @param struct - The struct used to validate the controller. * @param mode - The validation mode. * @param captureException - A utility function for reporting an error to Sentry. * @returns The validated controller state. @@ -469,12 +470,12 @@ export function validateControllerState< ControllerState extends StateConstraint, >( name: string, + controller: ValidatableController, state: unknown, - struct: Struct, mode: 'strict' | 'lenient', captureException?: (error: Error) => void, ): ControllerState { - const [validationError, result] = validate(state, struct); + const [validationError, result] = validate(state, controller.struct); if (mode === 'strict' && validationError) { throw validationError; From a015e549c22322cdb4a982fd62539ef258971c5d Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 1 Sep 2026 14:49:39 +0200 Subject: [PATCH 3/3] Validate controller state inputs during wallet initialzation --- packages/base-controller/src/BaseController.ts | 7 +++++-- packages/base-controller/src/index.ts | 7 ++++++- .../wallet/src/initialization/initialization.ts | 13 ++++++++++--- .../accounts-controller/accounts-controller.ts | 1 + .../address-book-controller.ts | 1 + .../approval-controller/approval-controller.ts | 1 + .../claims-controller/claims-controller.ts | 1 + .../config-registry-controller.ts | 1 + .../connectivity-controller.ts | 1 + .../gas-fee-controller/gas-fee-controller.ts | 1 + .../keyring-controller/keyring-controller.ts | 1 + .../network-controller/network-controller.ts | 1 + .../passkey-controller/passkey-controller.ts | 1 + .../remote-feature-flag-controller.ts | 1 + .../seedless-onboarding-controller.ts | 1 + .../shield-controller/shield-controller.ts | 1 + .../subscription-controller.ts | 1 + .../transaction-controller.ts | 1 + packages/wallet/src/initialization/types.ts | 12 +++++++++++- packages/wallet/src/types.ts | 6 ++---- 20 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/base-controller/src/BaseController.ts b/packages/base-controller/src/BaseController.ts index 2bb2eef26a..1e8de918fa 100644 --- a/packages/base-controller/src/BaseController.ts +++ b/packages/base-controller/src/BaseController.ts @@ -450,7 +450,10 @@ export function deriveStateFromMetadata< }, {} as never); } -type ValidatableController = { +export type ValidatableController< + Controller, + ControllerState extends StateConstraint, +> = (new (...args: any[]) => Controller) & { struct: Struct; }; @@ -470,7 +473,7 @@ export function validateControllerState< ControllerState extends StateConstraint, >( name: string, - controller: ValidatableController, + controller: ValidatableController, state: unknown, mode: 'strict' | 'lenient', captureException?: (error: Error) => void, diff --git a/packages/base-controller/src/index.ts b/packages/base-controller/src/index.ts index 0a615b60e1..b773efc117 100644 --- a/packages/base-controller/src/index.ts +++ b/packages/base-controller/src/index.ts @@ -11,5 +11,10 @@ export type { ControllerGetStateAction, ControllerStateChangeEvent, ControllerStateChangedEvent, + ValidatableController, +} from './BaseController.js'; +export { + BaseController, + deriveStateFromMetadata, + validateControllerState, } from './BaseController.js'; -export { BaseController, deriveStateFromMetadata } from './BaseController.js'; diff --git a/packages/wallet/src/initialization/initialization.ts b/packages/wallet/src/initialization/initialization.ts index 9ed426722d..f2e9d09373 100644 --- a/packages/wallet/src/initialization/initialization.ts +++ b/packages/wallet/src/initialization/initialization.ts @@ -1,3 +1,5 @@ +import { validateControllerState } from '@metamask/base-controller'; + import type { InstanceSpecificOptions, WalletOptions } from '../types.js'; import type { DefaultActions, @@ -32,15 +34,20 @@ export function initialize(options: InitializeOptions): DefaultInstances { const configurationEntries = initializationConfigurations.concat( Object.values(defaultConfigurations).filter( (config) => !overriddenConfiguration.includes(config.name), - ) as InitializationConfiguration[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as InitializationConfiguration[], ); const instances: Record = {}; for (const config of configurationEntries) { - const { name } = config; + const { name, reference } = config; + + const rawState = state[name]; - const instanceState = state[name]; + const instanceState = reference?.struct + ? validateControllerState(name, reference as never, rawState, 'lenient') + : rawState; const instanceMessenger = config.getMessenger(messenger); diff --git a/packages/wallet/src/initialization/instances/accounts-controller/accounts-controller.ts b/packages/wallet/src/initialization/instances/accounts-controller/accounts-controller.ts index ec2ea40bb6..dc1c51de33 100644 --- a/packages/wallet/src/initialization/instances/accounts-controller/accounts-controller.ts +++ b/packages/wallet/src/initialization/instances/accounts-controller/accounts-controller.ts @@ -16,6 +16,7 @@ export const accountsController: InitializationConfiguration< AccountsControllerMessenger > = { name: 'AccountsController', + reference: AccountsController, init: ({ state, messenger }) => new AccountsController({ state, diff --git a/packages/wallet/src/initialization/instances/address-book-controller/address-book-controller.ts b/packages/wallet/src/initialization/instances/address-book-controller/address-book-controller.ts index d21148e659..b1df4c61d3 100644 --- a/packages/wallet/src/initialization/instances/address-book-controller/address-book-controller.ts +++ b/packages/wallet/src/initialization/instances/address-book-controller/address-book-controller.ts @@ -11,6 +11,7 @@ export const addressBookController: InitializationConfiguration< AddressBookControllerMessenger > = { name: 'AddressBookController', + reference: AddressBookController, init: ({ state, messenger }) => new AddressBookController({ state, diff --git a/packages/wallet/src/initialization/instances/approval-controller/approval-controller.ts b/packages/wallet/src/initialization/instances/approval-controller/approval-controller.ts index d88456fb35..8bf3f5916d 100644 --- a/packages/wallet/src/initialization/instances/approval-controller/approval-controller.ts +++ b/packages/wallet/src/initialization/instances/approval-controller/approval-controller.ts @@ -31,6 +31,7 @@ export const approvalController: InitializationConfiguration< ApprovalControllerMessenger > = { name: 'ApprovalController', + reference: ApprovalController, init: ({ state, messenger, options }) => new ApprovalController({ state, diff --git a/packages/wallet/src/initialization/instances/claims-controller/claims-controller.ts b/packages/wallet/src/initialization/instances/claims-controller/claims-controller.ts index 8ebbf66a2f..d15b35abc5 100644 --- a/packages/wallet/src/initialization/instances/claims-controller/claims-controller.ts +++ b/packages/wallet/src/initialization/instances/claims-controller/claims-controller.ts @@ -9,6 +9,7 @@ export const claimsController: InitializationConfiguration< ClaimsControllerMessenger > = { name: 'ClaimsController', + reference: ClaimsController, init: ({ state, messenger }) => new ClaimsController({ messenger, diff --git a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts index 3b79a846e4..b99a43f4d5 100644 --- a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts +++ b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts @@ -11,6 +11,7 @@ export const configRegistryController: InitializationConfiguration< ConfigRegistryControllerMessenger > = { name: 'ConfigRegistryController', + reference: ConfigRegistryController, init: ({ state, messenger, options }) => new ConfigRegistryController({ messenger, diff --git a/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.ts b/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.ts index f9a4a727d9..9c3c59399a 100644 --- a/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.ts +++ b/packages/wallet/src/initialization/instances/connectivity-controller/connectivity-controller.ts @@ -16,6 +16,7 @@ export const connectivityController: InitializationConfiguration< ConnectivityControllerMessenger > = { name: 'ConnectivityController', + reference: ConnectivityController, init: ({ messenger, options }) => new ConnectivityController({ messenger, diff --git a/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.ts b/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.ts index fc2f1b2eff..5984d92d4b 100644 --- a/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.ts +++ b/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.ts @@ -18,6 +18,7 @@ export const gasFeeController: InitializationConfiguration< GasFeeMessenger > = { name: 'GasFeeController', + reference: GasFeeController, init: ({ state, messenger, options }) => { const { clientId, diff --git a/packages/wallet/src/initialization/instances/keyring-controller/keyring-controller.ts b/packages/wallet/src/initialization/instances/keyring-controller/keyring-controller.ts index fa6cc7c9db..274543dee2 100644 --- a/packages/wallet/src/initialization/instances/keyring-controller/keyring-controller.ts +++ b/packages/wallet/src/initialization/instances/keyring-controller/keyring-controller.ts @@ -14,6 +14,7 @@ export const keyringController: InitializationConfiguration< KeyringControllerMessenger > = { name: 'KeyringController', + reference: KeyringController, init: ({ state, messenger, options }) => new KeyringController({ state, diff --git a/packages/wallet/src/initialization/instances/network-controller/network-controller.ts b/packages/wallet/src/initialization/instances/network-controller/network-controller.ts index 5557dace48..ca817f64f0 100644 --- a/packages/wallet/src/initialization/instances/network-controller/network-controller.ts +++ b/packages/wallet/src/initialization/instances/network-controller/network-controller.ts @@ -11,6 +11,7 @@ export const networkController: InitializationConfiguration< NetworkControllerMessenger > = { name: 'NetworkController', + reference: NetworkController, init: ({ state, messenger, options }) => new NetworkController({ state, diff --git a/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts index 7e40e7ce59..a68ba978ca 100644 --- a/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts +++ b/packages/wallet/src/initialization/instances/passkey-controller/passkey-controller.ts @@ -16,6 +16,7 @@ export const passkeyController: InitializationConfiguration< PasskeyControllerMessenger > = { name: 'PasskeyController', + reference: PasskeyController, init: ({ state, messenger, options }) => new PasskeyController({ ...options, diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts index 29b29fdcd3..b10805b0af 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -11,6 +11,7 @@ export const remoteFeatureFlagController: InitializationConfiguration< RemoteFeatureFlagControllerMessenger > = { name: 'RemoteFeatureFlagController', + reference: RemoteFeatureFlagController, init: ({ state, messenger, options }) => new RemoteFeatureFlagController({ state, diff --git a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts index 1f07d40ffc..5d02a58160 100644 --- a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts @@ -12,6 +12,7 @@ export const seedlessOnboardingController: InitializationConfiguration< SeedlessOnboardingControllerMessenger > = { name: 'SeedlessOnboardingController', + reference: SeedlessOnboardingController, init: ({ state, messenger, options }) => new SeedlessOnboardingController({ ...options, diff --git a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts index 01261555f9..e296f67aba 100644 --- a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts +++ b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts @@ -11,6 +11,7 @@ export const shieldController: InitializationConfiguration< ShieldControllerMessenger > = { name: 'ShieldController', + reference: ShieldController, init: ({ state, messenger, options }) => new ShieldController({ messenger, diff --git a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts index c2247dc7fa..30032062e3 100644 --- a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts +++ b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts @@ -28,6 +28,7 @@ export const subscriptionController: InitializationConfiguration< SubscriptionControllerMessenger > = { name: 'SubscriptionController', + reference: SubscriptionController, init: ({ state, messenger, options }) => new SubscriptionController({ ...options, diff --git a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.ts b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.ts index 65eeb232fe..15e4c4b5d3 100644 --- a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.ts +++ b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.ts @@ -11,6 +11,7 @@ export const transactionController: InitializationConfiguration< TransactionControllerMessenger > = { name: 'TransactionController', + reference: TransactionController, init: ({ state, messenger, options }) => { const { disableSwaps = false, ...rest } = options; diff --git a/packages/wallet/src/initialization/types.ts b/packages/wallet/src/initialization/types.ts index 891b653c6d..56038dc7c4 100644 --- a/packages/wallet/src/initialization/types.ts +++ b/packages/wallet/src/initialization/types.ts @@ -1,3 +1,8 @@ +import type { + StateConstraint, + ValidatableController, +} from '@metamask/base-controller'; + import type { InstanceSpecificOptions } from '../types.js'; import type { DefaultActions, @@ -53,4 +58,9 @@ export type InitializationConfiguration = { getMessenger( parent: RootMessenger, ): InstanceMessenger; -}; +} & (InstanceState extends StateConstraint + ? { + reference: (new (...args: any[]) => Instance) & + Partial>>; + } + : { reference?: undefined }); diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 426ff0df53..84e8da9998 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -27,10 +27,8 @@ import type { InitializationConfiguration } from './initialization/types.js'; export type WalletOptions = { messenger?: RootMessenger; state?: Record | undefined>; - initializationConfigurations?: InitializationConfiguration< - unknown, - unknown - >[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializationConfigurations?: InitializationConfiguration[]; instanceOptions: InstanceSpecificOptions; };