Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
repo: metamask-extension
parent: controller-integration
---

# Controller Integration into the Extension Background

The integration surface is `app/scripts/messenger-client-init/` (the pattern is called "messenger client" init; types are `MessengerClientInitFunction`/`MessengerClientInitRequest`/`MessengerClientInitResult`). The root `AGENTS.md` "Workflow: Creating a Controller" predates this pattern — follow the steps below instead.

## 1. Path decision

| Path | When | Resolve via |
|---|---|---|
| Modular init (default) | New stateful controller or service | `MESSENGER_FACTORIES` + `*-init.ts`, `this.messengerClientsByName.<Name>` |
| Stateless service | No persisted state (e.g. an API service) | Same as modular, minus state steps |
| Wallet-managed | Name is one of: AccountsController, ApprovalController, ConnectivityController, KeyringController, NetworkController, RemoteFeatureFlagController, StorageService | `this.wallet.getInstance('<Name>')` — construction happens inside `@metamask/wallet` |

## 2. Canonical examples (read, don't guess)

| Controller | Copy for |
|---|---|
| `app/scripts/messenger-client-init/geolocation-controller-init.ts` + `messengers/geolocation-controller-messenger.ts` (+ both `.test.ts`) | Default pattern: init fn, default-state hydration, `api` methods, minimal messenger, tests |
| `app/scripts/messenger-client-init/transaction-pay-controller-init.ts` + `messengers/transaction-pay-controller-messenger.ts` | Separate init messenger used inside constructor callbacks, heavy cross-controller delegation |
| `app/scripts/messenger-client-init/rewards-controller-init.ts` + `messengers/rewards-controller-messenger.ts` | Remote-feature-flag gating via init messenger |
| `PerpsController` entries in `app/scripts/metamask-controller.js` + `shared/lib/environment.ts` | Build-flag gating (conditional init-map spread) |
| `app/scripts/messenger-client-init/data-deletion-service-init.ts` | Stateless service |

## 3. Ordered steps

| # | File | Action |
|---|---|---|
| 1 | `package.json` | `yarn add @metamask/<name>-controller`, then `yarn lint:lockfile:dedupe:fix && yarn allow-scripts auto && yarn lavamoat:auto && yarn attributions:generate` (skip if repo-local) |
| 2 | `app/scripts/messenger-client-init/messengers/<name>-controller-messenger.ts` | Create `getXControllerMessenger` (skeleton below). Add `getXControllerInitMessenger` (namespace `'XControllerInit'`) only if the init fn itself needs actions/events beyond the controller's messenger type |
| 3 | `app/scripts/messenger-client-init/messengers/<name>-controller-messenger.test.ts` | `expect(messenger).toBeInstanceOf(Messenger)`; optionally assert delegation via a `delegate` spy |
| 4 | `app/scripts/messenger-client-init/messengers/index.ts` | Export the factory fns; add to `MESSENGER_FACTORIES`: `{ getMessenger, getInitMessenger: noop }` (lodash `noop`) or the real init-messenger fn |
| 5 | `app/scripts/messenger-client-init/controller-list.ts` | Add the class to the `MessengerClient` union; add `XController['state'] &` to `MessengerClientFlatState` |
| 6 | `app/scripts/messenger-client-init/utils.ts` | Add `'XController'` to `MessengerClientsToInitialize` for typed messenger inference in the init fn |
| 7 | `app/scripts/messenger-client-init/<name>-controller-init.ts` | Create the init fn (skeleton below). UI-facing methods go in the returned `api` — `getApi()` in `metamask-controller.js` is closed for new additions |
| 8 | `app/scripts/messenger-client-init/<name>-controller-init.test.ts` | Use `buildControllerInitRequestMock()` from `./test/utils` (path is `test/utils.ts`) |
| 9 | `app/scripts/metamask-controller.js` | Import the init fn; add `XController: XControllerInit` to `messengerClientInitFunctions` **after every controller it calls during construction** — map insertion order is init order. ⚠️ This file is un-type-checked JS: a typo'd key fails only at runtime |
| 10 | `shared/types/background.ts` | Add each top-level state property to `ControllerStatePropertiesEnumerated` **and** the state type to `ControllerStateTypesMerged` |
| 11 | `app/scripts/lib/state-utils.ts` | ⚠️ Add sensitive top-level property names to `REMOVE_KEYS`/`REMOVE_PATHS` — this is the only filter before UI patches; `persist`/`anonymous` metadata leaves them through |
| 12 | `app/scripts/constants/sentry-state.ts` | Add a `SENTRY_BACKGROUND_STATE.XController` mask only for properties safe to send to Sentry; unlisted state is reduced to its `typeof` automatically |
| 13 | `test/e2e/tests/metrics/state-snapshots/*.json` | Refresh via the e2e run in §7 — any new mem-state key changes these enforced snapshots |
| 14 | `ui/selectors/<domain>.ts` (+ test) | State is **flattened** into `state.metamask` (top-level properties, un-nested); read with `??` fallbacks; derive with `createSelector` |
| 15 | `test/e2e/fixtures/default-fixture.json` + `withXController` in `test/e2e/fixtures/fixture-builder-v2.ts`; `test/data/mock-state.json` | Optional — only when e2e / UI unit tests need seeded state |
| 16 | `.github/CODEOWNERS` | Add `app/scripts/messenger-client-init/<sub> @MetaMask/<team>` if a team owns a new subfolder |

