Skip to content

Commit 8bab3bf

Browse files
authored
feat(card): add CardController shell to Engine (MetaMask#27020)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Introduces the `CardController` shell into the Engine as the first step toward the multi-provider Card architecture. **Why**: The Card feature is tightly coupled to Baanx with business logic scattered across hooks, views, and a 2,600-line SDK monolith. To support multiple card providers and clean up the architecture, we need a proper Engine controller that owns the Card feature's persistent state. This PR lays the foundation — an inert controller that holds state and syncs it to Redux via the standard Engine batcher. No user-facing changes. **What changed**: New files: - **`app/core/Engine/controllers/card-controller/types.ts`**: Defines `CardControllerState` (5 fields: `selectedCountry`, `activeProviderId`, `isAuthenticated`, `cardholderAccounts`, `providerData`), default state factory, and action/event/messenger types. - **`app/core/Engine/controllers/card-controller/CardController.ts`**: Bare-bones controller extending `BaseController`. All state is persisted and marked `usedInUi: true`. No business logic yet — provider delegation comes in subsequent PRs. - **`app/core/Engine/controllers/card-controller/index.ts`**: Init function following the standard Engine pattern — reads persisted state, creates controller instance. - **`app/core/Engine/messengers/card-controller-messenger/index.ts`**: Simple messenger with no delegated actions/events (those will be added when the controller needs to talk to `KeyringController`, `TransactionController`, etc.). - **`app/selectors/cardController.ts`**: Four selectors: `selectCardSelectedCountry`, `selectCardActiveProviderId`, `selectIsCardAuthenticated`, `selectCardholderAccounts`. Modified files: - **`app/core/Engine/types.ts`**: Added `CardController` to `Controllers`, `EngineState`, `GlobalActions`, `GlobalEvents`, and `ControllersToInitialize`. - **`app/core/Engine/Engine.ts`**: Registered init function, destructured from `controllersByName`, added to `this.context` and `state` getter. - **`app/core/Engine/messengers/index.ts`**: Added `CardController` entry to `CONTROLLER_MESSENGERS`. - **`app/core/Engine/constants.ts`**: Added `'CardController:stateChange'` to `BACKGROUND_STATE_CHANGE_EVENT_NAMES`. - **`app/util/test/initial-background-state.json`**: Added `CardController` default state to the test fixture. - **`.github/CODEOWNERS`**: Assigned `app/core/Engine/controllers/card-controller`, `app/core/Engine/messengers/card-controller-messenger`, and `app/selectors/cardController.ts` to `@MetaMask/card`. Test files: - **`CardController.test.ts`**: Tests controller construction with default state, partial state, and full persisted state. - **`index.test.ts`**: Tests the init function returns a controller instance, uses default state when none is persisted, and uses persisted state when provided. - **`cardController.test.ts`**: Tests all four selectors with populated state, empty state, and undefined `CardController` state (fallback values). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: CardController initialization Scenario: Controller initializes with default state on fresh install Given the app is freshly installed When the Engine initializes Then state.engine.backgroundState.CardController exists And selectedCountry is null And activeProviderId is null And isAuthenticated is false And cardholderAccounts is an empty array And providerData is an empty object Scenario: Controller state persists across app restarts Given the app has been launched before And CardController state was persisted When the Engine initializes again Then the persisted CardController state is restored Scenario: No user-facing changes Given the user is on any screen in the app When the app loads Then nothing visually changes And the Card feature continues to work as before ``` ## **Screenshots/Recordings** No UI changes — this is an infrastructure-only PR. ### **Before** No `CardController` in Engine. Card state managed entirely by Redux slice. ### **After** `CardController` exists in Engine with default state. Syncs to `state.engine.backgroundState.CardController`. Existing Card feature behavior is unchanged. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it updates core Engine initialization/types and introduces new persisted background state, which could affect startup or state serialization despite having no business logic yet. > > **Overview** > Introduces a new `CardController` (BaseController) that persists Card feature state (`selectedCountry`, `activeProviderId`, `isAuthenticated`, `cardholderAccounts`, `providerData`) and exposes it via the standard Engine background state batching. > > Wires the controller into Engine initialization and messaging (`Engine.ts`, `messengers/index.ts`, `types.ts`, `constants.ts`) so `CardController` state is included in `Engine.state`, state-change subscriptions, and debug/state log fixtures. > > Adds initial selectors in `selectors/cardController.ts`, plus unit tests for controller construction/init and selector fallbacks, and updates `CODEOWNERS` for new Card Engine paths. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8363bb1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 19722d7 commit 8bab3bf

15 files changed

Lines changed: 506 additions & 2 deletions

File tree

.github/CODEOWNERS

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ app/selectors/rampsController @MetaMask/ramp
7070
**/ramps/** @MetaMask/ramp
7171

7272
# Card Team
73-
app/components/UI/Card/ @MetaMask/card
74-
app/core/redux/slices/card/ @MetaMask/card
73+
app/components/UI/Card/ @MetaMask/card
74+
app/core/redux/slices/card/ @MetaMask/card
75+
app/core/Engine/controllers/card-controller @MetaMask/card
76+
app/core/Engine/messengers/card-controller-messenger @MetaMask/card
77+
app/selectors/cardController.ts @MetaMask/card
7578

7679
# Confirmation Team
7780
app/components/Views/confirmations @MetaMask/confirmations

app/core/Engine/Engine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ import { profileMetricsServiceInit } from './controllers/profile-metrics-service
177177
import { rampsServiceInit } from './controllers/ramps-controller/ramps-service-init';
178178
import { rampsControllerInit } from './controllers/ramps-controller/ramps-controller-init';
179179
import { aiDigestControllerInit } from './controllers/ai-digest-controller-init';
180+
import { cardControllerInit } from './controllers/card-controller';
180181
import { transakServiceInit } from './controllers/ramps-controller/transak-service-init';
181182

182183
// TODO: Replace "any" with type
@@ -371,6 +372,7 @@ export class Engine {
371372
TransakService: transakServiceInit,
372373
RampsController: rampsControllerInit,
373374
AiDigestController: aiDigestControllerInit,
375+
CardController: cardControllerInit,
374376
},
375377
persistedState: initialState as EngineState,
376378
baseControllerMessenger: this.controllerMessenger,
@@ -411,6 +413,7 @@ export class Engine {
411413
const transakService = controllersByName.TransakService;
412414
const rampsController = controllersByName.RampsController;
413415
const aiDigestController = controllersByName.AiDigestController;
416+
const cardController = controllersByName.CardController;
414417

415418
// Backwards compatibility for existing references
416419
this.accountsController = accountsController;
@@ -569,6 +572,7 @@ export class Engine {
569572
TransakService: transakService,
570573
RampsController: rampsController,
571574
AiDigestController: aiDigestController,
575+
CardController: cardController,
572576
};
573577

574578
const childControllers = Object.assign({}, this.context);
@@ -1291,6 +1295,7 @@ export default {
12911295
ApprovalController,
12921296
BridgeController,
12931297
BridgeStatusController,
1298+
CardController,
12941299
ConnectivityController,
12951300
CurrencyRateController,
12961301
DeFiPositionsController,
@@ -1394,6 +1399,7 @@ export default {
13941399
TransactionPayController: TransactionPayController.state,
13951400
RampsController: RampsController.state,
13961401
AiDigestController: AiDigestController.state,
1402+
CardController: CardController.state,
13971403
///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps)
13981404
AuthenticationController: AuthenticationController.state,
13991405
CronjobController: CronjobController.state,

app/core/Engine/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [
8383
///: END:ONLY_INCLUDE_IF
8484
'NetworkEnablementController:stateChange',
8585
'PredictController:stateChange',
86+
'CardController:stateChange',
8687
'DelegationController:stateChange',
8788
'ProfileMetricsController:stateChange',
8889
] as const;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Messenger } from '@metamask/messenger';
2+
import { CardController, defaultCardControllerState } from './CardController';
3+
import { type CardControllerActions, type CardControllerEvents } from './types';
4+
5+
function buildMessenger() {
6+
return new Messenger<
7+
'CardController',
8+
CardControllerActions,
9+
CardControllerEvents
10+
>({ namespace: 'CardController' });
11+
}
12+
13+
describe('CardController', () => {
14+
it('initializes with default state when no state is provided', () => {
15+
const controller = new CardController({
16+
messenger: buildMessenger(),
17+
});
18+
19+
expect(controller.state).toStrictEqual(defaultCardControllerState);
20+
});
21+
22+
it('initializes with provided state merged over defaults', () => {
23+
const controller = new CardController({
24+
messenger: buildMessenger(),
25+
state: {
26+
selectedCountry: 'US',
27+
activeProviderId: 'baanx',
28+
isAuthenticated: true,
29+
},
30+
});
31+
32+
expect(controller.state).toStrictEqual({
33+
selectedCountry: 'US',
34+
activeProviderId: 'baanx',
35+
isAuthenticated: true,
36+
cardholderAccounts: [],
37+
providerData: {},
38+
});
39+
});
40+
41+
it('preserves default values for fields not in partial state', () => {
42+
const controller = new CardController({
43+
messenger: buildMessenger(),
44+
state: {
45+
selectedCountry: 'GB',
46+
},
47+
});
48+
49+
expect(controller.state.selectedCountry).toBe('GB');
50+
expect(controller.state.activeProviderId).toBeNull();
51+
expect(controller.state.isAuthenticated).toBe(false);
52+
expect(controller.state.cardholderAccounts).toStrictEqual([]);
53+
expect(controller.state.providerData).toStrictEqual({});
54+
});
55+
56+
it('initializes with full persisted state including providerData', () => {
57+
const controller = new CardController({
58+
messenger: buildMessenger(),
59+
state: {
60+
selectedCountry: 'US',
61+
activeProviderId: 'baanx',
62+
isAuthenticated: true,
63+
cardholderAccounts: ['eip155:1:0xabc'],
64+
providerData: {
65+
baanx: { location: 'us' },
66+
},
67+
},
68+
});
69+
70+
expect(controller.state.cardholderAccounts).toStrictEqual([
71+
'eip155:1:0xabc',
72+
]);
73+
expect(controller.state.providerData).toStrictEqual({
74+
baanx: { location: 'us' },
75+
});
76+
});
77+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { BaseController, type StateMetadata } from '@metamask/base-controller';
2+
import {
3+
CARD_CONTROLLER_NAME,
4+
type CardControllerMessenger,
5+
type CardControllerState,
6+
} from './types';
7+
8+
const metadata: StateMetadata<CardControllerState> = {
9+
selectedCountry: {
10+
persist: true,
11+
includeInDebugSnapshot: true,
12+
includeInStateLogs: true,
13+
usedInUi: true,
14+
},
15+
activeProviderId: {
16+
persist: true,
17+
includeInDebugSnapshot: true,
18+
includeInStateLogs: true,
19+
usedInUi: true,
20+
},
21+
isAuthenticated: {
22+
persist: true,
23+
includeInDebugSnapshot: true,
24+
includeInStateLogs: true,
25+
usedInUi: true,
26+
},
27+
cardholderAccounts: {
28+
persist: true,
29+
includeInDebugSnapshot: true,
30+
includeInStateLogs: true,
31+
usedInUi: true,
32+
},
33+
providerData: {
34+
persist: true,
35+
includeInDebugSnapshot: false,
36+
includeInStateLogs: false,
37+
usedInUi: false,
38+
},
39+
};
40+
41+
export const defaultCardControllerState: CardControllerState = {
42+
selectedCountry: null,
43+
activeProviderId: null,
44+
isAuthenticated: false,
45+
cardholderAccounts: [],
46+
providerData: {},
47+
};
48+
49+
/**
50+
* CardController manages the MetaMask Card feature state.
51+
*
52+
* This is a thin coordination layer that will eventually delegate
53+
* to provider implementations. For now it owns the persisted state
54+
* (country, provider, auth status) and syncs it to Redux via the
55+
* standard Engine batcher.
56+
*/
57+
export class CardController extends BaseController<
58+
typeof CARD_CONTROLLER_NAME,
59+
CardControllerState,
60+
CardControllerMessenger
61+
> {
62+
constructor({
63+
messenger,
64+
state,
65+
}: {
66+
messenger: CardControllerMessenger;
67+
state?: Partial<CardControllerState>;
68+
}) {
69+
super({
70+
name: CARD_CONTROLLER_NAME,
71+
messenger,
72+
metadata,
73+
state: {
74+
...defaultCardControllerState,
75+
...state,
76+
},
77+
});
78+
}
79+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ExtendedMessenger } from '../../../ExtendedMessenger';
2+
import { buildControllerInitRequestMock } from '../../utils/test-utils';
3+
import { ControllerInitRequest } from '../../types';
4+
import { CardController, defaultCardControllerState } from './CardController';
5+
import {
6+
type CardControllerMessenger,
7+
type CardControllerState,
8+
} from './types';
9+
import { cardControllerInit } from '.';
10+
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';
11+
12+
jest.mock('./CardController', () => {
13+
const actual = jest.requireActual('./CardController');
14+
return {
15+
...actual,
16+
CardController: jest.fn(actual.CardController),
17+
};
18+
});
19+
20+
describe('cardControllerInit', () => {
21+
const cardControllerClassMock = jest.mocked(CardController);
22+
let initRequestMock: jest.Mocked<
23+
ControllerInitRequest<CardControllerMessenger>
24+
>;
25+
26+
beforeEach(() => {
27+
jest.resetAllMocks();
28+
29+
const baseControllerMessenger = new ExtendedMessenger<MockAnyNamespace>({
30+
namespace: MOCK_ANY_NAMESPACE,
31+
});
32+
33+
initRequestMock = buildControllerInitRequestMock(baseControllerMessenger);
34+
});
35+
36+
it('returns a controller instance', () => {
37+
const result = cardControllerInit(initRequestMock);
38+
39+
expect(result.controller).toBeInstanceOf(CardController);
40+
});
41+
42+
it('uses default state when no persisted state is provided', () => {
43+
initRequestMock.persistedState = {};
44+
45+
cardControllerInit(initRequestMock);
46+
47+
const constructorArgs = cardControllerClassMock.mock.calls[0][0];
48+
expect(constructorArgs.state).toStrictEqual(defaultCardControllerState);
49+
});
50+
51+
it('uses persisted state when provided', () => {
52+
const persistedState: CardControllerState = {
53+
selectedCountry: 'US',
54+
activeProviderId: 'baanx',
55+
isAuthenticated: true,
56+
cardholderAccounts: ['eip155:1:0xabc'],
57+
providerData: { baanx: { location: 'us' } },
58+
};
59+
60+
initRequestMock.persistedState = {
61+
...initRequestMock.persistedState,
62+
CardController: persistedState,
63+
};
64+
65+
cardControllerInit(initRequestMock);
66+
67+
const constructorArgs = cardControllerClassMock.mock.calls[0][0];
68+
expect(constructorArgs.state).toStrictEqual(persistedState);
69+
});
70+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { ControllerInitFunction } from '../../types';
2+
import { CardController, defaultCardControllerState } from './CardController';
3+
import type { CardControllerMessenger } from './types';
4+
5+
/**
6+
* Initialize the CardController.
7+
*
8+
* @param request - The request object.
9+
* @returns The CardController.
10+
*/
11+
export const cardControllerInit: ControllerInitFunction<
12+
CardController,
13+
CardControllerMessenger
14+
> = (request) => {
15+
const { controllerMessenger, persistedState } = request;
16+
17+
const controller = new CardController({
18+
messenger: controllerMessenger,
19+
state: persistedState.CardController ?? defaultCardControllerState,
20+
});
21+
22+
return { controller };
23+
};
24+
25+
export { CardController };
26+
export type { CardControllerMessenger };
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type {
2+
ControllerGetStateAction,
3+
ControllerStateChangeEvent,
4+
} from '@metamask/base-controller';
5+
import type { Messenger } from '@metamask/messenger';
6+
import type { Json } from '@metamask/utils';
7+
8+
export const CARD_CONTROLLER_NAME = 'CardController';
9+
10+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
11+
export type CardControllerState = {
12+
/** ISO 3166-1 alpha-2 country code selected by the user. */
13+
selectedCountry: string | null;
14+
/** Active provider ID, derived from selectedCountry. */
15+
activeProviderId: string | null;
16+
/** Whether the user is authenticated with the active provider. */
17+
isAuthenticated: boolean;
18+
/** CAIP-10 account IDs that are card holders. */
19+
cardholderAccounts: string[];
20+
/**
21+
* Per-provider persistent data keyed by provider ID.
22+
* Values are JSON-serializable objects (e.g. `{ location: 'us' }`).
23+
*/
24+
providerData: Record<string, Record<string, Json>>;
25+
};
26+
27+
export type CardControllerActions = ControllerGetStateAction<
28+
typeof CARD_CONTROLLER_NAME,
29+
CardControllerState
30+
>;
31+
32+
export type CardControllerEvents = ControllerStateChangeEvent<
33+
typeof CARD_CONTROLLER_NAME,
34+
CardControllerState
35+
>;
36+
37+
export type CardControllerMessenger = Messenger<
38+
typeof CARD_CONTROLLER_NAME,
39+
CardControllerActions,
40+
CardControllerEvents
41+
>;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
Messenger,
3+
type MessengerActions,
4+
type MessengerEvents,
5+
} from '@metamask/messenger';
6+
import type { CardControllerMessenger } from '../../controllers/card-controller/types';
7+
import type { RootMessenger } from '../../types';
8+
9+
/**
10+
* Get the CardControllerMessenger for the CardController.
11+
*
12+
* @param rootMessenger - The root messenger.
13+
* @returns The CardControllerMessenger.
14+
*/
15+
export function getCardControllerMessenger(
16+
rootMessenger: RootMessenger,
17+
): CardControllerMessenger {
18+
return new Messenger<
19+
'CardController',
20+
MessengerActions<CardControllerMessenger>,
21+
MessengerEvents<CardControllerMessenger>,
22+
RootMessenger
23+
>({
24+
namespace: 'CardController',
25+
parent: rootMessenger,
26+
});
27+
}

0 commit comments

Comments
 (0)