diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 05f49baaa16..2cb76c71bee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -91,6 +91,7 @@ /packages/permission-log-controller @MetaMask/wallet-api-platform-engineers @MetaMask/core-platform /packages/profile-sync-controller @MetaMask/identity /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/rewards-controller @MetaMask/rewards @MetaMask/core-platform /packages/foundryup @MetaMask/mobile-platform @MetaMask/extension-platform ## Package Release related @@ -140,6 +141,8 @@ /packages/phishing-controller/CHANGELOG.md @MetaMask/product-safety @MetaMask/core-platform /packages/profile-sync-controller/package.json @MetaMask/identity @MetaMask/core-platform /packages/profile-sync-controller/CHANGELOG.md @MetaMask/identity @MetaMask/core-platform +/packages/rewards-controller/package.json @MetaMask/rewards @MetaMask/core-platform +/packages/rewards-controller/CHANGELOG.md @MetaMask/rewards @MetaMask/core-platform /packages/selected-network-controller/package.json @MetaMask/wallet-api-platform-engineers @MetaMask/core-platform /packages/selected-network-controller/CHANGELOG.md @MetaMask/wallet-api-platform-engineers @MetaMask/core-platform /packages/signature-controller/package.json @MetaMask/confirmations @MetaMask/core-platform diff --git a/README.md b/README.md index 15d8b73b337..7a0648c4f6f 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/profile-sync-controller`](packages/profile-sync-controller) - [`@metamask/rate-limit-controller`](packages/rate-limit-controller) - [`@metamask/remote-feature-flag-controller`](packages/remote-feature-flag-controller) +- [`@metamask/rewards-controller`](packages/rewards-controller) - [`@metamask/sample-controllers`](packages/sample-controllers) - [`@metamask/seedless-onboarding-controller`](packages/seedless-onboarding-controller) - [`@metamask/selected-network-controller`](packages/selected-network-controller) @@ -130,6 +131,7 @@ linkStyle default opacity:0.5 profile_sync_controller(["@metamask/profile-sync-controller"]); rate_limit_controller(["@metamask/rate-limit-controller"]); remote_feature_flag_controller(["@metamask/remote-feature-flag-controller"]); + rewards_controller(["@metamask/rewards-controller"]); sample_controllers(["@metamask/sample-controllers"]); seedless_onboarding_controller(["@metamask/seedless-onboarding-controller"]); selected_network_controller(["@metamask/selected-network-controller"]); @@ -154,14 +156,17 @@ linkStyle default opacity:0.5 assets_controllers --> base_controller; assets_controllers --> controller_utils; assets_controllers --> polling_controller; + assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; assets_controllers --> keyring_controller; + assets_controllers --> multichain_account_service; assets_controllers --> network_controller; assets_controllers --> permission_controller; assets_controllers --> phishing_controller; assets_controllers --> preferences_controller; assets_controllers --> transaction_controller; + base_controller --> messenger; base_controller --> json_rpc_engine; bridge_controller --> base_controller; bridge_controller --> controller_utils; @@ -244,6 +249,7 @@ linkStyle default opacity:0.5 network_enablement_controller --> controller_utils; network_enablement_controller --> multichain_network_controller; network_enablement_controller --> network_controller; + network_enablement_controller --> transaction_controller; notification_services_controller --> base_controller; notification_services_controller --> controller_utils; notification_services_controller --> keyring_controller; @@ -277,6 +283,8 @@ linkStyle default opacity:0.5 selected_network_controller --> json_rpc_engine; selected_network_controller --> network_controller; selected_network_controller --> permission_controller; + shield_controller --> base_controller; + shield_controller --> transaction_controller; signature_controller --> base_controller; signature_controller --> controller_utils; signature_controller --> accounts_controller; @@ -284,6 +292,8 @@ linkStyle default opacity:0.5 signature_controller --> keyring_controller; signature_controller --> logging_controller; signature_controller --> network_controller; + subscription_controller --> base_controller; + subscription_controller --> profile_sync_controller; token_search_discovery_controller --> base_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; diff --git a/packages/rewards-controller/CHANGELOG.md b/packages/rewards-controller/CHANGELOG.md new file mode 100644 index 00000000000..04ecac98b41 --- /dev/null +++ b/packages/rewards-controller/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of the RewardsController. ([#6493](https://github.com/MetaMask/core/pull/6493)) + - Automatically authenticates a user through a silent signature from KeyringController if their EVM address owns a rewards profile. + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/rewards-controller/LICENSE b/packages/rewards-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/rewards-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/rewards-controller/README.md b/packages/rewards-controller/README.md new file mode 100644 index 00000000000..df0d9e7c389 --- /dev/null +++ b/packages/rewards-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/rewards-controller` + +Handles reward claiming, campaign fetching, and reward history + +## Installation + +`yarn add @metamask/rewards-controller` + +or + +`npm install @metamask/rewards-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/rewards-controller/jest.config.js b/packages/rewards-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/rewards-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/rewards-controller/package.json b/packages/rewards-controller/package.json new file mode 100644 index 00000000000..dc61e8a9ff3 --- /dev/null +++ b/packages/rewards-controller/package.json @@ -0,0 +1,72 @@ +{ + "name": "@metamask/rewards-controller", + "version": "0.0.0", + "description": "Handles reward claiming, campaign fetching, and reward history", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/rewards-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/rewards-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/rewards-controller", + "since-latest-release": "../../scripts/since-latest-release.sh", + "publish:preview": "yarn npm publish --tag preview", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/keyring-internal-api": "^8.1.0", + "@metamask/utils": "^11.4.2", + "@solana/addresses": "^2.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-controller": "^23.0.0", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/rewards-controller/src/RewardsController.test.ts b/packages/rewards-controller/src/RewardsController.test.ts new file mode 100644 index 00000000000..a27030ef81e --- /dev/null +++ b/packages/rewards-controller/src/RewardsController.test.ts @@ -0,0 +1,3700 @@ +import { isHardwareWallet } from '@metamask/bridge-controller'; +import { toHex } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { CaipAccountId } from '@metamask/utils'; +import { isAddress as isSolanaAddress } from '@solana/addresses'; + +import { getRewardsFeatureFlag } from './feature-flags'; +import { + getRewardsControllerDefaultState, + type RemoveSubscriptionToken, + RewardsController, + type RewardsControllerMessenger, + type StoreSubscriptionToken, +} from './RewardsController'; +import type { + RewardsAccountState, + RewardsControllerState, + SeasonDtoState, + SeasonStatusState, + SeasonTierDto, + SubscriptionReferralDetailsState, +} from './types'; + +jest.mock('./logger', () => { + const actual = jest.requireActual('./logger'); + const logSpy = jest.fn(); + return { + ...actual, + createModuleLogger: jest.fn(() => logSpy), + __logSpy: logSpy, + }; +}); + +const { __logSpy: logSpy } = jest.requireMock('./logger') as { + __logSpy: jest.Mock; +}; + +jest.mock('./feature-flags'); +jest.mock('@metamask/bridge-controller', () => ({ + isHardwareWallet: jest.fn(), +})); +jest.mock('@solana/addresses', () => ({ + isAddress: jest.fn(), +})); +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + toHex: jest.fn(), +})); + +// Type the mocked modules +const mockGetRewardsFeatureFlag = getRewardsFeatureFlag as jest.MockedFunction< + typeof getRewardsFeatureFlag +>; + +const mockIsHardwareWallet = isHardwareWallet as jest.MockedFunction< + typeof isHardwareWallet +>; +const mockIsSolanaAddress = isSolanaAddress as jest.MockedFunction< + typeof isSolanaAddress +>; +const mockToHex = toHex as jest.MockedFunction; + +const mockStoreSubscriptionToken = + jest.fn() as jest.MockedFunction; + +const mockRemoveSubscriptionToken = + jest.fn() as jest.MockedFunction; + +// Test constants - CAIP-10 format addresses +const CAIP_ACCOUNT_1: CaipAccountId = 'eip155:1:0x123' as CaipAccountId; +const CAIP_ACCOUNT_2: CaipAccountId = 'eip155:1:0x456' as CaipAccountId; +const CAIP_ACCOUNT_3: CaipAccountId = 'eip155:1:0x789' as CaipAccountId; + +// Helper function to create test tier data +const createTestTiers = (): SeasonTierDto[] => [ + { id: 'bronze', name: 'Bronze', pointsNeeded: 0 }, + { id: 'silver', name: 'Silver', pointsNeeded: 1000 }, + { id: 'gold', name: 'Gold', pointsNeeded: 5000 }, + { id: 'platinum', name: 'Platinum', pointsNeeded: 10000 }, +]; + +// Helper function to create test season status (API response format with Date objects) +const createTestSeasonStatus = ( + overrides: Partial<{ + season: Partial<{ + id: string; + name: string; + startDate: Date; + endDate: Date; + tiers: SeasonTierDto[]; + }>; + balance: Partial<{ + total: number; + refereePortion: number; + updatedAt: Date; + }>; + currentTierId: string; + }> = {}, +) => { + const defaultSeason = { + id: 'season123', + name: 'Test Season', + startDate: new Date(Date.now() - 86400000), // 1 day ago + endDate: new Date(Date.now() + 86400000), // 1 day from now + tiers: createTestTiers(), + }; + + const defaultBalance = { + total: 1500, + refereePortion: 300, + updatedAt: new Date(), + }; + + return { + season: { + ...defaultSeason, + ...overrides.season, + }, + balance: { + ...defaultBalance, + ...overrides.balance, + }, + currentTierId: overrides.currentTierId || 'silver', + }; +}; + +describe('RewardsController', () => { + let mockMessenger: jest.Mocked; + let controller: RewardsController; + + beforeEach(() => { + jest.clearAllMocks(); + + mockMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + + // Reset feature flag to enabled by default + mockGetRewardsFeatureFlag.mockReturnValue(true); + + controller = new RewardsController({ + messenger: mockMessenger, + storeSubscriptionToken: mockStoreSubscriptionToken, + removeSubscriptionToken: mockRemoveSubscriptionToken, + }); + }); + + describe('initialization', () => { + it('should initialize with default state', () => { + expect(controller.state).toStrictEqual( + getRewardsControllerDefaultState(), + ); + }); + + it('should register action handlers', () => { + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsController:getPointsEvents', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsController:estimatePoints', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsController:getPerpsDiscountForAccount', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsController:isRewardsFeatureEnabled', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsController:getSeasonStatus', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsController:getReferralDetails', + expect.any(Function), + ); + }); + + it('should subscribe to account change events', () => { + expect(mockMessenger.subscribe).toHaveBeenCalledWith( + 'AccountsController:selectedAccountChange', + expect.any(Function), + ); + }); + + it('should subscribe to keyring unlock events', () => { + expect(mockMessenger.subscribe).toHaveBeenCalledWith( + 'KeyringController:unlock', + expect.any(Function), + ); + }); + }); + + describe('state management', () => { + it('should reset state to default', () => { + // Set some initial state + const initialState: Partial = { + activeAccount: { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, + subscriptionId: 'test', + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 5.0, + lastPerpsDiscountRateFetched: Date.now(), + }, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: initialState, + }); + + controller.resetState(); + + expect(controller.state).toStrictEqual( + getRewardsControllerDefaultState(), + ); + }); + + it('should manage account state correctly', () => { + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: null, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + // Verify state was set correctly + expect(controller.state.accounts[CAIP_ACCOUNT_1]).toStrictEqual( + accountState, + ); + expect(controller.state.accounts[CAIP_ACCOUNT_2]).toBeUndefined(); + }); + }); + + describe('getHasAccountOptedIn', () => { + beforeEach(() => { + // Mock feature flag to be enabled by default for existing tests + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return false when feature flag is disabled', async () => { + mockGetRewardsFeatureFlag.mockReturnValue(false); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1); + + expect(result).toBe(false); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + expect.anything(), + ); + }); + + it('should return cached hasOptedIn value when cache is fresh', async () => { + const recentTime = Date.now() - 60000; // 1 minute ago + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, + subscriptionId: 'test', + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 5.0, + lastPerpsDiscountRateFetched: recentTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1); + + expect(result).toBe(true); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + expect.anything(), + ); + }); + + it('should return false from cached data when account has not opted in', async () => { + const recentTime = Date.now() - 60000; // 1 minute ago + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: recentTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1); + + expect(result).toBe(false); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + expect.anything(), + ); + }); + + it('should fetch fresh data when cache is stale', async () => { + const staleTime = Date.now() - 600000; // 10 minutes ago (stale) + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: staleTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue({ + hasOptedIn: true, + discount: 5.0, + }); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_1 }, + ); + expect(result).toBe(true); + }); + + it('should update store state with new hasOptedIn value when fetching fresh data', async () => { + const staleTime = Date.now() - 600000; // 10 minutes ago (stale) + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: staleTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue({ + hasOptedIn: true, + discount: 8.5, + }); + + // Act + await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1); + + // Assert - verify state has been updated + const updatedAccountState = controller.state.accounts[CAIP_ACCOUNT_1]; + expect(updatedAccountState).toBeDefined(); + expect(updatedAccountState.hasOptedIn).toBe(true); + expect(updatedAccountState.perpsFeeDiscount).toBe(8.5); + expect(updatedAccountState.lastPerpsDiscountRateFetched).toBeGreaterThan( + staleTime, + ); + }); + + it('should update store state when creating new account on first opt-in check', async () => { + mockMessenger.call.mockResolvedValue({ + hasOptedIn: true, + discount: 12.0, + }); + + // Act - check account that doesn't exist in state + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_2); + + // Assert - verify new account state was created + expect(result).toBe(true); + const newAccountState = controller.state.accounts[CAIP_ACCOUNT_2]; + expect(newAccountState).toBeDefined(); + expect(newAccountState.account).toBe(CAIP_ACCOUNT_2); + expect(newAccountState.hasOptedIn).toBe(true); + expect(newAccountState.perpsFeeDiscount).toBe(12.0); + expect(newAccountState.subscriptionId).toBeNull(); + expect(newAccountState.lastPerpsDiscountRateFetched).toBeLessThanOrEqual( + Date.now(), + ); + }); + + it('should call data service for unknown accounts', async () => { + mockMessenger.call.mockResolvedValue({ + hasOptedIn: false, + discount: 5.0, + }); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_2); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_2 }, + ); + expect(result).toBe(false); + }); + + it('should return true when data service indicates opted in', async () => { + mockMessenger.call.mockResolvedValue({ + hasOptedIn: true, + discount: 10.0, + }); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_2); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_2 }, + ); + expect(result).toBe(true); + }); + + it('should handle data service errors and return false', async () => { + mockMessenger.call.mockRejectedValue(new Error('Network error')); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_2); + + expect(result).toBe(false); + }); + + it('should fetch fresh data when no cache timestamp exists', async () => { + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue({ + hasOptedIn: true, + discount: 7.5, + }); + + const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_1 }, + ); + expect(result).toBe(true); + }); + }); + + describe('estimatePoints', () => { + beforeEach(() => { + // Mock feature flag to be enabled by default for existing tests + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return default response when feature flag is disabled', async () => { + mockGetRewardsFeatureFlag.mockReturnValue(false); + + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const result = await controller.estimatePoints(mockRequest); + + expect(result).toStrictEqual({ pointsEstimate: 0, bonusBips: 0 }); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:estimatePoints', + expect.anything(), + ); + }); + + it('should successfully estimate points', async () => { + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const mockResponse = { + pointsEstimate: 100, + bonusBips: 200, + }; + + mockMessenger.call.mockResolvedValue(mockResponse); + + const result = await controller.estimatePoints(mockRequest); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:estimatePoints', + mockRequest, + ); + expect(result).toStrictEqual(mockResponse); + }); + + it('should handle estimate points errors', async () => { + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + mockMessenger.call.mockRejectedValue(new Error('API error')); + + await expect(controller.estimatePoints(mockRequest)).rejects.toThrow( + 'API error', + ); + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Failed to estimate points:', + 'API error', + ); + }); + + it('should handle estimate points errors with string message', async () => { + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + mockMessenger.call.mockRejectedValue('not error object'); + + await expect(controller.estimatePoints(mockRequest)).rejects.toBe( + 'not error object', + ); + }); + }); + + describe('getPointsEvents', () => { + beforeEach(() => { + // Mock feature flag to be enabled by default for existing tests + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return empty response when feature flag is disabled', async () => { + mockGetRewardsFeatureFlag.mockReturnValue(false); + + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + }; + + const result = await controller.getPointsEvents(mockRequest); + + expect(result).toStrictEqual({ + has_more: false, + cursor: null, + total_results: 0, + results: [], + }); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getPointsEvents', + expect.anything(), + ); + }); + + it('should successfully get points events', async () => { + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + }; + + const mockResponse = { + has_more: true, + cursor: 'next-cursor', + total_results: 50, + results: [ + { + id: 'event-123', + timestamp: new Date('2024-01-01T10:00:00Z'), + value: 100, + bonus: { bips: 200, bonuses: ['loyalty'] }, + accountAddress: '0x123456789', + type: 'SWAP' as const, + payload: { + srcAsset: { + amount: '1000000000000000000', + type: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + destAsset: { + amount: '4500000000', + type: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + txHash: '0xabcdef123456', + }, + }, + ], + }; + + mockMessenger.call.mockResolvedValue(mockResponse); + + const result = await controller.getPointsEvents(mockRequest); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPointsEvents', + mockRequest, + ); + expect(result).toStrictEqual(mockResponse); + }); + + it('should successfully get points events with cursor', async () => { + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: 'cursor-abc', + }; + + const mockResponse = { + has_more: false, + cursor: null, + total_results: 25, + results: [ + { + id: 'event-456', + timestamp: new Date('2024-01-01T11:00:00Z'), + value: 50, + bonus: null, + accountAddress: '0x987654321', + type: 'REFERRAL' as const, + payload: null, + }, + ], + }; + + mockMessenger.call.mockResolvedValue(mockResponse); + + const result = await controller.getPointsEvents(mockRequest); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPointsEvents', + mockRequest, + ); + expect(result).toStrictEqual(mockResponse); + }); + + it('should handle getPointsEvents errors and rethrow them', async () => { + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + }; + + const apiError = new Error('API error'); + mockMessenger.call.mockRejectedValue(apiError); + + await expect(controller.getPointsEvents(mockRequest)).rejects.toThrow( + 'API error', + ); + + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Failed to get points events:', + 'API error', + ); + }); + + it('should handle getPointsEvents errors and return it when the error is not an object', async () => { + const mockRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + }; + + mockMessenger.call.mockRejectedValue('not error object'); + + await expect(controller.getPointsEvents(mockRequest)).rejects.toBe( + 'not error object', + ); + + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Failed to get points events:', + 'not error object', + ); + }); + }); + + describe('getPerpsDiscountForAccount', () => { + beforeEach(() => { + // Mock feature flag to be enabled by default for existing tests + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return 0 when feature flag is disabled', async () => { + mockGetRewardsFeatureFlag.mockReturnValue(false); + + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_1); + + expect(result).toBe(0); + }); + + it('should return cached discount when available and fresh', async () => { + const recentTime = Date.now() - 60000; // 1 minute ago + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 7.5, + lastPerpsDiscountRateFetched: recentTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_1); + + expect(result).toBe(7.5); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + expect.anything(), + ); + }); + + it('should fetch fresh discount when cache is stale', async () => { + const staleTime = Date.now() - 600000; // 10 minutes ago + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 7.5, + lastPerpsDiscountRateFetched: staleTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue({ + hasOptedIn: false, + discount: 10.0, + }); + + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_1); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_1 }, + ); + expect(result).toBe(10.0); + }); + + it('should update store state with new discount value when fetching fresh data', async () => { + const staleTime = Date.now() - 600000; // 10 minutes ago + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, + subscriptionId: 'test', + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 7.5, + lastPerpsDiscountRateFetched: staleTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue({ + hasOptedIn: true, + discount: 15.0, + }); + + // Act + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_1); + + // Assert - verify state has been updated + expect(result).toBe(15.0); + const updatedAccountState = controller.state.accounts[CAIP_ACCOUNT_1]; + expect(updatedAccountState).toBeDefined(); + expect(updatedAccountState.perpsFeeDiscount).toBe(15.0); + expect(updatedAccountState.hasOptedIn).toBe(true); + expect(updatedAccountState.lastPerpsDiscountRateFetched).toBeGreaterThan( + staleTime, + ); + }); + + it('should fetch discount for new accounts', async () => { + mockMessenger.call.mockResolvedValue({ + hasOptedIn: false, + discount: 15.0, + }); + + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_2); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_2 }, + ); + expect(result).toBe(15.0); + }); + + it('should update store state when creating new account on first discount check', async () => { + mockMessenger.call.mockResolvedValue({ + hasOptedIn: false, + discount: 20.0, + }); + + // Act - check discount for account that doesn't exist in state + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_3); + + // Assert - verify new account state was created with correct values + expect(result).toBe(20.0); + const newAccountState = controller.state.accounts[CAIP_ACCOUNT_3]; + expect(newAccountState).toBeDefined(); + expect(newAccountState.account).toBe(CAIP_ACCOUNT_3); + expect(newAccountState.hasOptedIn).toBe(false); + expect(newAccountState.perpsFeeDiscount).toBe(20.0); + expect(newAccountState.subscriptionId).toBeNull(); + expect(newAccountState.lastCheckedAuth).toBeGreaterThan(0); + expect(newAccountState.lastPerpsDiscountRateFetched).toBeLessThanOrEqual( + Date.now(), + ); + }); + + it('should return 0 on data service error', async () => { + mockMessenger.call.mockRejectedValue(new Error('Network error')); + + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_2); + + expect(result).toBe(0); + }); + + it('should return 0 when getPerpsDiscount returns undefined', async () => { + const staleTime = Date.now() - 600000; // 10 minutes ago + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 7.5, + lastPerpsDiscountRateFetched: staleTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue({ + hasOptedIn: false, + discount: undefined as unknown as number, + }); + + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_1); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_1 }, + ); + expect(result).toBe(0); + }); + + it('should return 0 when getPerpsDiscount call throws error which is not an object', async () => { + mockMessenger.call.mockRejectedValueOnce('not error object'); + + const result = + await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_2); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + { account: CAIP_ACCOUNT_2 }, + ); + expect(result).toBe(0); + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Failed to update perps fee discount:', + 'not error object', + ); + }); + }); + + describe('isRewardsFeatureEnabled', () => { + beforeEach(() => { + // Reset all mocks for this test suite + jest.clearAllMocks(); + }); + + it('should return true when feature flag is enabled', () => { + // Mock the feature flag selector to return true + mockGetRewardsFeatureFlag.mockReturnValue(true); + + const result = controller.isRewardsFeatureEnabled(); + + expect(result).toBe(true); + expect(mockGetRewardsFeatureFlag).toHaveBeenCalled(); + }); + + it('should return false when feature flag is disabled', () => { + // Mock the feature flag selector to return false + mockGetRewardsFeatureFlag.mockReturnValue(false); + + const result = controller.isRewardsFeatureEnabled(); + + expect(result).toBe(false); + expect(mockGetRewardsFeatureFlag).toHaveBeenCalled(); + }); + + it('should call selectRewardsEnabledFlag with store state', () => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + + controller.isRewardsFeatureEnabled(); + + expect(mockGetRewardsFeatureFlag).toHaveBeenCalled(); + }); + }); + + describe('default state', () => { + it('should return correct default state', () => { + const defaultState = getRewardsControllerDefaultState(); + + expect(defaultState).toStrictEqual({ + activeAccount: null, + accounts: {}, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }); + }); + }); + + describe('performSilentAuth message formatting', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should format and convert authentication message to hex correctly', async () => { + const mockInternalAccount = { + address: '0x1234567890abcdef', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + const mockTimestamp = 1609459200; // Fixed timestamp for predictable testing + const expectedMessage = `rewards,${mockInternalAccount.address},${mockTimestamp}`; + const expectedHexMessage = `0x${Buffer.from(expectedMessage, 'utf8').toString('hex')}`; + + // Mock Date.now to return predictable timestamp + const originalDateNow = Date.now; + jest.spyOn(Date, 'now').mockImplementation(() => mockTimestamp * 1000); + + mockMessenger.call + .mockReturnValueOnce(mockInternalAccount) + .mockResolvedValueOnce('0xsignature') + .mockResolvedValueOnce({ + sessionId: 'session123', + subscription: { id: 'sub123', referralCode: 'REF123', accounts: [] }, + }); + + // Trigger authentication via account change + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockInternalAccount, mockInternalAccount); + } + + // Verify the message was formatted and converted to hex correctly + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + { + data: expectedHexMessage, + from: mockInternalAccount.address, + }, + ); + + // Restore Date.now + Date.now = originalDateNow; + }); + }); + + describe('performSilentAuth CAIP conversion', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should handle CAIP account ID conversion from internal account scopes', async () => { + // Given: Internal account with valid EVM scope + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + const mockLoginResponse = { + sessionId: 'session123', + subscription: { id: 'sub123', referralCode: 'REF123', accounts: [] }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockInternalAccount) + .mockResolvedValueOnce('0xsignature') + .mockResolvedValueOnce(mockLoginResponse); + + // When: Authentication is triggered + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockInternalAccount, mockInternalAccount); + } + + // Then: Login should be called with the original address (not CAIP format) + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.objectContaining({ + account: '0x123', // Uses raw address, not CAIP format + signature: '0xsignature', + timestamp: expect.any(Number), + }), + ); + }); + }); + + describe('performSilentAuth error handling', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should exit silently when keyring is locked', async () => { + // Given: Internal account with valid EVM scope + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockInternalAccount) + .mockRejectedValueOnce(new Error('controller is locked')); // Simulate locked keyring error + + // When: Authentication is triggered + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockInternalAccount, mockInternalAccount); + } + + // Then: Login should be called with the original address (not CAIP format) + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Keyring is locked, skipping silent auth', + ); + }); + + it('should exit silently when error is not an object', async () => { + // Given: Internal account with valid EVM scope + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + const mockLoginResponse = { + sessionId: 'session123', + subscription: { id: 'sub123', referralCode: 'REF123', accounts: [] }, + }; + + mockIsHardwareWallet.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'string error'; // Simulate non-object error + }); + + mockMessenger.call + .mockReturnValueOnce(mockInternalAccount) + .mockResolvedValueOnce('0xsignature') + .mockResolvedValueOnce(mockLoginResponse); + + mockStoreSubscriptionToken.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'string error'; // Simulate non-object error + }); + + // When: Authentication is triggered + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockInternalAccount, mockInternalAccount); + } + + // Then: Login should be called with the original address (not CAIP format) + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Silent authentication failed:', + 'string error', + ); + }); + + it('should throw error when the error is not due to keyring locking', async () => { + // Given: Internal account with valid EVM scope + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockInternalAccount) + .mockRejectedValueOnce('random error'); // Simulate random error + + // When: Authentication is triggered + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockInternalAccount, mockInternalAccount); + } + + // Then: Signature generation error should be logged + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Silent authentication failed:', + 'random error', + ); + }); + + it('should return when account is format is not correct', async () => { + // Given: Internal account with valid EVM scope + const invalidInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['invalid-scope' as `${string}:${string}`], // Invalid scope format + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + const mockLoginResponse = { + sessionId: 'session123', + subscription: { id: 'sub123', referralCode: 'REF123', accounts: [] }, + }; + + mockMessenger.call + .mockReturnValueOnce(invalidInternalAccount) + .mockResolvedValueOnce('0xsignature') + .mockResolvedValueOnce(mockLoginResponse); + + // When: Authentication is triggered + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(invalidInternalAccount, invalidInternalAccount); + } + + // Then: Login should be called with the original address (not CAIP format) + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.objectContaining({ + account: '0x123', // Uses raw address, not CAIP format + signature: '0xsignature', + timestamp: expect.any(Number), + }), + ); + }); + + it('should update account state with no subscription', async () => { + // Given: Internal account with valid EVM scope + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockInternalAccount) + .mockResolvedValueOnce('0xsignature') + .mockRejectedValueOnce(new Error('401')); // Simulate random error + + // When: Authentication is triggered + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockInternalAccount, mockInternalAccount); + } + + // Then: Should have performed silent auth for the account + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Performing silent auth for', + '0x123', + ); + }); + + it('should log and throw error when storing session token fails', async () => { + // Given: Internal account with valid EVM scope + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockStoreSubscriptionToken.mockResolvedValueOnce({ + success: false, + error: 'Storage error', + }); + + const mockLoginResponse = { + sessionId: 'session123', + subscription: { id: 'sub123', referralCode: 'REF123', accounts: [] }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockInternalAccount) + .mockResolvedValueOnce('0xsignature') + .mockResolvedValueOnce(mockLoginResponse); + + // When: Authentication is triggered + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockInternalAccount, mockInternalAccount); + } + + // Then: Should have performed silent auth for the account + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Failed to store session token', + 'eip155:1:0x123', + ); + }); + }); + + describe('getSeasonStatus', () => { + const mockSeasonId = 'season123'; + const mockSubscriptionId = 'sub123'; + + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return null when feature flag is disabled', async () => { + mockGetRewardsFeatureFlag.mockReturnValue(false); + + const result = await controller.getSeasonStatus( + mockSubscriptionId, + mockSeasonId, + ); + expect(result).toBeNull(); + }); + + it('should return cached season status when cache is fresh', async () => { + const recentTime = Date.now() - 30000; // 30 seconds ago (within 1 minute threshold) + const compositeKey = `${mockSeasonId}:${mockSubscriptionId}`; + + const mockSeasonData: SeasonDtoState = { + id: mockSeasonId, + name: 'Test Season', + startDate: Date.now() - 86400000, // 1 day ago + endDate: Date.now() + 86400000, // 1 day from now + tiers: createTestTiers(), + }; + + const mockSeasonStatus: SeasonStatusState = { + season: mockSeasonData, + balance: { + total: 1500, + refereePortion: 300, + updatedAt: Date.now() - 3600000, // 1 hour ago + }, + tier: { + currentTier: { id: 'silver', name: 'Silver', pointsNeeded: 1000 }, + nextTier: { id: 'gold', name: 'Gold', pointsNeeded: 5000 }, + nextTierPointsNeeded: 3500, // 5000 - 1500 + }, + lastFetched: recentTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: { + [mockSeasonId]: mockSeasonData, + }, + subscriptionReferralDetails: {}, + seasonStatuses: { + [compositeKey]: mockSeasonStatus, + }, + }, + }); + + const result = await controller.getSeasonStatus( + mockSubscriptionId, + mockSeasonId, + ); + + expect(result).toStrictEqual(mockSeasonStatus); + expect(result?.season.id).toBe(mockSeasonId); + expect(result?.balance.total).toBe(1500); + expect(result?.tier.currentTier.id).toBe('silver'); + expect(result?.tier.nextTier?.id).toBe('gold'); + expect(result?.tier.nextTierPointsNeeded).toBe(3500); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getSeasonStatus', + expect.anything(), + expect.anything(), + ); + }); + + it('should use default seasonId as current and return cached season status when cache is fresh', async () => { + const recentTime = Date.now() - 30000; // 30 seconds ago (within 1 minute threshold) + const compositeKey = `current:${mockSubscriptionId}`; + + const mockSeasonData: SeasonDtoState = { + id: 'current', + name: 'Test Season', + startDate: Date.now() - 86400000, // 1 day ago + endDate: Date.now() + 86400000, // 1 day from now + tiers: createTestTiers(), + }; + + const mockSeasonStatus: SeasonStatusState = { + season: mockSeasonData, + balance: { + total: 1500, + refereePortion: 300, + updatedAt: Date.now() - 3600000, // 1 hour ago + }, + tier: { + currentTier: { id: 'silver', name: 'Silver', pointsNeeded: 1000 }, + nextTier: { id: 'gold', name: 'Gold', pointsNeeded: 5000 }, + nextTierPointsNeeded: 3500, // 5000 - 1500 + }, + lastFetched: recentTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: { + // eslint-disable-next-line no-useless-computed-key + ['current']: mockSeasonData, + }, + subscriptionReferralDetails: {}, + seasonStatuses: { + [compositeKey]: mockSeasonStatus, + }, + }, + }); + + const result = await controller.getSeasonStatus(mockSubscriptionId); + + expect(result).toStrictEqual(mockSeasonStatus); + expect(result?.season.id).toBe('current'); + expect(result?.balance.total).toBe(1500); + expect(result?.tier.currentTier.id).toBe('silver'); + expect(result?.tier.nextTier?.id).toBe('gold'); + expect(result?.tier.nextTierPointsNeeded).toBe(3500); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getSeasonStatus', + expect.anything(), + expect.anything(), + ); + }); + + it('should fetch fresh season status when cache is stale', async () => { + const mockApiResponse = createTestSeasonStatus(); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue(mockApiResponse); + + const result = await controller.getSeasonStatus( + mockSubscriptionId, + mockSeasonId, + ); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getSeasonStatus', + mockSeasonId, + mockSubscriptionId, + ); + + // Expect the result to be the converted state object, not the original DTO + expect(result).toBeDefined(); + expect(result?.balance.total).toBe(1500); + expect(result?.tier.currentTier.id).toBe('silver'); + expect(result?.lastFetched).toBeGreaterThan(Date.now() - 1000); + }); + + it('should update state when fetching fresh season status', async () => { + const mockApiResponse = createTestSeasonStatus({ + season: { + id: mockSeasonId, + name: 'Fresh Season', + startDate: new Date(), + endDate: new Date(), + tiers: createTestTiers(), + }, + balance: { total: 2500, updatedAt: new Date() }, + currentTierId: 'gold', + }); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue(mockApiResponse); + + const result = await controller.getSeasonStatus( + mockSubscriptionId, + mockSeasonId, + ); + + // Check that the result is the converted state object + expect(result).toBeDefined(); + expect(result?.balance.total).toBe(2500); + expect(result?.tier.currentTier.id).toBe('gold'); + expect(result?.tier.nextTier?.id).toBe('platinum'); + expect(result?.tier.nextTierPointsNeeded).toBe(7500); // 10000 - 2500 + expect(result?.lastFetched).toBeGreaterThan(Date.now() - 1000); + + // Check season status in root map with composite key + const compositeKey = `${mockSeasonId}:${mockSubscriptionId}`; + const seasonStatus = controller.state.seasonStatuses[compositeKey]; + expect(seasonStatus).toBeDefined(); + expect(seasonStatus).toStrictEqual(result); // Should be the same object + + // Check seasons map + const storedSeason = controller.state.seasons[mockSeasonId]; + expect(storedSeason).toBeDefined(); + expect(storedSeason.id).toBe(mockSeasonId); + expect(storedSeason.name).toBe(mockApiResponse.season.name); + expect(storedSeason.tiers).toHaveLength(4); + }); + + it('should handle error from data service', async () => { + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockRejectedValue(new Error('API error')); + + await expect( + controller.getSeasonStatus(mockSubscriptionId, mockSeasonId), + ).rejects.toThrow('API error'); + }); + + it('should handle error from data service and when error is not an object', async () => { + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockRejectedValue('no error object'); + + await expect( + controller.getSeasonStatus(mockSubscriptionId, mockSeasonId), + ).rejects.toBe('no error object'); + }); + }); + + describe('getReferralDetails', () => { + const mockSubscriptionId = 'sub123'; + + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return null when feature flag is disabled', async () => { + mockGetRewardsFeatureFlag.mockReturnValue(false); + + const result = await controller.getReferralDetails(mockSubscriptionId); + expect(result).toBeNull(); + }); + + it('should return cached referral details when cache is fresh', async () => { + const recentTime = Date.now() - 300000; // 5 minutes ago (within 10 minute threshold) + const mockReferralDetailsState: SubscriptionReferralDetailsState = { + referralCode: 'REF456', + totalReferees: 10, + lastFetched: recentTime, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: { + [mockSubscriptionId]: mockReferralDetailsState, + }, + seasonStatuses: {}, + }, + storeSubscriptionToken: mockStoreSubscriptionToken, + removeSubscriptionToken: mockRemoveSubscriptionToken, + }); + + const result = await controller.getReferralDetails(mockSubscriptionId); + + expect(result).toStrictEqual(mockReferralDetailsState); + expect(result?.referralCode).toBe('REF456'); + expect(result?.totalReferees).toBe(10); + expect(result?.lastFetched).toBe(recentTime); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getReferralDetails', + expect.anything(), + ); + }); + + it('should fetch fresh referral details when cache is stale', async () => { + const mockApiResponse = { + referralCode: 'NEWFRESH123', + totalReferees: 25, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue(mockApiResponse); + + const result = await controller.getReferralDetails(mockSubscriptionId); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getReferralDetails', + mockSubscriptionId, + ); + + // Expect the result to be the converted state object, not the original DTO + expect(result).toBeDefined(); + expect(result?.referralCode).toBe('NEWFRESH123'); + expect(result?.totalReferees).toBe(25); + expect(result?.lastFetched).toBeGreaterThan(Date.now() - 1000); + }); + + it('should update state when fetching fresh referral details', async () => { + const mockApiResponse = { + referralCode: 'UPDATED789', + totalReferees: 15, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue(mockApiResponse); + + const result = await controller.getReferralDetails(mockSubscriptionId); + + // Check that the result is the converted state object + expect(result).toBeDefined(); + expect(result?.referralCode).toBe('UPDATED789'); + expect(result?.totalReferees).toBe(15); + expect(result?.lastFetched).toBeGreaterThan(Date.now() - 1000); + + const updatedReferralDetails = + controller.state.subscriptionReferralDetails[mockSubscriptionId]; + expect(updatedReferralDetails).toBeDefined(); + expect(updatedReferralDetails).toStrictEqual(result); // Should be the same object + expect(updatedReferralDetails.referralCode).toBe( + mockApiResponse.referralCode, + ); + expect(updatedReferralDetails.totalReferees).toBe( + mockApiResponse.totalReferees, + ); + expect(updatedReferralDetails.lastFetched).toBeGreaterThan( + Date.now() - 1000, + ); + }); + + it('should handle errors from data service', async () => { + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockRejectedValue(new Error('API error')); + + await expect( + controller.getReferralDetails(mockSubscriptionId), + ).rejects.toThrow('API error'); + }); + + it('should throw error when getReferralDetails throws error which is not an object', async () => { + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: { + [mockSubscriptionId]: { + id: mockSubscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockRejectedValue('no error object'); + + await expect( + controller.getReferralDetails(mockSubscriptionId), + ).rejects.toBe('no error object'); + }); + }); + + describe('optIn', () => { + const mockInternalAccount = { + address: '0x123456789', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + } as InternalAccount; + + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should skip opt-in when feature flag is disabled', async () => { + // Arrange + mockGetRewardsFeatureFlag.mockReturnValue(false); + + // Act + await controller.optIn(mockInternalAccount); + + // Assert - Should not call generateChallenge, signPersonalMessage, or optin + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:generateChallenge', + expect.anything(), + ); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.anything(), + ); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:optin', + expect.anything(), + ); + }); + + it('should handle signature generation errors', async () => { + // Arrange + const mockChallengeResponse = { + id: 'challenge-123', + message: 'test challenge message', + }; + + mockMessenger.call + .mockResolvedValueOnce(mockChallengeResponse) + .mockRejectedValueOnce(new Error('Signature failed')); + + // Act & Assert + await expect(controller.optIn(mockInternalAccount)).rejects.toThrow( + 'Signature failed', + ); + }); + + it('should handle optin service errors', async () => { + // Arrange + const mockChallengeResponse = { + id: 'challenge-123', + message: 'test challenge message', + }; + const mockSignature = '0xsignature123'; + + mockMessenger.call + .mockResolvedValueOnce(mockChallengeResponse) + .mockResolvedValueOnce(mockSignature) + .mockRejectedValueOnce(new Error('Optin failed')); + + // Act & Assert + await expect(controller.optIn(mockInternalAccount)).rejects.toThrow( + 'Optin failed', + ); + }); + + it('should log error when subscription token storage fails', async () => { + // Arrange + const mockChallengeResponse = { + id: 'challenge-123', + message: 'test challenge message', + }; + const mockSignature = '0xsignature123'; + const mockOptinResponse = { + sessionId: 'session-456', + subscription: { + id: 'sub-789', + referralCode: 'REF123', + accounts: [], + }, + }; + const tokenStorageError = 'Failed to store token'; + + // Mock successful opt-in flow but failing token storage + mockToHex.mockReturnValue('0xhexmessage'); + mockMessenger.call + .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge + .mockResolvedValueOnce(mockSignature) // signPersonalMessage + .mockResolvedValueOnce(mockOptinResponse); // optin + + // Mock storeSubscriptionToken to fail + mockStoreSubscriptionToken.mockResolvedValueOnce({ + success: false, + error: tokenStorageError, + }); + + // Act + await controller.optIn(mockInternalAccount); + + // Assert + expect(mockStoreSubscriptionToken).toHaveBeenCalledWith({ + loginSessionId: 'session-456', + subscriptionId: 'sub-789', + }); + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Failed to store subscription token:', + tokenStorageError, + ); + + // Verify state was still updated correctly despite storage failure + expect(controller.state.activeAccount).toStrictEqual({ + account: 'eip155:1:0x123456789', + hasOptedIn: true, + subscriptionId: 'sub-789', + lastCheckedAuth: expect.any(Number), + lastCheckedAuthError: false, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }); + }); + + it('should log unknown error when subscription token storage fails without any error message', async () => { + // Arrange + const mockChallengeResponse = { + id: 'challenge-123', + message: 'test challenge message', + }; + const mockSignature = '0xsignature123'; + const mockOptinResponse = { + sessionId: 'session-456', + subscription: { + id: 'sub-789', + referralCode: 'REF123', + accounts: [], + }, + }; + + // Mock successful opt-in flow but failing token storage + mockToHex.mockReturnValue('0xhexmessage'); + mockMessenger.call + .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge + .mockResolvedValueOnce(mockSignature) // signPersonalMessage + .mockResolvedValueOnce(mockOptinResponse); // optin + + // Mock storeSubscriptionToken to fail + mockStoreSubscriptionToken.mockResolvedValueOnce({ + success: false, + error: undefined, + }); + + // Act + await controller.optIn(mockInternalAccount); + + // Assert + expect(mockStoreSubscriptionToken).toHaveBeenCalledWith({ + loginSessionId: 'session-456', + subscriptionId: 'sub-789', + }); + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Failed to store subscription token:', + 'Unknown error', + ); + + // Verify state was still updated correctly despite storage failure + expect(controller.state.activeAccount).toStrictEqual({ + account: 'eip155:1:0x123456789', + hasOptedIn: true, + subscriptionId: 'sub-789', + lastCheckedAuth: expect.any(Number), + lastCheckedAuthError: false, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }); + }); + + it('should return without updating state if account format is not supported', async () => { + // Arrange - Internal account with no valid EVM scope + const invalidInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['invalid-scope' as `${string}:${string}`], // Invalid scope format + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Arrange + const mockChallengeResponse = { + id: 'challenge-123', + message: 'test challenge message', + }; + const mockSignature = '0xsignature123'; + const mockOptinResponse = { + sessionId: 'session-456', + subscription: { + id: 'sub-789', + referralCode: 'REF123', + accounts: [], + }, + }; + const tokenStorageError = 'Failed to store token'; + + // Mock successful opt-in flow but failing token storage + mockToHex.mockReturnValue('0xhexmessage'); + mockMessenger.call + .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge + .mockResolvedValueOnce(mockSignature) // signPersonalMessage + .mockResolvedValueOnce(mockOptinResponse); // optin + + // Mock storeSubscriptionToken to fail + mockStoreSubscriptionToken.mockResolvedValueOnce({ + success: false, + error: tokenStorageError, + }); + + // Act + await controller.optIn(invalidInternalAccount); + + // Assert + expect(mockStoreSubscriptionToken).toHaveBeenCalledWith({ + loginSessionId: 'session-456', + subscriptionId: 'sub-789', + }); + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Failed to store subscription token:', + tokenStorageError, + ); + // Also expect CAIP conversion error due to invalid account format + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Failed to convert address to CAIP-10 format:', + expect.any(Error), + ); + + // Verify state is not set since account format is unsupported + expect(controller.state.activeAccount).toBeNull(); + }); + }); + + describe('logout', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should skip logout when feature flag is disabled', async () => { + // Arrange + mockGetRewardsFeatureFlag.mockReturnValue(false); + + // Act + await controller.logout(); + + // Assert - Should not call logout service + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:logout', + expect.anything(), + ); + }); + + it('should skip logout when no authenticated account exists', async () => { + // Arrange + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: {}, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + // Act + await controller.logout(); + + // Assert - Should not call logout service + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:logout', + expect.anything(), + ); + }); + + it('should successfully complete logout process', async () => { + // Arrange + const mockSubscriptionId = 'sub-123'; + const mockActiveAccount = { + account: CAIP_ACCOUNT_1, + lastCheckedAuthError: false, + hasOptedIn: true, + subscriptionId: mockSubscriptionId, + lastCheckedAuth: Date.now(), + perpsFeeDiscount: 10.0, + lastPerpsDiscountRateFetched: Date.now(), + } as RewardsAccountState; + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Ensure feature flag is enabled + mockGetRewardsFeatureFlag.mockReturnValue(true); + + // Mock getSelectedMultichainAccount to return valid account during initialization + mockMessenger.call.mockReturnValue(mockInternalAccount); + + // Mock token storage to succeed during initialization + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: mockActiveAccount, + accounts: { + [CAIP_ACCOUNT_1]: mockActiveAccount, + }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + removeSubscriptionToken: mockRemoveSubscriptionToken, + }); + + // Clear only the messenger calls made during initialization, preserve other mocks + mockMessenger.call.mockClear(); + mockStoreSubscriptionToken.mockClear(); + mockRemoveSubscriptionToken.mockClear(); + + // Mock successful data service logout and token removal for the actual test + mockMessenger.call.mockResolvedValue(undefined); + mockRemoveSubscriptionToken.mockResolvedValue({ success: true }); + + // Verify state is correctly set before calling logout + expect(controller.state.activeAccount).not.toBeNull(); + expect(controller.state.activeAccount?.subscriptionId).toBe( + mockSubscriptionId, + ); + + // Act + await controller.logout(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:logout', + mockSubscriptionId, + ); + + // Verify state was cleared + expect(controller.state.activeAccount).toBeNull(); + }); + + it('should successfully complete logout process and remove subscription token', async () => { + // Arrange + const mockSubscriptionId = 'sub-123'; + const mockActiveAccount = { + account: CAIP_ACCOUNT_1, + lastCheckedAuthError: false, + hasOptedIn: true, + subscriptionId: mockSubscriptionId, + lastCheckedAuth: Date.now(), + perpsFeeDiscount: 10.0, + lastPerpsDiscountRateFetched: Date.now(), + } as RewardsAccountState; + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Ensure feature flag is enabled + mockGetRewardsFeatureFlag.mockReturnValue(true); + + // Mock getSelectedMultichainAccount to return valid account during initialization + mockMessenger.call.mockReturnValue(mockInternalAccount); + + // Mock token storage to succeed during initialization + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: mockActiveAccount, + accounts: { + [CAIP_ACCOUNT_1]: mockActiveAccount, + }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + removeSubscriptionToken: mockRemoveSubscriptionToken, + }); + + // Clear only the messenger calls made during initialization, preserve other mocks + mockMessenger.call.mockClear(); + mockStoreSubscriptionToken.mockClear(); + mockRemoveSubscriptionToken.mockClear(); + + // Mock successful data service logout and token removal for the actual test + mockMessenger.call.mockResolvedValue(undefined); + mockRemoveSubscriptionToken.mockResolvedValue({ success: true }); + + // Verify state is correctly set before calling logout + expect(controller.state.activeAccount).not.toBeNull(); + expect(controller.state.activeAccount?.subscriptionId).toBe( + mockSubscriptionId, + ); + + // Act + await controller.logout(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:logout', + mockSubscriptionId, + ); + + // Verify state was cleared + expect(controller.state.activeAccount).toBeNull(); + + // Verify token removal was called + expect(mockRemoveSubscriptionToken).toHaveBeenCalledWith( + mockSubscriptionId, + ); + }); + + it('should clear last authenticated account only if subscription matches', async () => { + // Arrange + const mockSubscriptionId = 'sub-123'; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, + subscriptionId: mockSubscriptionId, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: 5.0, + lastPerpsDiscountRateFetched: Date.now(), + }, + accounts: {}, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + mockMessenger.call.mockResolvedValue(undefined); + + // Act + await controller.logout(); + + // Assert + expect(controller.state.activeAccount).toBeNull(); + }); + + it('should only log the error if session token removal fails', async () => { + // Arrange + const mockSubscriptionId = 'sub-123'; + const mockActiveAccount = { + account: CAIP_ACCOUNT_1, + lastCheckedAuthError: false, + hasOptedIn: true, + subscriptionId: mockSubscriptionId, + lastCheckedAuth: Date.now(), + perpsFeeDiscount: 10.0, + lastPerpsDiscountRateFetched: Date.now(), + } as RewardsAccountState; + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Ensure feature flag is enabled + mockGetRewardsFeatureFlag.mockReturnValue(true); + + // Mock getSelectedMultichainAccount to return valid account during initialization + mockMessenger.call.mockReturnValue(mockInternalAccount); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: mockActiveAccount, + accounts: { + [CAIP_ACCOUNT_1]: mockActiveAccount, + }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + removeSubscriptionToken: mockRemoveSubscriptionToken, + }); + + // Clear only the messenger calls made during initialization, preserve other mocks + mockMessenger.call.mockClear(); + mockStoreSubscriptionToken.mockClear(); + mockRemoveSubscriptionToken.mockClear(); + + // Mock successful data service logout and token removal for the actual test + mockMessenger.call.mockResolvedValue(undefined); + mockRemoveSubscriptionToken.mockResolvedValue({ success: false }); + + // Verify state is correctly set before calling logout + expect(controller.state.activeAccount).not.toBeNull(); + expect(controller.state.activeAccount?.subscriptionId).toBe( + mockSubscriptionId, + ); + + // Act + await controller.logout(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:logout', + mockSubscriptionId, + ); + + // Verify state was cleared + expect(controller.state.activeAccount).toBeNull(); + expect(logSpy).toHaveBeenNthCalledWith( + 4, + 'RewardsController: Warning - failed to remove session token:', + 'Unknown error', + ); + }); + + it('should only log the error if session token removal function is not passed', async () => { + // Arrange + const mockSubscriptionId = 'sub-123'; + const mockActiveAccount = { + account: CAIP_ACCOUNT_1, + lastCheckedAuthError: false, + hasOptedIn: true, + subscriptionId: mockSubscriptionId, + lastCheckedAuth: Date.now(), + perpsFeeDiscount: 10.0, + lastPerpsDiscountRateFetched: Date.now(), + } as RewardsAccountState; + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Ensure feature flag is enabled + mockGetRewardsFeatureFlag.mockReturnValue(true); + + // Mock getSelectedMultichainAccount to return valid account during initialization + mockMessenger.call.mockReturnValue(mockInternalAccount); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: mockActiveAccount, + accounts: { + [CAIP_ACCOUNT_1]: mockActiveAccount, + }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + removeSubscriptionToken: undefined, + }); + + // Clear only the messenger calls made during initialization, preserve other mocks + mockMessenger.call.mockClear(); + mockStoreSubscriptionToken.mockClear(); + + // Mock successful data service logout and token removal for the actual test + mockMessenger.call.mockResolvedValue(undefined); + + // Verify state is correctly set before calling logout + expect(controller.state.activeAccount).not.toBeNull(); + expect(controller.state.activeAccount?.subscriptionId).toBe( + mockSubscriptionId, + ); + + // Act + await controller.logout(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:logout', + mockSubscriptionId, + ); + + // Verify state was cleared + expect(controller.state.activeAccount).toBeNull(); + expect(logSpy).toHaveBeenNthCalledWith( + 4, + 'RewardsController: No removeSubscriptionToken function defined', + ); + }); + + it('should throw error if anything inside logout throws error', async () => { + // Arrange + const mockSubscriptionId = 'sub-123'; + const mockActiveAccount = { + account: CAIP_ACCOUNT_1, + lastCheckedAuthError: false, + hasOptedIn: true, + subscriptionId: mockSubscriptionId, + lastCheckedAuth: Date.now(), + perpsFeeDiscount: 10.0, + lastPerpsDiscountRateFetched: Date.now(), + } as RewardsAccountState; + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Ensure feature flag is enabled + mockGetRewardsFeatureFlag.mockReturnValue(true); + + // Mock getSelectedMultichainAccount to return valid account during initialization + mockMessenger.call.mockReturnValue(mockInternalAccount); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: mockActiveAccount, + accounts: { + [CAIP_ACCOUNT_1]: mockActiveAccount, + }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + removeSubscriptionToken: mockRemoveSubscriptionToken, + }); + + // Clear only the messenger calls made during initialization, preserve other mocks + mockMessenger.call.mockClear(); + mockStoreSubscriptionToken.mockClear(); + + // Mock successful data service logout and failed token removal for the actual test + mockMessenger.call.mockResolvedValue(undefined); + mockRemoveSubscriptionToken.mockRejectedValue( + new Error('Subscription token removal failed'), + ); + + // Verify state is correctly set before calling logout + expect(controller.state.activeAccount).not.toBeNull(); + expect(controller.state.activeAccount?.subscriptionId).toBe( + mockSubscriptionId, + ); + + // Act + await expect(controller.logout()).rejects.toThrow( + 'Subscription token removal failed', + ); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:logout', + mockSubscriptionId, + ); + + // Verify state was cleared + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Logout failed to complete', + 'Subscription token removal failed', + ); + }); + + it('should throw error if anything inside logout throws error which is not an object', async () => { + // Arrange + const mockSubscriptionId = 'sub-123'; + const mockActiveAccount = { + account: CAIP_ACCOUNT_1, + lastCheckedAuthError: false, + hasOptedIn: true, + subscriptionId: mockSubscriptionId, + lastCheckedAuth: Date.now(), + perpsFeeDiscount: 10.0, + lastPerpsDiscountRateFetched: Date.now(), + } as RewardsAccountState; + const mockInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Ensure feature flag is enabled + mockGetRewardsFeatureFlag.mockReturnValue(true); + + // Mock getSelectedMultichainAccount to return valid account during initialization + mockMessenger.call.mockReturnValue(mockInternalAccount); + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: mockActiveAccount, + accounts: { + [CAIP_ACCOUNT_1]: mockActiveAccount, + }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + removeSubscriptionToken: mockRemoveSubscriptionToken, + }); + + // Clear only the messenger calls made during initialization, preserve other mocks + mockMessenger.call.mockClear(); + mockStoreSubscriptionToken.mockClear(); + + // Mock successful data service logout and failed token removal for the actual test + mockMessenger.call.mockResolvedValue(undefined); + mockRemoveSubscriptionToken.mockRejectedValue('no error object'); + + // Verify state is correctly set before calling logout + expect(controller.state.activeAccount).not.toBeNull(); + expect(controller.state.activeAccount?.subscriptionId).toBe( + mockSubscriptionId, + ); + + // Act + await expect(controller.logout()).rejects.toBe('no error object'); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:logout', + mockSubscriptionId, + ); + + // Verify state was cleared + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Logout failed to complete', + 'no error object', + ); + }); + }); + + describe('validateReferralCode', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return false when feature flag is disabled', async () => { + // Arrange + mockGetRewardsFeatureFlag.mockReturnValue(false); + + // Act + const result = await controller.validateReferralCode('ABC123'); + + // Assert + expect(result).toBe(false); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:validateReferralCode', + expect.anything(), + ); + }); + + it('should return false for empty or whitespace-only codes', async () => { + // Act & Assert + expect(await controller.validateReferralCode('')).toBe(false); + expect(await controller.validateReferralCode(' ')).toBe(false); + expect(await controller.validateReferralCode('\t\n')).toBe(false); + }); + + it('should return false for codes with incorrect length', async () => { + // Act & Assert + expect(await controller.validateReferralCode('ABC12')).toBe(false); // Too short + expect(await controller.validateReferralCode('ABC1234')).toBe(false); // Too long + }); + + it('should return false for codes with invalid characters', async () => { + // Act & Assert + expect(await controller.validateReferralCode('ABC12@')).toBe(false); // Invalid character @ + expect(await controller.validateReferralCode('ABC120')).toBe(false); // Invalid character 0 + expect(await controller.validateReferralCode('ABC121')).toBe(false); // Invalid character 1 + expect(await controller.validateReferralCode('ABC12I')).toBe(false); // Invalid character I + expect(await controller.validateReferralCode('ABC12O')).toBe(false); // Invalid character O + }); + + it('should return true for valid referral codes from service', async () => { + // Arrange + jest.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockMessenger.call.mockImplementation((action, ..._args): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (action === 'RewardsDataService:validateReferralCode') { + return Promise.resolve({ valid: true }); + } + return Promise.resolve(); + }); + + // Act + const result = await controller.validateReferralCode('ABC234'); // Using valid Base32 code + + // Assert + expect(result).toBe(true); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:validateReferralCode', + 'ABC234', + ); + }); + + it('should return false for invalid referral codes from service', async () => { + // Arrange + jest.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockMessenger.call.mockImplementation((action, ..._args): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (action === 'RewardsDataService:validateReferralCode') { + return Promise.resolve({ valid: false }); + } + return Promise.resolve(); + }); + + // Act + const result = await controller.validateReferralCode('XYZ567'); // Using valid Base32 code + + // Assert + expect(result).toBe(false); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:validateReferralCode', + 'XYZ567', + ); + }); + + it('should accept valid base32 characters', async () => { + // Act & Assert + const validCodes = ['ABCDEF', 'ABC234', 'XYZ567', 'DEF237']; + + for (const code of validCodes) { + jest.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockMessenger.call.mockImplementation((action, ..._args): any => { + // eslint-disable-next-line jest/no-conditional-in-test + if (action === 'RewardsDataService:validateReferralCode') { + return Promise.resolve({ valid: true }); + } + return Promise.resolve(); + }); + + const result = await controller.validateReferralCode(code); + expect(result).toBe(true); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:validateReferralCode', + code, + ); + } + }); + + it('should handle service errors and return false', async () => { + // Arrange + jest.clearAllMocks(); + mockMessenger.call.mockRejectedValue(new Error('Service error')); + + // Act + const result = await controller.validateReferralCode('ABC123'); + + // Assert + expect(result).toBe(false); + }); + + it('should handle service errors and return false when error is not an object', async () => { + // Arrange + jest.clearAllMocks(); + mockMessenger.call.mockRejectedValue('not an object'); + + // Act + const result = await controller.validateReferralCode('ABC123'); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('calculateTierStatus', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should throw error when current tier ID is not found in season tiers', () => { + // Arrange + const tiers = createTestTiers(); + const invalidCurrentTierId = 'invalid-tier'; + const currentPoints = 1500; + + // Act & Assert + expect(() => { + controller.calculateTierStatus( + tiers, + invalidCurrentTierId, + currentPoints, + ); + }).toThrow( + `Current tier ${invalidCurrentTierId} not found in season tiers`, + ); + }); + + it('should return null for next tier when current tier is the last tier', () => { + // Arrange + const tiers = createTestTiers(); + const lastTierCurrentTierId = 'platinum'; // Last tier in createTestTiers + const currentPoints = 15000; // More than platinum tier + + // Act + const result = controller.calculateTierStatus( + tiers, + lastTierCurrentTierId, + currentPoints, + ); + + // Assert + expect(result.currentTier.id).toBe(lastTierCurrentTierId); + expect(result.nextTier).toBeNull(); + expect(result.nextTierPointsNeeded).toBeNull(); + }); + + it('should calculate nextTierPointsNeeded correctly with Math.max', () => { + // Arrange + const tiers = createTestTiers(); + const currentTierId = 'silver'; // Silver requires 1000 points, Gold requires 5000 + + // Test case where user has more points than needed for next tier + const currentPointsAboveNext = 6000; // More than Gold's 5000 requirement + + // Act + const result = controller.calculateTierStatus( + tiers, + currentTierId, + currentPointsAboveNext, + ); + + // Assert + expect(result.currentTier.id).toBe('silver'); + expect(result.nextTier?.id).toBe('gold'); + expect(result.nextTierPointsNeeded).toBe(0); // Math.max(0, 5000 - 6000) = 0 + }); + + it('should calculate nextTierPointsNeeded correctly when points needed is positive', () => { + // Arrange + const tiers = createTestTiers(); + const currentTierId = 'bronze'; // Bronze requires 0 points, Silver requires 1000 + const currentPoints = 250; // Less than Silver's 1000 requirement + + // Act + const result = controller.calculateTierStatus( + tiers, + currentTierId, + currentPoints, + ); + + // Assert + expect(result.currentTier.id).toBe('bronze'); + expect(result.nextTier?.id).toBe('silver'); + expect(result.nextTierPointsNeeded).toBe(750); // Math.max(0, 1000 - 250) = 750 + }); + + it('should sort tiers by points needed before processing', () => { + // Arrange - Create tiers in random order + const unsortedTiers: SeasonTierDto[] = [ + { id: 'platinum', name: 'Platinum', pointsNeeded: 10000 }, + { id: 'bronze', name: 'Bronze', pointsNeeded: 0 }, + { id: 'gold', name: 'Gold', pointsNeeded: 5000 }, + { id: 'silver', name: 'Silver', pointsNeeded: 1000 }, + ]; + const currentTierId = 'silver'; + const currentPoints = 1500; + + // Act + const result = controller.calculateTierStatus( + unsortedTiers, + currentTierId, + currentPoints, + ); + + // Assert - Should correctly identify next tier as Gold despite unsorted input + expect(result.currentTier.id).toBe('silver'); + expect(result.nextTier?.id).toBe('gold'); + expect(result.nextTierPointsNeeded).toBe(3500); // 5000 - 1500 + }); + }); + + describe('convertInternalAccountToCaipAccountId', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + jest.clearAllMocks(); + }); + + it('should log error when conversion fails due to invalid internal account', () => { + // Arrange + const invalidInternalAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['invalid-scope' as `${string}:${string}`], // Invalid scope format + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Act + const result = controller.convertInternalAccountToCaipAccountId( + invalidInternalAccount, + ); + + // Assert + expect(result).toBeNull(); + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Failed to convert address to CAIP-10 format:', + expect.any(Error), + ); + }); + + it('should return null and log error when account scopes is empty', () => { + // Arrange + const accountWithNoScopes = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: [] as `${string}:${string}`[], // Empty scopes array + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Act + const result = + controller.convertInternalAccountToCaipAccountId(accountWithNoScopes); + + // Assert + expect(result).toBeNull(); + + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Failed to convert address to CAIP-10 format:', + expect.any(Error), + ); + }); + + it('should successfully convert valid internal account to CAIP account ID', () => { + // Arrange + const validInternalAccount = { + address: '0x123456789', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Act + const result = + controller.convertInternalAccountToCaipAccountId(validInternalAccount); + + // Assert + expect(result).toBe('eip155:1:0x123456789'); + + expect(logSpy).not.toHaveBeenCalledWith( + 'RewardsController: Failed to convert address to CAIP-10 format:', + expect.anything(), + ); + }); + }); + + describe('silent auth skipping behavior', () => { + let originalDateNow: () => number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let subscribeCallback: any; + + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + mockIsHardwareWallet.mockReturnValue(false); + mockIsSolanaAddress.mockReturnValue(false); + + // Mock Date.now for consistent testing + originalDateNow = Date.now; + jest.spyOn(Date, 'now').mockImplementation(() => 1000000); // Fixed timestamp + + // Get the account change subscription callback + const subscribeCalls = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + ); + subscribeCallback = subscribeCalls + ? subscribeCalls[1] + : async () => undefined; + }); + + afterEach(() => { + Date.now = originalDateNow; + }); + + it('should skip silent auth for hardware accounts', async () => { + // Arrange + mockIsHardwareWallet.mockReturnValue(true); + const mockAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Hardware Account', + keyring: { type: 'Ledger Hardware' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call.mockReturnValue(mockAccount); + + // Act - trigger account change + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockAccount, mockAccount); + } + + // Assert - should not attempt to call login service for hardware accounts + expect(mockIsHardwareWallet).toHaveBeenCalledWith(mockAccount); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.anything(), + ); + }); + + it('should skip silent auth for Solana addresses', async () => { + // Arrange + mockIsSolanaAddress.mockReturnValue(true); + const mockAccount = { + address: 'solana-address', + type: 'solana:data-account' as const, + id: 'test-id', + scopes: ['solana:mainnet' as const], + options: {}, + methods: ['solana_signMessage'], + metadata: { + name: 'Solana Account', + keyring: { type: 'Solana Keyring' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call.mockReturnValue(mockAccount); + + // Act - trigger account change + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockAccount, mockAccount); + } + + // Assert - should not attempt to call login service for Solana accounts + expect(mockIsSolanaAddress).toHaveBeenCalledWith('solana-address'); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.anything(), + ); + }); + + it('should skip silent auth when within grace period for same account', async () => { + // Arrange + const now = 1000000; + const withinGracePeriod = now - 5 * 60 * 1000; // 5 minutes ago (within 10 minute grace period) + + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, + subscriptionId: 'test', + lastCheckedAuth: withinGracePeriod, + lastCheckedAuthError: false, + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: null, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: accountState, + accounts: { [CAIP_ACCOUNT_1]: accountState }, // Grace period logic looks here + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + const mockAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call.mockReturnValue(mockAccount); + + // Get the new subscription callback for the recreated controller + const newSubscribeCallback = mockMessenger.subscribe.mock.calls + .filter( + (call) => call[0] === 'AccountsController:selectedAccountChange', + ) + .pop()?.[1]; + + // Act - trigger account change + // eslint-disable-next-line jest/no-conditional-in-test + if (newSubscribeCallback) { + await newSubscribeCallback(mockAccount, mockAccount); + } + + // Assert - should not attempt authentication within grace period + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.anything(), + ); + }); + + it('should skip silent auth when within grace period for old account when new account is different', async () => { + // Arrange + const now = 1000000; + const withinGracePeriod = now - 5 * 60 * 1000; // 5 minutes ago (within 10 minute grace period) + + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: true, // Must be opted in for grace period to apply + subscriptionId: 'test', + lastCheckedAuth: withinGracePeriod, // Within grace period + lastCheckedAuthError: false, + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: null, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + const mockAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + const newAccount = { + address: '0x123456789', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockAccount) // getSelectedMultichainAccount + .mockResolvedValueOnce('0xsignature') // signPersonalMessage + .mockResolvedValueOnce({ + // login + sessionId: 'session123', + subscription: { + id: 'sub123', + referralCode: 'REF123', + accounts: [], + }, + }); + + // Get the new subscription callback for the recreated controller + const newSubscribeCallback = mockMessenger.subscribe.mock.calls + .filter( + (call) => call[0] === 'AccountsController:selectedAccountChange', + ) + .pop()?.[1]; + + // Act - trigger account change + // eslint-disable-next-line jest/no-conditional-in-test + if (newSubscribeCallback) { + await newSubscribeCallback(mockAccount, newAccount); + } + + // Assert - should not attempt authentication within grace period + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.anything(), + ); + }); + + it('should perform silent auth when outside grace period', async () => { + // Arrange + const now = 1000000; + const outsideGracePeriod = now - 15 * 60 * 1000; // 15 minutes ago (outside grace period) + + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: outsideGracePeriod, + lastCheckedAuthError: false, + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: null, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + const mockAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockAccount) // getSelectedMultichainAccount + .mockResolvedValueOnce('0xsignature') // signPersonalMessage + .mockResolvedValueOnce({ + // login + sessionId: 'session123', + subscription: { + id: 'sub123', + referralCode: 'REF123', + accounts: [], + }, + }); + + // Get the new subscription callback for the recreated controller + const newSubscribeCallback = mockMessenger.subscribe.mock.calls + .filter( + (call) => call[0] === 'AccountsController:selectedAccountChange', + ) + .pop()?.[1]; + + // Act - trigger account change + // eslint-disable-next-line jest/no-conditional-in-test + if (newSubscribeCallback) { + await newSubscribeCallback(mockAccount, mockAccount); + } + + // Assert - should attempt authentication outside grace period + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.objectContaining({ + from: '0x123', + }), + ); + }); + + it('should handle KeyringController unlock event to retry silent auth', async () => { + // Get the new KeyringController unlock event for the controller + const keyringControllerUnlock = mockMessenger.subscribe.mock.calls + .filter((call) => call[0] === 'KeyringController:unlock') + .pop()?.[1]; + + // Act - trigger unlock event + // eslint-disable-next-line jest/no-conditional-in-test + if (keyringControllerUnlock) { + await keyringControllerUnlock('newValue', 'oldValue'); + } + + // Assert - should attempt authentication + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.anything(), + ); + }); + }); + + describe('authentication trigger logging', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + // Don't clear all mocks here since we need the controller's subscriptions + }); + + it('should log feature flag disabled message when feature flag is disabled', async () => { + // Arrange + mockGetRewardsFeatureFlag.mockReturnValue(false); + jest.clearAllMocks(); + + // Create new controller to ensure fresh subscriptions + controller = new RewardsController({ + messenger: mockMessenger, + }); + + const subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1]; + + const mockAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + // Act - trigger account change when feature flag is disabled + // eslint-disable-next-line jest/no-conditional-in-test + if (subscribeCallback) { + await subscribeCallback(mockAccount, null); + } + + // Assert + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Feature flag disabled, skipping silent auth', + ); + }); + + it('should not throw throw errors for background authentication but log it', async () => { + // Arrange + (isHardwareWallet as jest.Mock).mockImplementationOnce(() => { + throw new Error('test error'); + }); + const now = 1000000; + const outsideGracePeriod = now - 15 * 60 * 1000; // 15 minutes ago (outside grace period) + + const accountState = { + account: CAIP_ACCOUNT_1, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: outsideGracePeriod, + lastCheckedAuthError: false, + perpsFeeDiscount: 0, + lastPerpsDiscountRateFetched: null, + }; + + controller = new RewardsController({ + messenger: mockMessenger, + state: { + activeAccount: null, + accounts: { [CAIP_ACCOUNT_1]: accountState as RewardsAccountState }, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + }, + }); + + const mockAccount = { + address: '0x123', + type: 'eip155:eoa' as const, + id: 'test-id', + scopes: ['eip155:1' as const], + options: {}, + methods: ['personal_sign'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + }; + + mockMessenger.call + .mockReturnValueOnce(mockAccount) // getSelectedMultichainAccount + .mockResolvedValueOnce('0xsignature') // signPersonalMessage + .mockResolvedValueOnce({ + // login + sessionId: 'session123', + subscription: { + id: 'sub123', + referralCode: 'REF123', + accounts: [], + }, + }); + + // Get the new subscription callback for the recreated controller + const newSubscribeCallback = mockMessenger.subscribe.mock.calls + .filter( + (call) => call[0] === 'AccountsController:selectedAccountChange', + ) + .pop()?.[1]; + + // Act - trigger account change + // eslint-disable-next-line jest/no-conditional-in-test + if (newSubscribeCallback) { + await newSubscribeCallback(mockAccount, mockAccount); + } + + // Assert + expect(logSpy).toHaveBeenLastCalledWith( + 'RewardsController: Silent authentication failed:', + 'test error', + ); + }); + }); + + describe('getGeoRewardsMetadata', () => { + beforeEach(() => { + mockGetRewardsFeatureFlag.mockReturnValue(true); + }); + + it('should return default metadata when rewards feature is disabled', async () => { + // Arrange + mockGetRewardsFeatureFlag.mockReturnValue(false); + + // Act + const result = await controller.getGeoRewardsMetadata(); + + // Assert + expect(result).toStrictEqual({ + geoLocation: 'UNKNOWN', + optinAllowedForGeo: false, + }); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + ); + + expect(logSpy).not.toHaveBeenCalledWith( + 'RewardsController: Fetching geo location for rewards metadata', + ); + }); + + it('should return cached geo location when available', async () => { + // Arrange + const mockCachedGeoData = { + geoLocation: 'US-NY', + optinAllowedForGeo: true, + }; + + // First call to populate cache + mockMessenger.call.mockResolvedValueOnce('US-NY'); + const firstResult = await controller.getGeoRewardsMetadata(); + + // Clear messenger call mock to verify no additional calls are made + jest.clearAllMocks(); + + // Act - Second call should use cache + const secondResult = await controller.getGeoRewardsMetadata(); + + // Assert - Verify cached data is returned + expect(secondResult).toStrictEqual(mockCachedGeoData); + expect(secondResult).toStrictEqual(firstResult); // Should be the same as first call + + // Assert - Verify cache log message + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Using cached geo location', + { + location: mockCachedGeoData, + }, + ); + + // Assert - Verify no additional API calls were made + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + ); + + // Assert - Verify fetching log message was not called on cached access + expect(logSpy).not.toHaveBeenCalledWith( + 'RewardsController: Fetching geo location for rewards metadata', + ); + }); + + it('should successfully fetch geo location for allowed region', async () => { + // Arrange + const mockGeoLocation = 'US-CA'; + mockMessenger.call.mockResolvedValueOnce(mockGeoLocation); + + // Act + const result = await controller.getGeoRewardsMetadata(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + ); + + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Fetching geo location for rewards metadata', + ); + + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Geo rewards metadata retrieved', + { + geoLocation: mockGeoLocation, + optinAllowedForGeo: true, + }, + ); + expect(result).toStrictEqual({ + geoLocation: mockGeoLocation, + optinAllowedForGeo: true, + }); + }); + + it('should handle blocked regions correctly', async () => { + // Arrange + const mockGeoLocation = 'UK-ENG'; // UK is in DEFAULT_BLOCKED_REGIONS + mockMessenger.call.mockResolvedValueOnce(mockGeoLocation); + + // Act + const result = await controller.getGeoRewardsMetadata(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + ); + expect(result).toStrictEqual({ + geoLocation: mockGeoLocation, + optinAllowedForGeo: false, + }); + }); + + it('should handle geo location service errors with fallback', async () => { + // Arrange + const geoServiceError = new Error('Geo service unavailable'); + mockMessenger.call.mockRejectedValueOnce(geoServiceError); + + // Act + const result = await controller.getGeoRewardsMetadata(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + ); + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Fetching geo location for rewards metadata', + ); + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Failed to get geo rewards metadata:', + geoServiceError.message, + ); + expect(result).toStrictEqual({ + geoLocation: 'UNKNOWN', + optinAllowedForGeo: true, + }); + }); + + it('should handle non-Error objects in catch block', async () => { + // Arrange + const nonErrorObject = 'String error'; + mockMessenger.call.mockRejectedValueOnce(nonErrorObject); + + // Act + const result = await controller.getGeoRewardsMetadata(); + + // Assert + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + ); + expect(logSpy).toHaveBeenCalledWith( + 'RewardsController: Failed to get geo rewards metadata:', + String(nonErrorObject), + ); + expect(result).toStrictEqual({ + geoLocation: 'UNKNOWN', + optinAllowedForGeo: true, + }); + }); + }); +}); diff --git a/packages/rewards-controller/src/RewardsController.ts b/packages/rewards-controller/src/RewardsController.ts new file mode 100644 index 00000000000..be62b5f5cb1 --- /dev/null +++ b/packages/rewards-controller/src/RewardsController.ts @@ -0,0 +1,1177 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable jsdoc/require-returns */ +/* eslint-disable jsdoc/tag-lines */ +import type { + AccountsControllerGetSelectedMultichainAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; +import { + BaseController, + type RestrictedMessenger, +} from '@metamask/base-controller'; +import { isHardwareWallet } from '@metamask/bridge-controller'; +import { toHex } from '@metamask/controller-utils'; +import type { + KeyringControllerSignPersonalMessageAction, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import { + type CaipAccountId, + parseCaipChainId, + toCaipAccountId, +} from '@metamask/utils'; +import { isAddress as isSolanaAddress } from '@solana/addresses'; + +import { + AUTH_GRACE_PERIOD_MS, + controllerName, + DEFAULT_BLOCKED_REGIONS, + PERPS_DISCOUNT_CACHE_THRESHOLD_MS, + REFERRAL_DETAILS_CACHE_THRESHOLD_MS, + SEASON_STATUS_CACHE_THRESHOLD_MS, +} from './constants'; +import { getRewardsFeatureFlag } from './feature-flags'; +import { projectLogger, createModuleLogger } from './logger'; +import type { + RewardsDataServiceEstimatePointsAction, + RewardsDataServiceFetchGeoLocationAction, + RewardsDataServiceGenerateChallengeAction, + RewardsDataServiceGetPerpsDiscountAction, + RewardsDataServiceGetPointsEventsAction, + RewardsDataServiceGetReferralDetailsAction, + RewardsDataServiceGetSeasonStatusAction, + RewardsDataServiceLoginAction, + RewardsDataServiceLogoutAction, + RewardsDataServiceOptinAction, + RewardsDataServiceValidateReferralCodeAction, +} from './services'; +import type { + RewardsControllerState, + RewardsAccountState, + LoginResponseDto, + PerpsDiscountData, + EstimatePointsDto, + EstimatedPointsDto, + SeasonStatusDto, + SeasonDtoState, + SeasonStatusState, + SeasonTierState, + SeasonTierDto, + SubscriptionReferralDetailsState, + SubscriptionTokenPayload, + GeoRewardsMetadata, + TokenResponse, + SubscriptionDto, + GetPointsEventsDto, + PaginatedPointsEventsDto, + RewardsControllerActions, + RewardsControllerEvents, +} from './types'; + +const log = createModuleLogger(projectLogger, controllerName); + +// Function to store subscription token +export type StoreSubscriptionToken = ( + subscriptionTokenPayload: SubscriptionTokenPayload, +) => Promise; + +// Function to remove subscription token +export type RemoveSubscriptionToken = ( + subscriptionId: string, +) => Promise; + +/** + * State metadata for the RewardsController + */ +const metadata = { + activeAccount: { persist: true, anonymous: false }, + accounts: { persist: true, anonymous: false }, + subscriptions: { persist: true, anonymous: false }, + seasons: { persist: true, anonymous: false }, + subscriptionReferralDetails: { persist: true, anonymous: false }, + seasonStatuses: { persist: true, anonymous: false }, +}; +/** + * Get the default state for the RewardsController + */ +export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ + activeAccount: null, + accounts: {}, + subscriptions: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, +}); + +export const defaultRewardsControllerState = getRewardsControllerDefaultState(); + +// Don't reexport as per guidelines +type AllowedActions = + | AccountsControllerGetSelectedMultichainAccountAction + | KeyringControllerSignPersonalMessageAction + | RewardsDataServiceLoginAction + | RewardsDataServiceGetPointsEventsAction + | RewardsDataServiceEstimatePointsAction + | RewardsDataServiceGetPerpsDiscountAction + | RewardsDataServiceGetSeasonStatusAction + | RewardsDataServiceGetReferralDetailsAction + | RewardsDataServiceGenerateChallengeAction + | RewardsDataServiceOptinAction + | RewardsDataServiceLogoutAction + | RewardsDataServiceFetchGeoLocationAction + | RewardsDataServiceValidateReferralCodeAction + | RemoteFeatureFlagControllerGetStateAction; + +// Don't reexport as per guidelines +type AllowedEvents = + | AccountsControllerSelectedAccountChangeEvent + | KeyringControllerUnlockEvent; + +export type RewardsControllerMessenger = RestrictedMessenger< + typeof controllerName, + RewardsControllerActions | AllowedActions, + RewardsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * Controller for managing user rewards and campaigns + * Handles reward claiming, campaign fetching, and reward history + */ +export class RewardsController extends BaseController< + typeof controllerName, + RewardsControllerState, + RewardsControllerMessenger +> { + #geoLocation: GeoRewardsMetadata | null = null; + + readonly #storeSubscriptionToken?: StoreSubscriptionToken; + + readonly #removeSubscriptionToken?: RemoveSubscriptionToken; + + // Constructor + constructor({ + messenger, + state, + storeSubscriptionToken, + removeSubscriptionToken, + }: { + messenger: RewardsControllerMessenger; + state?: Partial; + storeSubscriptionToken?: StoreSubscriptionToken; + removeSubscriptionToken?: RemoveSubscriptionToken; + }) { + super({ + name: controllerName, + metadata, + messenger, + state: { + ...defaultRewardsControllerState, + ...state, + }, + }); + + this.#storeSubscriptionToken = storeSubscriptionToken; + this.#removeSubscriptionToken = removeSubscriptionToken; + this.#registerActionHandlers(); + this.#initializeEventSubscriptions(); + } + + /** + * Calculate tier status and next tier information + * @param seasonTiers - Array of season tiers + * @param currentTierId - The ID of the current tier + * @param currentPoints - The user's current points + * @returns SeasonTierState - The current and next tier information + */ + calculateTierStatus( + seasonTiers: SeasonTierDto[], + currentTierId: string, + currentPoints: number, + ): SeasonTierState { + // Sort tiers by points needed (ascending) + const sortedTiers = [...seasonTiers].sort( + (a, b) => a.pointsNeeded - b.pointsNeeded, + ); + + // Find current tier + const currentTier = sortedTiers.find((tier) => tier.id === currentTierId); + if (!currentTier) { + throw new Error( + `Current tier ${currentTierId} not found in season tiers`, + ); + } + + // Find next tier (first tier with more points needed than current tier) + const currentTierIndex = sortedTiers.findIndex( + (tier) => tier.id === currentTierId, + ); + const nextTier = + currentTierIndex < sortedTiers.length - 1 + ? sortedTiers[currentTierIndex + 1] + : null; + + // Calculate points needed for next tier + const nextTierPointsNeeded = nextTier + ? Math.max(0, nextTier.pointsNeeded - currentPoints) + : null; + + return { + currentTier, + nextTier, + nextTierPointsNeeded, + }; + } + + /** + * Convert SeasonDto to SeasonDtoState for storage + * @param season - The season DTO from the API + * @returns SeasonDtoState - The converted season state + */ + #convertSeasonToState(season: SeasonStatusDto['season']): SeasonDtoState { + return { + id: season.id, + name: season.name, + startDate: season.startDate.getTime(), + endDate: season.endDate.getTime(), + tiers: season.tiers, + }; + } + + /** + * Convert SeasonStatusDto to SeasonStatusState and update seasons map + * @param seasonStatus - The season status DTO from the API + * @returns SeasonStatusState - The converted season status state + */ + #convertSeasonStatusToSubscriptionState( + seasonStatus: SeasonStatusDto, + ): SeasonStatusState { + const tierState = this.calculateTierStatus( + seasonStatus.season.tiers, + seasonStatus.currentTierId, + seasonStatus.balance.total, + ); + + return { + season: this.#convertSeasonToState(seasonStatus.season), + balance: { + total: seasonStatus.balance.total, + refereePortion: seasonStatus.balance.refereePortion, + updatedAt: seasonStatus.balance.updatedAt?.getTime(), + }, + tier: tierState, + lastFetched: Date.now(), + }; + } + + /** + * Register action handlers for this controller + */ + #registerActionHandlers(): void { + this.messagingSystem.registerActionHandler( + 'RewardsController:getHasAccountOptedIn', + this.getHasAccountOptedIn.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:getPointsEvents', + this.getPointsEvents.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:estimatePoints', + this.estimatePoints.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:getPerpsDiscountForAccount', + this.getPerpsDiscountForAccount.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:isRewardsFeatureEnabled', + this.isRewardsFeatureEnabled.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:getSeasonStatus', + this.getSeasonStatus.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:getReferralDetails', + this.getReferralDetails.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:optIn', + this.optIn.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:logout', + this.logout.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:getGeoRewardsMetadata', + this.getGeoRewardsMetadata.bind(this), + ); + this.messagingSystem.registerActionHandler( + 'RewardsController:validateReferralCode', + this.validateReferralCode.bind(this), + ); + } + + /** + * Initialize event subscriptions based on feature flag state + */ + #initializeEventSubscriptions(): void { + // Subscribe to account changes for silent authentication + this.messagingSystem.subscribe( + 'AccountsController:selectedAccountChange', + () => this.#handleAuthenticationTrigger('Account changed'), + ); + + // Subscribe to KeyringController unlock events to retry silent auth + this.messagingSystem.subscribe('KeyringController:unlock', () => + this.#handleAuthenticationTrigger('KeyringController unlocked'), + ); + + // Initialize silent authentication on startup + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#handleAuthenticationTrigger('Controller initialized'); + } + + /** + * Reset controller state to default + */ + resetState(): void { + this.update(() => getRewardsControllerDefaultState()); + } + + /** + * Get account state for a given CAIP-10 address + * @param account - The CAIP-10 account ID + * @returns RewardsAccountState or null if not found + */ + #getAccountState(account: CaipAccountId): RewardsAccountState | null { + return this.state.accounts[account] || null; + } + + /** + * Create composite key for season status storage + * @param seasonId - The season ID or 'current' + * @param subscriptionId - The subscription ID + * @returns Composite key string + */ + #createSeasonStatusCompositeKey( + seasonId: string, + subscriptionId: string, + ): string { + return `${seasonId}:${subscriptionId}`; + } + + /** + * Get stored season status for a given composite key + * @param subscriptionId - The subscription ID + * @param seasonId - The season ID or 'current' + * @returns SeasonStatusState or null if not found + */ + #getSeasonStatus( + subscriptionId: string, + seasonId: string | 'current', + ): SeasonStatusState | null { + const compositeKey = this.#createSeasonStatusCompositeKey( + seasonId, + subscriptionId, + ); + return this.state.seasonStatuses[compositeKey] || null; + } + + /** + * Sign a message for rewards authentication + * @param account - The account to sign with + * @param timestamp - The current timestamp + * @returns Promise - The signed message + */ + async #signRewardsMessage( + account: InternalAccount, + timestamp: number, + ): Promise { + const message = `rewards,${account.address},${timestamp}`; + + return await this.#signEvmMessage(account, message); + } + + async #signEvmMessage( + account: InternalAccount, + message: string, + ): Promise { + // Convert message to hex format for signing + const hexMessage = `0x${Buffer.from(message, 'utf8').toString('hex')}`; + + // Use KeyringController to sign the message + const signature = await this.messagingSystem.call( + 'KeyringController:signPersonalMessage', + { + data: hexMessage, + from: account.address, + }, + ); + log('RewardsController: EVM message signed for account', account.address); + return signature; + } + + /** + * Handle authentication triggers (account changes, keyring unlock) + * @param reason - Optional reason for the trigger + */ + async #handleAuthenticationTrigger(reason?: string): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + + if (!rewardsEnabled) { + log('RewardsController: Feature flag disabled, skipping silent auth'); + return; + } + log('RewardsController: handleAuthenticationTrigger', reason); + + try { + const selectedAccount = this.messagingSystem.call( + 'AccountsController:getSelectedMultichainAccount', + ); + await this.#performSilentAuth(selectedAccount); + } catch (error) { + // Silent failure - don't throw errors for background authentication + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage) { + log( + 'RewardsController: Silent authentication failed:', + error instanceof Error ? error.message : String(error), + ); + } + } + } + + /** + * Check if silent authentication should be skipped + * @param account - The CAIP-10 account ID + * @param address - The raw account address + * @param isHardwareAccount - Whether the account is a hardware wallet + * @returns boolean - True if silent auth should be skipped, false otherwise + */ + #shouldSkipSilentAuth( + account: CaipAccountId, + address: string, + isHardwareAccount: boolean, + ): boolean { + // Skip for hardware and Solana accounts + if (isHardwareAccount || isSolanaAddress(address)) { + return true; + } + + const now = Date.now(); + const accountState = this.#getAccountState(account); + if ( + accountState?.hasOptedIn && + now - accountState.lastCheckedAuth < AUTH_GRACE_PERIOD_MS + ) { + return true; + } + + return false; + } + + convertInternalAccountToCaipAccountId( + account: InternalAccount, + ): CaipAccountId | null { + try { + const [scope] = account.scopes; + const { namespace, reference } = parseCaipChainId(scope); + return toCaipAccountId(namespace, reference, account.address); + } catch (error) { + log( + 'RewardsController: Failed to convert address to CAIP-10 format:', + error, + ); + return null; + } + } + + /** + * Perform silent authentication for the given address + * @param internalAccount - The account address to authenticate + */ + async #performSilentAuth(internalAccount?: InternalAccount): Promise { + if (!internalAccount) { + this.update((state: RewardsControllerState) => { + state.activeAccount = null; + }); + return; + } + + const account: CaipAccountId | null = + this.convertInternalAccountToCaipAccountId(internalAccount); + + const shouldSkip = account + ? this.#shouldSkipSilentAuth( + account, + internalAccount.address, + isHardwareWallet(internalAccount), + ) + : false; + + if (shouldSkip) { + // This means that we'll have a record for this account + let accountState = this.#getAccountState(account as CaipAccountId); + if (accountState) { + // Update last authenticated account + this.update((state: RewardsControllerState) => { + state.activeAccount = accountState; + }); + } else { + // Update accounts map && last authenticated account + accountState = { + account: account as CaipAccountId, + hasOptedIn: false, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: null, // Default value, will be updated when fetched + lastPerpsDiscountRateFetched: null, + }; + this.update((state: RewardsControllerState) => { + state.accounts[account as CaipAccountId] = + accountState as RewardsAccountState; + state.activeAccount = accountState; + }); + } + return; + } + + let subscription: SubscriptionDto | null = null; + let authUnexpectedError = false; + + // Generate timestamp and sign the message + const timestamp = Math.floor(Date.now() / 1000); + + let signature; + try { + signature = await this.#signRewardsMessage(internalAccount, timestamp); + } catch (signError) { + log('RewardsController: Failed to generate signature:', signError); + + // Check if the error is due to locked keyring + if ( + signError && + typeof signError === 'object' && + 'message' in signError + ) { + const errorMessage = (signError as Error).message; + if (errorMessage.includes('controller is locked')) { + log('RewardsController: Keyring is locked, skipping silent auth'); + return; // Exit silently when keyring is locked + } + } + + throw signError; + } + + // Use data service through messenger + log( + 'RewardsController: Performing silent auth for', + internalAccount.address, + ); + try { + const loginResponse: LoginResponseDto = await this.messagingSystem.call( + 'RewardsDataService:login', + { + account: internalAccount.address, + timestamp, + signature, + }, + ); + + // Update state with successful authentication + subscription = loginResponse.subscription; + + // Store the session token for this subscription + if (this.#storeSubscriptionToken) { + const { success: tokenStoreSuccess } = + await this.#storeSubscriptionToken({ + subscriptionId: subscription.id, + loginSessionId: loginResponse.sessionId, + }); + if (!tokenStoreSuccess) { + log('RewardsController: Failed to store session token', account); + throw new Error('Failed to store session token'); + } + } + + log('RewardsController: Silent auth successful'); + } catch (error: unknown) { + // Handle 401 (not opted in) or other errors silently + if (!(error instanceof Error && error.message.includes('401'))) { + // Unknown error + subscription = null; + authUnexpectedError = true; + } + } finally { + // Update state so that we remember this account is not opted in + this.update((state: RewardsControllerState) => { + if (!account) { + return; + } + + // Create or update account state with no subscription + const accountState: RewardsAccountState = { + account, + hasOptedIn: authUnexpectedError ? undefined : Boolean(subscription), + subscriptionId: subscription?.id ?? null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: authUnexpectedError, + perpsFeeDiscount: null, // Default value, will be updated when fetched + lastPerpsDiscountRateFetched: null, + }; + + state.accounts[account] = accountState; + state.activeAccount = accountState; + + if (subscription) { + state.subscriptions[subscription.id] = subscription; + } + }); + } + } + + /** + * Update perps fee discount for a given account + * @param account - The account address in CAIP-10 format + * @returns Promise - The perps discount data or null on failure + */ + async #getPerpsFeeDiscountData( + account: CaipAccountId, + ): Promise { + const accountState = this.#getAccountState(account); + + // Check if we have a cached discount and if threshold hasn't been reached + if ( + accountState && + accountState.perpsFeeDiscount !== null && + accountState.lastPerpsDiscountRateFetched !== null && + Date.now() - accountState.lastPerpsDiscountRateFetched < + PERPS_DISCOUNT_CACHE_THRESHOLD_MS + ) { + log( + 'RewardsController: Using cached perps discount data for', + account, + accountState.perpsFeeDiscount, + ); + return { + hasOptedIn: Boolean(accountState.hasOptedIn), + discount: accountState.perpsFeeDiscount, + }; + } + + try { + log( + 'RewardsController: Fetching fresh perps discount data via API call for', + account, + ); + const perpsDiscountData = await this.messagingSystem.call( + 'RewardsDataService:getPerpsDiscount', + { account }, + ); + + this.update((state: RewardsControllerState) => { + const { hasOptedIn, discount } = { + hasOptedIn: perpsDiscountData.hasOptedIn, + discount: perpsDiscountData.discount ?? 0, + }; + // Create account state if it doesn't exist + if (!state.accounts[account]) { + state.accounts[account] = { + account, + hasOptedIn, + subscriptionId: null, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: discount, + lastPerpsDiscountRateFetched: Date.now(), + }; + } else { + // Update account state + state.accounts[account].hasOptedIn = hasOptedIn; + if (!hasOptedIn) { + state.accounts[account].subscriptionId = null; + } + state.accounts[account].perpsFeeDiscount = discount; + state.accounts[account].lastPerpsDiscountRateFetched = Date.now(); + } + }); + return perpsDiscountData; + } catch (error) { + log( + 'RewardsController: Failed to update perps fee discount:', + error instanceof Error ? error.message : String(error), + ); + return null; + } + } + + /** + * Check if the given account (caip-10 format) has opted in to rewards + * @param account - The account address in CAIP-10 format + * @returns Promise - True if the account has opted in, false otherwise + */ + async getHasAccountOptedIn(account: CaipAccountId): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + return false; + } + const accountState = this.#getAccountState(account); + if (accountState?.hasOptedIn) { + return accountState.hasOptedIn; + } + + // Right now we'll derive this from either cached map state or perps fee discount api call. + const perpsDiscountData = await this.#getPerpsFeeDiscountData(account); + return Boolean(perpsDiscountData?.hasOptedIn); + } + + /** + * Get perps fee discount for an account with caching and threshold logic + * @param account - The account address in CAIP-10 format + * @returns Promise - The discount number value + */ + async getPerpsDiscountForAccount(account: CaipAccountId): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + return 0; + } + const perpsDiscountData = await this.#getPerpsFeeDiscountData(account); + return perpsDiscountData?.discount || 0; + } + + /** + * Get points events for a given season + * @param params - The request parameters + * @returns Promise - The points events data + */ + async getPointsEvents( + params: GetPointsEventsDto, + ): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + return { has_more: false, cursor: null, total_results: 0, results: [] }; + } + + try { + const pointsEvents = await this.messagingSystem.call( + 'RewardsDataService:getPointsEvents', + params, + ); + return pointsEvents; + } catch (error) { + log( + 'RewardsController: Failed to get points events:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Estimate points for a given activity + * @param request - The estimate points request containing activity type and context + * @returns Promise - The estimated points and bonus information + */ + async estimatePoints( + request: EstimatePointsDto, + ): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + return { pointsEstimate: 0, bonusBips: 0 }; + } + try { + const estimatedPoints = await this.messagingSystem.call( + 'RewardsDataService:estimatePoints', + request, + ); + + return estimatedPoints; + } catch (error) { + log( + 'RewardsController: Failed to estimate points:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Check if the rewards feature is enabled via feature flag + * @returns boolean - True if rewards feature is enabled, false otherwise + */ + isRewardsFeatureEnabled(): boolean { + return getRewardsFeatureFlag(this.messagingSystem); + } + + /** + * Get season status with caching + * @param subscriptionId - The subscription ID for authentication + * @param seasonId - The ID of the season to get status for + * @returns Promise - The season status data + */ + async getSeasonStatus( + subscriptionId: string, + seasonId: string | 'current' = 'current', + ): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + return null; + } + + // Check if we have cached season status and if threshold hasn't been reached + const cachedSeasonStatus = this.#getSeasonStatus(subscriptionId, seasonId); + if ( + cachedSeasonStatus?.lastFetched && + Date.now() - cachedSeasonStatus.lastFetched < + SEASON_STATUS_CACHE_THRESHOLD_MS + ) { + log( + 'RewardsController: Using cached season status data for', + subscriptionId, + seasonId, + ); + + return cachedSeasonStatus; + } + + try { + log( + 'RewardsController: Fetching fresh season status data via API call for subscriptionId & seasonId', + subscriptionId, + seasonId, + ); + const seasonStatus = await this.messagingSystem.call( + 'RewardsDataService:getSeasonStatus', + seasonId, + subscriptionId, + ); + + const seasonState = this.#convertSeasonToState(seasonStatus.season); + const subscriptionSeasonStatus = + this.#convertSeasonStatusToSubscriptionState(seasonStatus); + + const compositeKey = this.#createSeasonStatusCompositeKey( + seasonId, + subscriptionId, + ); + + this.update((state: RewardsControllerState) => { + // Update seasons map with season data + state.seasons[seasonId] = seasonState; + + // Update season status with composite key + state.seasonStatuses[compositeKey] = subscriptionSeasonStatus; + }); + + return subscriptionSeasonStatus; + } catch (error) { + log( + 'RewardsController: Failed to get season status:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Get referral details with caching + * @param subscriptionId - The subscription ID for authentication + * @returns Promise - The referral details data + */ + async getReferralDetails( + subscriptionId: string, + ): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + return null; + } + + const cachedReferralDetails = + this.state.subscriptionReferralDetails[subscriptionId]; + + // Check if we have cached referral details and if threshold hasn't been reached + if ( + cachedReferralDetails?.lastFetched && + Date.now() - cachedReferralDetails.lastFetched < + REFERRAL_DETAILS_CACHE_THRESHOLD_MS + ) { + log( + 'RewardsController: Using cached referral details data for', + subscriptionId, + ); + return cachedReferralDetails; + } + + try { + log( + 'RewardsController: Fetching fresh referral details data via API call for', + subscriptionId, + ); + const referralDetails = await this.messagingSystem.call( + 'RewardsDataService:getReferralDetails', + subscriptionId, + ); + + const subscriptionReferralDetailsState: SubscriptionReferralDetailsState = + { + referralCode: referralDetails.referralCode, + totalReferees: referralDetails.totalReferees, + lastFetched: Date.now(), + }; + + this.update((state: RewardsControllerState) => { + // Update subscription referral details at root level + state.subscriptionReferralDetails[subscriptionId] = + subscriptionReferralDetailsState; + }); + + return subscriptionReferralDetailsState; + } catch (error) { + log( + 'RewardsController: Failed to get referral details:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Perform the complete opt-in process for rewards + * @param account - The account to opt in + * @param referralCode - Optional referral code + */ + async optIn(account: InternalAccount, referralCode?: string): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + log('RewardsController: Rewards feature is disabled, skipping optin', { + account: account.address, + }); + return; + } + + log('RewardsController: Starting optin process', { + account: account.address, + }); + + const challengeResponse = await this.messagingSystem.call( + 'RewardsDataService:generateChallenge', + { + address: account.address, + }, + ); + + // Try different encoding approaches to handle potential character issues + const hexMessage = `0x${Buffer.from(challengeResponse.message, 'utf8').toString('hex')}`; + + // Use KeyringController for silent signature + const signature = await this.messagingSystem.call( + 'KeyringController:signPersonalMessage', + { + data: hexMessage, + from: account.address, + }, + ); + + log('RewardsController: Submitting optin with signature...'); + const optinResponse = await this.messagingSystem.call( + 'RewardsDataService:optin', + { + challengeId: challengeResponse.id, + signature, + referralCode, + }, + ); + + log('RewardsController: Optin successful, updating controller state...'); + + // Store the subscription token for authenticated requests + if ( + optinResponse.subscription?.id && + optinResponse.sessionId && + this.#storeSubscriptionToken + ) { + const tokenResponse = await this.#storeSubscriptionToken({ + subscriptionId: optinResponse.subscription.id, + loginSessionId: optinResponse.sessionId, + }); + if (!tokenResponse.success) { + log( + 'RewardsController: Failed to store subscription token:', + tokenResponse?.error || 'Unknown error', + ); + } + } + + // Update state with opt-in response data + this.update((state) => { + const caipAccount: CaipAccountId | null = + this.convertInternalAccountToCaipAccountId(account); + if (!caipAccount) { + return; + } + state.activeAccount = { + account: caipAccount, + hasOptedIn: true, + subscriptionId: optinResponse.subscription.id, + lastCheckedAuth: Date.now(), + lastCheckedAuthError: false, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }; + state.accounts[caipAccount] = state.activeAccount; + state.subscriptions[optinResponse.subscription.id] = + optinResponse.subscription; + }); + } + + /** + * Logout user from rewards and clear associated data + */ + async logout(): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + log('RewardsController: Rewards feature is disabled, skipping logout'); + return; + } + + if (!this.state.activeAccount?.subscriptionId) { + log('RewardsController: No authenticated account found'); + return; + } + + const { subscriptionId } = this.state.activeAccount; + try { + // Call the data service logout if subscriptionId is provided + await this.messagingSystem.call( + 'RewardsDataService:logout', + subscriptionId, + ); + log('RewardsController: Successfully logged out from data service'); + + // Remove the session token from storage + if (this.#removeSubscriptionToken) { + const tokenRemovalResult = + await this.#removeSubscriptionToken(subscriptionId); + if (!tokenRemovalResult.success) { + log( + 'RewardsController: Warning - failed to remove session token:', + tokenRemovalResult?.error || 'Unknown error', + ); + } else { + log('RewardsController: Successfully removed session token'); + } + } else { + log('RewardsController: No removeSubscriptionToken function defined'); + } + + // Update controller state to reflect logout + this.update((state) => { + // Clear last authenticated account if it matches this subscription + if (state.activeAccount?.subscriptionId === subscriptionId) { + delete state.accounts[state.activeAccount.account]; + state.activeAccount = null; + log('RewardsController: Cleared last authenticated account'); + } + }); + + log('RewardsController: Logout completed successfully'); + } catch (error) { + log( + 'RewardsController: Logout failed to complete', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Get geo rewards metadata including location and support status + * @returns Promise - The geo rewards metadata + */ + async getGeoRewardsMetadata(): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled) { + return { + geoLocation: 'UNKNOWN', + optinAllowedForGeo: false, + }; + } + + if (this.#geoLocation) { + log('RewardsController: Using cached geo location', { + location: this.#geoLocation, + }); + + return this.#geoLocation; + } + + try { + log('RewardsController: Fetching geo location for rewards metadata'); + + // Get geo location from data service + const geoLocation = await this.messagingSystem.call( + 'RewardsDataService:fetchGeoLocation', + ); + + // Check if the location is supported (not in blocked regions) + const optinAllowedForGeo = !DEFAULT_BLOCKED_REGIONS.some( + (blockedRegion) => geoLocation.startsWith(blockedRegion), + ); + + const result: GeoRewardsMetadata = { + geoLocation, + optinAllowedForGeo, + }; + + log('RewardsController: Geo rewards metadata retrieved', result); + this.#geoLocation = result; + return result; + } catch (error) { + log( + 'RewardsController: Failed to get geo rewards metadata:', + error instanceof Error ? error.message : String(error), + ); + + // Return fallback metadata on error + return { + geoLocation: 'UNKNOWN', + optinAllowedForGeo: true, + }; + } + } + + /** + * Validate a referral code + * @param code - The referral code to validate + * @returns Promise - True if the code is valid, false otherwise + */ + async validateReferralCode(code: string): Promise { + const rewardsEnabled = getRewardsFeatureFlag(this.messagingSystem); + if (!rewardsEnabled || !code.trim() || code.trim().length !== 6) { + return false; + } + + try { + const response = await this.messagingSystem.call( + 'RewardsDataService:validateReferralCode', + code, + ); + return response.valid; + } catch (error) { + log( + 'RewardsController: Failed to validate referral code:', + error instanceof Error ? error.message : String(error), + ); + return false; + } + } +} diff --git a/packages/rewards-controller/src/constants.ts b/packages/rewards-controller/src/constants.ts new file mode 100644 index 00000000000..4cb22193f20 --- /dev/null +++ b/packages/rewards-controller/src/constants.ts @@ -0,0 +1,14 @@ +export const controllerName = 'RewardsController'; + +// Default timeout for all API requests (10 seconds) +export const DEFAULT_REQUEST_TIMEOUT_MS = 10000; + +export const DEFAULT_BLOCKED_REGIONS = ['UK']; +// Silent authentication constants +export const AUTH_GRACE_PERIOD_MS = 1000 * 60 * 10; // 10 minutes +// Perps discount refresh threshold +export const PERPS_DISCOUNT_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes +// Season status cache threshold +export const SEASON_STATUS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute +// Referral details cache threshold +export const REFERRAL_DETAILS_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes diff --git a/packages/rewards-controller/src/feature-flags.test.ts b/packages/rewards-controller/src/feature-flags.test.ts new file mode 100644 index 00000000000..855e8bf676f --- /dev/null +++ b/packages/rewards-controller/src/feature-flags.test.ts @@ -0,0 +1,97 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +import { getRewardsFeatureFlag } from './feature-flags'; + +type MockMessenger = { + call: jest.Mock< + RemoteFeatureFlagControllerState, + ['RemoteFeatureFlagController:getState'] + >; +}; + +const makeMessenger = ( + state: Partial | undefined, +): MockMessenger => { + return { + // When state is undefined, coerce to any so we can simulate a bad/missing controller state + call: jest.fn< + RemoteFeatureFlagControllerState, + ['RemoteFeatureFlagController:getState'] + >(() => state as RemoteFeatureFlagControllerState), + }; +}; + +describe('getRewardsFeatureFlag', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when rewards flag is true', () => { + const messenger = makeMessenger({ + remoteFeatureFlags: { + rewards: true, + }, + }); + + const res = getRewardsFeatureFlag(messenger); + expect(messenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(res).toBe(true); + }); + + it('returns false when rewards flag is false', () => { + const messenger = makeMessenger({ + remoteFeatureFlags: { + rewards: false, + }, + }); + + const res = getRewardsFeatureFlag(messenger); + expect(messenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(res).toBe(false); + }); + + it('returns false when rewards flag is missing', () => { + const messenger = makeMessenger({ + remoteFeatureFlags: { + // no rewards key + }, + }); + + // Note: the function’s TS return type is boolean, but at runtime it can be undefined + // because it casts via `as boolean` without defaulting. We assert the runtime behavior here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = getRewardsFeatureFlag(messenger as any); + expect(messenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(res).toBe(false); + }); + + it('returns false when remoteFeatureFlags is missing', () => { + const messenger = makeMessenger({ + // remoteFeatureFlags: undefined + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = getRewardsFeatureFlag(messenger as any); + expect(messenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(res).toBe(false); + }); + + it('returns false when controller state is undefined (defensive)', () => { + const messenger = makeMessenger(undefined); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = getRewardsFeatureFlag(messenger as any); + expect(messenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect(res).toBe(false); + }); +}); diff --git a/packages/rewards-controller/src/feature-flags.ts b/packages/rewards-controller/src/feature-flags.ts new file mode 100644 index 00000000000..552c161cf25 --- /dev/null +++ b/packages/rewards-controller/src/feature-flags.ts @@ -0,0 +1,24 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +/** + * Gets the rewards feature flags from the remote feature flag controller + * + * @param messenger - Any messenger with access to RemoteFeatureFlagController:getState + * @returns The rewards feature flag + */ +export function getRewardsFeatureFlag< + T extends { + call( + action: 'RemoteFeatureFlagController:getState', + ): RemoteFeatureFlagControllerState; + }, +>(messenger: T): boolean { + const remoteFeatureFlagControllerState = messenger.call( + 'RemoteFeatureFlagController:getState', + ); + + const rewardsFlag = (remoteFeatureFlagControllerState?.remoteFeatureFlags + ?.rewards ?? false) as boolean; + + return rewardsFlag; +} diff --git a/packages/rewards-controller/src/index.ts b/packages/rewards-controller/src/index.ts new file mode 100644 index 00000000000..03bb8beefc1 --- /dev/null +++ b/packages/rewards-controller/src/index.ts @@ -0,0 +1,7 @@ +import { + RewardsController, + type RewardsControllerMessenger, +} from './RewardsController'; + +export { RewardsController }; +export type { RewardsControllerMessenger }; diff --git a/packages/rewards-controller/src/logger.ts b/packages/rewards-controller/src/logger.ts new file mode 100644 index 00000000000..ca017b5ba54 --- /dev/null +++ b/packages/rewards-controller/src/logger.ts @@ -0,0 +1,7 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { controllerName } from './constants'; + +export const projectLogger = createProjectLogger(controllerName); + +export { createModuleLogger }; diff --git a/packages/rewards-controller/src/messenger/RewardsControllerMessenger.ts b/packages/rewards-controller/src/messenger/RewardsControllerMessenger.ts new file mode 100644 index 00000000000..2344e12d018 --- /dev/null +++ b/packages/rewards-controller/src/messenger/RewardsControllerMessenger.ts @@ -0,0 +1,59 @@ +import type { + AccountsControllerGetSelectedMultichainAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { RestrictedMessenger } from '@metamask/base-controller'; +import type { + KeyringControllerSignPersonalMessageAction, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; + +import type { controllerName } from '../constants'; +import type { + RewardsDataServiceLoginAction, + RewardsDataServiceEstimatePointsAction, + RewardsDataServiceGetPerpsDiscountAction, + RewardsDataServiceGetSeasonStatusAction, + RewardsDataServiceGetReferralDetailsAction, + RewardsDataServiceGenerateChallengeAction, + RewardsDataServiceOptinAction, + RewardsDataServiceLogoutAction, + RewardsDataServiceFetchGeoLocationAction, + RewardsDataServiceValidateReferralCodeAction, + RewardsDataServiceGetPointsEventsAction, +} from '../services'; +import type { + RewardsControllerActions, + RewardsControllerEvents, +} from '../types'; + +// Don't reexport as per guidelines +type AllowedActions = + | AccountsControllerGetSelectedMultichainAccountAction + | KeyringControllerSignPersonalMessageAction + | RewardsDataServiceLoginAction + | RewardsDataServiceGetPointsEventsAction + | RewardsDataServiceEstimatePointsAction + | RewardsDataServiceGetPerpsDiscountAction + | RewardsDataServiceGetSeasonStatusAction + | RewardsDataServiceGetReferralDetailsAction + | RewardsDataServiceGenerateChallengeAction + | RewardsDataServiceOptinAction + | RewardsDataServiceLogoutAction + | RewardsDataServiceFetchGeoLocationAction + | RewardsDataServiceValidateReferralCodeAction + | RemoteFeatureFlagControllerGetStateAction; + +// Don't reexport as per guidelines +type AllowedEvents = + | AccountsControllerSelectedAccountChangeEvent + | KeyringControllerUnlockEvent; + +export type RewardsControllerMessenger = RestrictedMessenger< + typeof controllerName, + RewardsControllerActions | AllowedActions, + RewardsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; diff --git a/packages/rewards-controller/src/services/index.ts b/packages/rewards-controller/src/services/index.ts new file mode 100644 index 00000000000..892a4c28c8b --- /dev/null +++ b/packages/rewards-controller/src/services/index.ts @@ -0,0 +1,19 @@ +// Export the Rewards Data Service +export type { + RewardsDataServiceActions, + RewardsDataServiceEvents, + RewardsDataServiceGetPointsEventsAction, + RewardsDataServiceLoginAction, + RewardsDataServiceEstimatePointsAction, + RewardsDataServiceGetPerpsDiscountAction, + RewardsDataServiceMessenger, + RewardsDataServiceGetSeasonStatusAction, + RewardsDataServiceGetReferralDetailsAction, + RewardsDataServiceGenerateChallengeAction, + RewardsDataServiceOptinAction, + RewardsDataServiceLogoutAction, + RewardsDataServiceFetchGeoLocationAction, + RewardsDataServiceValidateReferralCodeAction, +} from './rewards-data-service'; + +export { RewardsDataService } from './rewards-data-service'; diff --git a/packages/rewards-controller/src/services/rewards-data-service.test.ts b/packages/rewards-controller/src/services/rewards-data-service.test.ts new file mode 100644 index 00000000000..7aec3d67fd7 --- /dev/null +++ b/packages/rewards-controller/src/services/rewards-data-service.test.ts @@ -0,0 +1,1653 @@ +import { successfulFetch } from '@metamask/controller-utils'; +import type { CaipAccountId } from '@metamask/utils'; + +import { + RewardsDataService, + type RewardsDataServiceMessenger, +} from './rewards-data-service'; +import { + type LoginResponseDto, + type EstimatePointsDto, + type EstimatedPointsDto, + type SeasonStatusDto, + type SubscriptionReferralDetailsDto, + EnvironmentType, +} from '../types'; + +const RewardsApiUrl = 'https://api.rewards.test'; +// Mock dependencies +jest.mock('@metamask/controller-utils', () => ({ + successfulFetch: jest.fn(), +})); + +jest.mock('../logger', () => { + const actual = jest.requireActual('../logger'); + const logSpy = jest.fn(); + return { + ...actual, + createModuleLogger: jest.fn(() => logSpy), + __logSpy: logSpy, + }; +}); + +const { __logSpy: logSpy } = jest.requireMock('../logger') as { + __logSpy: jest.Mock; +}; + +let mockMessenger: jest.Mocked; + +const mockGetSubscriptionToken = jest.fn< + Promise<{ success: boolean; token?: string }>, + [string] +>(); + +const okJsonResponse = (data: T, status = 200): Response => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => data, + text: async () => JSON.stringify(data), + }) as unknown as Response; + +/** + * Creates a fetch mock that simulates an operation that can be aborted via AbortSignal. + * + * @returns A fetch mock that simulates an operation that can be aborted via AbortSignal. + */ +function makeAbortingFetchMock() { + class AbortErr extends Error { + name = 'AbortError'; + } + return jest.fn().mockImplementation((_url: string, init?: RequestInit) => { + const signal = init?.signal as AbortSignal | undefined; + return new Promise((_resolve, reject) => { + // If already aborted, reject immediately + if (signal?.aborted) { + return reject(new AbortErr('Aborted')); + } + // Otherwise, wait for abort event + signal?.addEventListener('abort', () => reject(new AbortErr('Aborted'))); + // Intentionally never resolve; the test will abort via timer + return undefined; + }); + }); +} + +// Helper to build service with injectable deps +const buildService = ({ + fetchImpl = jest + .fn() + .mockResolvedValue( + okJsonResponse({ sessionId: 'sess', subscription: { id: 'sub' } }), + ), + getToken = mockGetSubscriptionToken, + environment = EnvironmentType.Development, + appType = 'mobile', + version = '1.0.0', + locale = 'en-US', + rewardsApiUrl = 'https://api.rewards.test', +}: Partial<{ + fetchImpl: typeof fetch; + getToken: (subId: string) => Promise<{ success: boolean; token?: string }>; + environment: EnvironmentType; + appType: 'mobile' | 'extension'; + version: string; + locale: string; + rewardsApiUrl: string; +}> = {}) => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: 'test-bearer-token', + }); + mockMessenger = { + registerActionHandler: jest.fn(), + call: jest.fn(), + } as unknown as jest.Mocked; + + const svc = new RewardsDataService({ + messenger: mockMessenger, + fetch: fetchImpl, + version, + appType, + locale, + rewardsApiUrl, + environment, + getSubscriptionToken: getToken, + }); + + return { svc, mockMessenger, fetchImpl, getToken }; +}; + +const mockSuccessfulFetch = successfulFetch as jest.MockedFunction< + typeof successfulFetch +>; + +describe('RewardsDataService', () => { + let mockFetch: jest.MockedFunction; + let service: RewardsDataService; + + beforeEach(() => { + jest.clearAllMocks(); + + mockFetch = jest.fn(); + + const { svc } = buildService({ + fetchImpl: mockFetch, + }); + service = svc; + }); + describe('initialization', () => { + it('should register all action handlers', () => { + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getPointsEvents', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:estimatePoints', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:logout', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:generateChallenge', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getSeasonStatus', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getReferralDetails', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:validateReferralCode', + expect.any(Function), + ); + }); + }); + + describe('initialization with default values', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetch = jest.fn(); + // Create service with minimal params to use defaults + new RewardsDataService({ + messenger: mockMessenger, + fetch: mockFetch, + version: '1.0.0', + appType: 'mobile', + locale: 'en-US', + rewardsApiUrl: 'https://api.rewards.test', + environment: EnvironmentType.Development, + getSubscriptionToken: mockGetSubscriptionToken, + }); + }); + it('should register all action handlers', () => { + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getPointsEvents', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:estimatePoints', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsDiscount', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:logout', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:generateChallenge', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getSeasonStatus', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:getReferralDetails', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:fetchGeoLocation', + expect.any(Function), + ); + expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( + 'RewardsDataService:validateReferralCode', + expect.any(Function), + ); + }); + }); + + describe('login', () => { + const mockLoginRequest = { + account: '0x123456789', + timestamp: 1234567890, + signature: '0xabcdef', + }; + + const mockLoginResponse: LoginResponseDto = { + sessionId: 'test-session-id', + subscription: { + id: 'test-subscription-id', + referralCode: 'test-referral-code', + accounts: [], + }, + }; + + it('should successfully login', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockLoginResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.login(mockLoginRequest); + + expect(result).toStrictEqual(mockLoginResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/mobile-login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockLoginRequest), + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }), + ); + }); + + it('should handle login errors', async () => { + const mockResponse = { + ok: false, + status: 401, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect(service.login(mockLoginRequest)).rejects.toThrow( + 'Login failed: 401', + ); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(service.login(mockLoginRequest)).rejects.toThrow( + 'Network error', + ); + }); + }); + + describe('getPointsEvents', () => { + const mockGetPointsEventsRequest = { + seasonId: 'current', + subscriptionId: 'sub-123', + cursor: null, + }; + + const mockPointsEventsResponse = { + has_more: true, + cursor: 'next-cursor-123', + total_results: 100, + results: [ + { + id: 'event-123', + timestamp: '2024-01-01T10:00:00Z', + value: 100, + bonus: { bips: 200, bonuses: ['loyalty'] }, + accountAddress: '0x123456789', + type: 'SWAP', + payload: { + srcAsset: { + amount: '1000000000000000000', + type: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + destAsset: { + amount: '4500000000', + type: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + txHash: '0xabcdef123456', + }, + }, + { + id: 'event-456', + timestamp: '2024-01-01T11:00:00Z', + value: 50, + bonus: null, + accountAddress: '0x987654321', + type: 'REFERRAL', + payload: null, + }, + ], + }; + + it('should successfully get points events without cursor', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockPointsEventsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.getPointsEvents(mockGetPointsEventsRequest); + + expect(result).toStrictEqual(mockPointsEventsResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/seasons/current/points-events', + { + credentials: 'omit', + method: 'GET', + headers: { + 'Accept-Language': 'en-US', + 'Content-Type': 'application/json', + 'rewards-api-key': 'test-bearer-token', + 'rewards-client-id': 'mobile-1.0.0', + }, + signal: expect.any(AbortSignal), + }, + ); + }); + + it('should successfully get points events with cursor', async () => { + const requestWithCursor = { + ...mockGetPointsEventsRequest, + cursor: 'cursor-abc123', + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockPointsEventsResponse, + has_more: false, + cursor: null, + }), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.getPointsEvents(requestWithCursor); + + expect(result.has_more).toBe(false); + expect(result.cursor).toBeNull(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/seasons/current/points-events?cursor=cursor-abc123', + expect.objectContaining({ + method: 'GET', + credentials: 'omit', + }), + ); + }); + + it('should properly encode cursor parameter in URL', async () => { + const requestWithSpecialCursor = { + ...mockGetPointsEventsRequest, + cursor: 'cursor/with+special=chars', + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockPointsEventsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await service.getPointsEvents(requestWithSpecialCursor); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/seasons/current/points-events?cursor=cursor%2Fwith%2Bspecial%3Dchars', + expect.any(Object), + ); + }); + + it('should include authentication headers with subscription token', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockPointsEventsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await service.getPointsEvents(mockGetPointsEventsRequest); + + expect(mockGetSubscriptionToken).toHaveBeenCalledWith( + mockGetPointsEventsRequest.subscriptionId, + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'rewards-api-key': 'test-bearer-token', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + + it('should handle missing subscription token gracefully', async () => { + mockGetSubscriptionToken.mockResolvedValue({ + success: false, + token: undefined, + }); + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockPointsEventsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.getPointsEvents(mockGetPointsEventsRequest); + + expect(result).toStrictEqual(mockPointsEventsResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'rewards-api-key': expect.any(String), + }), + }), + ); + }); + + it('should handle get points events errors', async () => { + const mockResponse = { + ok: false, + status: 404, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getPointsEvents(mockGetPointsEventsRequest), + ).rejects.toThrow('Get points events failed: 404'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect( + service.getPointsEvents(mockGetPointsEventsRequest), + ).rejects.toThrow('Network error'); + }); + + it('should handle timeout errors', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValue(abortError); + + await expect( + service.getPointsEvents(mockGetPointsEventsRequest), + ).rejects.toThrow('Request timeout after 10000ms'); + }); + }); + + describe('estimatePoints', () => { + const mockEstimateRequest: EstimatePointsDto = { + activityType: 'SWAP', + account: 'eip155:1:0x123', + activityContext: { + swapContext: { + srcAsset: { id: 'eip155:1/slip44:60', amount: '1000000000000000000' }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + amount: '4500000000', + }, + feeAsset: { id: 'eip155:1/slip44:60', amount: '5000000000000000' }, + }, + }, + }; + + const mockEstimateResponse: EstimatedPointsDto = { + pointsEstimate: 100, + bonusBips: 500, + }; + + it('should successfully estimate points', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockEstimateResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.estimatePoints(mockEstimateRequest); + + expect(result).toStrictEqual(mockEstimateResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/points-estimation', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockEstimateRequest), + }), + ); + }); + + it('should handle estimate points errors', async () => { + const mockResponse = { + ok: false, + status: 400, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect(service.estimatePoints(mockEstimateRequest)).rejects.toThrow( + 'Points estimation failed: 400', + ); + }); + }); + + describe('getPerpsDiscount', () => { + const testAddress = 'eip155:1:0x123456789' as CaipAccountId; + + it('should successfully get perps discount', async () => { + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue('1,5.5'), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.getPerpsDiscount({ + account: testAddress as CaipAccountId, + }); + + expect(result).toStrictEqual({ + hasOptedIn: true, + discount: 5.5, + }); + expect(mockFetch).toHaveBeenCalledWith( + `https://api.rewards.test/public/rewards/perps-fee-discount/${testAddress}`, + expect.objectContaining({ + method: 'GET', + }), + ); + }); + + it('should parse not opted in response', async () => { + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue('0,10.0'), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await service.getPerpsDiscount({ + account: testAddress as CaipAccountId, + }); + + expect(result).toStrictEqual({ + hasOptedIn: false, + discount: 10.0, + }); + }); + + it('should handle perps discount errors', async () => { + const mockResponse = { + ok: false, + status: 404, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getPerpsDiscount({ account: testAddress as CaipAccountId }), + ).rejects.toThrow('Get Perps discount failed: 404'); + }); + + it('should handle invalid response format', async () => { + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue('invalid_format'), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getPerpsDiscount({ account: testAddress as CaipAccountId }), + ).rejects.toThrow( + 'Invalid perps discount response format: invalid_format', + ); + }); + + it('should handle when discount is not a number', async () => { + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue('1, not_a_number'), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getPerpsDiscount({ account: testAddress as CaipAccountId }), + ).rejects.toThrow('Invalid perps discount values: optIn'); + }); + + it('should handle when opt-in status is invalid', async () => { + // "2" is not a valid opt-in status (should be 0 or 1) + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue('2, 10'), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getPerpsDiscount({ account: testAddress as CaipAccountId }), + ).rejects.toThrow('Invalid opt-in status: 2. Expected 0 or 1.'); + }); + }); + + describe('timeout handling', () => { + it('should handle request timeouts', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValue(abortError); + + await expect( + service.login({ + account: '0x123', + timestamp: 1234567890, + signature: '0xabc', + }), + ).rejects.toThrow('Request timeout after 10000ms'); + }); + + it('should include AbortSignal in requests', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await service.login({ + account: '0x123', + timestamp: 1234567890, + signature: '0xabc', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + }); + + describe('makeRequest timeout behavior', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(global, 'setTimeout'); + jest.spyOn(global, 'clearTimeout'); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('aborts and throws when the (custom) timeout elapses', async () => { + const fetchMock = makeAbortingFetchMock(); + const { svc } = buildService({ fetchImpl: fetchMock }); + + // Call the private method directly to pass a custom timeout + const customTimeout = 1234; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p: Promise = (svc as any).makeRequest( + '/slow', + { method: 'GET' }, + /* subscriptionId */ undefined, + /* timeoutMs */ customTimeout, + ); + + // A single timer should be scheduled with our custom timeout + expect(setTimeout).toHaveBeenCalledTimes(1); + expect((setTimeout as unknown as jest.Mock).mock.calls[0][1]).toBe( + customTimeout, + ); + + // Fast-forward time to trigger AbortController.abort() + jest.advanceTimersByTime(customTimeout); + + // The promise should reject with the timeout message coming from catch block + await expect(p).rejects.toThrow( + `Request timeout after ${customTimeout}ms`, + ); + + // The timer must be cleared in the catch path + expect(clearTimeout).toHaveBeenCalledTimes(1); + + // And fetch should have been called once with an AbortSignal + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + expect(init?.signal).toBeDefined(); + }); + + it('aborts and throws using the default timeout when none is provided', async () => { + const fetchMock = makeAbortingFetchMock(); + const { svc } = buildService({ fetchImpl: fetchMock }); + + // Hit makeRequest via a public method (e.g., login) which uses the default timeout + const op = svc.login({ + account: '0xabc', + timestamp: 1, + signature: '0xsig', + }); + + // Extract the ms used by setTimeout for this call (the implementation’s default) + expect(setTimeout).toHaveBeenCalledTimes(1); + const defaultMs = (setTimeout as unknown as jest.Mock).mock + .calls[0][1] as number; + + // Advance exactly that amount + jest.advanceTimersByTime(defaultMs); + + await expect(op).rejects.toThrow(`Request timeout after ${defaultMs}ms`); + expect(clearTimeout).toHaveBeenCalledTimes(1); + }); + }); + + describe('headers', () => { + it('should include correct headers in requests', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await service.login({ + account: '0x123', + timestamp: 1234567890, + signature: '0xabc', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { + 'Accept-Language': 'en-US', + 'Content-Type': 'application/json', + 'rewards-client-id': 'mobile-1.0.0', + // Should not include rewards-api-key header + }, + }), + ); + }); + }); + + const mockSeasonStatusResponse: SeasonStatusDto = { + season: { + id: 'season-123', + name: 'Test Season', + startDate: new Date('2023-06-01T00:00:00Z'), + endDate: new Date('2023-08-31T23:59:59Z'), + tiers: [ + { + id: 'tier-gold', + name: 'Gold Tier', + pointsNeeded: 1000, + }, + { + id: 'tier-silver', + name: 'Silver Tier', + pointsNeeded: 500, + }, + ], + }, + balance: { + total: 1000, + refereePortion: 500, + updatedAt: new Date('2023-12-01T10:00:00Z'), + }, + currentTierId: 'tier-gold', + }; + + describe('getSeasonStatus', () => { + const mockSeasonId = 'season-123'; + const mockSubscriptionId = 'subscription-456'; + + beforeEach(() => { + // Mock successful fetch response for season status + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + season: { + ...mockSeasonStatusResponse.season, + startDate: '2023-06-01T00:00:00Z', // API returns strings, not Date objects + endDate: '2023-08-31T23:59:59Z', + }, + balance: { + ...mockSeasonStatusResponse.balance, + updatedAt: '2023-12-01T10:00:00Z', // API returns string, not Date + }, + currentTierId: mockSeasonStatusResponse.currentTierId, + }), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + }); + + it('should successfully get season status', async () => { + const result = await service.getSeasonStatus( + mockSeasonId, + mockSubscriptionId, + ); + + expect(result).toStrictEqual(mockSeasonStatusResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${RewardsApiUrl}/seasons/${mockSeasonId}/status`, + { + credentials: 'omit', + method: 'GET', + headers: { + 'Accept-Language': 'en-US', + 'Content-Type': 'application/json', + 'rewards-api-key': 'test-bearer-token', + 'rewards-client-id': 'mobile-1.0.0', + }, + signal: expect.any(AbortSignal), + }, + ); + }); + + it('should convert date strings to Date objects', async () => { + const result = await service.getSeasonStatus( + mockSeasonId, + mockSubscriptionId, + ); + + // Check balance updatedAt + expect(result.balance.updatedAt).toBeInstanceOf(Date); + expect(result.balance.updatedAt?.getTime()).toBe( + new Date('2023-12-01T10:00:00Z').getTime(), + ); + + // Check season dates + expect(result.season.startDate).toBeInstanceOf(Date); + expect(result.season.startDate?.getTime()).toBe( + new Date('2023-06-01T00:00:00Z').getTime(), + ); + expect(result.season.endDate).toBeInstanceOf(Date); + expect(result.season.endDate.getTime()).toBe( + new Date('2023-08-31T23:59:59Z').getTime(), + ); + }); + + it('should include authentication headers with subscription token', async () => { + await service.getSeasonStatus(mockSeasonId, mockSubscriptionId); + + expect(mockGetSubscriptionToken).toHaveBeenCalledWith(mockSubscriptionId); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'rewards-api-key': 'test-bearer-token', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + + it('should throw error when response is not ok', async () => { + const mockResponse = { + ok: false, + status: 404, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getSeasonStatus(mockSeasonId, mockSubscriptionId), + ).rejects.toThrow('Get season status failed: 404'); + }); + + it('should throw error when fetch fails', async () => { + const fetchError = new Error('Network error'); + mockFetch.mockRejectedValue(fetchError); + + await expect( + service.getSeasonStatus(mockSeasonId, mockSubscriptionId), + ).rejects.toThrow('Network error'); + }); + + it('should handle missing subscription token gracefully', async () => { + // Mock token retrieval failure + mockGetSubscriptionToken.mockResolvedValue({ + success: false, + token: undefined, + }); + + const result = await service.getSeasonStatus( + mockSeasonId, + mockSubscriptionId, + ); + + expect(result).toStrictEqual(mockSeasonStatusResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'rewards-api-key': expect.any(String), + }), + }), + ); + }); + }); + + describe('generateChallenge', () => { + it('generateChallenge() posts and returns JSON', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValue(okJsonResponse({ challengeId: 'c1', message: 'm' })); + const { svc } = buildService({ fetchImpl }); + + const res = await svc.generateChallenge({ address: '0xabc' }); + expect(res).toStrictEqual({ challengeId: 'c1', message: 'm' }); + }); + it('generateChallenge() throws on non-ok response', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValue({ ok: false, status: 400 } as Response); + const { svc } = buildService({ fetchImpl }); + + await expect(svc.generateChallenge({ address: '0xabc' })).rejects.toThrow( + 'Generate challenge failed: 400', + ); + }); + }); + + describe('getReferralDetails', () => { + const mockSubscriptionId = 'test-subscription-123'; + + const mockReferralDetailsResponse: SubscriptionReferralDetailsDto = { + referralCode: 'TEST123', + totalReferees: 5, + }; + + beforeEach(() => { + // Mock successful response for each test + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockReferralDetailsResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + service = buildService({ + fetchImpl: mockFetch, + appType: 'mobile', + }).svc; + }); + + it('should successfully get referral details', async () => { + const result = await service.getReferralDetails(mockSubscriptionId); + + expect(result).toStrictEqual(mockReferralDetailsResponse); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/subscriptions/referral-details', + expect.objectContaining({ + method: 'GET', + credentials: 'omit', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'rewards-api-key': 'test-bearer-token', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + + it('should include subscription ID in token retrieval', async () => { + await service.getReferralDetails(mockSubscriptionId); + + expect(mockGetSubscriptionToken).toHaveBeenCalledWith(mockSubscriptionId); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'rewards-api-key': 'test-bearer-token', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + + it('should throw error when response is not ok', async () => { + const mockResponse = { + ok: false, + status: 404, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getReferralDetails(mockSubscriptionId), + ).rejects.toThrow('Get referral details failed: 404'); + }); + + it('should throw error when fetch fails', async () => { + const fetchError = new Error('Network error'); + mockFetch.mockRejectedValue(fetchError); + + await expect( + service.getReferralDetails(mockSubscriptionId), + ).rejects.toThrow('Network error'); + }); + + it('should handle missing subscription token gracefully', async () => { + // Mock token retrieval failure + mockGetSubscriptionToken.mockResolvedValue({ + success: false, + token: undefined, + }); + + const result = await service.getReferralDetails(mockSubscriptionId); + + expect(result).toStrictEqual(mockReferralDetailsResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'rewards-api-key': expect.any(String), + }), + }), + ); + }); + + it('should handle subscription token retrieval error', async () => { + // Mock token retrieval throwing an error + mockGetSubscriptionToken.mockRejectedValue(new Error('Token error')); + + const result = await service.getReferralDetails(mockSubscriptionId); + + expect(result).toStrictEqual(mockReferralDetailsResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'rewards-api-key': expect.any(String), + }), + }), + ); + }); + }); + + const mockLoginResponse: LoginResponseDto = { + sessionId: 'test-session-id', + subscription: { + id: 'test-subscription-id', + referralCode: 'test-referral-code', + accounts: [], + }, + }; + + describe('Client Header', () => { + it('should include rewards-client-id header in requests', async () => { + // Mock successful response + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockLoginResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await service.login({ + account: '0x123', + timestamp: 1234567890, + signature: '0xsignature', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/mobile-login', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + }); + + describe('Accept-Language Header', () => { + it('should include Accept-Language header with default locale', async () => { + // Arrange - service already initialized with default locale 'en-US' + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockLoginResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + await service.login({ + account: '0x123', + timestamp: 1234567890, + signature: '0xsignature', + }); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/mobile-login', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Accept-Language': 'en-US', + }), + }), + ); + }); + + it('should include Accept-Language header with custom locale', async () => { + // Arrange - create service with custom locale + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockLoginResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const { svc } = buildService({ + fetchImpl: mockFetch, + appType: 'mobile', + locale: 'es-ES', + }); + + const customLocaleService = svc; + + // Act + await customLocaleService.login({ + account: '0x123', + timestamp: 1234567890, + signature: '0xsignature', + }); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/mobile-login', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Accept-Language': 'es-ES', + }), + }), + ); + }); + + it('should not include Accept-Language header when locale is empty', async () => { + // Arrange - create service with empty locale + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockLoginResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + const { svc } = buildService({ + fetchImpl: mockFetch, + appType: 'mobile', + locale: '', + }); + const emptyLocaleService = svc; + + // Act + await emptyLocaleService.login({ + account: '0x123', + timestamp: 1234567890, + signature: '0xsignature', + }); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/mobile-login', + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'Accept-Language': expect.any(String), + }), + }), + ); + }); + }); + + describe('optin', () => { + const mockOptinRequest = { + challengeId: 'challenge-123', + signature: '0xsignature123', + referralCode: 'REF123', + }; + + it('should successfully perform optin', async () => { + // Arrange + const mockOptinResponse = { + sessionId: 'session-456', + subscription: { + id: 'sub-789', + referralCode: 'REF123', + accounts: [], + }, + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockOptinResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + const result = await service.optin(mockOptinRequest); + + // Assert + expect(result).toStrictEqual(mockOptinResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockOptinRequest), + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + + it('should handle optin without referral code', async () => { + // Arrange + const requestWithoutReferral = { + challengeId: 'challenge-123', + signature: '0xsignature123', + }; + + const mockOptinResponse = { + sessionId: 'session-456', + subscription: { + id: 'sub-789', + referralCode: 'AUTO123', + accounts: [], + }, + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockOptinResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + const result = await service.optin(requestWithoutReferral); + + // Assert + expect(result).toStrictEqual(mockOptinResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requestWithoutReferral), + }), + ); + }); + + it('should handle optin errors', async () => { + // Arrange + const mockResponse = { + ok: false, + status: 400, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act & Assert + await expect(service.optin(mockOptinRequest)).rejects.toThrow( + 'Optin failed: 400', + ); + }); + + it('should handle network errors during optin', async () => { + // Arrange + mockFetch.mockRejectedValue(new Error('Network error')); + + // Act & Assert + await expect(service.optin(mockOptinRequest)).rejects.toThrow( + 'Network error', + ); + }); + }); + + describe('logout', () => { + const mockSubscriptionId = 'sub-123'; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: 'test-bearer-token', + }); + }); + + it('should successfully perform logout', async () => { + // Arrange + const mockResponse = { + ok: true, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + await service.logout(mockSubscriptionId); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/logout', + expect.objectContaining({ + method: 'POST', + credentials: 'omit', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'rewards-api-key': 'test-bearer-token', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + + it('should perform logout without subscription ID', async () => { + // Arrange + const mockResponse = { + ok: true, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + await service.logout(); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/logout', + expect.objectContaining({ + method: 'POST', + headers: expect.not.objectContaining({ + 'rewards-api-key': expect.any(String), + }), + }), + ); + }); + + it('should handle logout errors', async () => { + // Arrange + const mockResponse = { + ok: false, + status: 401, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act & Assert + await expect(service.logout(mockSubscriptionId)).rejects.toThrow( + 'Logout failed: 401', + ); + }); + + it('should handle network errors during logout', async () => { + // Arrange + mockFetch.mockRejectedValue(new Error('Network error')); + + // Act & Assert + await expect(service.logout(mockSubscriptionId)).rejects.toThrow( + 'Network error', + ); + }); + + it('should handle missing subscription token gracefully', async () => { + // Arrange + mockGetSubscriptionToken.mockResolvedValue({ + success: false, + token: undefined, + }); + + const mockResponse = { + ok: true, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + await service.logout(mockSubscriptionId); + + // Assert + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/logout', + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'rewards-api-key': expect.any(String), + }), + }), + ); + }); + }); + + describe('fetchGeoLocation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should successfully fetch geolocation in DEV environment', async () => { + // Arrange + const mockLocation = 'US'; + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue(mockLocation), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSuccessfulFetch.mockResolvedValue(mockResponse as any); + + // Act + const result = await service.fetchGeoLocation(); + + // Assert + expect(result).toBe(mockLocation); + expect(mockSuccessfulFetch).toHaveBeenCalledWith( + 'https://on-ramp.dev-api.cx.metamask.io/geolocation', + ); + }); + + it('should successfully fetch geolocation in PROD environment', async () => { + // Arrange + service = buildService({ + environment: EnvironmentType.Production, + }).svc; + const mockLocation = 'US'; + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue(mockLocation), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSuccessfulFetch.mockResolvedValue(mockResponse as any); + + // Act + const result = await service.fetchGeoLocation(); + + // Assert + expect(result).toBe(mockLocation); + expect(mockSuccessfulFetch).toHaveBeenCalledWith( + 'https://on-ramp.api.cx.metamask.io/geolocation', + ); + }); + + it('should return UNKNOWN when geolocation request fails', async () => { + // Arrange + const mockResponse = { + ok: false, + status: 500, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSuccessfulFetch.mockResolvedValue(mockResponse as any); + + // Act + const result = await service.fetchGeoLocation(); + + // Assert + expect(result).toBe('UNKNOWN'); + }); + + it('should return UNKNOWN when network error occurs', async () => { + // Arrange + mockSuccessfulFetch.mockRejectedValue(new Error('Network error')); + + // Act + const result = await service.fetchGeoLocation(); + + // Assert + expect(result).toBe('UNKNOWN'); + }); + + it('should return UNKNOWN when response text parsing fails', async () => { + // Arrange + const mockResponse = { + ok: true, + text: jest.fn().mockRejectedValue(new Error('Parse error')), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSuccessfulFetch.mockResolvedValue(mockResponse as any); + + // Act + const result = await service.fetchGeoLocation(); + + // Assert + expect(result).toBe('UNKNOWN'); + expect(logSpy).toHaveBeenCalledWith( + 'RewardsDataService: Failed to fetch geoloaction', + expect.any(Error), + ); + }); + + it('should return location string from response', async () => { + // Arrange + const mockLocation = 'UK'; + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue(mockLocation), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSuccessfulFetch.mockResolvedValue(mockResponse as any); + + // Act + const result = await service.fetchGeoLocation(); + + // Assert + expect(result).toBe(mockLocation); + }); + }); + + describe('validateReferralCode', () => { + it('should successfully validate a referral code', async () => { + // Arrange + const referralCode = 'ABC123'; + const mockValidationResponse = { valid: true }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockValidationResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + const result = await service.validateReferralCode(referralCode); + + // Assert + expect(result).toStrictEqual(mockValidationResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/referral/validate?code=ABC123', + expect.objectContaining({ + method: 'GET', + credentials: 'omit', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'rewards-client-id': 'mobile-1.0.0', + }), + }), + ); + }); + + it('should return invalid response for invalid codes', async () => { + // Arrange + const referralCode = 'INVALID'; + const mockValidationResponse = { valid: false }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockValidationResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + const result = await service.validateReferralCode(referralCode); + + // Assert + expect(result).toStrictEqual(mockValidationResponse); + expect(result.valid).toBe(false); + }); + + it('should properly encode special characters in referral code', async () => { + // Arrange + const referralCode = 'A+B/C='; + const mockValidationResponse = { valid: true }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockValidationResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + const result = await service.validateReferralCode(referralCode); + + // Assert + expect(result).toStrictEqual(mockValidationResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/referral/validate?code=A%2BB%2FC%3D', + expect.any(Object), + ); + }); + + it('should handle validation errors', async () => { + // Arrange + const referralCode = 'ABC123'; + const mockResponse = { + ok: false, + status: 400, + } as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act & Assert + await expect(service.validateReferralCode(referralCode)).rejects.toThrow( + 'Failed to validate referral code. Please try again shortly.', + ); + }); + + it('should handle network errors during validation', async () => { + // Arrange + const referralCode = 'ABC123'; + mockFetch.mockRejectedValue(new Error('Network error')); + + // Act & Assert + await expect(service.validateReferralCode(referralCode)).rejects.toThrow( + 'Network error', + ); + }); + + it('should handle timeout errors during validation', async () => { + // Arrange + const referralCode = 'ABC123'; + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValue(abortError); + + // Act & Assert + await expect(service.validateReferralCode(referralCode)).rejects.toThrow( + 'Request timeout after 10000ms', + ); + }); + }); +}); diff --git a/packages/rewards-controller/src/services/rewards-data-service.ts b/packages/rewards-controller/src/services/rewards-data-service.ts new file mode 100644 index 00000000000..7c7baaa08d0 --- /dev/null +++ b/packages/rewards-controller/src/services/rewards-data-service.ts @@ -0,0 +1,577 @@ +/* eslint-disable jsdoc/tag-lines */ +import type { RestrictedMessenger } from '@metamask/base-controller'; +import { successfulFetch } from '@metamask/controller-utils'; + +import { DEFAULT_REQUEST_TIMEOUT_MS } from '../constants'; +import { createModuleLogger, projectLogger } from '../logger'; +import type { + LoginResponseDto, + EstimatePointsDto, + EstimatedPointsDto, + GetPerpsDiscountDto, + PerpsDiscountData, + SeasonStatusDto, + SubscriptionReferralDetailsDto, + TokenResponse, + GenerateChallengeDto, + ChallengeResponseDto, + LoginDto, + EnvironmentType, + GetPointsEventsDto, + PaginatedPointsEventsDto, +} from '../types'; + +const SERVICE_NAME = 'RewardsDataService'; + +const log = createModuleLogger(projectLogger, SERVICE_NAME); + +// Geolocation URLs for different environments +const GEOLOCATION_URLS = { + DEV: 'https://on-ramp.dev-api.cx.metamask.io/geolocation', + PROD: 'https://on-ramp.api.cx.metamask.io/geolocation', +}; + +// Function to get subscription token +type GetSubscriptionToken = (subscriptionId: string) => Promise; + +// Auth endpoint action types + +export type RewardsDataServiceLoginAction = { + type: `${typeof SERVICE_NAME}:login`; + handler: RewardsDataService['login']; +}; + +export type RewardsDataServiceGetPointsEventsAction = { + type: `${typeof SERVICE_NAME}:getPointsEvents`; + handler: RewardsDataService['getPointsEvents']; +}; + +export type RewardsDataServiceEstimatePointsAction = { + type: `${typeof SERVICE_NAME}:estimatePoints`; + handler: RewardsDataService['estimatePoints']; +}; + +export type RewardsDataServiceGetPerpsDiscountAction = { + type: `${typeof SERVICE_NAME}:getPerpsDiscount`; + handler: RewardsDataService['getPerpsDiscount']; +}; + +export type RewardsDataServiceOptinAction = { + type: `${typeof SERVICE_NAME}:optin`; + handler: RewardsDataService['optin']; +}; + +export type RewardsDataServiceLogoutAction = { + type: `${typeof SERVICE_NAME}:logout`; + handler: RewardsDataService['logout']; +}; + +export type RewardsDataServiceGenerateChallengeAction = { + type: `${typeof SERVICE_NAME}:generateChallenge`; + handler: RewardsDataService['generateChallenge']; +}; + +export type RewardsDataServiceGetSeasonStatusAction = { + type: `${typeof SERVICE_NAME}:getSeasonStatus`; + handler: RewardsDataService['getSeasonStatus']; +}; + +export type RewardsDataServiceGetReferralDetailsAction = { + type: `${typeof SERVICE_NAME}:getReferralDetails`; + handler: RewardsDataService['getReferralDetails']; +}; + +export type RewardsDataServiceFetchGeoLocationAction = { + type: `${typeof SERVICE_NAME}:fetchGeoLocation`; + handler: RewardsDataService['fetchGeoLocation']; +}; + +export type RewardsDataServiceValidateReferralCodeAction = { + type: `${typeof SERVICE_NAME}:validateReferralCode`; + handler: RewardsDataService['validateReferralCode']; +}; + +export type RewardsDataServiceActions = + | RewardsDataServiceLoginAction + | RewardsDataServiceGetPointsEventsAction + | RewardsDataServiceEstimatePointsAction + | RewardsDataServiceGetPerpsDiscountAction + | RewardsDataServiceGetSeasonStatusAction + | RewardsDataServiceGetReferralDetailsAction + | RewardsDataServiceOptinAction + | RewardsDataServiceLogoutAction + | RewardsDataServiceGenerateChallengeAction + | RewardsDataServiceFetchGeoLocationAction + | RewardsDataServiceValidateReferralCodeAction; + +type AllowedActions = never; + +export type RewardsDataServiceEvents = never; + +type AllowedEvents = never; + +export type RewardsDataServiceMessenger = RestrictedMessenger< + typeof SERVICE_NAME, + RewardsDataServiceActions, + RewardsDataServiceEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +/** + * Data service for rewards API endpoints + */ +export class RewardsDataService { + readonly #messenger: RewardsDataServiceMessenger; + + readonly #fetch: typeof fetch; + + readonly #version: string; + + readonly #appType: 'mobile' | 'extension'; + + readonly #locale: string; + + readonly #rewardsApiUrl: string; + + readonly #environment: EnvironmentType; + + readonly #getSubscriptionToken: GetSubscriptionToken; + + constructor({ + messenger, + fetch: fetchFunction, + version, + appType, + locale, + rewardsApiUrl, + environment, + getSubscriptionToken, + }: { + messenger: RewardsDataServiceMessenger; + fetch: typeof fetch; + version: string; + appType: 'mobile' | 'extension'; + locale: string; + rewardsApiUrl: string; + environment: EnvironmentType; + getSubscriptionToken: GetSubscriptionToken; + }) { + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#version = version; + this.#appType = appType; + this.#locale = locale; + this.#rewardsApiUrl = rewardsApiUrl; + this.#environment = environment; + this.#getSubscriptionToken = getSubscriptionToken; + // Register all action handlers + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:login`, + this.login.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPointsEvents`, + this.getPointsEvents.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:estimatePoints`, + this.estimatePoints.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsDiscount`, + this.getPerpsDiscount.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:optin`, + this.optin.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:logout`, + this.logout.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:generateChallenge`, + this.generateChallenge.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getSeasonStatus`, + this.getSeasonStatus.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getReferralDetails`, + this.getReferralDetails.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:fetchGeoLocation`, + this.fetchGeoLocation.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:validateReferralCode`, + this.validateReferralCode.bind(this), + ); + } + + /** + * Make a request to the rewards API + * @param endpoint - The endpoint to request + * @param options - The options for the request + * @param subscriptionId - The subscription ID to use for the request, used for authenticated requests + * @param timeoutMs - Custom timeout in milliseconds, defaults to DEFAULT_REQUEST_TIMEOUT_MS + * @returns The response from the request + */ + private async makeRequest( + endpoint: string, + options: RequestInit, + subscriptionId?: string, + timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + headers['rewards-client-id'] = `${this.#appType}-${this.#version}`; + + // Add bearer token for authenticated requests + try { + if (subscriptionId) { + const tokenResult = await this.#getSubscriptionToken(subscriptionId); + if (tokenResult.success && tokenResult.token) { + headers['rewards-api-key'] = tokenResult.token; + } + } + } catch (error) { + // Continue without bearer token if retrieval fails + console.warn('Failed to retrieve bearer token:', error); + } + + // Add locale header for internationalization + if (this.#locale) { + headers['Accept-Language'] = this.#locale; + } + + // Construct full URL + const url = `${this.#rewardsApiUrl}${endpoint}`; + + // Create AbortController for timeout handling + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + // istanbul ignore next + controller.abort(); + }, timeoutMs); + + try { + const response = await this.#fetch(url, { + credentials: 'omit', + ...options, + headers: { + ...headers, + ...options.headers, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + + // Check if the error is due to timeout + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timeout after ${timeoutMs}ms`); + } + + throw error; + } + } + + /** + * Perform login via signature for the current account. + * @param body - The login request body containing account, timestamp, and signature. + * @param body.account - The CAIP-10 account address. + * @param body.timestamp - The timestamp of the signature. + * @param body.signature - The signature. + * @returns The login response DTO. + */ + async login(body: { + account: string; + timestamp: number; + signature: string; + }): Promise { + // For now, we're using the mobile-login endpoint for these types of login requests. + // Our previous login endpoint had a slightly different flow as it was not based around silent auth. + const response = await this.makeRequest('/auth/mobile-login', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Login failed: ${response.status}`); + } + + return (await response.json()) as LoginResponseDto; + } + + /** + * Get a list of points events for the season + * @param params - The request parameters containing + * @returns The list of points events DTO. + */ + async getPointsEvents( + params: GetPointsEventsDto, + ): Promise { + const { seasonId, subscriptionId, cursor } = params; + + let url = `/seasons/${seasonId}/points-events`; + if (cursor) { + url += `?cursor=${encodeURIComponent(cursor)}`; + } + + const response = await this.makeRequest( + url, + { + method: 'GET', + }, + subscriptionId, + ); + + if (!response.ok) { + throw new Error(`Get points events failed: ${response.status}`); + } + + return (await response.json()) as PaginatedPointsEventsDto; + } + + /** + * Estimate points for a given activity. + * @param body - The estimate points request body. + * @returns The estimated points response DTO. + */ + async estimatePoints(body: EstimatePointsDto): Promise { + const response = await this.makeRequest('/points-estimation', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Points estimation failed: ${response.status}`); + } + + return (await response.json()) as EstimatedPointsDto; + } + + /** + * Get Perps fee discount for a given address. + * @param params - The request parameters containing the CAIP-10 address. + * @returns The parsed Perps discount data containing opt-in status and discount percentage. + */ + async getPerpsDiscount( + params: GetPerpsDiscountDto, + ): Promise { + const response = await this.makeRequest( + `/public/rewards/perps-fee-discount/${params.account}`, + { + method: 'GET', + }, + ); + + if (!response.ok) { + throw new Error(`Get Perps discount failed: ${response.status}`); + } + + const responseText = await response.text(); + + // Parse the X,Y format where X is opt-in status (0 or 1) and Y is discount + const parts = responseText.split(','); + if (parts.length !== 2) { + throw new Error( + `Invalid perps discount response format: ${responseText}`, + ); + } + + const optInStatus = parseInt(parts[0]); + const discount = parseFloat(parts[1]); + + if (isNaN(optInStatus) || isNaN(discount)) { + throw new Error( + `Invalid perps discount values: optIn=${parts[0]}, discount=${parts[1]}`, + ); + } + + if (optInStatus !== 0 && optInStatus !== 1) { + throw new Error( + `Invalid opt-in status: ${optInStatus}. Expected 0 or 1.`, + ); + } + + return { + hasOptedIn: optInStatus === 1, + discount, + }; + } + + /** + * Generate a challenge for authentication. + * @param body - The challenge request body containing the address. + * @returns The challenge response DTO. + */ + async generateChallenge( + body: GenerateChallengeDto, + ): Promise { + const response = await this.makeRequest('/auth/challenge/generate', { + method: 'POST', + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`Generate challenge failed: ${response.status}`); + } + + return (await response.json()) as ChallengeResponseDto; + } + + /** + * Perform optin (login) via challenge and signature. + * @param body - The login request body containing challengeId, signature, and optional referralCode. + * @returns The login response DTO. + */ + async optin(body: LoginDto): Promise { + const response = await this.makeRequest('/auth/login', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Optin failed: ${response.status}`); + } + + return (await response.json()) as LoginResponseDto; + } + + /** + * Perform logout for the current authenticated session. + * @param subscriptionId - The subscription ID to use for the authenticated request. + * @returns Promise that resolves when logout is complete. + */ + async logout(subscriptionId?: string): Promise { + const response = await this.makeRequest( + '/auth/logout', + { + method: 'POST', + }, + subscriptionId, + ); + + if (!response.ok) { + throw new Error(`Logout failed: ${response.status}`); + } + } + + /** + * Get season status for a specific season. + * @param seasonId - The ID of the season to get status for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The season status DTO. + */ + async getSeasonStatus( + seasonId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/seasons/${seasonId}/status`, + { + method: 'GET', + }, + subscriptionId, + ); + + if (!response.ok) { + throw new Error(`Get season status failed: ${response.status}`); + } + + const data = await response.json(); + + // Convert date strings to Date objects + if (data.balance?.updatedAt) { + data.balance.updatedAt = new Date(data.balance.updatedAt); + } + if (data.season) { + if (data.season.startDate) { + data.season.startDate = new Date(data.season.startDate); + } + if (data.season.endDate) { + data.season.endDate = new Date(data.season.endDate); + } + } + + return data as SeasonStatusDto; + } + + /** + * Get referral details for a specific subscription. + * @param subscriptionId - The subscription ID for authentication. + * @returns The referral details DTO. + */ + async getReferralDetails( + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + '/subscriptions/referral-details', + { + method: 'GET', + }, + subscriptionId, + ); + + if (!response.ok) { + throw new Error(`Get referral details failed: ${response.status}`); + } + + return (await response.json()) as SubscriptionReferralDetailsDto; + } + + /** + * Fetch geolocation information from MetaMask's geolocation service. + * Returns location in Country or Country-Region format (e.g., 'US', 'CA-ON', 'FR'). + * @returns Promise - The geolocation string or 'UNKNOWN' on failure. + */ + async fetchGeoLocation(): Promise { + let location = 'UNKNOWN'; + + try { + const response = await successfulFetch( + GEOLOCATION_URLS[this.#environment], + ); + + if (!response.ok) { + return location; + } + location = await response?.text(); + return location; + } catch (e) { + log('RewardsDataService: Failed to fetch geoloaction', e); + return location; + } + } + + /** + * Validate a referral code. + * @param code - The referral code to validate. + * @returns Promise<{valid: boolean}> - Object indicating if the code is valid. + */ + async validateReferralCode(code: string): Promise<{ valid: boolean }> { + const response = await this.makeRequest( + `/referral/validate?code=${encodeURIComponent(code)}`, + { + method: 'GET', + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to validate referral code. Please try again shortly.`, + ); + } + + return (await response.json()) as { valid: boolean }; + } +} diff --git a/packages/rewards-controller/src/types.ts b/packages/rewards-controller/src/types.ts new file mode 100644 index 00000000000..5f2be58ca79 --- /dev/null +++ b/packages/rewards-controller/src/types.ts @@ -0,0 +1,607 @@ +/* eslint-disable jsdoc/tag-lines */ +import type { ControllerGetStateAction } from '@metamask/base-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { CaipAccountId, CaipAssetType } from '@metamask/utils'; + +export type LoginResponseDto = { + sessionId: string; + subscription: SubscriptionDto; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SubscriptionDto = { + id: string; + referralCode: string; + accounts: { + address: string; + chainId: number; + }[]; +}; + +export type GenerateChallengeDto = { + address: string; +}; + +export type ChallengeResponseDto = { + id: string; + message: string; + domain?: string; + address?: string; + issuedAt?: string; + expirationTime?: string; + nonce?: string; +}; + +export type LoginDto = { + challengeId: string; + signature: string; + referralCode?: string; +}; + +export type EstimateAssetDto = { + /** + * Asset identifier in CAIP-19 format + * @example 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + */ + id: CaipAssetType; + /** + * Amount of the asset as a string + * @example '25739959426' + */ + amount: string; + /** + * Asset price in USD PER TOKEN. Using ETH as an example, 1 ETH = 4493.23 USD at the time of writing. If provided, this will be used instead of doing a network call to get the current price. + * @example '4512.34' + */ + usdPrice?: string; +}; + +export type EstimateSwapContextDto = { + /** + * Source asset information, in caip19 format + * @example { + * id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + * amount: '25739959426' + * } + */ + srcAsset: EstimateAssetDto; + + /** + * Destination asset information, in caip19 format. + * @example { + * id: 'eip155:1/slip44:60', + * amount: '9912500000000000000' + * } + */ + destAsset: EstimateAssetDto; + + /** + * Fee asset information, in caip19 format + * @example { + * id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + * amount: '100' + * } + */ + feeAsset: EstimateAssetDto; +}; + +export type EstimatePerpsContextDto = { + /** + * Type of the PERPS action (open position, close position, stop/loss, take profit, ...) + * @example 'OPEN_POSITION' + */ + type: 'OPEN_POSITION' | 'CLOSE_POSITION' | 'STOP_LOSS' | 'TAKE_PROFIT'; + + /** + * USD fee value + * @example '12.34' + */ + usdFeeValue: string; + + /** + * Asset symbol (e.g., "ETH", "BTC") + * @example 'ETH' + */ + coin: string; +}; + +export type EstimatePointsContextDto = { + /** + * Swap context data, must be present for SWAP activity + */ + swapContext?: EstimateSwapContextDto; + + /** + * PERPS context data, must be present for PERPS activity + */ + perpsContext?: EstimatePerpsContextDto; +}; + +/** + * Type of point earning activity. Swap is for swaps and bridges. PERPS is for perps activities. + * @example 'SWAP' + */ +export type PointsEventEarnType = + | 'SWAP' + | 'PERPS' + | 'REFERRAL' + | 'SIGN_UP_BONUS' + | 'LOYALTY_BONUS' + | 'ONE_TIME_BONUS'; + +export type GetPointsEventsDto = { + seasonId: string; + subscriptionId: string; + cursor: string | null; +}; + +/** + * Paginated list of points events + */ +export type PaginatedPointsEventsDto = { + has_more: boolean; + cursor: string | null; + total_results: number; + results: PointsEventDto[]; +}; + +/** + * Asset information for events + */ +export type EventAssetDto = { + /** + * Amount of the token as a string + * @example '1000000000000000000' + */ + amount: string; + + /** + * CAIP-19 asset type + * @example 'eip155:1/slip44:60' + */ + type: string; + + /** + * Decimals of the token + * @example 18 + */ + decimals: number; + + /** + * Name of the token + * @example 'Ethereum' + */ + name?: string; + + /** + * Symbol of the token + * @example 'ETH' + */ + symbol?: string; + + /** + * Icon URL of the token + * @example 'https://example.com/icon.png' + */ + iconUrl?: string; +}; + +/** + * Swap event payload + */ +export type SwapEventPayload = { + /** + * Source asset details + */ + srcAsset: EventAssetDto; + + /** + * Destination asset details + */ + destAsset?: EventAssetDto; + + /** + * Transaction hash + * @example '0x.......' + */ + txHash?: string; +}; + +/** + * PERPS event payload + */ +export type PerpsEventPayload = { + /** + * Type of the PERPS event + * @example 'OPEN_POSITION' + */ + type: 'OPEN_POSITION' | 'CLOSE_POSITION' | 'TAKE_PROFIT' | 'STOP_LOSS'; + + /** + * Direction of the position + * @example 'LONG' + */ + direction?: 'LONG' | 'SHORT'; + + /** + * Asset information + */ + asset: EventAssetDto; +}; + +/** + * Base points event interface + */ +type BasePointsEventDto = { + /** + * ID of the point earning activity + * @example '01974010-377f-7553-a365-0c33c8130980' + */ + id: string; + + /** + * Timestamp of the point earning activity + * @example '2021-01-01T00:00:00.000Z' + */ + timestamp: Date; + + /** + * Value of the point earning activity + * @example 100 + */ + value: number; + + /** + * Bonus of the point earning activity + * @example {} + */ + bonus: { + bips?: number | null; + bonuses?: string[] | null; + } | null; + + /** + * Account address of the point earning activity + * @example '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6' + */ + accountAddress: string | null; +}; + +/** + * Points event with discriminated union for payloads + */ +export type PointsEventDto = BasePointsEventDto & + ( + | { + type: 'SWAP'; + payload: SwapEventPayload | null; + } + | { + type: 'PERPS'; + payload: PerpsEventPayload | null; + } + | { + type: 'REFERRAL' | 'SIGN_UP_BONUS' | 'LOYALTY_BONUS' | 'ONE_TIME_BONUS'; + payload: null; + } + ); + +export type EstimatePointsDto = { + /** + * Type of point earning activity + * @example 'SWAP' + */ + activityType: PointsEventEarnType; + + /** + * Account address performing the activity in CAIP-10 format + * @example 'eip155:1:0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6' + */ + account: CaipAccountId; + + /** + * Context data specific to the activity type + */ + activityContext: EstimatePointsContextDto; +}; + +/** + * Action for getting points events for a given season + */ +export type RewardsControllerGetPointsEventsAction = { + type: 'RewardsController:getPointsEvents'; + handler: (params: GetPointsEventsDto) => Promise; +}; + +export type EstimatedPointsDto = { + /** + * Earnable for the activity + * @example 100 + */ + pointsEstimate: number; + + /** + * Bonus applied to the points estimate, in basis points. 100 = 1% + * @example 200 + */ + bonusBips: number; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SeasonTierDto = { + id: string; + name: string; + pointsNeeded: number; + // Add other tier properties as needed +}; + +export type SeasonDto = { + id: string; + name: string; + startDate: Date; + endDate: Date; + tiers: SeasonTierDto[]; +}; + +export type SeasonStatusBalanceDto = { + total: number; + refereePortion: number; + updatedAt?: Date; +}; + +export type SeasonStatusDto = { + season: SeasonDto; + balance: SeasonStatusBalanceDto; + currentTierId: string; +}; + +export type SubscriptionReferralDetailsDto = { + referralCode: string; + totalReferees: number; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SubscriptionReferralDetailsState = { + referralCode: string; + totalReferees: number; + lastFetched?: number; +}; + +// Serializable versions for state storage (Date objects converted to timestamps) +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SeasonDtoState = { + id: string; + name: string; + startDate: number; // timestamp + endDate: number; // timestamp + tiers: SeasonTierDto[]; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SeasonStatusBalanceDtoState = { + total: number; + refereePortion: number; + updatedAt?: number; // timestamp +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SeasonTierState = { + currentTier: SeasonTierDto; + nextTier: SeasonTierDto | 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; + hasOptedIn?: boolean; + subscriptionId: string | null; + lastCheckedAuth: number; + lastCheckedAuthError: boolean; + perpsFeeDiscount: number | null; + lastPerpsDiscountRateFetched: number | null; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type RewardsControllerState = { + activeAccount: RewardsAccountState | null; + accounts: { [account: CaipAccountId]: RewardsAccountState }; + subscriptions: { [subscriptionId: string]: SubscriptionDto }; + seasons: { [seasonId: string]: SeasonDtoState }; + subscriptionReferralDetails: { + [subscriptionId: string]: SubscriptionReferralDetailsState; + }; + seasonStatuses: { [compositeId: string]: SeasonStatusState }; +}; + +/** + * Events that can be emitted by the RewardsController + */ +export type RewardsControllerEvents = { + type: 'RewardsController:stateChange'; + payload: [RewardsControllerState, Patch[]]; +}; + +/** + * Patch type for state changes + */ +export type Patch = { + op: 'replace' | 'add' | 'remove'; + path: string[]; + value?: unknown; +}; + +/** + * Request for getting Perps discount + */ +export type GetPerpsDiscountDto = { + /** + * Account address in CAIP-10 format + * @example 'eip155:1:0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6' + */ + account: CaipAccountId; +}; + +/** + * Parsed response for Perps discount data + */ +export type PerpsDiscountData = { + /** + * Whether the account has opted in (0 = not opted in, 1 = opted in) + */ + hasOptedIn: boolean; + /** + * The discount percentage as a number + * @example 5.5 + */ + discount: number; +}; + +/** + * Geo rewards metadata containing location and support info + */ +export type GeoRewardsMetadata = { + /** + * The geographic location string (e.g., 'US', 'CA-ON', 'FR') + */ + geoLocation: string; + /** + * Whether the location is allowed for opt-in + */ + optinAllowedForGeo: boolean; +}; + +/** + * Actions that can be performed by the RewardsController + */ +export type RewardsControllerActions = + | ControllerGetStateAction<'RewardsController', RewardsControllerState> + | RewardsControllerGetHasAccountOptedInAction + | RewardsControllerGetPointsEventsAction + | RewardsControllerEstimatePointsAction + | RewardsControllerGetPerpsDiscountAction + | RewardsControllerIsRewardsFeatureEnabledAction + | RewardsControllerGetSeasonStatusAction + | RewardsControllerGetReferralDetailsAction + | RewardsControllerOptInAction + | RewardsControllerLogoutAction + | RewardsControllerGetGeoRewardsMetadataAction + | RewardsControllerValidateReferralCodeAction; + +/** + * Type to be stored in a subscription token + */ +export type SubscriptionTokenPayload = { + subscriptionId: string; + loginSessionId: string; +}; + +/** + * Response type for token operations + */ +export type TokenResponse = { + success: boolean; + token?: string; + error?: string; +}; + +/** + * Different environment types for the application + */ +export enum EnvironmentType { + Production = 'PROD', + Development = 'DEV', +} + +/** + * Action for getting whether the account (caip-10 format) has opted in + */ +export type RewardsControllerGetHasAccountOptedInAction = { + type: 'RewardsController:getHasAccountOptedIn'; + handler: (account: CaipAccountId) => Promise; +}; + +/** + * Action for estimating points for a given activity + */ +export type RewardsControllerEstimatePointsAction = { + type: 'RewardsController:estimatePoints'; + handler: (request: EstimatePointsDto) => Promise; +}; + +/** + * Action for getting perps fee discount for an account + */ +export type RewardsControllerGetPerpsDiscountAction = { + type: 'RewardsController:getPerpsDiscountForAccount'; + handler: (account: CaipAccountId) => Promise; +}; + +/** + * Action for checking if rewards feature is enabled via feature flag + */ +export type RewardsControllerIsRewardsFeatureEnabledAction = { + type: 'RewardsController:isRewardsFeatureEnabled'; + handler: () => boolean; +}; + +/** + * Action for getting season status with caching + */ +export type RewardsControllerGetSeasonStatusAction = { + type: 'RewardsController:getSeasonStatus'; + handler: ( + seasonId: string, + subscriptionId: string, + ) => Promise; +}; + +/** + * Action for getting referral details with caching + */ +export type RewardsControllerGetReferralDetailsAction = { + type: 'RewardsController:getReferralDetails'; + handler: ( + subscriptionId: string, + ) => Promise; +}; + +/** + * Action for logging out a user + */ +export type RewardsControllerLogoutAction = { + type: 'RewardsController:logout'; + handler: () => Promise; +}; + +/** + * Action for getting geo rewards metadata + */ +export type RewardsControllerGetGeoRewardsMetadataAction = { + type: 'RewardsController:getGeoRewardsMetadata'; + handler: () => Promise; +}; + +/** + * Action for validating referral codes + */ +export type RewardsControllerValidateReferralCodeAction = { + type: 'RewardsController:validateReferralCode'; + handler: (code: string) => Promise; +}; + +/** + * Action for updating state with opt-in response + */ +export type RewardsControllerOptInAction = { + type: 'RewardsController:optIn'; + handler: (account: InternalAccount, referralCode?: string) => Promise; +}; diff --git a/packages/rewards-controller/tsconfig.build.json b/packages/rewards-controller/tsconfig.build.json new file mode 100644 index 00000000000..c8703deae90 --- /dev/null +++ b/packages/rewards-controller/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../bridge-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/rewards-controller/tsconfig.json b/packages/rewards-controller/tsconfig.json new file mode 100644 index 00000000000..7c62471075a --- /dev/null +++ b/packages/rewards-controller/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../accounts-controller" }, + { "path": "../base-controller" }, + { "path": "../bridge-controller" }, + { "path": "../keyring-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/rewards-controller/typedoc.json b/packages/rewards-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/rewards-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index c69244cb42b..fefab7bfbd5 100644 --- a/teams.json +++ b/teams.json @@ -41,6 +41,7 @@ "metamask/profile-sync-controller": "team-assets", "metamask/rate-limit-controller": "team-snaps-platform", "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", + "metamask/rewards-controller": "team-rewards", "metamask/sample-controllers": "team-wallet-framework", "metamask/selected-network-controller": "team-wallet-api-platform,team-wallet-framework,team-assets", "metamask/signature-controller": "team-confirmations", diff --git a/tsconfig.build.json b/tsconfig.build.json index d35e4fe67a5..4cdc39216ce 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -50,6 +50,7 @@ { "path": "./packages/profile-sync-controller/tsconfig.build.json" }, { "path": "./packages/rate-limit-controller/tsconfig.build.json" }, { "path": "./packages/remote-feature-flag-controller/tsconfig.build.json" }, + { "path": "./packages/rewards-controller/tsconfig.build.json" }, { "path": "./packages/sample-controllers/tsconfig.build.json" }, { "path": "./packages/seedless-onboarding-controller/tsconfig.build.json" }, { "path": "./packages/selected-network-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 3aca2850cd1..eb7a900d419 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -50,6 +50,7 @@ { "path": "./packages/profile-sync-controller" }, { "path": "./packages/rate-limit-controller" }, { "path": "./packages/remote-feature-flag-controller" }, + { "path": "./packages/rewards-controller" }, { "path": "./packages/sample-controllers" }, { "path": "./packages/seedless-onboarding-controller" }, { "path": "./packages/selected-network-controller" }, diff --git a/yarn.lock b/yarn.lock index 0c1a26f152d..b954d13ddad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4348,6 +4348,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/rewards-controller@workspace:packages/rewards-controller": + version: 0.0.0-use.local + resolution: "@metamask/rewards-controller@workspace:packages/rewards-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/keyring-controller": "npm:^23.0.0" + "@metamask/keyring-internal-api": "npm:^8.1.0" + "@metamask/utils": "npm:^11.4.2" + "@solana/addresses": "npm:^2.0.0" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + languageName: unknown + linkType: soft + "@metamask/rpc-errors@npm:^7.0.2": version: 7.0.2 resolution: "@metamask/rpc-errors@npm:7.0.2"