Persistence and UI state need zero store wiring: the `...controllerPersistedState` / `...controllerMemState` spreads in `metamask-controller.js` pick the controller up under its default keys. A brand-new controller also never needs a migration — init hydrates with `persistedState.X ?? default`.

### Messenger skeleton (step 2)

```ts
import { Messenger, MessengerActions, MessengerEvents } from '@metamask/messenger';
import { RootMessenger } from '../../lib/messenger';

export function getXControllerMessenger(
messenger: RootMessenger<
MessengerActions<XControllerMessenger>,
MessengerEvents<XControllerMessenger>
>,
): XControllerMessenger {
const controllerMessenger: XControllerMessenger = new Messenger({
namespace: 'XController',
parent: messenger,
});
messenger.delegate({
messenger: controllerMessenger,
actions: ['OtherController:getState'],
events: ['OtherController:stateChange'],
});
return controllerMessenger;
}
```

`delegate()` is the runtime authorization — the type union alone does not allow `messenger.call(...)`.

### Init function skeleton (step 7)

```ts
export const XControllerInit: MessengerClientInitFunction<
XController,
XControllerMessenger
> = ({ controllerMessenger, persistedState }) => {
const messengerClient = new XController({
messenger: controllerMessenger,
state: persistedState.XController ?? getDefaultXControllerState(),
});
return {
messengerClient,
api: { getThing: messengerClient.getThing.bind(messengerClient) },
};
};
```

## 4. Deltas

**Stateless service** — skip steps 10-15; in step 5 add to the `MessengerClient` union only; in step 7 return `{ messengerClient, persistedStateKey: null, memStateKey: null }`.

**Wallet-managed** — skip all steps above. Edit the builder in `app/scripts/wallet-init/instance-options/<name>.ts` and, if needed, `app/scripts/wallet-init/initialization.ts`; instances come from `this.wallet.getInstance('<Name>')`.

**Legacy store keys** — `CurrencyController` and `AccountTracker` persist under historical keys via explicit entries in the `metamask-controller.js` store maps; keep those entries intact, persisted user state lives under them.

## 5. Feature gating

