|
| 1 | +--- |
| 2 | +repo: metamask-extension |
| 3 | +parent: controller-integration |
| 4 | +--- |
| 5 | + |
| 6 | +# Controller Integration into the Extension Background |
| 7 | + |
| 8 | +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. |
| 9 | + |
| 10 | +## 1. Path decision |
| 11 | + |
| 12 | +| Path | When | Resolve via | |
| 13 | +|---|---|---| |
| 14 | +| Modular init (default) | New stateful controller or service | `MESSENGER_FACTORIES` + `*-init.ts`, `this.messengerClientsByName.<Name>` | |
| 15 | +| Stateless service | No persisted state (e.g. an API service) | Same as modular, minus state steps | |
| 16 | +| Wallet-managed | Name is one of: AccountsController, ApprovalController, ConnectivityController, KeyringController, NetworkController, RemoteFeatureFlagController, StorageService | `this.wallet.getInstance('<Name>')` — construction happens inside `@metamask/wallet` | |
| 17 | + |
| 18 | +Independently of the path above, the controller class itself comes from one of two sources — the `messenger-client-init/` wiring in §3 is identical either way, only step 1 (and where types are imported from) differs: |
| 19 | + |
| 20 | +| Source | When | Where the class lives | |
| 21 | +|---|---|---| |
| 22 | +| npm package | Controller is owned/shared outside the extension (most new controllers) | `@metamask/<name>-controller`, imported as a dependency | |
| 23 | +| Repo-local | Controller is extension-only | `app/scripts/controllers/<name>/` (class + a `<name>-controller.types.ts` exporting the messenger type) — e.g. `app/scripts/controllers/rewards/rewards-controller.ts` | |
| 24 | + |
| 25 | +## 2. Canonical examples (read, don't guess) |
| 26 | + |
| 27 | +| Controller | Copy for | |
| 28 | +|---|---| |
| 29 | +| `app/scripts/messenger-client-init/geolocation-controller-init.ts` + `messengers/geolocation-controller-messenger.ts` (+ both `.test.ts`) | Default pattern (npm-package controller): init fn, default-state hydration, `api` methods, minimal messenger, tests | |
| 30 | +| `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 | |
| 31 | +| `app/scripts/controllers/rewards/rewards-controller.ts` (+ `rewards-controller.types.ts` exporting `RewardsControllerMessenger`) + `app/scripts/messenger-client-init/rewards-controller-init.ts` + `messengers/rewards-controller-messenger.ts` | Repo-local controller pattern, plus remote-feature-flag gating via init messenger. The init/messenger files are indistinguishable in shape from the npm-package case — only the class import path differs | |
| 32 | +| `PerpsController` entries in `app/scripts/metamask-controller.js` + `shared/lib/environment.ts` | Build-flag gating (conditional init-map spread) | |
| 33 | +| `app/scripts/messenger-client-init/data-deletion-service-init.ts` | Stateless service | |
| 34 | + |
| 35 | +## 3. Ordered steps |
| 36 | + |
| 37 | +| # | File | Action | |
| 38 | +|---|---|---| |
| 39 | +| 1 | `package.json`, or `app/scripts/controllers/<name>/` | **npm package**: `yarn add @metamask/<name>-controller`, then `yarn lint:lockfile:dedupe:fix && yarn allow-scripts auto && yarn lavamoat:auto && yarn attributions:generate`. **Repo-local**: create the folder with the controller class and a `<name>-controller.types.ts` exporting the messenger type — mirror `app/scripts/controllers/rewards/` — no dependency commands needed | |
| 40 | +| 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 | |
| 41 | +| 3 | `app/scripts/messenger-client-init/messengers/<name>-controller-messenger.test.ts` | `expect(messenger).toBeInstanceOf(Messenger)`; optionally assert delegation via a `delegate` spy | |
| 42 | +| 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 | |
| 43 | +| 5 | `app/scripts/messenger-client-init/controller-list.ts` | Add the class to the `MessengerClient` union; add `XController['state'] &` to `MessengerClientFlatState` | |
| 44 | +| 6 | `app/scripts/messenger-client-init/utils.ts` | Add `'XController'` to `MessengerClientsToInitialize` for typed messenger inference in the init fn | |
| 45 | +| 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 | |
| 46 | +| 8 | `app/scripts/messenger-client-init/<name>-controller-init.test.ts` | Use `buildControllerInitRequestMock()` from `./test/utils` (path is `test/utils.ts`) | |
| 47 | +| 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 | |
| 48 | +| 10 | `shared/types/background.ts` | Add each top-level state property to `ControllerStatePropertiesEnumerated` **and** the state type to `ControllerStateTypesMerged` | |
| 49 | +| 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 | |
| 50 | +| 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 | |
| 51 | +| 13 | `test/e2e/tests/metrics/state-snapshots/*.json` | Refresh via the e2e run in §7 — any new mem-state key changes these enforced snapshots | |
| 52 | +| 14 | `ui/selectors/<domain>.ts` (+ test) | State is **flattened** into `state.metamask` (top-level properties, un-nested); read with `??` fallbacks; derive with `createSelector` | |
| 53 | +| 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 | |
| 54 | +| 16 | `.github/CODEOWNERS` | Add `app/scripts/messenger-client-init/<sub> @MetaMask/<team>` if a team owns a new subfolder | |
| 55 | + |
| 56 | +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`. |
| 57 | + |
| 58 | +### Messenger skeleton (step 2) |
| 59 | + |
| 60 | +```ts |
| 61 | +import { Messenger, MessengerActions, MessengerEvents } from '@metamask/messenger'; |
| 62 | +import { RootMessenger } from '../../lib/messenger'; |
| 63 | + |
| 64 | +export function getXControllerMessenger( |
| 65 | + messenger: RootMessenger< |
| 66 | + MessengerActions<XControllerMessenger>, |
| 67 | + MessengerEvents<XControllerMessenger> |
| 68 | + >, |
| 69 | +): XControllerMessenger { |
| 70 | + const controllerMessenger: XControllerMessenger = new Messenger({ |
| 71 | + namespace: 'XController', |
| 72 | + parent: messenger, |
| 73 | + }); |
| 74 | + messenger.delegate({ |
| 75 | + messenger: controllerMessenger, |
| 76 | + actions: ['OtherController:getState'], |
| 77 | + events: ['OtherController:stateChange'], |
| 78 | + }); |
| 79 | + return controllerMessenger; |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +`delegate()` is the runtime authorization — the type union alone does not allow `messenger.call(...)`. |
| 84 | + |
| 85 | +### Init function skeleton (step 7) |
| 86 | + |
| 87 | +```ts |
| 88 | +export const XControllerInit: MessengerClientInitFunction< |
| 89 | + XController, |
| 90 | + XControllerMessenger |
| 91 | +> = ({ controllerMessenger, persistedState }) => { |
| 92 | + const messengerClient = new XController({ |
| 93 | + messenger: controllerMessenger, |
| 94 | + state: persistedState.XController ?? getDefaultXControllerState(), |
| 95 | + }); |
| 96 | + return { |
| 97 | + messengerClient, |
| 98 | + api: { getThing: messengerClient.getThing.bind(messengerClient) }, |
| 99 | + }; |
| 100 | +}; |
| 101 | +``` |
| 102 | + |
| 103 | +## 4. Deltas |
| 104 | + |
| 105 | +**Stateless service** — skip steps 10-15; in step 5 add to the `MessengerClient` union only; in step 7 return `{ messengerClient, persistedStateKey: null, memStateKey: null }`. |
| 106 | + |
| 107 | +**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>')`. |
| 108 | + |
| 109 | +**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. |
| 110 | + |
| 111 | +## 5. Feature gating |
| 112 | + |
| 113 | +| Idiom | Where | Reference | |
| 114 | +|---|---|---| |
| 115 | +| Init-messenger flag read (lazy, per call) | Constructor callbacks call `initMessenger.call('RemoteFeatureFlagController:getState')` | `rewards-controller-init.ts` | |
| 116 | +| Runtime flag subscription | Delegate `'RemoteFeatureFlagController:stateChange'` on the controller messenger; the package reacts itself | `messengers/perps-controller-messenger.ts` | |
| 117 | +| Selector gating | `createSelector` over `getRemoteFeatureFlags` | `ui/selectors/config-registry/config-registry.ts` | |
| 118 | +| 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` | |
| 119 | + |
| 120 | +## 6. Silent failures (no type error — verify manually) |
| 121 | + |
| 122 | +| Mistake | Symptom | |
| 123 | +|---|---| |
| 124 | +| Init map entry placed before a dependency | Throw `Messenger client requested before it was initialized: <Name>` — move the entry later | |
| 125 | +| Typo'd key in `messengerClientInitFunctions` | Fails at runtime in the factory lookup — the JSDoc `@type {InitFunctions}` is advisory only | |
| 126 | +| Calling a non-delegated action/event | Runtime rejection from `@metamask/messenger` at call/subscribe time | |
| 127 | +| UI methods defined outside the init result `api` | Methods never reach the UI — the connection API is `{...getApi(), ...messengerClientApi}` only | |
| 128 | +| `persistedStateKey: null` on a controller that needs persistence | State silently resets every restart; `persistedState.X` hydrates as `undefined` | |
| 129 | +| Sensitive top-level key without a `REMOVE_KEYS`/`REMOVE_PATHS` entry | Secrets stream to the UI in state patches | |
| 130 | +| Relying on `anonymous` metadata for Sentry | Sentry masking is the manual `SENTRY_BACKGROUND_STATE` object; an over-permissive `true` entry leaks the value | |
| 131 | +| State property lacking `persist` metadata | Patch filter counts it as persisted but `deriveStateFromMetadata` drops it from disk writes | |
| 132 | + |
| 133 | +## 7. Verify |
| 134 | + |
| 135 | +```bash |
| 136 | +yarn test:unit app/scripts/messenger-client-init/<name>-controller-init.test.ts |
| 137 | +yarn lint:tsc |
| 138 | +yarn test:e2e:single test/e2e/tests/metrics/errors.spec.ts --update-snapshot # refreshes enforced Sentry state snapshots |
| 139 | +``` |
0 commit comments