Skip to content

Commit fad21fb

Browse files
feat(skills): add controller-integration skill for mobile and extension (#62)
## Description Adds the `controller-integration` skill: a base `skill.md` plus repo overlays for MetaMask Mobile (Engine) and MetaMask Extension (background messenger-client init), based on verified read-only exploration of both codebases. Fixes https://consensyssoftware.atlassian.net/browse/MCWP-471 ## Type of Change - [x] New skill ## Skill Details (if adding a new skill) **Provider Name:** MetaMask **Skill Name:** controller-integration **Brief Description:** Wire a new or existing controller into the MetaMask Mobile Engine or the MetaMask Extension background — init function, messenger, delegation, state persistence, feature gating, and verification steps for each repo. ## Checklist - [x] I have read the CONTRIBUTING.md guidelines - [x] My skill follows the SKILL_TEMPLATE.md format - [x] I have tested this skill with an AI agent - [x] My skill does not contain any secrets, private keys, or sensitive data - [x] I have added appropriate documentation - [x] My changes don't break existing skills ## Testing - `metamask-skills.mjs describe coding/controller-integration --repo metamask-extension` → `repo match: yes` - `metamask-skills.mjs describe coding/controller-integration --repo metamask-mobile` → `repo match: yes` - `node --test test/*.test.mjs` → 36/36 pass ## Additional Context ### Example of agent chat using the skill #### Mobile (Sonnet 4.6 medium) --- I want to implement a controller, how should I do? --- Here is the full process to integrate a controller into the MetaMask Mobile Engine. --- ## Path decision First, determine which path applies to your controller: | Path | When | |---|---| | **Modular init** (default) | New stateful controller | | **Stateless service** | No persisted state | | **Wallet-owned** | `AccountsController`, `KeyringController`, `NetworkController`, etc. — use `this.#wallet.getInstance('<Name>')` | Then, decide the **source** of the class: | Source | When | |---|---| | **npm package** | `yarn add @metamask/<name>-controller` | | **Repo-local** | Create `app/core/Engine/controllers/<name>-controller/` with a class, `types.ts`, and default state | --- ## Canonical examples to copy from - `config-registry-controller-init.ts` + its messenger — standard npm-package pattern - `card-controller/` — repo-local pattern - `money-account-upgrade-controller-init.ts` — cross-controller delegation and remote-flag gating - `rewards-controller/` — repo-local + remote-flag gating --- ## Steps (in order) 1. **Add the package** (`yarn add`) or create the local controller folder 2. **Create the messenger** in `app/core/Engine/messengers/<name>-controller-messenger.ts` — use `getXControllerMessenger` with `messenger.delegate(...)` for every allowed action/event 3. **Write a messenger test** — `expect(messenger).toBeInstanceOf(Messenger)` 4. **Add to `MESSENGER_FACTORIES`** in `app/core/Engine/messengers/index.ts` 5. **Update `app/core/Engine/types.ts`** — add to `GlobalActions`, `GlobalEvents`, `MessengerClients`, `EngineState`, `MessengerClientsToInitialize` 6. **Create the init function** — `MessengerClientInitFunction` returning `{ controller }` 7. **Write an init test** — use `buildMessengerClientInitRequestMock` 8. **Add the state-change event** to `BACKGROUND_STATE_CHANGE_EVENT_NAMES` in `constants.ts` (⚠️ check whether the package uses `:stateChange` or `:stateChanged`) 9. **Wire into `Engine.ts`** — import init fn, add to `initFunctions` after its dependencies, add to `this.context`, add to `get state()` 10. **Update `app/util/test/initial-background-state.json`** with the default state 11. **Create a selector** in `app/selectors/<name>.ts` — `state.engine.backgroundState.<Name> ?? getDefault<Name>State()` 12. **Add to `.github/CODEOWNERS`** 13. If remote-flag gated, **register the flag key** in `tests/feature-flags/feature-flag-registry.ts` --- ## Verify your wiring ```bash yarn jest app/core/Engine/Engine.test.ts -t 'matches initial state fixture' yarn tsc --noEmit -p tsconfig.json ``` --- ## Common silent failures (no type error) - Missing event in `BACKGROUND_STATE_CHANGE_EVENT_NAMES` → Redux state goes stale after init - Wrong event name (`:stateChange` vs `:stateChanged`) → same symptom - Controller missing from `get state()` → Redux gets `undefined` for that key - Non-delegated action called at runtime → throw from `@metamask/messenger` --- #### Extension (Sonnet 4.6 medium) --- I want to implement a controller, how should I do? --- Here's a summary of the key steps to implement a controller in this codebase: --- ## 1. File structure Create a folder under `app/scripts/controllers/your-controller/`: - `your-controller.ts` — main controller file - `your-controller.test.ts` — colocated tests - `types.ts` — TypeScript types and messenger types --- ## 2. Define types and messenger ```typescript // types.ts import type { ControllerGetStateAction, ControllerStateChangeEvent, RestrictedControllerMessenger } from '@metamask/base-controller'; export type YourControllerState = { items: Item[]; }; export type YourControllerGetStateAction = ControllerGetStateAction<'YourController', YourControllerState>; export type YourControllerActions = YourControllerGetStateAction; export type YourControllerStateChangeEvent = ControllerStateChangeEvent<'YourController', YourControllerState>; export type YourControllerEvents = YourControllerStateChangeEvent; type AllowedActions = NetworkControllerGetStateAction; // whatever you need type AllowedEvents = NetworkControllerStateChangeEvent; export type YourControllerMessenger = RestrictedControllerMessenger< 'YourController', YourControllerActions | AllowedActions, YourControllerEvents | AllowedEvents, AllowedActions['type'], AllowedEvents['type'] >; ``` --- ## 3. Define metadata and default state ```typescript // your-controller.ts const yourControllerMetadata = { items: { anonymous: true, // no PII, safe for Sentry includeInStateLogs: true, persist: true, usedInUi: true, }, }; export function getDefaultYourControllerState(): YourControllerState { return { items: [] }; // always a function, never a plain object export } ``` --- ## 4. Implement the controller class ```typescript export class YourController extends BaseController< 'YourController', YourControllerState, YourControllerMessenger > { constructor({ messenger, state = {}, }: { messenger: YourControllerMessenger; state?: Partial<YourControllerState>; }) { super({ name: 'YourController', metadata: yourControllerMetadata, messenger, state: { ...getDefaultYourControllerState(), ...state }, }); // Subscribe to other controllers via messenger this.messagingSystem.subscribe('NetworkController:stateChange', this.#handleNetworkChange.bind(this)); } // High-level action methods, not setters addItem(item: Item): void { if (!item.id) throw new Error('Item id is required'); this.update((state) => { state.items.push(item); }); } removeItem(id: string): void { this.update((state) => { state.items = state.items.filter((i) => i.id !== id); }); } #handleNetworkChange(networkState: NetworkControllerState): void { // react to network changes } destroy(): void { // clean up intervals, etc. super.destroy(); } } ``` --- ## 5. Export selectors (not getters) ```typescript import { createSelector } from 'reselect'; const selectItems = (state: YourControllerState) => state.items; export const yourControllerSelectors = { selectItems, selectItemCount: createSelector([selectItems], (items) => items.length), }; ``` --- ## Key rules to remember | Rule | Why | |---|---| | `this.update()` only, never direct mutation | Immer-based, ensures immutability | | Single options bag in constructor | Consistency across the codebase | | Export `getDefault...State()` function, not object | Prevents shared reference mutations | | No getters — use selectors | Reusable outside the controller, memoizable | | Messenger for inter-controller communication | Decoupled, testable | | Methods model user actions, not property setters | Clear intent | Once you're done, if you also need to **wire the controller into the extension**, let me know and I can guide you through the integration step (connecting it to `metamask-controller.ts`, the messenger client, and Redux). --- --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent df389a9 commit fad21fb

3 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
```
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
Independently of the path above, the controller class itself comes from one of two sources — the wiring steps in §3 are identical either way, only step 1 (and where types are imported from) differs:
17+
18+
| Source | When | Where the class lives |
19+
|---|---|---|
20+
| npm package | Controller is owned/shared outside mobile (most new controllers) | `@metamask/<name>-controller`, imported as a dependency |
21+
| Repo-local | Controller is mobile-only | `app/core/Engine/controllers/<name>-controller/` (own folder: class, `types.ts`, default state, init fn) — e.g. `card-controller/` (`CardController`), `rewards-controller/` (`RewardsController`) |
22+
23+
## 2. Canonical examples (read, don't guess)
24+
25+
| Controller | Copy for |
26+
|---|---|
27+
| `app/core/Engine/controllers/config-registry-controller-init.ts` + `app/core/Engine/messengers/config-registry-controller-messenger.ts` | Default pattern (npm-package controller): init fn, messenger + delegate, tests, selector, fixture entry |
28+
| `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 |
29+
| `app/core/Engine/controllers/card-controller/` (`CardController.ts`, `types.ts`, `index.ts` as init) + `app/core/Engine/messengers/card-controller-messenger.ts` | Repo-local controller pattern: class + messenger type + default state co-located in one folder, init fn in the folder's `index.ts` |
30+
| `app/core/Engine/controllers/rewards-controller/` (`RewardsController.ts`) + `app/core/Engine/messengers/rewards-controller-messenger.ts` | Repo-local controller with remote-flag gating |
31+
| `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 |
32+
33+
## 3. Ordered steps
34+
35+
| # | File | Action |
36+
|---|---|---|
37+
| 1 | `package.json`, or `app/core/Engine/controllers/<name>-controller/` | **npm package**: `yarn add @metamask/<name>-controller`. **Repo-local**: create the folder with the controller class (extend `BaseController`), `types.ts` (state, messenger type, `ControllerStateChangeEvent`-based Events union), and default state — mirror `card-controller/` or `rewards-controller/` |
38+
| 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`) |
39+
| 3 | `app/core/Engine/messengers/<name>-controller-messenger.test.ts` | `expect(messenger).toBeInstanceOf(Messenger)` |
40+
| 4 | `app/core/Engine/messengers/index.ts` | Add to `MESSENGER_FACTORIES`: `{ getMessenger, getInitMessenger: noop }` (or the real init messenger fn) |
41+
| 5 | `app/core/Engine/types.ts` | Add to `GlobalActions`, `GlobalEvents`, `MessengerClients`, `EngineState`, `MessengerClientsToInitialize` (+ `RequiredControllers`/`OptionalControllers` if optional). Import the controller/messenger types from `@metamask/<name>-controller` (package) or from the local `controllers/<name>-controller/types.ts` (repo-local) |
42+
| 6 | `app/core/Engine/controllers/<name>-controller-init.ts`, or `app/core/Engine/controllers/<name>-controller/index.ts` for a repo-local controller | Create `MessengerClientInitFunction` (skeleton below), return `{ controller }` only |
43+
| 7 | `app/core/Engine/controllers/<name>-controller-init.test.ts` (co-located `index.test.ts` if repo-local) | Use `buildMessengerClientInitRequestMock` from `app/core/Engine/utils/test-utils.ts` |
44+
| 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` |
45+
| 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()` |
46+
| 10 | `app/util/test/initial-background-state.json` | Add default state (run step in §7 verify to get the exact expected value) |
47+
| 11 | `app/selectors/<name>.ts` (+ test) | `state.engine.backgroundState.<Name> ?? getDefault<Name>State()`; derive with `createSelector` |
48+
| 12 | `.github/CODEOWNERS` | Add glob entries for the init and messenger paths |
49+
| 13 | `tests/feature-flags/feature-flag-registry.ts` | Register the flag key, if the controller is remote-flag gated |
50+
51+
### Messenger skeleton (step 2)
52+
53+
```ts
54+
type AllowedActions = MessengerActions<XControllerMessenger>;
55+
type AllowedEvents = MessengerEvents<XControllerMessenger>;
56+
57+
export function getXControllerMessenger(
58+
messenger: RootMessenger,
59+
): XControllerMessenger {
60+
const controllerMessenger = new Messenger<'X', AllowedActions, AllowedEvents, RootMessenger>({
61+
namespace: 'X',
62+
parent: messenger,
63+
});
64+
65+
messenger.delegate({
66+
messenger: controllerMessenger,
67+
actions: ['OtherController:getState'],
68+
events: ['OtherController:stateChange'],
69+
});
70+
71+
return controllerMessenger;
72+
}
73+
```
74+
75+
`delegate()` is the runtime authorization — a type union alone does not allow `messenger.call(...)`.
76+
77+
### Init function skeleton (step 6)
78+
79+
```ts
80+
export const xControllerInit: MessengerClientInitFunction<
81+
XController,
82+
XControllerMessenger
83+
> = ({ controllerMessenger, persistedState }) => {
84+
const controller = new XController({
85+
messenger: controllerMessenger,
86+
state: persistedState.X,
87+
});
88+
return { controller };
89+
};
90+
```
91+
92+
## 4. Deltas
93+
94+
**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`.
95+
96+
**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.
97+
98+
## 5. Feature gating
99+
100+
| Idiom | Where | Reference |
101+
|---|---|---|
102+
| Init-time flag subscription | Init messenger `subscribe('RemoteFeatureFlagController:stateChange', ...)` before controller runs expensive work | `money-account-upgrade-controller-init.ts` |
103+
| Selector gating | UI reads a selector composed from `selectRemoteFeatureFlags` | `app/selectors/configRegistry.ts` (`getIsConfigRegistryApiEnabled`) |
104+
| Delegated `getState` | Controller checks flag itself; delegate `RemoteFeatureFlagController:getState`/`:stateChange` on its own messenger | `config-registry-controller-messenger.ts` |
105+
| 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` |
106+
107+
## 6. Silent failures (no type error — verify manually)
108+
109+
| Mistake | Symptom |
110+
|---|---|
111+
| 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 |
112+
| Wrong event name (`:stateChange` vs `:stateChanged`) | Same as above — subscription never fires |
113+
| Controller missing from `Engine.state` getter | `UPDATE_BG_STATE` writes `undefined` into Redux for that key |
114+
| Calling a non-delegated action | Runtime throw from `@metamask/messenger` at call time |
115+
| Dependency read before it's initialized | `Error: Messenger client requested before it was initialized: <Name>` — move the entry later in `initFunctions` |
116+
| Controller metadata has no `persist: true` key | Redux updates but state never survives restart (silently skipped, logged only). For a repo-local controller this metadata is authored by you in the class's own `metadata` object, not inherited from a package |
117+
118+
## 7. Verify
119+
120+
```bash
121+
yarn jest app/core/Engine/Engine.test.ts -t 'matches initial state fixture'
122+
yarn tsc --noEmit -p tsconfig.json
123+
```

0 commit comments

Comments
 (0)