Skip to content

Commit b1a9bcf

Browse files
feat(controller-integration): add metamask-extension overlay
Adds the verified metamask-extension repo overlay for the controller-integration skill and generalizes the base skill description to cover both mobile Engine and extension background integration paths. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent df389a9 commit b1a9bcf

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
## 2. Canonical examples (read, don't guess)
19+
20+
| Controller | Copy for |
21+
|---|---|
22+
| `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 |
23+
| `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 |
24+
| `app/scripts/messenger-client-init/rewards-controller-init.ts` + `messengers/rewards-controller-messenger.ts` | Remote-feature-flag gating via init messenger |
25+
| `PerpsController` entries in `app/scripts/metamask-controller.js` + `shared/lib/environment.ts` | Build-flag gating (conditional init-map spread) |
26+
| `app/scripts/messenger-client-init/data-deletion-service-init.ts` | Stateless service |
27+
28+
## 3. Ordered steps
29+
30+
| # | File | Action |
31+
|---|---|---|
32+
| 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) |
33+
| 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 |
34+
| 3 | `app/scripts/messenger-client-init/messengers/<name>-controller-messenger.test.ts` | `expect(messenger).toBeInstanceOf(Messenger)`; optionally assert delegation via a `delegate` spy |
35+
| 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 |
36+
| 5 | `app/scripts/messenger-client-init/controller-list.ts` | Add the class to the `MessengerClient` union; add `XController['state'] &` to `MessengerClientFlatState` |
37+
| 6 | `app/scripts/messenger-client-init/utils.ts` | Add `'XController'` to `MessengerClientsToInitialize` for typed messenger inference in the init fn |
38+
| 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 |
39+
| 8 | `app/scripts/messenger-client-init/<name>-controller-init.test.ts` | Use `buildControllerInitRequestMock()` from `./test/utils` (path is `test/utils.ts`) |
40+
| 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 |
41+
| 10 | `shared/types/background.ts` | Add each top-level state property to `ControllerStatePropertiesEnumerated` **and** the state type to `ControllerStateTypesMerged` |
42+
| 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 |
43+
| 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 |
44+
| 13 | `test/e2e/tests/metrics/state-snapshots/*.json` | Refresh via the e2e run in §7 — any new mem-state key changes these enforced snapshots |
45+
| 14 | `ui/selectors/<domain>.ts` (+ test) | State is **flattened** into `state.metamask` (top-level properties, un-nested); read with `??` fallbacks; derive with `createSelector` |
46+
| 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 |
47+
| 16 | `.github/CODEOWNERS` | Add `app/scripts/messenger-client-init/<sub> @MetaMask/<team>` if a team owns a new subfolder |
48+
49+
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`.
50+
51+
### Messenger skeleton (step 2)
52+
53+
```ts
54+
import { Messenger, MessengerActions, MessengerEvents } from '@metamask/messenger';
55+
import { RootMessenger } from '../../lib/messenger';
56+
57+
export function getXControllerMessenger(
58+
messenger: RootMessenger<
59+
MessengerActions<XControllerMessenger>,
60+
MessengerEvents<XControllerMessenger>
61+
>,
62+
): XControllerMessenger {
63+
const controllerMessenger: XControllerMessenger = new Messenger({
64+
namespace: 'XController',
65+
parent: messenger,
66+
});
67+
messenger.delegate({
68+
messenger: controllerMessenger,
69+
actions: ['OtherController:getState'],
70+
events: ['OtherController:stateChange'],
71+
});
72+
return controllerMessenger;
73+
}
74+
```
75+
76+
`delegate()` is the runtime authorization — the type union alone does not allow `messenger.call(...)`.
77+
78+
### Init function skeleton (step 7)
79+
80+
```ts
81+
export const XControllerInit: MessengerClientInitFunction<
82+
XController,
83+
XControllerMessenger
84+
> = ({ controllerMessenger, persistedState }) => {
85+
const messengerClient = new XController({
86+
messenger: controllerMessenger,
87+
state: persistedState.XController ?? getDefaultXControllerState(),
88+
});
89+
return {
90+
messengerClient,
91+
api: { getThing: messengerClient.getThing.bind(messengerClient) },
92+
};
93+
};
94+
```
95+
96+
## 4. Deltas
97+
98+
**Stateless service** — skip steps 10-15; in step 5 add to the `MessengerClient` union only; in step 7 return `{ messengerClient, persistedStateKey: null, memStateKey: null }`.
99+
100+
**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>')`.
101+
102+
**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.
103+
104+
## 5. Feature gating
105+
106+
| Idiom | Where | Reference |
107+
|---|---|---|
108+
| Init-messenger flag read (lazy, per call) | Constructor callbacks call `initMessenger.call('RemoteFeatureFlagController:getState')` | `rewards-controller-init.ts` |
109+
| Runtime flag subscription | Delegate `'RemoteFeatureFlagController:stateChange'` on the controller messenger; the package reacts itself | `messengers/perps-controller-messenger.ts` |
110+
| Selector gating | `createSelector` over `getRemoteFeatureFlags` | `ui/selectors/config-registry/config-registry.ts` |
111+
| 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` |
112+
113+
## 6. Silent failures (no type error — verify manually)
114+
115+
| Mistake | Symptom |
116+
|---|---|
117+
| Init map entry placed before a dependency | Throw `Messenger client requested before it was initialized: <Name>` — move the entry later |
118+
| Typo'd key in `messengerClientInitFunctions` | Fails at runtime in the factory lookup — the JSDoc `@type {InitFunctions}` is advisory only |
119+
| Calling a non-delegated action/event | Runtime rejection from `@metamask/messenger` at call/subscribe time |
120+
| UI methods defined outside the init result `api` | Methods never reach the UI — the connection API is `{...getApi(), ...messengerClientApi}` only |
121+
| `persistedStateKey: null` on a controller that needs persistence | State silently resets every restart; `persistedState.X` hydrates as `undefined` |
122+
| Sensitive top-level key without a `REMOVE_KEYS`/`REMOVE_PATHS` entry | Secrets stream to the UI in state patches |
123+
| Relying on `anonymous` metadata for Sentry | Sentry masking is the manual `SENTRY_BACKGROUND_STATE` object; an over-permissive `true` entry leaks the value |
124+
| State property lacking `persist` metadata | Patch filter counts it as persisted but `deriveStateFromMetadata` drops it from disk writes |
125+
126+
## 7. Verify
127+
128+
```bash
129+
yarn test:unit app/scripts/messenger-client-init/<name>-controller-init.test.ts
130+
yarn lint:tsc
131+
yarn test:e2e:single test/e2e/tests/metrics/errors.spec.ts --update-snapshot # refreshes enforced Sentry state snapshots
132+
```
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
repo: metamask-mobile
3+
parent: controller-integration
4+
---
5+
6+
# Controller Integration into Engine
7+
8+
## 1. Path decision
9+
10+
| Path | When | Resolve via |
11+
|---|---|---|
12+
| Modular init (default) | New stateful controller | `MESSENGER_FACTORIES` + `*-init.ts`, `messengerClientsByName.<Name>` |
13+
| Stateless service | No persisted state (e.g. an API service) | Same as modular, minus state steps |
14+
| Wallet-owned | Name is one of: AccountsController, ApprovalController, ConnectivityController, KeyringController, NetworkController, RemoteFeatureFlagController, StorageService | `this.#wallet.getInstance('<Name>')` — never an init function |
15+
16+
## 2. Canonical examples (read, don't guess)
17+
18+
| Controller | Copy for |
19+
|---|---|
20+
| `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 |
21+
| `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 |
22+
| `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 |
23+
24+
## 3. Ordered steps
25+
26+
| # | File | Action |
27+
|---|---|---|
28+
| 1 | `package.json` | `yarn add @metamask/<name>-controller` (skip if repo-local) |
29+
| 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`) |
30+
| 3 | `app/core/Engine/messengers/<name>-controller-messenger.test.ts` | `expect(messenger).toBeInstanceOf(Messenger)` |
31+
| 4 | `app/core/Engine/messengers/index.ts` | Add to `MESSENGER_FACTORIES`: `{ getMessenger, getInitMessenger: noop }` (or the real init messenger fn) |
32+
| 5 | `app/core/Engine/types.ts` | Add to `GlobalActions`, `GlobalEvents`, `MessengerClients`, `EngineState`, `MessengerClientsToInitialize` (+ `RequiredControllers`/`OptionalControllers` if optional) |
33+
| 6 | `app/core/Engine/controllers/<name>-controller-init.ts` | Create `MessengerClientInitFunction` (skeleton below), return `{ controller }` only |
34+
| 7 | `app/core/Engine/controllers/<name>-controller-init.test.ts` | Use `buildMessengerClientInitRequestMock` from `app/core/Engine/utils/test-utils.ts` |
35+
| 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` |
36+
| 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()` |
37+
| 10 | `app/util/test/initial-background-state.json` | Add default state (run step in §7 verify to get the exact expected value) |
38+
| 11 | `app/selectors/<name>.ts` (+ test) | `state.engine.backgroundState.<Name> ?? getDefault<Name>State()`; derive with `createSelector` |
39+
| 12 | `.github/CODEOWNERS` | Add glob entries for the init and messenger paths |
40+
| 13 | `tests/feature-flags/feature-flag-registry.ts` | Register the flag key, if the controller is remote-flag gated |
41+
42+
### Messenger skeleton (step 2)
43+
44+
```ts
45+
type AllowedActions = MessengerActions<XControllerMessenger>;
46+
type AllowedEvents = MessengerEvents<XControllerMessenger>;
47+
48+
export function getXControllerMessenger(
49+
messenger: RootMessenger,
50+
): XControllerMessenger {
51+
const controllerMessenger = new Messenger<'X', AllowedActions, AllowedEvents, RootMessenger>({
52+
namespace: 'X',
53+
parent: messenger,
54+
});
55+
56+
messenger.delegate({
57+
messenger: controllerMessenger,
58+
actions: ['OtherController:getState'],
59+
events: ['OtherController:stateChange'],
60+
});
61+
62+
return controllerMessenger;
63+
}
64+
```
65+
66+
`delegate()` is the runtime authorization — a type union alone does not allow `messenger.call(...)`.
67+
68+
### Init function skeleton (step 6)
69+
70+
```ts
71+
export const xControllerInit: MessengerClientInitFunction<
72+
XController,
73+
XControllerMessenger
74+
> = ({ controllerMessenger, persistedState }) => {
75+
const controller = new XController({
76+
messenger: controllerMessenger,
77+
state: persistedState.X,
78+
});
79+
return { controller };
80+
};
81+
```
82+
83+
## 4. Deltas
84+
85+
**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`.
86+
87+
**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.
88+
89+
## 5. Feature gating
90+
91+
| Idiom | Where | Reference |
92+
|---|---|---|
93+
| Init-time flag subscription | Init messenger `subscribe('RemoteFeatureFlagController:stateChange', ...)` before controller runs expensive work | `money-account-upgrade-controller-init.ts` |
94+
| Selector gating | UI reads a selector composed from `selectRemoteFeatureFlags` | `app/selectors/configRegistry.ts` (`getIsConfigRegistryApiEnabled`) |
95+
| Delegated `getState` | Controller checks flag itself; delegate `RemoteFeatureFlagController:getState`/`:stateChange` on its own messenger | `config-registry-controller-messenger.ts` |
96+
| 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` |
97+
98+
## 6. Silent failures (no type error — verify manually)
99+
100+
| Mistake | Symptom |
101+
|---|---|
102+
| 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 |
103+
| Wrong event name (`:stateChange` vs `:stateChanged`) | Same as above — subscription never fires |
104+
| Controller missing from `Engine.state` getter | `UPDATE_BG_STATE` writes `undefined` into Redux for that key |
105+
| Calling a non-delegated action | Runtime throw from `@metamask/messenger` at call time |
106+
| Dependency read before it's initialized | `Error: Messenger client requested before it was initialized: <Name>` — move the entry later in `initFunctions` |
107+
| Controller metadata has no `persist: true` key | Redux updates but state never survives restart (silently skipped, logged only) |
108+
109+
## 7. Verify
110+
111+
```bash
112+
yarn jest app/core/Engine/Engine.test.ts -t 'matches initial state fixture'
113+
yarn tsc --noEmit -p tsconfig.json
114+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
name: controller-integration
3+
description: >
4+
Wire a new or existing controller into the MetaMask Mobile Engine or the
5+
MetaMask Extension background. Use this skill when: adding a controller to
6+
Engine or to the extension's messenger-client init, creating a controller
7+
init function or messenger, delegating actions/events between controllers,
8+
adding a controller to EngineState/Engine.context or to MESSENGER_FACTORIES
9+
and the MessengerClient union, or debugging a controller whose state never
10+
reaches Redux/the UI or resets on restart.
11+
---

0 commit comments

Comments
 (0)