Skip to content

Commit 2a5af25

Browse files
baptiste-marchandsatyajeetkolhapureVGR-GITNidhiKJha
authored
feat: rewards controller & data service (#36926)
## **Description** This PR makes the rewards controller & data service available. It is based around the implementation in mobile, and should suffice for upcoming needs to: - check if account group has opted in - initiate a session via silent signing for an account in an account group - estimating points - validating referral code (for opting in) - opting into the rewards program, and thus creating a subscription - linking an account to an existing subscription - getting the season status for an opted in account, tied to the subscription. When an account group changes, we handle it async by trying to detect if there's a subscription. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/RWDS-274 <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > Introduces a feature-flagged RewardsController and RewardsDataService, wires them into initialization/messengers, and adds supporting utils, API constants, and extensive tests. > > - **Rewards (feature-flagged)**: > - **Controllers/Services**: Add `RewardsController` and `RewardsDataService` with actions for opt-in/login, opt-in status, season metadata/status, points estimation, geo lookup, referral validation, and account linking. > - **Initialization/Messengers**: Implement `RewardsControllerInit`, `RewardsDataServiceInit`, and restricted messengers; integrate into `controller-list` and `metamask-controller`. > - **State/Telemetry**: Add `RewardsController` fields to Sentry background/UI state; caching helpers (`wrapWithCache`) and invalidation paths. > - **Supporting utilities**: > - Shared feature flag gating (`validatedVersionGatedFeatureFlag`, `hasMinimumRequiredVersion`); rewards API URLs (`shared/constants/rewards`). > - Account helpers: `isHardwareAccount`; Solana Snap signing (`utils/solana-snap`); account sorting (`utils/sortAccounts`). > - **Tests**: > - Extensive unit tests for controllers, data service, messengers, utils, and version gating; update e2e state snapshots. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a46270d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Satyajeet Kolhapure <[email protected]> Co-authored-by: VGR <[email protected]> Co-authored-by: NidhiKJha <[email protected]>
1 parent 4e8fc60 commit 2a5af25

28 files changed

+9639
-0
lines changed

app/scripts/constants/sentry-state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,14 @@ export const SENTRY_BACKGROUND_STATE = {
270270
remoteFeatureFlags: true,
271271
cacheTimestamp: false,
272272
},
273+
RewardsController: {
274+
rewardsActiveAccount: false,
275+
rewardsAccounts: false,
276+
rewardsSubscriptions: false,
277+
rewardsSeasons: false,
278+
rewardsSeasonStatuses: false,
279+
rewardsSubscriptionTokens: false,
280+
},
273281
NotificationServicesPushController: {
274282
fcmToken: false,
275283
},

app/scripts/controller-init/controller-list.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ import { MetaMetricsDataDeletionController } from '../controllers/metametrics-da
9898
import AppMetadataController from '../controllers/app-metadata';
9999
import DecryptMessageController from '../controllers/decrypt-message';
100100
import EncryptionPublicKeyController from '../controllers/encryption-public-key';
101+
import { RewardsDataService } from '../controllers/rewards/rewards-data-service';
102+
import { RewardsController } from '../controllers/rewards/rewards-controller';
101103

102104
/**
103105
* Union of all controllers supporting or required by modular initialization.
@@ -157,6 +159,8 @@ export type Controller =
157159
| RateLimitController<RateLimitedApiMap>
158160
| RatesController
159161
| RemoteFeatureFlagController
162+
| RewardsController
163+
| RewardsDataService
160164
| SeedlessOnboardingController<EncryptionKey>
161165
| SelectedNetworkController
162166
| ShieldController
@@ -237,6 +241,7 @@ export type ControllerFlatState = AccountOrderController['state'] &
237241
PreferencesController['state'] &
238242
RatesController['state'] &
239243
RemoteFeatureFlagController['state'] &
244+
RewardsController['state'] &
240245
SeedlessOnboardingController<EncryptionKey>['state'] &
241246
SelectedNetworkController['state'] &
242247
ShieldController['state'] &

app/scripts/controller-init/messengers/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ import {
135135
getRemoteFeatureFlagControllerInitMessenger,
136136
getRemoteFeatureFlagControllerMessenger,
137137
} from './remote-feature-flag-controller-messenger';
138+
import {
139+
getRewardsControllerInitMessenger,
140+
getRewardsControllerMessenger,
141+
} from './rewards-controller-messenger';
138142
import {
139143
getSwapsControllerInitMessenger,
140144
getSwapsControllerMessenger,
@@ -190,6 +194,7 @@ import {
190194
getUserOperationControllerInitMessenger,
191195
getUserOperationControllerMessenger,
192196
} from './user-operation-controller-messenger';
197+
import { getRewardsDataServiceMessenger } from './reward-data-service-messenger';
193198

194199
export type { AccountOrderControllerMessenger } from './account-order-controller-messenger';
195200
export { getAccountOrderControllerMessenger } from './account-order-controller-messenger';
@@ -336,6 +341,12 @@ export {
336341
} from './signature-controller-messenger';
337342
export type { SubjectMetadataControllerMessenger } from './subject-metadata-controller-messenger';
338343
export { getSubjectMetadataControllerMessenger } from './subject-metadata-controller-messenger';
344+
export type {
345+
RewardsControllerMessenger,
346+
RewardsControllerActions,
347+
RewardsControllerEvents,
348+
} from './rewards-controller-messenger';
349+
export { getRewardsControllerMessenger } from './rewards-controller-messenger';
339350
export type {
340351
SwapsControllerMessenger,
341352
SwapsControllerInitMessenger,
@@ -632,6 +643,14 @@ export const CONTROLLER_MESSENGERS = {
632643
getMessenger: getSubscriptionServiceMessenger,
633644
getInitMessenger: noop,
634645
},
646+
RewardsDataService: {
647+
getMessenger: getRewardsDataServiceMessenger,
648+
getInitMessenger: noop,
649+
},
650+
RewardsController: {
651+
getMessenger: getRewardsControllerMessenger,
652+
getInitMessenger: getRewardsControllerInitMessenger,
653+
},
635654
SwapsController: {
636655
getMessenger: getSwapsControllerMessenger,
637656
getInitMessenger: getSwapsControllerInitMessenger,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Messenger, RestrictedMessenger } from '@metamask/base-controller';
2+
import type { PreferencesControllerGetStateAction } from '../../controllers/preferences-controller';
3+
import { getRewardsDataServiceMessenger } from './reward-data-service-messenger';
4+
5+
describe('getRewardsDataServiceMessenger', () => {
6+
it('returns a restricted messenger', () => {
7+
const messenger = new Messenger<
8+
PreferencesControllerGetStateAction,
9+
never
10+
>();
11+
const rewardsDataServiceMessenger =
12+
getRewardsDataServiceMessenger(messenger);
13+
14+
expect(rewardsDataServiceMessenger).toBeInstanceOf(RestrictedMessenger);
15+
});
16+
17+
it('allows PreferencesController:getState action', () => {
18+
const baseMessenger = new Messenger<
19+
PreferencesControllerGetStateAction,
20+
never
21+
>();
22+
23+
// Register a mock handler for PreferencesController:getState
24+
baseMessenger.registerActionHandler(
25+
'PreferencesController:getState',
26+
() => ({ currentLocale: 'en-US' }) as never,
27+
);
28+
29+
const restrictedMessenger = getRewardsDataServiceMessenger(baseMessenger);
30+
31+
// This should not throw since PreferencesController:getState is allowed
32+
expect(() => {
33+
restrictedMessenger.call('PreferencesController:getState');
34+
}).not.toThrow();
35+
});
36+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Messenger } from '@metamask/base-controller';
2+
import { PreferencesControllerGetStateAction } from '../../controllers/preferences-controller';
3+
import type { RewardsDataServiceActions } from '../../controllers/rewards/rewards-data-service-types';
4+
5+
type AllowedActions =
6+
| RewardsDataServiceActions
7+
| PreferencesControllerGetStateAction;
8+
9+
type AllowedEvents = never;
10+
11+
export type RewardsDataServiceMessenger = ReturnType<
12+
typeof getRewardsDataServiceMessenger
13+
>;
14+
15+
/**
16+
* Get a messenger restricted to the actions and events that the
17+
* rewards data service is allowed to handle.
18+
*
19+
* @param messenger - The controller messenger to restrict.
20+
* @returns The restricted controller messenger.
21+
*/
22+
export function getRewardsDataServiceMessenger(
23+
messenger: Messenger<AllowedActions, AllowedEvents>,
24+
) {
25+
return messenger.getRestricted({
26+
name: 'RewardsDataService',
27+
allowedActions: ['PreferencesController:getState'],
28+
allowedEvents: [],
29+
});
30+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Messenger, RestrictedMessenger } from '@metamask/base-controller';
2+
import {
3+
getRewardsControllerMessenger,
4+
getRewardsControllerInitMessenger,
5+
} from './rewards-controller-messenger';
6+
7+
describe('getRewardsControllerMessenger', () => {
8+
it('returns a restricted messenger', () => {
9+
const messenger = new Messenger<never, never>();
10+
const rewardsControllerMessenger = getRewardsControllerMessenger(messenger);
11+
12+
expect(rewardsControllerMessenger).toBeInstanceOf(RestrictedMessenger);
13+
});
14+
});
15+
16+
describe('getRewardsControllerInitMessenger', () => {
17+
it('returns a restricted messenger', () => {
18+
const messenger = new Messenger<never, never>();
19+
const rewardsControllerInitMessenger =
20+
getRewardsControllerInitMessenger(messenger);
21+
22+
expect(rewardsControllerInitMessenger).toBeInstanceOf(RestrictedMessenger);
23+
});
24+
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import {
2+
ControllerGetStateAction,
3+
Messenger,
4+
RestrictedMessenger,
5+
} from '@metamask/base-controller';
6+
7+
import {
8+
AccountsControllerGetSelectedMultichainAccountAction,
9+
AccountsControllerListMultichainAccountsAction,
10+
} from '@metamask/accounts-controller';
11+
import {
12+
AccountTreeControllerGetAccountsFromSelectedAccountGroupAction,
13+
AccountTreeControllerSelectedAccountGroupChangeEvent,
14+
} from '@metamask/account-tree-controller';
15+
import {
16+
KeyringControllerSignPersonalMessageAction,
17+
KeyringControllerUnlockEvent,
18+
} from '@metamask/keyring-controller';
19+
import { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller';
20+
import type { HandleSnapRequest } from '@metamask/snaps-controllers';
21+
import { PreferencesControllerGetStateAction } from '../../controllers/preferences-controller';
22+
import {
23+
RewardsDataServiceGetOptInStatusAction,
24+
RewardsDataServiceEstimatePointsAction,
25+
RewardsDataServiceGetSeasonStatusAction,
26+
RewardsDataServiceLoginAction,
27+
RewardsDataServiceMobileJoinAction,
28+
RewardsDataServiceMobileOptinAction,
29+
RewardsDataServiceValidateReferralCodeAction,
30+
RewardsDataServiceFetchGeoLocationAction,
31+
RewardsDataServiceGetSeasonMetadataAction,
32+
RewardsDataServiceGetDiscoverSeasonsAction,
33+
} from '../../controllers/rewards/rewards-data-service-types';
34+
import {
35+
RewardsControllerState,
36+
Patch,
37+
RewardsControllerAccountLinkedEvent,
38+
RewardsControllerOptInAction,
39+
RewardsControllerGetOptInStatusAction,
40+
RewardsControllerEstimatePointsAction,
41+
RewardsControllerIsRewardsFeatureEnabledAction,
42+
RewardsControllerValidateReferralCodeAction,
43+
RewardsControllerIsOptInSupportedAction,
44+
RewardsControllerLinkAccountToSubscriptionAction,
45+
RewardsControllerLinkAccountsToSubscriptionCandidateAction,
46+
RewardsControllerGetGeoRewardsMetadataAction,
47+
RewardsControllerGetCandidateSubscriptionIdAction,
48+
RewardsControllerGetHasAccountOptedInAction,
49+
RewardsControllerGetActualSubscriptionIdAction,
50+
RewardsControllerGetFirstSubscriptionIdAction,
51+
RewardsControllerGetSeasonMetadataAction,
52+
RewardsControllerGetSeasonStatusAction,
53+
} from '../../controllers/rewards/rewards-controller.types';
54+
55+
const name = 'RewardsController';
56+
57+
/**
58+
* Events that can be emitted by the RewardsController
59+
*/
60+
export type RewardsControllerEvents =
61+
| {
62+
type: 'RewardsController:stateChange';
63+
payload: [RewardsControllerState, Patch[]];
64+
}
65+
| RewardsControllerAccountLinkedEvent;
66+
67+
/**
68+
* Actions that can be performed by the RewardsController
69+
*/
70+
export type RewardsControllerActions =
71+
| ControllerGetStateAction<'RewardsController', RewardsControllerState>
72+
| RewardsControllerGetOptInStatusAction
73+
| RewardsControllerEstimatePointsAction
74+
| RewardsControllerIsRewardsFeatureEnabledAction
75+
| RewardsControllerOptInAction
76+
| RewardsControllerGetGeoRewardsMetadataAction
77+
| RewardsControllerValidateReferralCodeAction
78+
| RewardsControllerIsOptInSupportedAction
79+
| RewardsControllerLinkAccountToSubscriptionAction
80+
| RewardsControllerLinkAccountsToSubscriptionCandidateAction
81+
| RewardsControllerGetCandidateSubscriptionIdAction
82+
| RewardsControllerGetHasAccountOptedInAction
83+
| RewardsControllerGetActualSubscriptionIdAction
84+
| RewardsControllerGetFirstSubscriptionIdAction
85+
| RewardsControllerGetSeasonMetadataAction
86+
| RewardsControllerGetSeasonStatusAction;
87+
88+
// Don't reexport as per guidelines
89+
type AllowedActions =
90+
| AccountsControllerGetSelectedMultichainAccountAction
91+
| AccountsControllerListMultichainAccountsAction
92+
| KeyringControllerSignPersonalMessageAction
93+
| RewardsDataServiceLoginAction
94+
| RewardsDataServiceEstimatePointsAction
95+
| RewardsDataServiceGetSeasonStatusAction
96+
| RewardsDataServiceFetchGeoLocationAction
97+
| RewardsDataServiceMobileOptinAction
98+
| RewardsDataServiceValidateReferralCodeAction
99+
| RewardsDataServiceMobileJoinAction
100+
| RewardsDataServiceGetOptInStatusAction
101+
| RewardsDataServiceGetSeasonMetadataAction
102+
| RewardsDataServiceGetDiscoverSeasonsAction
103+
| AccountTreeControllerGetAccountsFromSelectedAccountGroupAction
104+
| HandleSnapRequest;
105+
106+
type AllowedEvents =
107+
| KeyringControllerUnlockEvent
108+
| AccountTreeControllerSelectedAccountGroupChangeEvent;
109+
110+
export type RewardsControllerMessenger = RestrictedMessenger<
111+
typeof name,
112+
RewardsControllerActions | AllowedActions,
113+
RewardsControllerEvents | AllowedEvents,
114+
AllowedActions['type'],
115+
AllowedEvents['type']
116+
>;
117+
118+
export function getRewardsControllerMessenger(
119+
messenger: Messenger<
120+
RewardsControllerActions | AllowedActions,
121+
RewardsControllerEvents | AllowedEvents
122+
>,
123+
): RewardsControllerMessenger {
124+
return messenger.getRestricted({
125+
name,
126+
allowedActions: [
127+
'AccountsController:getSelectedMultichainAccount',
128+
'AccountTreeController:getAccountsFromSelectedAccountGroup',
129+
'AccountsController:listMultichainAccounts',
130+
'KeyringController:signPersonalMessage',
131+
'RewardsDataService:login',
132+
'RewardsDataService:estimatePoints',
133+
'RewardsDataService:getSeasonStatus',
134+
'RewardsDataService:fetchGeoLocation',
135+
'RewardsDataService:mobileOptin',
136+
'RewardsDataService:validateReferralCode',
137+
'RewardsDataService:mobileJoin',
138+
'RewardsDataService:getOptInStatus',
139+
'RewardsDataService:getSeasonMetadata',
140+
'RewardsDataService:getDiscoverSeasons',
141+
'SnapController:handleRequest',
142+
],
143+
allowedEvents: [
144+
'AccountTreeController:selectedAccountGroupChange',
145+
'KeyringController:unlock',
146+
],
147+
});
148+
}
149+
150+
type AllowedInitializationActions =
151+
| RemoteFeatureFlagControllerGetStateAction
152+
| PreferencesControllerGetStateAction;
153+
154+
export type RewardsControllerInitMessenger = ReturnType<
155+
typeof getRewardsControllerInitMessenger
156+
>;
157+
158+
export function getRewardsControllerInitMessenger(
159+
messenger: Messenger<AllowedInitializationActions, never>,
160+
) {
161+
return messenger.getRestricted({
162+
name: 'RewardsControllerInit',
163+
allowedActions: [
164+
'RemoteFeatureFlagController:getState',
165+
'PreferencesController:getState',
166+
],
167+
allowedEvents: [],
168+
});
169+
}

0 commit comments

Comments
 (0)