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.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 2cc4047e45..1e8de918fa 100644 --- a/packages/base-controller/src/BaseController.ts +++ b/packages/base-controller/src/BaseController.ts @@ -5,6 +5,7 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import { Struct, validate } from '@metamask/superstruct'; import type { Json, PublicInterface } from '@metamask/utils'; import { enablePatches, produceWithPatches, applyPatches, freeze } from 'immer'; import type { Draft, Patch } from 'immer'; @@ -244,6 +245,7 @@ export class BaseController< metadata, name, state, + struct, }: { messenger: ControllerActions< ControllerName, @@ -264,6 +266,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 @@ -446,3 +449,50 @@ export function deriveStateFromMetadata< } }, {} as never); } + +export type ValidatableController< + Controller, + ControllerState extends StateConstraint, +> = (new (...args: any[]) => Controller) & { + 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 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, + controller: ValidatableController, + state: unknown, + mode: 'strict' | 'lenient', + captureException?: (error: Error) => void, +): ControllerState { + const [validationError, result] = validate(state, controller.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/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; }; 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"