diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 856b26f1b506..618891a1a916 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -5485,6 +5485,17 @@
"reward": {
"message": "Reward"
},
+ "rewardsOptIn": {
+ "message": "Sign up for Rewards"
+ },
+ "rewardsPointsBalance": {
+ "message": "$1 points",
+ "description": "$1 is the formatted number of rewards points"
+ },
+ "rewardsPointsIcon": {
+ "message": "Rewards Points",
+ "description": "Alt text for the rewards points icon"
+ },
"rpcNameOptional": {
"message": "RPC Name (Optional)"
},
diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json
index 856b26f1b506..618891a1a916 100644
--- a/app/_locales/en_GB/messages.json
+++ b/app/_locales/en_GB/messages.json
@@ -5485,6 +5485,17 @@
"reward": {
"message": "Reward"
},
+ "rewardsOptIn": {
+ "message": "Sign up for Rewards"
+ },
+ "rewardsPointsBalance": {
+ "message": "$1 points",
+ "description": "$1 is the formatted number of rewards points"
+ },
+ "rewardsPointsIcon": {
+ "message": "Rewards Points",
+ "description": "Alt text for the rewards points icon"
+ },
"rpcNameOptional": {
"message": "RPC Name (Optional)"
},
diff --git a/app/images/metamask-rewards-points.svg b/app/images/metamask-rewards-points.svg
new file mode 100644
index 000000000000..18f9dba2822c
--- /dev/null
+++ b/app/images/metamask-rewards-points.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/scripts/controllers/rewards/rewards-controller.test.ts b/app/scripts/controllers/rewards/rewards-controller.test.ts
index a1c444027e58..73b30503864e 100644
--- a/app/scripts/controllers/rewards/rewards-controller.test.ts
+++ b/app/scripts/controllers/rewards/rewards-controller.test.ts
@@ -29,6 +29,7 @@ import {
RewardsControllerMessenger,
} from '../../controller-init/messengers';
import { getRootMessenger } from '../../lib/messenger';
+import { SeasonDtoState } from '../../../../shared/types/rewards';
import {
RewardsController,
getRewardsControllerDefaultState,
@@ -42,7 +43,6 @@ import type {
SubscriptionDto,
EstimatePointsDto,
EstimatedPointsDto,
- SeasonDtoState,
DiscoverSeasonsDto,
SeasonMetadataDto,
SeasonStateDto,
@@ -1075,6 +1075,34 @@ describe('RewardsController', () => {
);
});
});
+
+ it('should throw AuthorizationFailedError when subscription token is missing', async () => {
+ const state: Partial = {
+ rewardsSeasons: {
+ [MOCK_SEASON_ID]: {
+ id: MOCK_SEASON_ID,
+ name: 'Season 1',
+ startDate: new Date('2024-01-01').getTime(),
+ endDate: new Date('2024-12-31').getTime(),
+ tiers: MOCK_SEASON_TIERS,
+ },
+ },
+ };
+
+ await withController(
+ { state, isDisabled: false },
+ async ({ controller }) => {
+ await expect(
+ controller.getSeasonStatus(MOCK_SUBSCRIPTION_ID, MOCK_SEASON_ID),
+ ).rejects.toThrow(
+ `No subscription token found for subscription ID: ${MOCK_SUBSCRIPTION_ID}`,
+ );
+ await expect(
+ controller.getSeasonStatus(MOCK_SUBSCRIPTION_ID, MOCK_SEASON_ID),
+ ).rejects.toBeInstanceOf(AuthorizationFailedError);
+ },
+ );
+ });
});
describe('optIn', () => {
@@ -1676,6 +1704,102 @@ describe('RewardsController', () => {
expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]);
});
});
+
+ it('should force fresh check for not-opted-in accounts checked more than 5 minutes ago', async () => {
+ const state: Partial = {
+ rewardsAccounts: {
+ [MOCK_CAIP_ACCOUNT]: {
+ account: MOCK_CAIP_ACCOUNT,
+ hasOptedIn: false,
+ subscriptionId: null,
+ perpsFeeDiscount: null,
+ lastPerpsDiscountRateFetched: null,
+ lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 6, // 6 minutes ago (exceeds 5 minute threshold)
+ },
+ },
+ };
+
+ await withController({ state, isDisabled: false }, ({ controller }) => {
+ const addressToAccountMap = new Map();
+ addressToAccountMap.set(
+ MOCK_ACCOUNT_ADDRESS.toLowerCase(),
+ MOCK_INTERNAL_ACCOUNT,
+ );
+
+ const result = controller.checkOptInStatusAgainstCache(
+ [MOCK_ACCOUNT_ADDRESS],
+ addressToAccountMap,
+ );
+
+ expect(result.cachedOptInResults).toEqual([null]);
+ expect(result.cachedSubscriptionIds).toEqual([null]);
+ expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]);
+ });
+ });
+
+ it('should use cached data for not-opted-in accounts checked within 5 minutes', async () => {
+ const state: Partial = {
+ rewardsAccounts: {
+ [MOCK_CAIP_ACCOUNT]: {
+ account: MOCK_CAIP_ACCOUNT,
+ hasOptedIn: false,
+ subscriptionId: null,
+ perpsFeeDiscount: null,
+ lastPerpsDiscountRateFetched: null,
+ lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 2, // 2 minutes ago (within 5 minute threshold)
+ },
+ },
+ };
+
+ await withController({ state, isDisabled: false }, ({ controller }) => {
+ const addressToAccountMap = new Map();
+ addressToAccountMap.set(
+ MOCK_ACCOUNT_ADDRESS.toLowerCase(),
+ MOCK_INTERNAL_ACCOUNT,
+ );
+
+ const result = controller.checkOptInStatusAgainstCache(
+ [MOCK_ACCOUNT_ADDRESS],
+ addressToAccountMap,
+ );
+
+ expect(result.cachedOptInResults).toEqual([false]);
+ expect(result.cachedSubscriptionIds).toEqual([null]);
+ expect(result.addressesNeedingFresh).toEqual([]);
+ });
+ });
+
+ it('should force fresh check for not-opted-in accounts without lastFreshOptInStatusCheck', async () => {
+ const state: Partial = {
+ rewardsAccounts: {
+ [MOCK_CAIP_ACCOUNT]: {
+ account: MOCK_CAIP_ACCOUNT,
+ hasOptedIn: false,
+ subscriptionId: null,
+ perpsFeeDiscount: null,
+ lastPerpsDiscountRateFetched: null,
+ lastFreshOptInStatusCheck: undefined,
+ },
+ },
+ };
+
+ await withController({ state, isDisabled: false }, ({ controller }) => {
+ const addressToAccountMap = new Map();
+ addressToAccountMap.set(
+ MOCK_ACCOUNT_ADDRESS.toLowerCase(),
+ MOCK_INTERNAL_ACCOUNT,
+ );
+
+ const result = controller.checkOptInStatusAgainstCache(
+ [MOCK_ACCOUNT_ADDRESS],
+ addressToAccountMap,
+ );
+
+ expect(result.cachedOptInResults).toEqual([null]);
+ expect(result.cachedSubscriptionIds).toEqual([null]);
+ expect(result.addressesNeedingFresh).toEqual([MOCK_ACCOUNT_ADDRESS]);
+ });
+ });
});
describe('shouldSkipSilentAuth', () => {
@@ -1724,6 +1848,30 @@ describe('RewardsController', () => {
});
});
+ it('should skip for not-opted-in accounts checked within 5 minutes', async () => {
+ const state: Partial = {
+ rewardsAccounts: {
+ [MOCK_CAIP_ACCOUNT]: {
+ account: MOCK_CAIP_ACCOUNT,
+ hasOptedIn: false,
+ subscriptionId: null,
+ perpsFeeDiscount: null,
+ lastPerpsDiscountRateFetched: null,
+ lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 2, // 2 minutes ago (within 5 minute threshold)
+ },
+ },
+ };
+
+ await withController({ state, isDisabled: false }, ({ controller }) => {
+ const result = controller.shouldSkipSilentAuth(
+ MOCK_CAIP_ACCOUNT,
+ MOCK_INTERNAL_ACCOUNT,
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
it('should not skip for stale not-opted-in accounts', async () => {
const state: Partial = {
rewardsAccounts: {
@@ -1733,7 +1881,7 @@ describe('RewardsController', () => {
subscriptionId: null,
perpsFeeDiscount: null,
lastPerpsDiscountRateFetched: null,
- lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 60 * 24 * 2, // 2 days ago
+ lastFreshOptInStatusCheck: Date.now() - 1000 * 60 * 6, // 6 minutes ago (exceeds 5 minute threshold)
},
},
};
@@ -2532,11 +2680,14 @@ describe('Additional RewardsController edge cases', () => {
});
describe('getCandidateSubscriptionId - error scenarios', () => {
- it('should return subscription ID from cache if session token exists', async () => {
+ it('should return subscription ID from cache if session token and subscription exist', async () => {
const state: Partial = {
rewardsSubscriptionTokens: {
[MOCK_SUBSCRIPTION_ID]: MOCK_SESSION_TOKEN,
},
+ rewardsSubscriptions: {
+ [MOCK_SUBSCRIPTION_ID]: MOCK_SUBSCRIPTION,
+ },
};
await withController(
@@ -2579,6 +2730,42 @@ describe('Additional RewardsController edge cases', () => {
},
);
});
+
+ it('should continue to silent auth when subscription exists but session token is missing', async () => {
+ const state: Partial = {
+ rewardsSubscriptions: {
+ [MOCK_SUBSCRIPTION_ID]: MOCK_SUBSCRIPTION,
+ },
+ };
+
+ await withController(
+ { state, isDisabled: false },
+ async ({ controller, mockMessengerCall }) => {
+ mockMessengerCall.mockImplementation((actionType) => {
+ if (actionType === 'AccountsController:listMultichainAccounts') {
+ return [MOCK_INTERNAL_ACCOUNT];
+ }
+ if (actionType === 'RewardsDataService:getOptInStatus') {
+ return Promise.resolve({
+ ois: [true],
+ sids: [MOCK_SUBSCRIPTION_ID],
+ });
+ }
+ if (actionType === 'KeyringController:signPersonalMessage') {
+ return Promise.resolve('0xmocksignature');
+ }
+ if (actionType === 'RewardsDataService:login') {
+ return Promise.resolve(MOCK_LOGIN_RESPONSE);
+ }
+ return undefined;
+ });
+
+ const result = await controller.getCandidateSubscriptionId();
+
+ expect(result).toBe(MOCK_SUBSCRIPTION_ID);
+ },
+ );
+ });
});
describe('linkAccountsToSubscriptionCandidate - error handling', () => {
diff --git a/app/scripts/controllers/rewards/rewards-controller.ts b/app/scripts/controllers/rewards/rewards-controller.ts
index c4ad633a89a5..4dfb6b629320 100644
--- a/app/scripts/controllers/rewards/rewards-controller.ts
+++ b/app/scripts/controllers/rewards/rewards-controller.ts
@@ -12,15 +12,17 @@ import { base58, isAddress as isEvmAddress } from 'ethers/lib/utils';
import { HandleSnapRequest } from '@metamask/snaps-controllers';
import { RewardsControllerMessenger } from '../../controller-init/messengers/rewards-controller-messenger';
import { isHardwareAccount } from '../../../../shared/lib/accounts';
+import {
+ SeasonDtoState,
+ SeasonStatusState,
+ SeasonTierState,
+} from '../../../../shared/types/rewards';
import {
type RewardsControllerState,
type RewardsAccountState,
type LoginResponseDto,
type EstimatePointsDto,
type EstimatedPointsDto,
- type SeasonDtoState,
- type SeasonStatusState,
- type SeasonTierState,
type SeasonTierDto,
type SeasonStatusDto,
type SubscriptionDto,
@@ -49,8 +51,8 @@ const SEASON_STATUS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute
// Season metadata cache threshold
const SEASON_METADATA_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes
-// Opt-in status stale threshold for not opted-in accounts to force a fresh check
-const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60 * 24; // 24 hours
+// Opt-in status stale threshold for not opted-in accounts to force a fresh check (less strict than in mobile for now)
+const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes
/**
* State metadata for the RewardsController
@@ -845,7 +847,6 @@ export class RewardsController extends BaseController<
// Update state with successful authentication
subscription = loginResponse.subscription;
- // TODO: Re-enable multi-subscription token vault when implemented
// Store the session token for this subscription
this.#storeSubscriptionToken(subscription.id, loginResponse.sessionId);
@@ -1263,11 +1264,6 @@ export class RewardsController extends BaseController<
if (!cached) {
return undefined;
}
- log.info(
- 'RewardsController: Using cached season status data for',
- subscriptionId,
- seasonId,
- );
return { payload: cached, lastFetched: cached.lastFetched };
},
fetchFresh: async () => {
@@ -1279,7 +1275,7 @@ export class RewardsController extends BaseController<
);
const subscriptionToken = this.#getSubscriptionToken(subscriptionId);
if (!subscriptionToken) {
- throw new Error(
+ throw new AuthorizationFailedError(
`No subscription token found for subscription ID: ${subscriptionId}`,
);
}
@@ -1406,6 +1402,7 @@ export class RewardsController extends BaseController<
}
state.rewardsAccounts = {};
state.rewardsSubscriptions = {};
+ state.rewardsSubscriptionTokens = {};
});
log.info('RewardsController: Invalidated accounts and subscriptions');
}
@@ -1804,7 +1801,11 @@ export class RewardsController extends BaseController<
const sessionToken = subscriptionId
? this.state.rewardsSubscriptionTokens[subscriptionId]
: undefined;
- if (subscriptionId && Boolean(sessionToken)) {
+ if (
+ subscriptionId &&
+ Boolean(sessionToken) &&
+ this.state.rewardsSubscriptions[subscriptionId]
+ ) {
return subscriptionId;
}
try {
diff --git a/app/scripts/controllers/rewards/rewards-controller.types.ts b/app/scripts/controllers/rewards/rewards-controller.types.ts
index 8b6d9bf72874..1c6d98b817f5 100644
--- a/app/scripts/controllers/rewards/rewards-controller.types.ts
+++ b/app/scripts/controllers/rewards/rewards-controller.types.ts
@@ -1,5 +1,10 @@
import { CaipAccountId, CaipAssetType } from '@metamask/utils';
import { InternalAccount } from '@metamask/keyring-internal-api';
+import {
+ SeasonDtoState,
+ SeasonRewardType,
+ SeasonStatusState,
+} from '../../../../shared/types/rewards';
export type LoginResponseDto = {
sessionId: string;
@@ -410,13 +415,6 @@ export type SeasonRewardDto = {
rewardType: SeasonRewardType;
};
-export enum SeasonRewardType {
- Generic = 'Generic',
- PerpsDiscount = 'PerpsDiscount',
- PointsBoost = 'PointsBoost',
- AlphaFoxInvite = 'AlphaFoxInvite',
-}
-
export type SeasonDto = {
id: string;
name: string;
@@ -607,68 +605,6 @@ export type ThemeImage = {
darkModeUrl: string;
};
-export type ClaimRewardDto = {
- data?: Record;
-};
-
-// Serializable versions for state storage (Date objects converted to timestamps)
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type SeasonRewardDtoState = {
- id: string;
- name: string;
- shortDescription: string;
- longDescription: string;
- shortUnlockedDescription: string;
- longUnlockedDescription: string;
- claimUrl?: string;
- iconName: string;
- rewardType: SeasonRewardType;
-};
-
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type SeasonTierDtoState = {
- id: string;
- name: string;
- pointsNeeded: number;
- image: {
- lightModeUrl: string;
- darkModeUrl: string;
- };
- levelNumber: string;
- rewards: SeasonRewardDtoState[];
-};
-
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type SeasonDtoState = {
- id: string;
- name: string;
- startDate: number; // timestamp
- endDate: number; // timestamp
- tiers: SeasonTierDtoState[];
- lastFetched?: number;
-};
-
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type SeasonStatusBalanceDtoState = {
- total: number;
- updatedAt?: number; // timestamp
-};
-
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type SeasonTierState = {
- currentTier: SeasonTierDtoState;
- nextTier: SeasonTierDtoState | null;
- nextTierPointsNeeded: number | null;
-};
-
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type SeasonStatusState = {
- season: SeasonDtoState;
- balance: SeasonStatusBalanceDtoState;
- tier: SeasonTierState;
- lastFetched?: number;
-};
-
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type RewardsAccountState = {
account: CaipAccountId;
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index cc59691aba58..c0e4a38a85ce 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -2444,6 +2444,20 @@ export default class MetamaskController extends EventEmitter {
this.subscriptionController,
),
+ // rewards
+ getCandidateSubscriptionId: this.controllerMessenger.call.bind(
+ this.controllerMessenger,
+ 'RewardsController:getCandidateSubscriptionId',
+ ),
+ getRewardsSeasonMetadata: this.controllerMessenger.call.bind(
+ this.controllerMessenger,
+ 'RewardsController:getSeasonMetadata',
+ ),
+ getRewardsSeasonStatus: this.controllerMessenger.call.bind(
+ this.controllerMessenger,
+ 'RewardsController:getSeasonStatus',
+ ),
+
// hardware wallets
connectHardware: this.connectHardware.bind(this),
forgetDevice: this.forgetDevice.bind(this),
diff --git a/shared/types/background.ts b/shared/types/background.ts
index baadeb4e289a..f618a63835ef 100644
--- a/shared/types/background.ts
+++ b/shared/types/background.ts
@@ -70,6 +70,7 @@ import type { OnboardingControllerState } from '../../app/scripts/controllers/on
import type { MetaMetricsControllerState } from '../../app/scripts/controllers/metametrics-controller';
import type { AppMetadataControllerState } from '../../app/scripts/controllers/app-metadata';
import type { SwapsControllerState } from '../../app/scripts/controllers/swaps/swaps.types';
+import type { RewardsControllerState } from '../../app/scripts/controllers/rewards/rewards-controller.types';
export type ControllerStatePropertiesEnumerated = {
internalAccounts: AccountsControllerState['internalAccounts'];
@@ -310,6 +311,12 @@ export type ControllerStatePropertiesEnumerated = {
isAccountSyncingEnabled: UserStorageController.UserStorageControllerState['isAccountSyncingEnabled'];
isContactSyncingEnabled: UserStorageController.UserStorageControllerState['isContactSyncingEnabled'];
isContactSyncingInProgress: UserStorageController.UserStorageControllerState['isContactSyncingInProgress'];
+ rewardsActiveAccount: RewardsControllerState['rewardsActiveAccount'];
+ rewardsAccounts: RewardsControllerState['rewardsAccounts'];
+ rewardsSubscriptions: RewardsControllerState['rewardsSubscriptions'];
+ rewardsSeasons: RewardsControllerState['rewardsSeasons'];
+ rewardsSeasonStatuses: RewardsControllerState['rewardsSeasonStatuses'];
+ rewardsSubscriptionTokens: RewardsControllerState['rewardsSubscriptionTokens'];
};
type ControllerStateTypesMerged = AccountsControllerState &
@@ -370,7 +377,8 @@ type ControllerStateTypesMerged = AccountsControllerState &
TokenRatesControllerState &
TransactionControllerState &
UserOperationControllerState &
- UserStorageController.UserStorageControllerState;
+ UserStorageController.UserStorageControllerState &
+ RewardsControllerState;
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
// eslint-disable-next-line @typescript-eslint/naming-convention
diff --git a/shared/types/rewards.ts b/shared/types/rewards.ts
new file mode 100644
index 000000000000..4be5c1d4d082
--- /dev/null
+++ b/shared/types/rewards.ts
@@ -0,0 +1,62 @@
+/**
+ * Shared types for rewards functionality
+ * These types are used across UI and background to avoid import restrictions
+ */
+
+export enum SeasonRewardType {
+ Generic = 'Generic',
+ PerpsDiscount = 'PerpsDiscount',
+ PointsBoost = 'PointsBoost',
+ AlphaFoxInvite = 'AlphaFoxInvite',
+}
+
+export type SeasonRewardDtoState = {
+ id: string;
+ name: string;
+ shortDescription: string;
+ longDescription: string;
+ shortUnlockedDescription: string;
+ longUnlockedDescription: string;
+ claimUrl?: string;
+ iconName: string;
+ rewardType: SeasonRewardType;
+};
+
+export type SeasonTierDtoState = {
+ id: string;
+ name: string;
+ pointsNeeded: number;
+ image: {
+ lightModeUrl: string;
+ darkModeUrl: string;
+ };
+ levelNumber: string;
+ rewards: SeasonRewardDtoState[];
+};
+
+export type SeasonDtoState = {
+ id: string;
+ name: string;
+ startDate: number; // timestamp
+ endDate: number; // timestamp
+ tiers: SeasonTierDtoState[];
+ lastFetched?: number;
+};
+
+export type SeasonStatusBalanceDtoState = {
+ total: number;
+ updatedAt?: number; // timestamp
+};
+
+export type SeasonTierState = {
+ currentTier: SeasonTierDtoState;
+ nextTier: SeasonTierDtoState | null;
+ nextTierPointsNeeded: number | null;
+};
+
+export type SeasonStatusState = {
+ season: SeasonDtoState;
+ balance: SeasonStatusBalanceDtoState;
+ tier: SeasonTierState;
+ lastFetched?: number;
+};
diff --git a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx
index 55a82fc2e6f5..8e703681d33d 100644
--- a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx
+++ b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.test.tsx
@@ -38,7 +38,7 @@ describe('AccountGroupBalanceChange', () => {
const renderComponent = () =>
renderWithProvider(
- null} />,
+ null} />,
mockStore,
);
diff --git a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx
index 9c00e5b50eea..b642c1747df8 100644
--- a/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx
+++ b/ui/components/app/assets/account-group-balance-change/account-group-balance-change.tsx
@@ -18,14 +18,14 @@ import { useAccountGroupBalanceDisplay } from './useAccountGroupBalanceDisplay';
export type AccountGroupBalanceChangeProps = {
period: BalanceChangePeriod;
- portfolioButton: () => JSX.Element | null;
+ trailingChild: () => JSX.Element | null;
};
const balanceAmountSpanStyle = { whiteSpace: 'pre' } as const;
const AccountGroupBalanceChangeComponent: React.FC<
AccountGroupBalanceChangeProps
-> = ({ period, portfolioButton }) => {
+> = ({ period, trailingChild }) => {
const { privacyMode, color, amountChange, percentChange } =
useAccountGroupBalanceDisplay(period);
const { formatCurrency, formatPercentWithMinThreshold } = useFormatters();
@@ -61,7 +61,7 @@ const AccountGroupBalanceChangeComponent: React.FC<
{`(${formatPercentWithMinThreshold(percentChange, { signDisplay: 'always' })})`}
- {portfolioButton()}
+ {trailingChild()}
);
};
diff --git a/ui/components/app/rewards/RewardsPointsBalance.test.tsx b/ui/components/app/rewards/RewardsPointsBalance.test.tsx
new file mode 100644
index 000000000000..351d9933b8a4
--- /dev/null
+++ b/ui/components/app/rewards/RewardsPointsBalance.test.tsx
@@ -0,0 +1,269 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { useSelector } from 'react-redux';
+import { useI18nContext } from '../../../hooks/useI18nContext';
+import { useRewardsContext } from '../../../contexts/rewards';
+import type { RewardsContextValue } from '../../../contexts/rewards';
+import { RewardsPointsBalance } from './RewardsPointsBalance';
+
+// Mock dependencies
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock('../../../hooks/useI18nContext', () => ({
+ useI18nContext: jest.fn(),
+}));
+
+jest.mock('../../../contexts/rewards', () => ({
+ useRewardsContext: jest.fn(),
+}));
+
+jest.mock('../../component-library/skeleton', () => ({
+ Skeleton: ({ width }: { width: string }) => (
+
+ Loading...
+
+ ),
+}));
+
+const mockUseSelector = useSelector as jest.MockedFunction;
+const mockUseI18nContext = useI18nContext as jest.MockedFunction<
+ typeof useI18nContext
+>;
+const mockUseRewardsContext = useRewardsContext as jest.MockedFunction<
+ typeof useRewardsContext
+>;
+
+describe('RewardsPointsBalance', () => {
+ const mockT = jest.fn((key: string, values?: string[]) => {
+ if (key === 'rewardsOptIn') {
+ return 'Opt In';
+ }
+ if (key === 'rewardsPointsBalance' && values) {
+ return `${values[0]} points`;
+ }
+ if (key === 'rewardsPointsIcon') {
+ return 'Rewards Points Icon';
+ }
+ return key;
+ });
+
+ // Mock season status with complete structure
+ const mockSeasonStatus = {
+ season: {
+ id: 'test-season',
+ name: 'Test Season',
+ startDate: Date.now() - 86400000, // 1 day ago
+ endDate: Date.now() + 86400000, // 1 day from now
+ tiers: [],
+ },
+ balance: {
+ total: 1000,
+ },
+ tier: {
+ currentTier: {
+ id: 'tier-1',
+ name: 'Bronze',
+ pointsNeeded: 0,
+ image: {
+ lightModeUrl: 'light.png',
+ darkModeUrl: 'dark.png',
+ },
+ levelNumber: '1',
+ rewards: [],
+ },
+ nextTier: null,
+ nextTierPointsNeeded: null,
+ },
+ };
+
+ // Mock rewards context value with complete structure
+ const mockRewardsContextValue: RewardsContextValue = {
+ rewardsEnabled: true,
+ candidateSubscriptionId: 'test-subscription-id',
+ candidateSubscriptionIdError: false,
+ seasonStatus: mockSeasonStatus,
+ seasonStatusError: null,
+ seasonStatusLoading: false,
+ refetchSeasonStatus: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseI18nContext.mockReturnValue(mockT);
+ mockUseSelector.mockReturnValue('en-US'); // Default locale
+ });
+
+ it('should render null when rewards are not enabled', () => {
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ rewardsEnabled: false,
+ seasonStatus: null,
+ candidateSubscriptionId: null,
+ });
+
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render null when candidateSubscriptionId is null', () => {
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ seasonStatus: null,
+ candidateSubscriptionId: null,
+ });
+
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render skeleton when loading and no balance exists', () => {
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ seasonStatus: null,
+ seasonStatusLoading: true,
+ });
+
+ render();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('should not render skeleton when loading but balance exists', () => {
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ seasonStatusLoading: true,
+ });
+
+ render();
+
+ expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
+ expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('rewards-points-balance-value'),
+ ).toHaveTextContent('1,000 points');
+ });
+
+ it('should render formatted points balance with default locale', () => {
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ seasonStatus: {
+ ...mockSeasonStatus,
+ balance: {
+ total: 12345,
+ },
+ },
+ });
+
+ render();
+
+ expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('rewards-points-balance-value'),
+ ).toHaveTextContent('12,345 points');
+ expect(screen.getByAltText('Rewards Points Icon')).toBeInTheDocument();
+ });
+
+ it('should render formatted points balance with German locale', () => {
+ mockUseSelector.mockReturnValue('de-DE');
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ seasonStatus: {
+ ...mockSeasonStatus,
+ balance: {
+ total: 12345,
+ },
+ },
+ });
+
+ render();
+
+ expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('rewards-points-balance-value'),
+ ).toHaveTextContent('12.345 points');
+ });
+
+ it('should render zero points correctly', () => {
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ seasonStatus: {
+ ...mockSeasonStatus,
+ balance: {
+ total: 0,
+ },
+ },
+ });
+
+ render();
+
+ expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('rewards-points-balance-value'),
+ ).toHaveTextContent('0 points');
+ });
+
+ it('should handle undefined seasonStatus gracefully', () => {
+ mockUseRewardsContext.mockReturnValue({
+ ...mockRewardsContextValue,
+ seasonStatus: null,
+ });
+
+ render();
+
+ expect(screen.getByTestId('rewards-points-balance')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('rewards-points-balance-value'),
+ ).toHaveTextContent('0 points');
+ });
+
+ it('should render with correct CSS classes and structure', () => {
+ mockUseRewardsContext.mockReturnValue(mockRewardsContextValue);
+
+ render();
+
+ const container = screen.getByTestId('rewards-points-balance');
+ expect(container).toHaveClass(
+ 'flex',
+ 'items-center',
+ 'gap-1',
+ 'px-1.5',
+ 'bg-background-muted',
+ 'rounded',
+ );
+
+ const image = screen.getByAltText('Rewards Points Icon');
+ expect(image).toHaveAttribute(
+ 'src',
+ './images/metamask-rewards-points.svg',
+ );
+ expect(image).toHaveStyle({ width: '16px', height: '16px' });
+ });
+
+ it('should call useSelector with getIntlLocale selector', () => {
+ mockUseRewardsContext.mockReturnValue(mockRewardsContextValue);
+
+ render();
+
+ expect(mockUseSelector).toHaveBeenCalled();
+ });
+
+ it('should call useI18nContext hook', () => {
+ mockUseRewardsContext.mockReturnValue(mockRewardsContextValue);
+
+ render();
+
+ expect(mockUseI18nContext).toHaveBeenCalled();
+ });
+
+ it('should call useRewardsContext hook', () => {
+ mockUseRewardsContext.mockReturnValue(mockRewardsContextValue);
+
+ render();
+
+ expect(mockUseRewardsContext).toHaveBeenCalled();
+ });
+});
diff --git a/ui/components/app/rewards/RewardsPointsBalance.tsx b/ui/components/app/rewards/RewardsPointsBalance.tsx
new file mode 100644
index 000000000000..8ff8b773b3b4
--- /dev/null
+++ b/ui/components/app/rewards/RewardsPointsBalance.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { Box, Text, TextVariant } from '@metamask/design-system-react';
+import { useI18nContext } from '../../../hooks/useI18nContext';
+import { getIntlLocale } from '../../../ducks/locale/locale';
+import { useRewardsContext } from '../../../contexts/rewards';
+import { Skeleton } from '../../component-library/skeleton';
+
+const RewardsBadge = ({ text }: { text: string }) => {
+ const t = useI18nContext();
+
+ return (
+
+
+
+ {text}
+
+
+ );
+};
+
+/**
+ * Component to display the rewards points balance
+ * Shows the points balance with an icon for users who haven't opted in yet
+ * (i.e., when rewardsActiveAccount?.subscriptionId is null)
+ */
+export const RewardsPointsBalance = () => {
+ const t = useI18nContext();
+ const locale = useSelector(getIntlLocale);
+
+ const {
+ rewardsEnabled,
+ seasonStatus,
+ seasonStatusLoading,
+ candidateSubscriptionId,
+ } = useRewardsContext();
+
+ if (!rewardsEnabled) {
+ return null;
+ }
+
+ if (!candidateSubscriptionId) {
+ return null;
+ }
+
+ if (seasonStatusLoading && !seasonStatus?.balance) {
+ return ;
+ }
+
+ // Format the points balance with proper locale-aware number formatting
+ const formattedPoints = new Intl.NumberFormat(locale).format(
+ seasonStatus?.balance?.total ?? 0,
+ );
+
+ return ;
+};
diff --git a/ui/components/app/rewards/index.ts b/ui/components/app/rewards/index.ts
new file mode 100644
index 000000000000..76ceef7614cc
--- /dev/null
+++ b/ui/components/app/rewards/index.ts
@@ -0,0 +1 @@
+export { RewardsPointsBalance } from './RewardsPointsBalance';
diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx
index 62ebf51fd5c2..966734eaa9c4 100644
--- a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx
+++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx
@@ -429,9 +429,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => {
totalFiatBalance: 289.96,
});
const { container } = render(
- null}
- />,
+ null} />,
);
expect(container).toMatchSnapshot();
});
@@ -457,7 +455,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => {
});
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByText('(+0.00%)');
const numberElement = screen.getByText('+$0.00');
@@ -504,7 +502,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => {
const expectedAmountChange = '-$0.97';
const expectedPercentageChange = '(-0.33%)';
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
@@ -551,7 +549,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => {
const expectedAmountChange = '+$0.96';
const expectedPercentageChange = '(+0.33%)';
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
@@ -597,7 +595,7 @@ describe('AggregatedPercentageOverviewCrossChains', () => {
const expectedAmountChange = '+$0.22';
const expectedPercentageChange = '(+0.08%)';
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx
index be8b8164480a..2b7b26a32dca 100644
--- a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx
+++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx
@@ -32,9 +32,9 @@ import { Skeleton } from '../../component-library/skeleton';
import { isZeroAmount } from '../../../helpers/utils/number-utils';
export const AggregatedPercentageOverviewCrossChains = ({
- portfolioButton,
+ trailingChild,
}: {
- portfolioButton: () => JSX.Element | null;
+ trailingChild: () => JSX.Element | null;
}) => {
const { formatCurrencyCompact } = useFormatters();
const fiatCurrency = useSelector(getCurrentCurrency);
@@ -187,7 +187,7 @@ export const AggregatedPercentageOverviewCrossChains = ({
{formattedPercentChangeCrossChains}
- {portfolioButton()}
+ {trailingChild()}
);
};
diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx
index 8b76289bc62e..b969f37af71d 100644
--- a/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx
+++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.stories.tsx
@@ -23,16 +23,16 @@ const Story = {
export default Story;
export const Default = () => (
- null} />
+ null} />
);
export const Multichain = () => (
- null} />
+ null} />
);
export const MultichainPrivacyMode = () => (
null}
+ trailingChild={() => null}
/>
);
diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx
index 6ea41f8396a2..edfdcfb7cf5d 100644
--- a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx
+++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx
@@ -234,7 +234,7 @@ describe('AggregatedPercentageOverview', () => {
totalFiatBalance: 0,
});
const { container } = render(
- null} />,
+ null} />,
);
expect(container).toMatchSnapshot();
});
@@ -252,7 +252,7 @@ describe('AggregatedPercentageOverview', () => {
totalFiatBalance: 0,
});
- render( null} />);
+ render( null} />);
const percentageElement = screen.getByText('(+0.00%)');
const numberElement = screen.getByText('+$0.00');
expect(percentageElement).toBeInTheDocument();
@@ -311,7 +311,7 @@ describe('AggregatedPercentageOverview', () => {
});
const expectedAmountChange = '-$0.09';
const expectedPercentageChange = '(-0.29%)';
- render( null} />);
+ render( null} />);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
expect(percentageElement).toBeInTheDocument();
@@ -371,7 +371,7 @@ describe('AggregatedPercentageOverview', () => {
mockGetTokensMarketData.mockReturnValue(positiveMarketDataMock);
const expectedAmountChange = '+$0.09';
const expectedPercentageChange = '(+0.29%)';
- render( null} />);
+ render( null} />);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
expect(percentageElement).toBeInTheDocument();
@@ -431,7 +431,7 @@ describe('AggregatedPercentageOverview', () => {
mockGetTokensMarketData.mockReturnValue(mixedMarketDataMock);
const expectedAmountChange = '-$0.07';
const expectedPercentageChange = '(-0.23%)';
- render( null} />);
+ render( null} />);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
expect(percentageElement).toBeInTheDocument();
@@ -542,7 +542,7 @@ describe('AggregatedPercentageOverview', () => {
});
const expectedAmountChange = '-$0.39';
const expectedPercentageChange = '(-1.08%)';
- render( null} />);
+ render( null} />);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
expect(percentageElement).toBeInTheDocument();
@@ -640,7 +640,7 @@ describe('AggregatedPercentageOverview', () => {
});
const expectedAmountChange = '-$0.01';
const expectedPercentageChange = '(-0.03%)';
- render( null} />);
+ render( null} />);
const percentageElement = screen.getByText(expectedPercentageChange);
const numberElement = screen.getByText(expectedAmountChange);
expect(percentageElement).toBeInTheDocument();
@@ -684,7 +684,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
describe('render', () => {
it('renders correctly with zero values', () => {
const { container } = render(
- null} />,
+ null} />,
);
expect(container).toMatchSnapshot();
});
@@ -701,7 +701,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
});
const { container } = render(
- null} />,
+ null} />,
);
expect(container).toMatchSnapshot();
});
@@ -718,7 +718,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
});
const { container } = render(
- null} />,
+ null} />,
);
expect(container).toMatchSnapshot();
});
@@ -726,7 +726,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
it('should display zero percentage and amount when balance is zero', () => {
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByTestId(
'aggregated-percentage-change',
@@ -748,7 +748,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
});
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByTestId(
'aggregated-percentage-change',
@@ -770,7 +770,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
});
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByTestId(
'aggregated-percentage-change',
@@ -793,7 +793,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
render(
null}
+ trailingChild={() => null}
privacyMode={true}
/>,
);
@@ -817,7 +817,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
});
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByTestId(
'aggregated-percentage-change',
@@ -839,7 +839,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
});
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByTestId(
'aggregated-percentage-change',
@@ -851,7 +851,7 @@ describe('AggregatedMultichainPercentageOverview', () => {
it('should use correct color for zero values', () => {
render(
- null} />,
+ null} />,
);
const percentageElement = screen.getByTestId(
'aggregated-percentage-change',
diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx
index d85ac0b5a572..ae8fc864a779 100644
--- a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx
+++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx
@@ -38,9 +38,9 @@ type MarketDataDetails = {
};
export const AggregatedPercentageOverview = ({
- portfolioButton,
+ trailingChild,
}: {
- portfolioButton: () => JSX.Element | null;
+ trailingChild: () => JSX.Element | null;
}) => {
const tokensMarketData: Record =
useSelector(getTokensMarketData);
@@ -148,16 +148,16 @@ export const AggregatedPercentageOverview = ({
{formattedPercentChange}
- {portfolioButton()}
+ {trailingChild()}
);
};
export const AggregatedMultichainPercentageOverview = ({
- portfolioButton,
+ trailingChild,
privacyMode = false,
}: {
- portfolioButton: () => JSX.Element | null;
+ trailingChild: () => JSX.Element | null;
privacyMode?: boolean;
}) => {
const locale = useSelector(getIntlLocale);
@@ -240,7 +240,7 @@ export const AggregatedMultichainPercentageOverview = ({
{localizedPercentChange})
- {portfolioButton()}
+ {trailingChild()}
);
};
diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx
index 7c1376f65d11..1e88567e93e3 100644
--- a/ui/components/app/wallet-overview/coin-overview.tsx
+++ b/ui/components/app/wallet-overview/coin-overview.tsx
@@ -52,6 +52,8 @@ import { useMultichainSelector } from '../../../hooks/useMultichainSelector';
import { AggregatedBalance } from '../../ui/aggregated-balance/aggregated-balance';
import { Skeleton } from '../../component-library/skeleton';
import { isZeroAmount } from '../../../helpers/utils/number-utils';
+import { useRewardsEnabled } from '../../../hooks/rewards';
+import { RewardsPointsBalance } from '../rewards';
import WalletOverview from './wallet-overview';
import CoinButtons from './coin-buttons';
import {
@@ -220,6 +222,7 @@ export const CoinOverview = ({
const anyEnabledNetworksAreAvailable = useSelector(
selectAnyEnabledNetworksAreAvailable,
);
+ const isRewardsEnabled = useRewardsEnabled();
const handleSensitiveToggle = () => {
dispatch(setPrivacyMode(!privacyMode));
@@ -245,7 +248,10 @@ export const CoinOverview = ({
}, [isMarketingEnabled, isMetaMetricsEnabled, metaMetricsId, trackEvent]);
const renderPercentageAndAmountChange = () => {
- const renderPortfolioButton = () => {
+ const renderPercentageAndAmountChangeTrail = () => {
+ if (isRewardsEnabled) {
+ return ;
+ }
return (
);
- return null;
};
const renderNativeTokenView = () => {
@@ -270,7 +275,7 @@ export const CoinOverview = ({
>
- {renderPortfolioButton()}
+ {renderPercentageAndAmountChangeTrail()}
);
@@ -280,11 +285,11 @@ export const CoinOverview = ({
{isTokenNetworkFilterEqualCurrentNetwork ? (
) : (
)}
@@ -294,7 +299,7 @@ export const CoinOverview = ({
);
@@ -305,7 +310,7 @@ export const CoinOverview = ({
);
diff --git a/ui/components/app/wallet-overview/index.scss b/ui/components/app/wallet-overview/index.scss
index dc1de8761849..f18172536745 100644
--- a/ui/components/app/wallet-overview/index.scss
+++ b/ui/components/app/wallet-overview/index.scss
@@ -39,6 +39,7 @@
display: flex;
flex-direction: row;
gap: 8px;
+ flex-wrap: wrap;
}
}
@@ -72,7 +73,6 @@
min-width: 0;
position: relative;
align-items: start;
- max-width: 326px;
}
&__primary-container {
diff --git a/ui/contexts/rewards/index.tsx b/ui/contexts/rewards/index.tsx
new file mode 100644
index 000000000000..1b6ffd98959a
--- /dev/null
+++ b/ui/contexts/rewards/index.tsx
@@ -0,0 +1,70 @@
+import React, { useContext } from 'react';
+import type { SeasonStatusState } from '../../../shared/types/rewards';
+import { useCandidateSubscriptionId } from '../../hooks/rewards/useCandidateSubscriptionId';
+import { useSeasonStatus } from '../../hooks/rewards/useSeasonStatus';
+import { useRewardsEnabled } from '../../hooks/rewards/useRewardsEnabled';
+
+export type RewardsContextValue = {
+ rewardsEnabled: boolean;
+ candidateSubscriptionId: string | null;
+ candidateSubscriptionIdError: boolean;
+ seasonStatus: SeasonStatusState | null;
+ seasonStatusError: string | null;
+ seasonStatusLoading: boolean;
+ refetchSeasonStatus: () => Promise;
+};
+
+export const RewardsContext = React.createContext({
+ rewardsEnabled: false,
+ candidateSubscriptionId: null,
+ candidateSubscriptionIdError: false,
+ seasonStatus: null,
+ seasonStatusError: null,
+ seasonStatusLoading: false,
+ refetchSeasonStatus: async () => {
+ // Default empty function
+ },
+});
+
+export const useRewardsContext = () => {
+ const context = useContext(RewardsContext);
+ if (!context) {
+ throw new Error('useRewardsContext must be used within a RewardsProvider');
+ }
+ return context;
+};
+
+export const RewardsProvider: React.FC = ({ children }) => {
+ const rewardsEnabled = useRewardsEnabled();
+ const {
+ candidateSubscriptionId,
+ candidateSubscriptionIdError,
+ fetchCandidateSubscriptionId,
+ } = useCandidateSubscriptionId();
+
+ const {
+ seasonStatus,
+ seasonStatusError,
+ seasonStatusLoading,
+ fetchSeasonStatus,
+ } = useSeasonStatus({
+ subscriptionId: candidateSubscriptionId,
+ onAuthorizationError: fetchCandidateSubscriptionId,
+ });
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/ui/hooks/rewards/index.ts b/ui/hooks/rewards/index.ts
new file mode 100644
index 000000000000..a7012d668043
--- /dev/null
+++ b/ui/hooks/rewards/index.ts
@@ -0,0 +1,4 @@
+export { useRewardsContext } from '../../contexts/rewards';
+export { useCandidateSubscriptionId } from './useCandidateSubscriptionId';
+export { useSeasonStatus } from './useSeasonStatus';
+export { useRewardsEnabled } from './useRewardsEnabled';
diff --git a/ui/hooks/rewards/useCandidateSubscriptionId.test.ts b/ui/hooks/rewards/useCandidateSubscriptionId.test.ts
new file mode 100644
index 000000000000..bb6e7dfc3db4
--- /dev/null
+++ b/ui/hooks/rewards/useCandidateSubscriptionId.test.ts
@@ -0,0 +1,535 @@
+import { act } from '@testing-library/react-hooks';
+import { waitFor } from '@testing-library/react';
+import log from 'loglevel';
+import { submitRequestToBackground } from '../../store/background-connection';
+import { renderHookWithProvider } from '../../../test/lib/render-helpers';
+import mockState from '../../../test/data/mock-state.json';
+import { useCandidateSubscriptionId } from './useCandidateSubscriptionId';
+import { useRewardsEnabled } from './useRewardsEnabled';
+
+// Mock dependencies
+jest.mock('../../store/background-connection', () => ({
+ submitRequestToBackground: jest.fn(),
+}));
+
+jest.mock('./useRewardsEnabled', () => ({
+ useRewardsEnabled: jest.fn(),
+}));
+
+jest.mock('loglevel', () => ({
+ error: jest.fn(),
+ setLevel: jest.fn(),
+}));
+
+// Mock console.log to avoid noise in tests
+const originalConsoleLog = console.log;
+beforeAll(() => {
+ console.log = jest.fn();
+});
+
+afterAll(() => {
+ console.log = originalConsoleLog;
+});
+
+const mockSubmitRequestToBackground =
+ submitRequestToBackground as jest.MockedFunction<
+ typeof submitRequestToBackground
+ >;
+const mockUseRewardsEnabled = useRewardsEnabled as jest.MockedFunction<
+ typeof useRewardsEnabled
+>;
+const mockLogError = log.error as jest.MockedFunction;
+
+describe('useCandidateSubscriptionId', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseRewardsEnabled.mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('Initial State', () => {
+ it('should return initial values', () => {
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ mockState,
+ );
+
+ expect(result.current.candidateSubscriptionId).toBeNull();
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ expect(typeof result.current.fetchCandidateSubscriptionId).toBe(
+ 'function',
+ );
+ });
+ });
+
+ describe('Conditional Fetching', () => {
+ it('should not fetch when rewards are disabled', () => {
+ mockUseRewardsEnabled.mockReturnValue(false);
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ expect(result.current.candidateSubscriptionId).toBeNull();
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch when wallet is locked', () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+
+ const testState = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ isUnlocked: false,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ testState,
+ );
+
+ expect(result.current.candidateSubscriptionId).toBeNull();
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch when rewardsActiveAccountCaipAccountId is missing', () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: null,
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ expect(result.current.candidateSubscriptionId).toBeNull();
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+
+ it('should fetch when subscription IDs do not match', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+ mockSubmitRequestToBackground.mockResolvedValue('new-sub-id');
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'different-sub-id',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getCandidateSubscriptionId',
+ [],
+ );
+ });
+
+ await waitFor(() => {
+ expect(result.current.candidateSubscriptionId).toBe('new-sub-id');
+ });
+ });
+ });
+
+ describe('fetchCandidateSubscriptionId Function', () => {
+ it('should fetch successfully and update state', async () => {
+ const mockId = 'test-subscription-id';
+ mockSubmitRequestToBackground.mockResolvedValue(mockId);
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await act(async () => {
+ await result.current.fetchCandidateSubscriptionId();
+ });
+
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getCandidateSubscriptionId',
+ [],
+ );
+ expect(result.current.candidateSubscriptionId).toBe(mockId);
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ });
+
+ it('should handle errors and update error state', async () => {
+ const mockError = new Error('API Error');
+ mockSubmitRequestToBackground.mockRejectedValue(mockError);
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await act(async () => {
+ await result.current.fetchCandidateSubscriptionId();
+ });
+
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getCandidateSubscriptionId',
+ [],
+ );
+ expect(result.current.candidateSubscriptionId).toBeNull();
+ expect(result.current.candidateSubscriptionIdError).toBe(true);
+ expect(mockLogError).toHaveBeenCalledWith(
+ '[useCandidateSubscriptionId] Error fetching candidate subscription ID:',
+ mockError,
+ );
+ });
+
+ it('should not fetch when rewards are disabled in fetchCandidateSubscriptionId', async () => {
+ mockUseRewardsEnabled.mockReturnValue(false);
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await act(async () => {
+ await result.current.fetchCandidateSubscriptionId();
+ });
+
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ expect(result.current.candidateSubscriptionId).toBeNull();
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ });
+ });
+
+ describe('useEffect Behavior', () => {
+ it('should fetch when wallet becomes unlocked', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+ mockSubmitRequestToBackground.mockResolvedValue('test-id-123');
+
+ // Test with locked wallet first
+ const { result: lockedResult } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: false,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ // Initially should not fetch
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ expect(lockedResult.current.candidateSubscriptionId).toBeNull();
+
+ // Test with unlocked wallet
+ const { result: unlockedResult } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getCandidateSubscriptionId',
+ [],
+ );
+ });
+
+ await waitFor(() => {
+ expect(unlockedResult.current.candidateSubscriptionId).toBe(
+ 'test-id-123',
+ );
+ });
+ });
+
+ it('should fetch when rewards become enabled', async () => {
+ mockSubmitRequestToBackground.mockResolvedValue('test-id-456');
+
+ // Test with rewards disabled first
+ const mockUseRewardsEnabledLocal = useRewardsEnabled as jest.Mock;
+ mockUseRewardsEnabledLocal.mockReturnValue(false);
+
+ const { result: disabledResult } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ // Initially should not fetch
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ expect(disabledResult.current.candidateSubscriptionId).toBeNull();
+
+ // Test with rewards enabled
+ mockUseRewardsEnabledLocal.mockReturnValue(true);
+
+ const { result: enabledResult } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getCandidateSubscriptionId',
+ [],
+ );
+ });
+
+ await waitFor(() => {
+ expect(enabledResult.current.candidateSubscriptionId).toBe(
+ 'test-id-456',
+ );
+ });
+ });
+
+ it('should fetch when rewardsActiveAccountCaipAccountId becomes available', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+ mockSubmitRequestToBackground.mockResolvedValue('test-id-789');
+
+ // Test without rewardsActiveAccount
+ const { result: noAccountResult } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: null,
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ // Initially should not fetch
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ expect(noAccountResult.current.candidateSubscriptionId).toBeNull();
+
+ // Test with rewardsActiveAccount
+ const { result: withAccountResult } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getCandidateSubscriptionId',
+ [],
+ );
+ });
+
+ await waitFor(() => {
+ expect(withAccountResult.current.candidateSubscriptionId).toBe(
+ 'test-id-789',
+ );
+ });
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle null response from background', async () => {
+ mockSubmitRequestToBackground.mockResolvedValue(null);
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await act(async () => {
+ await result.current.fetchCandidateSubscriptionId();
+ });
+
+ expect(result.current.candidateSubscriptionId).toBeNull();
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ });
+
+ it('should handle undefined response from background', async () => {
+ mockSubmitRequestToBackground.mockResolvedValue(undefined);
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await act(async () => {
+ await result.current.fetchCandidateSubscriptionId();
+ });
+
+ expect(result.current.candidateSubscriptionId).toBeUndefined();
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ });
+
+ it('should handle empty string response from background', async () => {
+ mockSubmitRequestToBackground.mockResolvedValue('');
+
+ const { result } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ },
+ );
+
+ await act(async () => {
+ await result.current.fetchCandidateSubscriptionId();
+ });
+
+ expect(result.current.candidateSubscriptionId).toBe('');
+ expect(result.current.candidateSubscriptionIdError).toBe(false);
+ });
+
+ it('should maintain function reference stability for fetchCandidateSubscriptionId', () => {
+ const initialState = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ isUnlocked: false,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ };
+
+ const updatedState = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'eip155:1:0x123',
+ subscriptionId: 'sub-123',
+ },
+ rewardsSubscriptions: {},
+ },
+ };
+
+ const { result, rerender } = renderHookWithProvider(
+ () => useCandidateSubscriptionId(),
+ initialState,
+ );
+
+ const firstFetchFunction = result.current.fetchCandidateSubscriptionId;
+
+ rerender({ children: updatedState });
+
+ const secondFetchFunction = result.current.fetchCandidateSubscriptionId;
+
+ expect(firstFetchFunction).toBe(secondFetchFunction);
+ });
+ });
+});
diff --git a/ui/hooks/rewards/useCandidateSubscriptionId.ts b/ui/hooks/rewards/useCandidateSubscriptionId.ts
new file mode 100644
index 000000000000..5825162641f0
--- /dev/null
+++ b/ui/hooks/rewards/useCandidateSubscriptionId.ts
@@ -0,0 +1,81 @@
+import { useState, useCallback, useEffect } from 'react';
+import log from 'loglevel';
+import { useSelector } from 'react-redux';
+import { submitRequestToBackground } from '../../store/background-connection';
+import { getIsUnlocked } from '../../ducks/metamask/metamask';
+import { useAppSelector } from '../../store/store';
+import { useRewardsEnabled } from './useRewardsEnabled';
+
+type UseCandidateSubscriptionIdReturn = {
+ candidateSubscriptionId: string | null;
+ candidateSubscriptionIdError: boolean;
+ fetchCandidateSubscriptionId: () => Promise;
+};
+
+/**
+ * Hook to fetch and manage candidate subscription ID
+ */
+export const useCandidateSubscriptionId =
+ (): UseCandidateSubscriptionIdReturn => {
+ const [candidateSubscriptionId, setCandidateSubscriptionId] = useState<
+ string | null
+ >(null);
+ const [candidateSubscriptionIdError, setCandidateSubscriptionIdError] =
+ useState(false);
+ const isUnlocked = useSelector(getIsUnlocked);
+ const isRewardsEnabled = useRewardsEnabled();
+ const rewardsActiveAccountSubscriptionId = useAppSelector(
+ (state) => state.metamask.rewardsActiveAccount?.subscriptionId,
+ );
+ const rewardsActiveAccountCaipAccountId = useAppSelector(
+ (state) => state.metamask.rewardsActiveAccount?.account,
+ );
+ const rewardsSubscriptions = useAppSelector(
+ (state) => state.metamask.rewardsSubscriptions,
+ );
+
+ const fetchCandidateSubscriptionId = useCallback(async () => {
+ try {
+ if (!isRewardsEnabled) {
+ setCandidateSubscriptionId(null);
+ setCandidateSubscriptionIdError(false);
+ return;
+ }
+ const candidateId = await submitRequestToBackground(
+ 'getCandidateSubscriptionId',
+ [],
+ );
+ setCandidateSubscriptionId(candidateId);
+ setCandidateSubscriptionIdError(false);
+ } catch (error) {
+ log.error(
+ '[useCandidateSubscriptionId] Error fetching candidate subscription ID:',
+ error,
+ );
+ setCandidateSubscriptionIdError(true);
+ }
+ }, [isRewardsEnabled]);
+
+ useEffect(() => {
+ if (
+ isUnlocked &&
+ rewardsActiveAccountCaipAccountId &&
+ (!candidateSubscriptionId ||
+ rewardsActiveAccountSubscriptionId !== candidateSubscriptionId)
+ ) {
+ fetchCandidateSubscriptionId();
+ }
+ }, [
+ isUnlocked,
+ fetchCandidateSubscriptionId,
+ rewardsActiveAccountCaipAccountId,
+ rewardsActiveAccountSubscriptionId,
+ candidateSubscriptionId,
+ rewardsSubscriptions,
+ ]);
+ return {
+ candidateSubscriptionId,
+ candidateSubscriptionIdError,
+ fetchCandidateSubscriptionId,
+ };
+ };
diff --git a/ui/hooks/rewards/useRewardsEnabled.test.ts b/ui/hooks/rewards/useRewardsEnabled.test.ts
new file mode 100644
index 000000000000..36802bba9cfb
--- /dev/null
+++ b/ui/hooks/rewards/useRewardsEnabled.test.ts
@@ -0,0 +1,320 @@
+import { renderHookWithProvider } from '../../../test/lib/render-helpers';
+import mockState from '../../../test/data/mock-state.json';
+import { useRewardsEnabled } from './useRewardsEnabled';
+
+describe('useRewardsEnabled', () => {
+ describe('with boolean feature flags', () => {
+ it('should return true when both useExternalServices and rewardsEnabled feature flag are true', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: true,
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(true);
+ });
+
+ it('should return false when useExternalServices is false', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: false,
+ remoteFeatureFlags: {
+ rewardsEnabled: true,
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('should return false when rewardsEnabled feature flag is false', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: false,
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('should return false when both useExternalServices and rewardsEnabled feature flag are false', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: false,
+ remoteFeatureFlags: {
+ rewardsEnabled: false,
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('should return false when useExternalServices is undefined', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ // useExternalServices is undefined
+ remoteFeatureFlags: {
+ rewardsEnabled: true,
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('should return false when rewardsEnabled feature flag is undefined', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ // rewardsEnabled is undefined
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+ });
+
+ describe('with VersionGatedFeatureFlag', () => {
+ it('should return true when useExternalServices is true and VersionGatedFeatureFlag is enabled with valid version', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: {
+ enabled: true,
+ minimumVersion: '12.0.0',
+ },
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(true);
+ });
+
+ it('should return false when VersionGatedFeatureFlag is disabled', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: {
+ enabled: false,
+ minimumVersion: '12.0.0',
+ },
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('should return false when VersionGatedFeatureFlag has invalid structure', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: {
+ enabled: true,
+ minimumVersion: null, // Invalid structure
+ },
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('should return false when useExternalServices is false even with valid VersionGatedFeatureFlag', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: false,
+ remoteFeatureFlags: {
+ rewardsEnabled: {
+ enabled: true,
+ minimumVersion: '12.0.0',
+ },
+ },
+ },
+ };
+
+ const { result } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+
+ expect(result.current).toBe(false);
+ });
+ });
+
+ describe('memoization behavior', () => {
+ it('should return consistent results for the same inputs', () => {
+ const state = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: true,
+ },
+ },
+ };
+
+ const { result, rerender } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ state,
+ );
+ const firstResult = result.current;
+
+ rerender();
+ const secondResult = result.current;
+
+ expect(firstResult).toBe(true);
+ expect(secondResult).toBe(true);
+ });
+
+ it('should return different results for different useExternalServices values', () => {
+ const stateWithExternalServicesTrue = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: true,
+ },
+ },
+ };
+
+ const stateWithExternalServicesFalse = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: false,
+ remoteFeatureFlags: {
+ rewardsEnabled: true,
+ },
+ },
+ };
+
+ const { result: resultTrue } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ stateWithExternalServicesTrue,
+ );
+ const { result: resultFalse } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ stateWithExternalServicesFalse,
+ );
+
+ expect(resultTrue.current).toBe(true);
+ expect(resultFalse.current).toBe(false);
+ });
+
+ it('should return different results for different rewardsEnabled feature flag values', () => {
+ const stateWithRewardsTrue = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: true,
+ },
+ },
+ };
+
+ const stateWithRewardsFalse = {
+ ...mockState,
+ metamask: {
+ ...mockState.metamask,
+ useExternalServices: true,
+ remoteFeatureFlags: {
+ rewardsEnabled: false,
+ },
+ },
+ };
+
+ const { result: resultTrue } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ stateWithRewardsTrue,
+ );
+ const { result: resultFalse } = renderHookWithProvider(
+ () => useRewardsEnabled(),
+ stateWithRewardsFalse,
+ );
+
+ expect(resultTrue.current).toBe(true);
+ expect(resultFalse.current).toBe(false);
+ });
+ });
+});
diff --git a/ui/hooks/rewards/useRewardsEnabled.ts b/ui/hooks/rewards/useRewardsEnabled.ts
new file mode 100644
index 000000000000..7560a8a1c6a9
--- /dev/null
+++ b/ui/hooks/rewards/useRewardsEnabled.ts
@@ -0,0 +1,43 @@
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { getUseExternalServices } from '../../selectors';
+import { getRemoteFeatureFlags } from '../../selectors/remote-feature-flags';
+import {
+ validatedVersionGatedFeatureFlag,
+ VersionGatedFeatureFlag,
+} from '../../../shared/lib/feature-flags/version-gating';
+
+/**
+ * Custom hook to check if rewards feature is enabled.
+ * Follows the same logic as the RewardsController's isDisabled function.
+ *
+ * @returns boolean - True if rewards feature is enabled, false otherwise
+ */
+export const useRewardsEnabled = (): boolean => {
+ const remoteFeatureFlags = useSelector(getRemoteFeatureFlags);
+ const useExternalServices = useSelector(getUseExternalServices);
+
+ const isRewardsEnabled = useMemo(() => {
+ const rewardsFeatureFlag = remoteFeatureFlags?.rewardsEnabled as
+ | VersionGatedFeatureFlag
+ | boolean
+ | undefined;
+
+ // Resolve the feature flag (can be boolean or VersionGatedFeatureFlag)
+ const resolveFlag = (flag: unknown): boolean => {
+ if (typeof flag === 'boolean') {
+ return flag;
+ }
+ return Boolean(
+ validatedVersionGatedFeatureFlag(flag as VersionGatedFeatureFlag),
+ );
+ };
+
+ const featureFlagEnabled = resolveFlag(rewardsFeatureFlag);
+
+ // Rewards are enabled when BOTH feature flag is enabled AND useExternalServices is true
+ return featureFlagEnabled && Boolean(useExternalServices);
+ }, [remoteFeatureFlags, useExternalServices]);
+
+ return isRewardsEnabled;
+};
diff --git a/ui/hooks/rewards/useSeasonStatus.test.ts b/ui/hooks/rewards/useSeasonStatus.test.ts
new file mode 100644
index 000000000000..980a90e286ac
--- /dev/null
+++ b/ui/hooks/rewards/useSeasonStatus.test.ts
@@ -0,0 +1,612 @@
+import { act } from '@testing-library/react-hooks';
+import { waitFor } from '@testing-library/react';
+import log from 'loglevel';
+import { submitRequestToBackground } from '../../store/background-connection';
+import { renderHookWithProvider } from '../../../test/lib/render-helpers';
+import { SeasonStatusState } from '../../../shared/types/rewards';
+import { useSeasonStatus } from './useSeasonStatus';
+import { useRewardsEnabled } from './useRewardsEnabled';
+
+// Mock dependencies
+jest.mock('../../store/background-connection', () => ({
+ submitRequestToBackground: jest.fn(),
+}));
+
+jest.mock('./useRewardsEnabled', () => ({
+ useRewardsEnabled: jest.fn(),
+}));
+
+jest.mock('loglevel', () => ({
+ error: jest.fn(),
+ setLevel: jest.fn(),
+}));
+
+// Suppress console.log during tests
+const originalConsoleLog = console.log;
+beforeAll(() => {
+ console.log = jest.fn();
+});
+
+afterAll(() => {
+ console.log = originalConsoleLog;
+});
+
+const mockSubmitRequestToBackground =
+ submitRequestToBackground as jest.MockedFunction<
+ typeof submitRequestToBackground
+ >;
+const mockUseRewardsEnabled = useRewardsEnabled as jest.MockedFunction<
+ typeof useRewardsEnabled
+>;
+const mockLogError = log.error as jest.MockedFunction;
+
+// Mock authorization error callback
+const mockOnAuthorizationError = jest.fn().mockResolvedValue(undefined);
+
+describe('useSeasonStatus', () => {
+ const mockSeasonStatus: SeasonStatusState = {
+ season: {
+ id: 'season-1',
+ name: 'Season 1',
+ startDate: 1640995200000, // 2022-01-01
+ endDate: 1672531200000, // 2023-01-01
+ tiers: [
+ {
+ id: 'tier-1',
+ name: 'Bronze',
+ pointsNeeded: 0,
+ image: {
+ lightModeUrl: 'https://example.com/bronze-light.png',
+ darkModeUrl: 'https://example.com/bronze-dark.png',
+ },
+ levelNumber: '1',
+ rewards: [],
+ },
+ {
+ id: 'tier-2',
+ name: 'Silver',
+ pointsNeeded: 100,
+ image: {
+ lightModeUrl: 'https://example.com/silver-light.png',
+ darkModeUrl: 'https://example.com/silver-dark.png',
+ },
+ levelNumber: '2',
+ rewards: [],
+ },
+ ],
+ lastFetched: Date.now(),
+ },
+ balance: {
+ total: 50,
+ updatedAt: Date.now(),
+ },
+ tier: {
+ currentTier: {
+ id: 'tier-1',
+ name: 'Bronze',
+ pointsNeeded: 0,
+ image: {
+ lightModeUrl: 'https://example.com/bronze-light.png',
+ darkModeUrl: 'https://example.com/bronze-dark.png',
+ },
+ levelNumber: '1',
+ rewards: [],
+ },
+ nextTier: {
+ id: 'tier-2',
+ name: 'Silver',
+ pointsNeeded: 100,
+ image: {
+ lightModeUrl: 'https://example.com/silver-light.png',
+ darkModeUrl: 'https://example.com/silver-dark.png',
+ },
+ levelNumber: '2',
+ rewards: [],
+ },
+ nextTierPointsNeeded: 50,
+ },
+ lastFetched: Date.now(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseRewardsEnabled.mockReturnValue(false); // Start with rewards disabled to prevent auto-fetch
+ mockSubmitRequestToBackground.mockResolvedValue(mockSeasonStatus);
+ mockOnAuthorizationError.mockClear();
+ });
+
+ describe('when rewards are enabled and user is unlocked', () => {
+ it('should fetch season status with subscriptionId', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current.seasonStatus).toEqual(mockSeasonStatus);
+ });
+
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getRewardsSeasonMetadata',
+ ['current'],
+ );
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getRewardsSeasonStatus',
+ ['test-subscription-id', undefined],
+ );
+ expect(result.current.seasonStatusError).toBeNull();
+ expect(result.current.seasonStatusLoading).toBe(false);
+ });
+
+ it('should not fetch season status without subscriptionId', () => {
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: null,
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBeNull();
+ expect(result.current.seasonStatusLoading).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when rewards are disabled', () => {
+ it('should not fetch season status', () => {
+ mockUseRewardsEnabled.mockReturnValue(false);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBeNull();
+ expect(result.current.seasonStatusLoading).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when user is locked', () => {
+ it('should not fetch season status', () => {
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: false,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBeNull();
+ expect(result.current.seasonStatusLoading).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when activeRewardsCaipAccountId is missing', () => {
+ it('should not fetch season status when activeRewardsCaipAccountId is null', () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: null,
+ },
+ },
+ );
+
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBeNull();
+ expect(result.current.seasonStatusLoading).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch season status when activeRewardsCaipAccountId is undefined', () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: undefined,
+ },
+ },
+ );
+
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBeNull();
+ expect(result.current.seasonStatusLoading).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch season status when rewardsActiveAccount.account is null', () => {
+ mockUseRewardsEnabled.mockReturnValue(true);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: null,
+ },
+ },
+ },
+ );
+
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBeNull();
+ expect(result.current.seasonStatusLoading).toBe(false);
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('error handling', () => {
+ it('should handle fetch errors', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test
+ const mockError = new Error('Failed to fetch season status');
+ mockSubmitRequestToBackground.mockRejectedValue(mockError);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current.seasonStatusLoading).toBe(false);
+ });
+
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBe(
+ 'Failed to fetch season status',
+ );
+ expect(mockLogError).toHaveBeenCalledWith(
+ '[useSeasonStatus] Error fetching season status:',
+ mockError,
+ );
+ });
+
+ it('should call onAuthorizationError when authorization fails', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test
+ const mockAuthError = new Error('Authorization failed');
+ mockAuthError.name = 'AuthorizationFailedError';
+ mockSubmitRequestToBackground.mockRejectedValue(mockAuthError);
+
+ const localMockOnAuthorizationError = jest
+ .fn()
+ .mockResolvedValue(undefined);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: localMockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current.seasonStatusLoading).toBe(false);
+ });
+
+ expect(localMockOnAuthorizationError).toHaveBeenCalled();
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBe('Authorization failed');
+ });
+
+ it('should not call onAuthorizationError for non-authorization errors', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test
+ const mockError = new Error('Network error');
+ mockSubmitRequestToBackground.mockRejectedValue(mockError);
+
+ const localMockOnAuthorizationError = jest.fn();
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: localMockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current.seasonStatusLoading).toBe(false);
+ });
+
+ expect(localMockOnAuthorizationError).not.toHaveBeenCalled();
+ expect(result.current.seasonStatusError).toBe('Network error');
+ });
+ });
+
+ describe('loading states', () => {
+ it('should show loading state during fetch', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test
+ let resolvePromise!: (value: SeasonStatusState) => void;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockSubmitRequestToBackground.mockReturnValue(promise);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ expect(result.current.seasonStatusLoading).toBe(true);
+ expect(result.current.seasonStatus).toBeNull();
+ expect(result.current.seasonStatusError).toBeNull();
+
+ // Resolve the promise
+ resolvePromise(mockSeasonStatus);
+
+ await waitFor(() => {
+ expect(result.current.seasonStatusLoading).toBe(false);
+ });
+
+ expect(result.current.seasonStatus).toEqual(mockSeasonStatus);
+ });
+ });
+
+ describe('fetchSeasonStatus function', () => {
+ it('should expose fetchSeasonStatus function', () => {
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ expect(typeof result.current.fetchSeasonStatus).toBe('function');
+ });
+
+ it('should allow manual refetch via fetchSeasonStatus', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ // Wait for initial fetch
+ await waitFor(() => {
+ expect(result.current.seasonStatusLoading).toBe(false);
+ });
+
+ // Clear previous calls
+ mockSubmitRequestToBackground.mockClear();
+
+ // Manual refetch
+ await act(async () => {
+ await result.current.fetchSeasonStatus();
+ });
+
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getRewardsSeasonMetadata',
+ ['current'],
+ );
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getRewardsSeasonStatus',
+ ['test-subscription-id', undefined],
+ );
+ });
+
+ it('should not fetch if subscriptionId is not available during manual refetch', async () => {
+ const { result } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: null,
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ await act(async () => {
+ await result.current.fetchSeasonStatus();
+ });
+
+ expect(mockSubmitRequestToBackground).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('dependency changes', () => {
+ it('should refetch when subscriptionId changes', async () => {
+ mockUseRewardsEnabled.mockReturnValue(true); // Enable rewards for this test
+
+ let subscriptionId = 'subscription-1';
+ const { result, rerender } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId,
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ // Wait for initial fetch
+ await waitFor(() => {
+ expect(result.current.seasonStatusLoading).toBe(false);
+ });
+
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getRewardsSeasonMetadata',
+ ['current'],
+ );
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getRewardsSeasonStatus',
+ ['subscription-1', undefined],
+ );
+
+ // Change subscriptionId and rerender
+ subscriptionId = 'subscription-2';
+ rerender();
+
+ // Wait for refetch
+ await waitFor(() => {
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledWith(
+ 'getRewardsSeasonStatus',
+ ['subscription-2', undefined],
+ );
+ });
+
+ expect(mockSubmitRequestToBackground).toHaveBeenCalledTimes(4); // 2 calls for each fetch (metadata + status)
+ });
+ });
+
+ describe('memoization', () => {
+ it('should return stable fetchSeasonStatus function', () => {
+ const { result, rerender } = renderHookWithProvider(
+ () =>
+ useSeasonStatus({
+ subscriptionId: 'test-subscription-id',
+ onAuthorizationError: mockOnAuthorizationError,
+ }),
+ {
+ metamask: {
+ isUnlocked: true,
+ rewardsActiveAccount: {
+ account: 'test-account-id',
+ },
+ },
+ },
+ );
+
+ const firstFetchFunction = result.current.fetchSeasonStatus;
+
+ rerender();
+
+ const secondFetchFunction = result.current.fetchSeasonStatus;
+
+ expect(firstFetchFunction).toBe(secondFetchFunction);
+ });
+ });
+});
diff --git a/ui/hooks/rewards/useSeasonStatus.ts b/ui/hooks/rewards/useSeasonStatus.ts
new file mode 100644
index 000000000000..635a8fd2a8da
--- /dev/null
+++ b/ui/hooks/rewards/useSeasonStatus.ts
@@ -0,0 +1,110 @@
+import { useState, useCallback, useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import log from 'loglevel';
+import { submitRequestToBackground } from '../../store/background-connection';
+import { getIsUnlocked } from '../../ducks/metamask/metamask';
+import {
+ SeasonDtoState,
+ SeasonStatusState,
+} from '../../../shared/types/rewards';
+import { useAppSelector } from '../../store/store';
+import { useRewardsEnabled } from './useRewardsEnabled';
+
+type UseSeasonStatusOptions = {
+ subscriptionId: string | null;
+ onAuthorizationError: () => Promise;
+};
+
+type UseSeasonStatusReturn = {
+ seasonStatus: SeasonStatusState | null;
+ seasonStatusError: string | null;
+ seasonStatusLoading: boolean;
+ fetchSeasonStatus: () => Promise;
+};
+
+/**
+ * Hook to fetch and manage season status
+ *
+ * @param options0
+ * @param options0.subscriptionId
+ * @param options0.onAuthorizationError
+ */
+export const useSeasonStatus = ({
+ subscriptionId,
+ onAuthorizationError,
+}: UseSeasonStatusOptions): UseSeasonStatusReturn => {
+ const isUnlocked = useSelector(getIsUnlocked);
+ const isRewardsEnabled = useRewardsEnabled();
+ const [seasonStatus, setSeasonStatus] = useState(
+ null,
+ );
+ const [seasonStatusError, setSeasonStatusError] = useState(
+ null,
+ );
+ const [seasonStatusLoading, setSeasonStatusLoading] = useState(false);
+ const activeRewardsCaipAccountId = useAppSelector(
+ (state) => state.metamask.rewardsActiveAccount?.account,
+ );
+
+ const fetchSeasonStatus = useCallback(async (): Promise => {
+ // Don't fetch if no subscriptionId or season metadata
+ if (!subscriptionId || !isRewardsEnabled) {
+ setSeasonStatus(null);
+ setSeasonStatusLoading(false);
+ return;
+ }
+
+ setSeasonStatusLoading(true);
+
+ try {
+ const currentSeasonMetadata =
+ await submitRequestToBackground(
+ 'getRewardsSeasonMetadata',
+ ['current'],
+ );
+
+ if (!currentSeasonMetadata) {
+ throw new Error('No season metadata found');
+ }
+
+ const statusData = await submitRequestToBackground(
+ 'getRewardsSeasonStatus',
+ [subscriptionId, currentSeasonMetadata.id],
+ );
+
+ setSeasonStatus(statusData);
+ setSeasonStatusError(null);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+ log.error('[useSeasonStatus] Error fetching season status:', error);
+ setSeasonStatusError(errorMessage);
+
+ // If authorization failed, trigger callback
+ if (
+ (errorMessage.includes('Authorization') ||
+ errorMessage.includes('Unauthorized')) &&
+ onAuthorizationError
+ ) {
+ await onAuthorizationError();
+ setSeasonStatus(null);
+ }
+ } finally {
+ setSeasonStatusLoading(false);
+ }
+ }, [subscriptionId, onAuthorizationError, isRewardsEnabled]);
+
+ // Fetch season status when dependencies change
+ useEffect(() => {
+ if (isUnlocked && activeRewardsCaipAccountId) {
+ fetchSeasonStatus();
+ }
+ }, [isUnlocked, fetchSeasonStatus, activeRewardsCaipAccountId]);
+
+ return {
+ seasonStatus,
+ seasonStatusError,
+ seasonStatusLoading,
+ fetchSeasonStatus,
+ };
+};
diff --git a/ui/pages/index.js b/ui/pages/index.js
index a93e9bad7b87..f98ff564fc92 100644
--- a/ui/pages/index.js
+++ b/ui/pages/index.js
@@ -13,6 +13,7 @@ import { MetamaskNotificationsProvider } from '../contexts/metamask-notification
import { AssetPollingProvider } from '../contexts/assetPolling';
import { MetamaskIdentityProvider } from '../contexts/identity';
import { ShieldSubscriptionProvider } from '../contexts/shield/shield-subscription';
+import { RewardsProvider } from '../contexts/rewards';
import ErrorPage from './error-page/error-page.component';
import Routes from './routes';
@@ -56,7 +57,9 @@ class Index extends PureComponent {
-
+
+
+