| Idiom | Where | Reference |
|---|---|---|
| Init-messenger flag read (lazy, per call) | Constructor callbacks call `initMessenger.call('RemoteFeatureFlagController:getState')` | `rewards-controller-init.ts` |
| Runtime flag subscription | Delegate `'RemoteFeatureFlagController:stateChange'` on the controller messenger; the package reacts itself | `messengers/perps-controller-messenger.ts` |
| Selector gating | `createSelector` over `getRemoteFeatureFlags` | `ui/selectors/config-registry/config-registry.ts` |
| Build flag | `process.env` helper in `shared/lib/environment.ts` + conditional spread in `messengerClientInitFunctions`; guard consumers with `?.` (the extension idiom — mobile-style `ONLY_INCLUDE_IF` fences don't gate controllers here) | `getIsPerpsIncludedInBuild` + the `PerpsController` spread in `metamask-controller.js` |

## 6. Silent failures (no type error — verify manually)

| Mistake | Symptom |
|---|---|
| Init map entry placed before a dependency | Throw `Messenger client requested before it was initialized: <Name>` — move the entry later |
| Typo'd key in `messengerClientInitFunctions` | Fails at runtime in the factory lookup — the JSDoc `@type {InitFunctions}` is advisory only |
| Calling a non-delegated action/event | Runtime rejection from `@metamask/messenger` at call/subscribe time |
| UI methods defined outside the init result `api` | Methods never reach the UI — the connection API is `{...getApi(), ...messengerClientApi}` only |
| `persistedStateKey: null` on a controller that needs persistence | State silently resets every restart; `persistedState.X` hydrates as `undefined` |
| Sensitive top-level key without a `REMOVE_KEYS`/`REMOVE_PATHS` entry | Secrets stream to the UI in state patches |
| Relying on `anonymous` metadata for Sentry | Sentry masking is the manual `SENTRY_BACKGROUND_STATE` object; an over-permissive `true` entry leaks the value |
| State property lacking `persist` metadata | Patch filter counts it as persisted but `deriveStateFromMetadata` drops it from disk writes |

## 7. Verify

```bash
yarn test:unit app/scripts/messenger-client-init/<name>-controller-init.test.ts
yarn lint:tsc
yarn test:e2e:single test/e2e/tests/metrics/errors.spec.ts --update-snapshot # refreshes enforced Sentry state snapshots
```
114 changes: 114 additions & 0 deletions domains/coding/skills/controller-integration/repos/metamask-mobile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
repo: metamask-mobile
parent: controller-integration
---

# Controller Integration into Engine

## 1. Path decision

| Path | When | Resolve via |
|---|---|---|
| Modular init (default) | New stateful controller | `MESSENGER_FACTORIES` + `*-init.ts`, `messengerClientsByName.<Name>` |
| Stateless service | No persisted state (e.g. an API service) | Same as modular, minus state steps |
| Wallet-owned | Name is one of: AccountsController, ApprovalController, ConnectivityController, KeyringController, NetworkController, RemoteFeatureFlagController, StorageService | `this.#wallet.getInstance('<Name>')` — never an init function |

## 2. Canonical examples (read, don't guess)

| Controller | Copy for |
|---|---|
| `app/core/Engine/controllers/config-registry-controller-init.ts` + `app/core/Engine/messengers/config-registry-controller-messenger.ts` | Default pattern: init fn, messenger + delegate, tests, selector, fixture entry |
| `app/core/Engine/controllers/money-account-upgrade-controller-init.ts` + `.../money-account-upgrade-controller-messenger.ts` | Init messenger, cross-controller delegation, remote-flag init-time gating |
| `app/features/SampleFeature/controllers/sample-petnames-controller-init.ts` | `ONLY_INCLUDE_IF(sample-feature)` fencing only — its tests/selector lag the pattern, do not copy those |

## 3. Ordered steps

| # | File | Action |
|---|---|---|
| 1 | `package.json` | `yarn add @metamask/<name>-controller` (skip if repo-local) |
| 2 | `app/core/Engine/messengers/<name>-controller-messenger.ts` | Create `getXControllerMessenger` (skeleton below). Add `getXControllerInitMessenger` too if init-time gating is needed (own namespace `<Name>Initialization`) |
| 3 | `app/core/Engine/messengers/<name>-controller-messenger.test.ts` | `expect(messenger).toBeInstanceOf(Messenger)` |
| 4 | `app/core/Engine/messengers/index.ts` | Add to `MESSENGER_FACTORIES`: `{ getMessenger, getInitMessenger: noop }` (or the real init messenger fn) |
Comment thread
NicolasMassart marked this conversation as resolved.
| 5 | `app/core/Engine/types.ts` | Add to `GlobalActions`, `GlobalEvents`, `MessengerClients`, `EngineState`, `MessengerClientsToInitialize` (+ `RequiredControllers`/`OptionalControllers` if optional) |
| 6 | `app/core/Engine/controllers/<name>-controller-init.ts` | Create `MessengerClientInitFunction` (skeleton below), return `{ controller }` only |
| 7 | `app/core/Engine/controllers/<name>-controller-init.test.ts` | Use `buildMessengerClientInitRequestMock` from `app/core/Engine/utils/test-utils.ts` |
| 8 | `app/core/Engine/constants.ts` | Append `'<Name>:stateChange'` to `BACKGROUND_STATE_CHANGE_EVENT_NAMES`. ⚠️ Copy the exact event name from the package's `Events` type — some emit `:stateChanged` |
| 9 | `app/core/Engine/Engine.ts` | (a) import init fn (b) add to `initFunctions` **after** every controller it depends on — insertion order is init order (c) add to `this.context` (d) add to **both** the destructuring and return object of `get state()` |
| 10 | `app/util/test/initial-background-state.json` | Add default state (run step in §7 verify to get the exact expected value) |
| 11 | `app/selectors/<name>.ts` (+ test) | `state.engine.backgroundState.<Name> ?? getDefault<Name>State()`; derive with `createSelector` |
| 12 | `.github/CODEOWNERS` | Add glob entries for the init and messenger paths |
| 13 | `tests/feature-flags/feature-flag-registry.ts` | Register the flag key, if the controller is remote-flag gated |

### Messenger skeleton (step 2)

```ts
type AllowedActions = MessengerActions<XControllerMessenger>;
type AllowedEvents = MessengerEvents<XControllerMessenger>;

export function getXControllerMessenger(
messenger: RootMessenger,
): XControllerMessenger {
const controllerMessenger = new Messenger<'X', AllowedActions, AllowedEvents, RootMessenger>({
namespace: 'X',
parent: messenger,
});

messenger.delegate({
messenger: controllerMessenger,
actions: ['OtherController:getState'],
events: ['OtherController:stateChange'],
});

return controllerMessenger;
}
```

`delegate()` is the runtime authorization — a type union alone does not allow `messenger.call(...)`.

### Init function skeleton (step 6)

```ts
export const xControllerInit: MessengerClientInitFunction<
XController,
XControllerMessenger
> = ({ controllerMessenger, persistedState }) => {
const controller = new XController({
messenger: controllerMessenger,
state: persistedState.X,
});
return { controller };
};
```

## 4. Deltas

**Stateless service** — skip step 8, step 10, step 11, and the `EngineState`/state-getter parts of steps 5 and 9d. Add the name to `STATELESS_NON_CONTROLLER_NAMES` in `constants.ts`.

**Wallet-owned** — skip steps 2-4, 6-7, and the `MessengerClientsToInitialize` part of step 5. Add `app/core/Engine/wallet-init/instance-options/<name>.ts` (+ test), wire into `initializeWallet`'s `instanceOptions`. In `Engine.ts`, resolve via `this.#wallet.getInstance('<Name>')`; keep steps 5 (types), 8, 9c-d, 10.

## 5. Feature gating

| Idiom | Where | Reference |
|---|---|---|
| Init-time flag subscription | Init messenger `subscribe('RemoteFeatureFlagController:stateChange', ...)` before controller runs expensive work | `money-account-upgrade-controller-init.ts` |
| Selector gating | UI reads a selector composed from `selectRemoteFeatureFlags` | `app/selectors/configRegistry.ts` (`getIsConfigRegistryApiEnabled`) |
| Delegated `getState` | Controller checks flag itself; delegate `RemoteFeatureFlagController:getState`/`:stateChange` on its own messenger | `config-registry-controller-messenger.ts` |
| Build flag | `///: BEGIN:ONLY_INCLUDE_IF(flag)` / `END` — fence types.ts, constants.ts, messengers/index.ts, Engine.ts (import + initFunctions + context + state getter) **consistently** or the fenced build fails to typecheck | `SamplePetnamesController` |

## 6. Silent failures (no type error — verify manually)

| Mistake | Symptom |
|---|---|
| Event name missing from `BACKGROUND_STATE_CHANGE_EVENT_NAMES` | Redux gets the value once at init, then stale forever; nothing persisted; state resets every app restart |
| Wrong event name (`:stateChange` vs `:stateChanged`) | Same as above — subscription never fires |
| Controller missing from `Engine.state` getter | `UPDATE_BG_STATE` writes `undefined` into Redux for that key |
| Calling a non-delegated action | Runtime throw from `@metamask/messenger` at call time |
| Dependency read before it's initialized | `Error: Messenger client requested before it was initialized: <Name>` — move the entry later in `initFunctions` |
| Controller metadata has no `persist: true` key | Redux updates but state never survives restart (silently skipped, logged only) |

## 7. Verify

```bash
yarn jest app/core/Engine/Engine.test.ts -t 'matches initial state fixture'
yarn tsc --noEmit -p tsconfig.json
```
11 changes: 11 additions & 0 deletions domains/coding/skills/controller-integration/skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
name: controller-integration
description: >
Wire a new or existing controller into the MetaMask Mobile Engine or the
MetaMask Extension background. Use this skill when: adding a controller to
Engine or to the extension's messenger-client init, creating a controller
init function or messenger, delegating actions/events between controllers,
adding a controller to EngineState/Engine.context or to MESSENGER_FACTORIES
and the MessengerClient union, or debugging a controller whose state never
reaches Redux/the UI or resets on restart.
---