diff --git a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx index 6a765d7db11..4269515c41c 100644 --- a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx +++ b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.test.tsx @@ -4,8 +4,6 @@ import { shallow } from 'enzyme'; import { ApprovalTypes } from '../../../core/RPCMethods/RPCMethodMiddleware'; import SwitchChainApproval from './SwitchChainApproval'; import { networkSwitched } from '../../../actions/onboardNetwork'; -// eslint-disable-next-line import/no-namespace -import * as networks from '../../../util/networks'; import { Caip25CaveatType, Caip25EndowmentPermissionName, @@ -102,9 +100,6 @@ const mockApprovalRequestData = { describe('SwitchChainApproval', () => { beforeEach(() => { jest.clearAllMocks(); - jest - .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled') - .mockReturnValue(false); }); it('renders', () => { @@ -166,11 +161,7 @@ describe('SwitchChainApproval', () => { }); }); - it('calls selectNetwork when remove global network selector are enabled', () => { - jest - .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled') - .mockReturnValue(true); - + it('calls selectNetwork when confirm is pressed', () => { mockApprovalRequest({ type: ApprovalTypes.SWITCH_ETHEREUM_CHAIN, requestData: mockApprovalRequestData, @@ -187,25 +178,4 @@ describe('SwitchChainApproval', () => { networkStatus: true, }); }); - - it('does not call selectNetwork when remove global network selector is disabled', () => { - jest - .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled') - .mockReturnValue(false); - - mockApprovalRequest({ - type: ApprovalTypes.SWITCH_ETHEREUM_CHAIN, - requestData: mockApprovalRequestData, - }); - - const wrapper = shallow(); - wrapper.find('SwitchCustomNetwork').simulate('confirm'); - - expect(mockSelectNetwork).not.toHaveBeenCalled(); - expect(networkSwitched).toHaveBeenCalledTimes(1); - expect(networkSwitched).toHaveBeenCalledWith({ - networkUrl: URL_MOCK, - networkStatus: true, - }); - }); }); diff --git a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx index 1dc9ac328dd..1401f4f96d9 100644 --- a/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx +++ b/app/components/Approvals/SwitchChainApproval/SwitchChainApproval.tsx @@ -5,7 +5,6 @@ import ApprovalModal from '../ApprovalModal'; import SwitchCustomNetwork from '../../UI/SwitchCustomNetwork'; import { networkSwitched } from '../../../actions/onboardNetwork'; import { useDispatch, useSelector } from 'react-redux'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks'; import { NetworkType, useNetworksByNamespace, @@ -54,9 +53,7 @@ const SwitchChainApproval = () => { defaultOnConfirm(); // If remove global network selector is enabled should set network filter - if (isRemoveGlobalNetworkSelectorEnabled()) { - selectNetwork(chainId); - } + selectNetwork(chainId); dispatch( networkSwitched({ diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 470de787633..2ea2e0cb4cb 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -52,7 +52,8 @@ import ContactForm from '../../Views/Settings/Contacts/ContactForm'; import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import TrendingView from '../../Views/TrendingView/TrendingView'; -import SitesListView from '../../Views/TrendingView/SitesListView'; +import SwapsAmountView from '../../UI/Swaps'; +import SwapsQuotesView from '../../UI/Swaps/QuotesView'; import CollectiblesDetails from '../../UI/CollectibleModal'; import OptinMetrics from '../../UI/OptinMetrics'; @@ -131,6 +132,8 @@ import { TOKEN, } from '../../Views/AddAsset/AddAsset.constants'; import { strings } from '../../../../locales/i18n'; +import SitesFullView from '../../Views/SitesFullView/SitesFullView'; +import BrowserWrapper from '../../Views/TrendingView/components/BrowserWrapper/BrowserWrapper'; import BridgeView from '../../UI/Bridge/Views/BridgeView'; const Stack = createStackNavigator(); @@ -291,26 +294,6 @@ const TrendingHome = () => ( component={TrendingView} options={{ headerShown: false }} /> - ({ - cardStyle: { - transform: [ - { - translateX: current.progress.interpolate({ - inputRange: [0, 1], - outputRange: [layouts.screen.width, 0], - }), - }, - ], - }, - }), - }} - /> ); @@ -967,6 +950,26 @@ const MainNavigator = () => { }} /> + ({ + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + }, + }), + }} + /> { }), }} /> + ({ + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + }, + }), + }} + /> + + = ({ const optionsDisabled = !toggleOptions; return ( - + = ({ style={[styles.icon, optionsDisabled && styles.disabledIcon]} /> - + ); }; diff --git a/app/components/UI/NetworkModal/index.test.tsx b/app/components/UI/NetworkModal/index.test.tsx index 88adea936d3..ed1e00034be 100644 --- a/app/components/UI/NetworkModal/index.test.tsx +++ b/app/components/UI/NetworkModal/index.test.tsx @@ -12,7 +12,6 @@ import { selectNetworkConfigurations } from '../../../selectors/networkControlle jest.mock('../../../util/networks', () => ({ ...jest.requireActual('../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: jest.fn().mockReturnValue(false), isPrivateConnection: jest.fn().mockReturnValue(false), })); @@ -384,15 +383,12 @@ describe('NetworkDetails', () => { }); }); - describe('when isRemoveGlobalNetworkSelectorEnabled is true', () => { + describe('Network Manager Integration', () => { let mockSelectNetwork: jest.Mock; beforeEach(() => { jest.clearAllMocks(); - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - mockSelectNetwork = jest.fn(); const useNetworkSelectionModule = jest.requireMock( '../../hooks/useNetworkSelection/useNetworkSelection', @@ -404,7 +400,7 @@ describe('NetworkDetails', () => { }); }); - it('should call selectNetwork when adding a new network and feature flag is enabled', async () => { + it('should call selectNetwork when adding a new network', async () => { (useSelector as jest.Mock).mockImplementation((selector) => { if (selector === selectNetworkConfigurations) return {}; return {}; @@ -435,7 +431,7 @@ describe('NetworkDetails', () => { expect(mockSelectNetwork).toHaveBeenCalledWith('0x1'); }); - it('should call selectNetwork when switching networks and feature flag is enabled', async () => { + it('should call selectNetwork when switching networks', async () => { const { getByTestId } = renderWithTheme(); const approveButton = getByTestId( @@ -462,7 +458,7 @@ describe('NetworkDetails', () => { expect(mockSelectNetwork).toHaveBeenCalledWith('0x1'); }); - it('should call selectNetwork when updating an existing network and feature flag is enabled', async () => { + it('should call selectNetwork when updating an existing network', async () => { (useSelector as jest.Mock).mockImplementation((selector) => { if (selector === selectNetworkName) return 'Ethereum Main Network'; if (selector === selectUseSafeChainsListValidation) return true; @@ -500,38 +496,7 @@ describe('NetworkDetails', () => { expect(mockSelectNetwork).toHaveBeenCalledWith('0x1'); }); - it('should not call selectNetwork when feature flag is disabled', async () => { - const networksModule = jest.requireMock('../../../util/networks'); - networksModule.isRemoveGlobalNetworkSelectorEnabled.mockReturnValue( - false, - ); - - const { getByTestId } = renderWithTheme(); - - const approveButton = getByTestId( - NetworkApprovalBottomSheetSelectorsIDs.APPROVE_BUTTON, - ); - fireEvent.press(approveButton); - - const switchButton = getByTestId( - NetworkAddedBottomSheetSelectorsIDs.SWITCH_NETWORK_BUTTON, - ); - - ( - Engine.context.NetworkController.addNetwork as jest.Mock - ).mockResolvedValue({ - rpcEndpoints: [{ networkClientId: 'test-network-id' }], - defaultRpcEndpointIndex: 0, - }); - - await act(async () => { - fireEvent.press(switchButton); - }); - - expect(mockSelectNetwork).not.toHaveBeenCalled(); - }); - - it('should call selectNetwork with correct chainId format when feature flag is enabled', async () => { + it('should call selectNetwork with correct chainId format', async () => { const propsWithDifferentChainId = { ...props, networkConfiguration: { diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index b42643e2c5b..95f90c9207d 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -6,10 +6,7 @@ import Text from '../../Base/Text'; import NetworkDetails from './NetworkDetails'; import NetworkAdded from './NetworkAdded'; import Engine from '../../../core/Engine'; -import { - isPrivateConnection, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../util/networks'; +import { isPrivateConnection } from '../../../util/networks'; import { toggleUseSafeChainsListValidation } from '../../../util/networks/engineNetworkUtils'; import getDecimalChainId from '../../../util/networks/getDecimalChainId'; import URLPARSE from 'url-parse'; @@ -142,9 +139,7 @@ const NetworkModals = (props: NetworkProps) => { }; const onUpdateNetworkFilter = useCallback(() => { - if (isRemoveGlobalNetworkSelectorEnabled()) { - selectNetwork(chainId as `0x${string}`); - } + selectNetwork(chainId as `0x${string}`); }, [chainId, selectNetwork]); const cancelButtonProps: ButtonProps = { diff --git a/app/components/UI/PaymentRequest/index.js b/app/components/UI/PaymentRequest/index.js index d46a8e34f18..f9d6b6de6ad 100644 --- a/app/components/UI/PaymentRequest/index.js +++ b/app/components/UI/PaymentRequest/index.js @@ -46,11 +46,7 @@ import { getTicker } from '../../../util/transactions'; import { toLowerCaseEquals } from '../../../util/general'; import { utils as ethersUtils } from 'ethers'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import { - isTestNet, - getDecimalChainId, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../util/networks'; +import { isTestNet, getDecimalChainId } from '../../../util/networks'; import { isTokenDetectionSupportedForNetwork } from '@metamask/assets-controllers'; import { selectChainId, @@ -934,15 +930,13 @@ class PaymentRequest extends PureComponent { return ( - {isRemoveGlobalNetworkSelectorEnabled() && ( - - - - )} + + + ({ @@ -169,10 +167,6 @@ describe('PaymentRequest', () => { }); it('renders correctly with network picker when feature flag is enabled', () => { - jest - .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled') - .mockReturnValue(true); - const { toJSON } = renderComponent({ chainId: '0x1', networkImageSource: ethLogo, @@ -265,10 +259,6 @@ describe('PaymentRequest', () => { describe('handleNetworkPickerPress', () => { it('should navigate to network selector modal when feature flag is enabled', () => { - jest - .spyOn(networks, 'isRemoveGlobalNetworkSelectorEnabled') - .mockReturnValue(true); - const mockMetrics = { trackEvent: jest.fn(), createEventBuilder: jest.fn(() => ({ diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts new file mode 100644 index 00000000000..c706f351a71 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.styles.ts @@ -0,0 +1,179 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + }, + headerTitle: { + textAlign: 'center', + }, + headerSpacer: { + width: 32, + }, + contentContainer: { + flex: 1, + paddingHorizontal: 16, + justifyContent: 'space-between', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 24, + }, + section: { + marginTop: 24, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + amountSection: { + marginTop: 24, + marginBottom: 16, + }, + sliderSection: { + marginBottom: 24, + }, + infoSection: { + gap: 16, + marginBottom: 16, + }, + labelWithIcon: { + flexDirection: 'row', + alignItems: 'center', + }, + infoIcon: { + marginLeft: 4, + padding: 4, + }, + keypadFooter: { + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 16, + backgroundColor: colors.background.default, + }, + percentageButtonsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 8, + marginBottom: 16, + }, + percentageButton: { + flex: 1, + }, + keypad: {}, + infoCard: { + backgroundColor: colors.background.alternative, + borderRadius: 8, + padding: 16, + gap: 12, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + labelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + maxButton: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 4, + backgroundColor: colors.primary.muted, + }, + amountDisplay: { + alignItems: 'center', + paddingVertical: 16, + }, + slider: { + width: '100%', + height: 40, + }, + sliderLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 8, + }, + impactCard: { + backgroundColor: colors.info.muted, + borderRadius: 8, + padding: 16, + gap: 12, + }, + impactCardWarning: { + backgroundColor: colors.warning.muted, + }, + impactCardDanger: { + backgroundColor: colors.error.muted, + }, + impactHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + impactTitle: { + flex: 1, + }, + impactRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + changeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + strikethrough: { + textDecorationLine: 'line-through', + }, + warningCard: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + backgroundColor: colors.warning.muted, + borderRadius: 8, + padding: 16, + }, + warningCardDanger: { + backgroundColor: colors.error.muted, + }, + warningText: { + flex: 1, + }, + warningTextContainer: { + flex: 1, + gap: 4, + }, + footer: { + padding: 16, + borderTopWidth: 1, + borderTopColor: colors.border.muted, + backgroundColor: colors.background.default, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx new file mode 100644 index 00000000000..c544f4cc494 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx @@ -0,0 +1,286 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import PerpsAdjustMarginView from './PerpsAdjustMarginView'; +import type { Position } from '../../controllers/types'; + +// Mock dependencies +jest.mock('react-native-reanimated', () => + jest.requireActual('react-native-reanimated/mock'), +); + +jest.mock('react-native-gesture-handler', () => ({ + GestureHandlerRootView: 'View', + GestureDetector: 'View', + Gesture: { + Pan: jest.fn().mockReturnValue({ + onUpdate: jest.fn().mockReturnThis(), + onEnd: jest.fn().mockReturnThis(), + }), + }, +})); + +jest.mock('react-native-safe-area-context', () => { + const { View } = jest.requireActual('react-native'); + const inset = { top: 0, right: 0, bottom: 0, left: 0 }; + return { + SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), + SafeAreaView: jest + .fn() + .mockImplementation(({ children, ...props }) => ( + {children} + )), + useSafeAreaInsets: jest.fn().mockImplementation(() => inset), + }; +}); + +const mockHandleAddMargin = jest.fn(); +const mockHandleRemoveMargin = jest.fn(); +const mockGoBack = jest.fn(); +const mockUsePerpsMarginAdjustment = jest.fn(); + +jest.mock('../../hooks/usePerpsMarginAdjustment', () => ({ + usePerpsMarginAdjustment: (opts: unknown) => + mockUsePerpsMarginAdjustment(opts), +})); + +const mockUsePerpsLiveAccount = jest.fn(); +const mockUsePerpsLivePrices = jest.fn(); + +jest.mock('../../hooks/stream', () => ({ + usePerpsLiveAccount: () => mockUsePerpsLiveAccount(), + usePerpsLivePrices: () => mockUsePerpsLivePrices(), +})); + +const mockUsePerpsMarkets = jest.fn(); + +jest.mock('../../hooks/usePerpsMarkets', () => ({ + usePerpsMarkets: () => mockUsePerpsMarkets(), +})); + +jest.mock('../../hooks/usePerpsMeasurement', () => ({ + usePerpsMeasurement: jest.fn(), +})); + +jest.mock('../../utils/marginUtils', () => ({ + calculateMaxRemovableMargin: jest.fn(() => 200), + calculateNewLiquidationPrice: jest.fn(() => 1800), +})); + +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatPerpsFiat: jest.fn((value) => { + const num = typeof value === 'string' ? parseFloat(value) : value; + return `$${num.toFixed(2)}`; + }), + PRICE_RANGES_UNIVERSAL: {}, + PRICE_RANGES_MINIMAL_VIEW: {}, +})); + +const mockNavigation = { + navigate: jest.fn(), + goBack: mockGoBack, + setOptions: jest.fn(), + addListener: jest.fn(), + canGoBack: jest.fn(() => true), +}; + +let mockRouteParams: Record = {}; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => mockNavigation, + useRoute: () => ({ + params: mockRouteParams, + key: 'test-route', + name: 'PerpsAdjustMargin', + }), +})); + +jest.mock('./PerpsAdjustMarginView.styles', () => ({ + __esModule: true, + default: () => ({ + container: {}, + scrollView: {}, + scrollContent: {}, + amountSection: {}, + sliderSection: {}, + infoSection: {}, + infoRow: {}, + changeContainer: {}, + footer: {}, + errorContainer: {}, + }), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + icon: { alternative: '#888' }, + }, + }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key) => key), +})); + +// Mock PerpsOrderHeader component to render title prop +jest.mock('../../components/PerpsOrderHeader', () => { + const ReactModule = jest.requireActual('react'); + const RNModule = jest.requireActual('react-native'); + return function MockPerpsOrderHeader({ title }: { title: string }) { + return ReactModule.createElement(RNModule.Text, null, title); + }; +}); +jest.mock('../../components/PerpsAmountDisplay', () => 'PerpsAmountDisplay'); +jest.mock('../../components/PerpsSlider', () => 'PerpsSlider'); + +describe('PerpsAdjustMarginView', () => { + const mockPosition: Position = { + coin: 'ETH', + size: '2.5', + marginUsed: '500', + entryPrice: '2000', + liquidationPrice: '1900', + unrealizedPnl: '100', + returnOnEquity: '0.20', + leverage: { value: 10, type: 'isolated' }, + cumulativeFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + positionValue: '5000', + maxLeverage: 50, + takeProfitCount: 0, + stopLossCount: 0, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockHandleAddMargin.mockResolvedValue(undefined); + mockHandleRemoveMargin.mockResolvedValue(undefined); + + // Set default mock return values + mockUsePerpsMarginAdjustment.mockReturnValue({ + handleAddMargin: mockHandleAddMargin, + handleRemoveMargin: mockHandleRemoveMargin, + isAdjusting: false, + }); + + mockUsePerpsLiveAccount.mockReturnValue({ + account: { availableBalance: '1000' }, + }); + + mockUsePerpsLivePrices.mockReturnValue({ + ETH: { price: '2000', markPrice: '2000', percentChange24h: '2.5' }, + }); + + mockUsePerpsMarkets.mockReturnValue({ + markets: [{ coin: 'ETH', maxLeverage: 50 }], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('add mode', () => { + beforeEach(() => { + mockRouteParams = { + position: mockPosition, + mode: 'add', + }; + }); + + it('renders add margin title', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.add_title'), + ).toBeOnTheScreen(); + }); + + it('displays perps balance available to add', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.perps_balance'), + ).toBeOnTheScreen(); + expect(screen.getByText('$1000.00')).toBeOnTheScreen(); + }); + + it('displays liquidation price label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.liquidation_price'), + ).toBeOnTheScreen(); + }); + + it('displays liquidation distance label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.liquidation_distance'), + ).toBeOnTheScreen(); + }); + + it('displays add margin button label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.add_margin'), + ).toBeOnTheScreen(); + }); + }); + + describe('remove mode', () => { + beforeEach(() => { + mockRouteParams = { + position: mockPosition, + mode: 'remove', + }; + }); + + it('renders remove margin title', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.remove_title'), + ).toBeOnTheScreen(); + }); + + it('displays current position margin', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.margin_in_position'), + ).toBeOnTheScreen(); + expect(screen.getByText('$500.00')).toBeOnTheScreen(); + }); + + it('displays reduce margin button label', () => { + render(); + + expect( + screen.getByText('perps.adjust_margin.reduce_margin'), + ).toBeOnTheScreen(); + }); + }); + + describe('error handling', () => { + it('renders view when route params are provided', () => { + mockRouteParams = { + position: mockPosition, + mode: 'add', + }; + + render(); + + // Verify view rendered by checking for title + expect( + screen.getByText('perps.adjust_margin.add_title'), + ).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx new file mode 100644 index 00000000000..609766be765 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx @@ -0,0 +1,547 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useStyles } from '../../../../../component-library/hooks'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import Button, { + ButtonVariants, + ButtonWidthTypes, + ButtonSize, +} from '../../../../../component-library/components/Buttons/Button'; +import { strings } from '../../../../../../locales/i18n'; +import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream'; +import type { Position } from '../../controllers/types'; +import styleSheet from './PerpsAdjustMarginView.styles'; +import { useTheme } from '../../../../../util/theme'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../../component-library/components/Icons/Icon'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../../../component-library/components/Buttons/ButtonIcon'; +import { usePerpsMarginAdjustment } from '../../hooks/usePerpsMarginAdjustment'; +import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; +import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; +import { TraceName } from '../../../../../util/trace'; +import Logger from '../../../../../util/Logger'; +import { ensureError } from '../../utils/perpsErrorHandler'; +import { + calculateMaxRemovableMargin, + calculateNewLiquidationPrice, +} from '../../utils/marginUtils'; +import PerpsAmountDisplay from '../../components/PerpsAmountDisplay'; +import PerpsSlider from '../../components/PerpsSlider'; +import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip'; +import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; +import Keypad from '../../../../Base/Keypad'; +import { + formatPerpsFiat, + PRICE_RANGES_UNIVERSAL, + PRICE_RANGES_MINIMAL_VIEW, +} from '../../utils/formatUtils'; +import { MARGIN_ADJUSTMENT_CONFIG } from '../../constants/perpsConfig'; + +interface AdjustMarginRouteParams { + position: Position; + mode: 'add' | 'remove'; +} + +const PerpsAdjustMarginView: React.FC = () => { + const navigation = useNavigation(); + const route = + useRoute>(); + const { position, mode } = route.params || {}; + const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); + const { account } = usePerpsLiveAccount(); + + const [marginAmountString, setMarginAmountString] = useState('0'); + const [isInputFocused, setIsInputFocused] = useState(false); + const [selectedTooltip, setSelectedTooltip] = + useState(null); + + // Derived numeric value from string + const marginAmount = useMemo( + () => parseFloat(marginAmountString) || 0, + [marginAmountString], + ); + + const isAddMode = mode === 'add'; + + // Use margin adjustment hook for handling margin operations + const { handleAddMargin, handleRemoveMargin, isAdjusting } = + usePerpsMarginAdjustment({ + onSuccess: () => navigation.goBack(), + }); + + // Get market info for max leverage (needed for remove mode) + // Each token has different max leverage limits - must look up from markets + const { markets } = usePerpsMarkets(); + const marketInfo = useMemo( + () => + position?.coin ? markets.find((m) => m.symbol === position.coin) : null, + [position?.coin, markets], + ); + // maxLeverage in PerpsMarketData is a formatted string (e.g., '40x'), parse to number + const maxLeverage = marketInfo?.maxLeverage + ? parseInt(marketInfo.maxLeverage, 10) + : MARGIN_ADJUSTMENT_CONFIG.FALLBACK_MAX_LEVERAGE; + + // Add performance measurement for this view + usePerpsMeasurement({ + traceName: TraceName.PerpsAdjustMarginView, + conditions: [!isAdjusting, !!position], + debugContext: { mode }, + }); + + // Get live prices for liquidation distance calculation + const livePrices = usePerpsLivePrices({ + symbols: position?.coin ? [position.coin] : [], + throttleMs: 1000, + }); + const currentPrice = useMemo( + () => parseFloat(livePrices?.[position?.coin]?.price || '0'), + [livePrices, position?.coin], + ); + + // Current position data + const currentMargin = useMemo( + () => parseFloat(position?.marginUsed || '0'), + [position], + ); + + const currentLiquidationPrice = useMemo( + () => parseFloat(position?.liquidationPrice || '0'), + [position], + ); + + const positionSize = useMemo( + () => Math.abs(parseFloat(position?.size || '0')), + [position], + ); + + const entryPrice = useMemo( + () => parseFloat(position?.entryPrice || '0'), + [position], + ); + + const isLong = useMemo( + () => parseFloat(position?.size || '0') > 0, + [position], + ); + + // Available balance for add mode + const availableBalance = useMemo( + () => parseFloat(account?.availableBalance || '0'), + [account], + ); + + // Calculate maximum amount based on mode + const maxAmount = useMemo(() => { + if (isAddMode) { + return Math.max(0, availableBalance); + } + return calculateMaxRemovableMargin({ + currentMargin, + positionSize, + entryPrice, + currentPrice, + maxLeverage, + }); + }, [ + isAddMode, + availableBalance, + currentMargin, + positionSize, + entryPrice, + currentPrice, + maxLeverage, + ]); + + // Calculate new values after adjustment + const newMargin = useMemo(() => { + if (isAddMode) { + return currentMargin + marginAmount; + } + return Math.max(0, currentMargin - marginAmount); + }, [isAddMode, currentMargin, marginAmount]); + + // Calculate new liquidation price + const newLiquidationPrice = useMemo(() => { + if (newMargin === 0 || positionSize === 0) return currentLiquidationPrice; + + // For add mode, use simplified calculation + if (isAddMode) { + const marginPerUnit = newMargin / positionSize; + if (isLong) { + return Math.max(0, entryPrice - marginPerUnit); + } + return entryPrice + marginPerUnit; + } + + // For remove mode, use utility function + return calculateNewLiquidationPrice({ + newMargin, + positionSize, + entryPrice, + isLong, + currentLiquidationPrice, + }); + }, [ + isAddMode, + newMargin, + positionSize, + entryPrice, + isLong, + currentLiquidationPrice, + ]); + + // Calculate liquidation distance percentage + const calculateLiquidationDistance = useCallback( + (liquidationPrice: number) => { + if (currentPrice === 0 || !currentPrice || liquidationPrice === 0) { + return 0; + } + return (Math.abs(currentPrice - liquidationPrice) / currentPrice) * 100; + }, + [currentPrice], + ); + + const currentLiquidationDistance = useMemo( + () => calculateLiquidationDistance(currentLiquidationPrice), + [calculateLiquidationDistance, currentLiquidationPrice], + ); + + const newLiquidationDistance = useMemo( + () => calculateLiquidationDistance(newLiquidationPrice), + [calculateLiquidationDistance, newLiquidationPrice], + ); + + const handleSliderChange = useCallback((value: number) => { + setMarginAmountString(Math.floor(value).toString()); + }, []); + + const handleMaxPress = useCallback(() => { + setMarginAmountString(Math.floor(maxAmount).toString()); + }, [maxAmount]); + + // Keypad handlers + const handleAmountPress = useCallback(() => { + setIsInputFocused(true); + }, []); + + const handleKeypadChange = useCallback( + ({ value }: { value: string }) => { + const numValue = parseFloat(value) || 0; + // Clamp to maxAmount for remove mode to prevent invalid submissions + if (!isAddMode && numValue > maxAmount) { + setMarginAmountString(Math.floor(maxAmount).toString()); + } else { + setMarginAmountString(value || '0'); + } + }, + [isAddMode, maxAmount], + ); + + const handleDonePress = useCallback(() => { + setIsInputFocused(false); + }, []); + + const handlePercentagePress = useCallback( + (percentage: number) => { + const amount = Math.floor(maxAmount * percentage); + setMarginAmountString(amount.toString()); + }, + [maxAmount], + ); + + // Tooltip handlers + const handleTooltipPress = useCallback( + (contentKey: PerpsTooltipContentKey) => { + setSelectedTooltip(contentKey); + }, + [], + ); + + const handleTooltipClose = useCallback(() => { + setSelectedTooltip(null); + }, []); + + const handleConfirm = useCallback(async () => { + if (marginAmount <= 0 || !position) return; + + // Prevent submission if amount exceeds max removable (extra safety for remove mode) + if (!isAddMode && marginAmount > maxAmount) { + return; + } + + try { + if (isAddMode) { + await handleAddMargin(position.coin, marginAmount); + } else { + await handleRemoveMargin(position.coin, marginAmount); + } + } catch (error) { + Logger.error( + ensureError(error), + `Failed to ${isAddMode ? 'add' : 'remove'} margin for ${position.coin}`, + ); + // Note: Toast notification is handled by usePerpsMarginAdjustment hook + } + }, [ + marginAmount, + position, + isAddMode, + maxAmount, + handleAddMargin, + handleRemoveMargin, + ]); + + if (!position || !mode) { + return ( + + + + {strings('perps.errors.position_not_found')} + + + + ); + } + + const title = isAddMode + ? strings('perps.adjust_margin.add_title') + : strings('perps.adjust_margin.remove_title'); + + const buttonLabel = isAddMode + ? strings('perps.adjust_margin.add_margin') + : strings('perps.adjust_margin.reduce_margin'); + + return ( + + + navigation.goBack()} + iconColor={IconColor.Default} + size={ButtonIconSizes.Md} + /> + + {title} + + + + + + {/* Amount Display */} + + + + + {/* Slider - Hide when keypad is active */} + {!isInputFocused && ( + + + + )} + + {/* Info Section - Always visible */} + + {/* First row: Perps balance or Margin in position */} + + + {isAddMode + ? strings('perps.adjust_margin.perps_balance') + : strings('perps.adjust_margin.margin_in_position')} + + + {formatPerpsFiat(isAddMode ? availableBalance : currentMargin, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + })} + + + + {/* Second row: Liquidation price with transition */} + + + + {strings('perps.adjust_margin.liquidation_price')} + + handleTooltipPress('liquidation_price')} + style={styles.infoIcon} + > + + + + {marginAmount > 0 ? ( + + + {formatPerpsFiat(currentLiquidationPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + })} + + + + {formatPerpsFiat(newLiquidationPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + })} + + + ) : ( + + {formatPerpsFiat(currentLiquidationPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + })} + + )} + + + {/* Third row: Liquidation distance with transition */} + + + + {strings('perps.adjust_margin.liquidation_distance')} + + handleTooltipPress('liquidation_distance')} + style={styles.infoIcon} + > + + + + {marginAmount > 0 ? ( + + + {currentLiquidationDistance.toFixed(0)}% + + + + {newLiquidationDistance.toFixed(0)}% + + + ) : ( + + {currentLiquidationDistance.toFixed(0)}% + + )} + + + + + {/* Footer - Shows either Add Margin button or Keypad */} + {!isInputFocused ? ( + + + + + ); +}; + +export default BasicFunctionalityEmptyState; diff --git a/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx b/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx new file mode 100644 index 00000000000..ab877cef7ed --- /dev/null +++ b/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx @@ -0,0 +1,37 @@ +import React, { useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Browser from '../../../Browser'; +import Routes from '../../../../../constants/navigation/Routes'; + +// Wrapper component to intercept navigation +const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => { + const navigation = useNavigation(); + const tw = useTailwind(); + + // Create a custom navigation object that intercepts navigate calls + const customNavigation = useMemo(() => { + const originalNavigate = navigation.navigate.bind(navigation); + + return { + ...navigation, + navigate: (routeName: string, params?: object) => { + // If trying to navigate to TRENDING_VIEW, go back in stack instead + if (routeName === Routes.TRENDING_VIEW) { + navigation.goBack(); + } else { + originalNavigate(routeName, params); + } + }, + }; + }, [navigation]); + + return ( + + + + ); +}; + +export default BrowserWrapper; diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx index 989a94b1b1b..45887c5c11d 100644 --- a/app/components/Views/TrendingView/config/sections.config.tsx +++ b/app/components/Views/TrendingView/config/sections.config.tsx @@ -19,10 +19,10 @@ import { usePerpsMarkets } from '../../../UI/Perps/hooks'; import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../../../UI/Perps/providers/PerpsStreamManager'; import { Box, IconName } from '@metamask/design-system-react-native'; -import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem'; -import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper'; -import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton'; -import { useSitesData } from '../SectionSites/hooks/useSitesData'; +import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import SiteRowItemWrapper from '../../../UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper'; +import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; +import { useSitesData } from '../../../UI/Sites/hooks/useSiteData/useSitesData'; import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites'; @@ -180,7 +180,7 @@ export const SECTIONS_CONFIG: Record = { title: strings('trending.sites'), icon: IconName.Global, viewAllAction: (navigation) => { - navigation.navigate(Routes.SITES_LIST_VIEW); + navigation.navigate(Routes.SITES_FULL_VIEW); }, RowItem: ({ item, navigation }) => ( diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx index a0b1bb6101f..3b88652f62c 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx @@ -92,8 +92,6 @@ jest.mock( }), ); jest.mock('../../../selectors/networkController', () => ({ - selectChainId: jest.fn(), - selectIsPopularNetwork: jest.fn(), selectEvmNetworkConfigurationsByChainId: jest.fn(), selectNetworkConfigurations: jest.fn(), selectProviderType: jest.fn(), @@ -120,7 +118,6 @@ jest.mock('../../../util/transactions', () => ({ jest.mock('../../../util/networks', () => ({ __esModule: true, - isRemoveGlobalNetworkSelectorEnabled: jest.fn(() => false), findBlockExplorerForRpc: jest.fn(() => 'https://explorer.example'), getBlockExplorerAddressUrl: jest.fn(), })); @@ -302,8 +299,6 @@ const { selectTokens } = jest.requireMock( '../../../selectors/tokensController', ); const { - selectChainId, - selectIsPopularNetwork, selectEvmNetworkConfigurationsByChainId, selectNetworkConfigurations, selectProviderType, @@ -319,7 +314,6 @@ const { updateIncomingTransactions } = jest.requireMock( '../../../util/transaction-controller', ); const networksMock = jest.requireMock('../../../util/networks'); -const { isRemoveGlobalNetworkSelectorEnabled } = networksMock; describe('UnifiedTransactionsView', () => { const mockUseSelector = useSelector as unknown as jest.Mock; @@ -341,7 +335,6 @@ describe('UnifiedTransactionsView', () => { url: 'https://explorer.example/address/0xabc', title: 'explorer.example', })); - isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); mockUseSelector.mockImplementation((selector: unknown) => { if (selector === selectSortedEVMTransactionsForSelectedAccountGroup) @@ -359,8 +352,6 @@ describe('UnifiedTransactionsView', () => { }, ]; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEvmNetworkConfigurationsByChainId) return { '0x1': { @@ -418,8 +409,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEVMEnabledNetworks) return ['0x1']; if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet']; if (selector === selectCurrentCurrency) return 'USD'; @@ -462,8 +451,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEVMEnabledNetworks) return ['0x1']; if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet']; if (selector === selectCurrentCurrency) return 'USD'; @@ -491,8 +478,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEvmNetworkConfigurationsByChainId) return { '0x1': { @@ -524,8 +509,7 @@ describe('UnifiedTransactionsView', () => { }); describe('block explorer url', () => { - it('uses selected chain block explorer when global selector is enabled with a single chain', () => { - isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); + it('uses selected chain block explorer when a single chain is enabled', () => { mockUseSelector.mockImplementation((selector: unknown) => { if (selector === selectSortedEVMTransactionsForSelectedAccountGroup) return []; @@ -536,8 +520,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEvmNetworkConfigurationsByChainId) return { '0x5': { @@ -573,15 +555,9 @@ describe('UnifiedTransactionsView', () => { 'https://explorer1.example', ); expect(networksMock.getBlockExplorerAddressUrl).toHaveBeenCalledTimes(1); - isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); }); - it('omits block explorer when multiple EVM chains are selected with global selector enabled', () => { - isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - networksMock.getBlockExplorerAddressUrl.mockImplementationOnce(() => ({ - url: undefined, - title: 'explorer.example', - })); + it('omits block explorer when multiple EVM chains are selected', () => { mockUseSelector.mockImplementation((selector: unknown) => { if (selector === selectSortedEVMTransactionsForSelectedAccountGroup) return []; @@ -592,8 +568,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEvmNetworkConfigurationsByChainId) return { '0x1': { @@ -623,13 +597,12 @@ describe('UnifiedTransactionsView', () => { rpcBlockExplorer?: string; onViewBlockExplorer?: () => void; }; + + // When multiple chains are selected, block explorer should be omitted expect(footerProps.rpcBlockExplorer).toBeUndefined(); - footerProps.onViewBlockExplorer?.(); - // When configBlockExplorerUrl is undefined (multiple chains case), - // the component uses blockExplorerUrl directly without calling getBlockExplorerAddressUrl + // Block explorer address URL should not be called since no single chain is selected expect(networksMock.getBlockExplorerAddressUrl).not.toHaveBeenCalled(); - isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); }); }); @@ -654,8 +627,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectNetworkConfigurations) return {}; if (selector === selectProviderType) return 'rpc'; if (selector === selectRpcUrl) return 'https://rpc.example'; @@ -702,9 +673,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: 'bc1abcd', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) - return 'bip122:000000000019d6689c085ae165831e93'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectNetworkConfigurations) return {}; if (selector === selectProviderType) return 'rpc'; if (selector === selectRpcUrl) return 'https://rpc.example'; @@ -746,8 +714,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectProviderConfig) return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return []; @@ -782,8 +748,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectProviderConfig) return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return []; @@ -829,8 +793,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEVMEnabledNetworks) return ['0x1']; if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet']; @@ -866,8 +828,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEVMEnabledNetworks) return ['0x1']; if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet']; if (selector === selectCurrentCurrency) return 'USD'; @@ -901,8 +861,6 @@ describe('UnifiedTransactionsView', () => { if (selector === selectSelectedInternalAccount) return { address: '0xabc', metadata: { importTime: 0 } }; if (selector === selectTokens) return []; - if (selector === selectChainId) return '0x1'; - if (selector === selectIsPopularNetwork) return false; if (selector === selectEVMEnabledNetworks) return ['0x1']; if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet']; if (selector === selectCurrentCurrency) return 'USD'; diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx index 7dfbd2651c7..d3b43135b20 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx @@ -1,8 +1,7 @@ import { Transaction as NonEvmTransaction } from '@metamask/keyring-api'; import { SupportedCaipChainId } from '@metamask/multichain-network-controller'; import { SmartTransaction } from '@metamask/smart-transactions-controller'; -import { CHAIN_IDS, TransactionMeta } from '@metamask/transaction-controller'; -import { Hex } from '@metamask/utils'; +import { TransactionMeta } from '@metamask/transaction-controller'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { FlashList, FlashListRef } from '@shopify/flash-list'; import React, { useCallback, useMemo, useRef, useState } from 'react'; @@ -18,8 +17,6 @@ import { selectCurrentCurrency } from '../../../selectors/currencyRateController import { selectNonEvmTransactionsForSelectedAccountGroup } from '../../../selectors/multichain/multichain'; import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; import { - selectChainId, - selectIsPopularNetwork, selectEvmNetworkConfigurationsByChainId, selectProviderType, } from '../../../selectors/networkController'; @@ -36,11 +33,7 @@ import { sortTransactions, } from '../../../util/activity'; import { areAddressesEqual, isHardwareAccount } from '../../../util/address'; -import { - getBlockExplorerAddressUrl, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../util/networks'; -import { PopularList } from '../../../util/networks/customNetworks'; +import { getBlockExplorerAddressUrl } from '../../../util/networks'; import { useTheme } from '../../../util/theme'; import { updateIncomingTransactions } from '../../../util/transaction-controller'; import { addAccountTimeFlagFilter } from '../../../util/transactions'; @@ -139,7 +132,7 @@ const UnifiedTransactionsView = ({ ); return solanaAccount?.address ?? ''; }, [selectedAccountGroupInternalAccounts]); - const isPopularNetwork = useSelector(selectIsPopularNetwork); + const enabledEVMNetworks = useSelector(selectEVMEnabledNetworks); const enabledEVMChainIds = useMemo( () => enabledEVMNetworks ?? [], @@ -155,10 +148,6 @@ const UnifiedTransactionsView = ({ selectEvmNetworkConfigurationsByChainId, ); - // TODO: This should be deleted once we deprecate the global network selector, - // we need to use the selected account group chain ids - const currentEvmChainId = useSelector(selectChainId); - const bridgeHistory = useSelector(selectBridgeHistoryForAccount); const { data, nonEvmTransactionsForSelectedChain } = useMemo<{ @@ -232,29 +221,10 @@ const UnifiedTransactionsView = ({ }) as TransactionMetaWithImport[]; // Network filtering for confirmed EVM txs - let allConfirmedFiltered: TransactionMetaWithImport[] = []; - if (isRemoveGlobalNetworkSelectorEnabled()) { - allConfirmedFiltered = allConfirmed.filter((tx) => + const allConfirmedFiltered: TransactionMetaWithImport[] = + allConfirmed.filter((tx) => isTransactionOnChains(tx, enabledEVMChainIds, transactionMetaPool), ); - } else if (isPopularNetwork) { - const popularChainIds: Hex[] = [ - CHAIN_IDS.MAINNET as Hex, - CHAIN_IDS.LINEA_MAINNET as Hex, - ...PopularList.map((n) => n.chainId as Hex), - ]; - allConfirmedFiltered = allConfirmed.filter((tx) => - isTransactionOnChains(tx, popularChainIds, transactionMetaPool), - ); - } else { - allConfirmedFiltered = allConfirmed.filter((tx) => - isTransactionOnChains( - tx, - currentEvmChainId ? [currentEvmChainId as Hex] : [], - transactionMetaPool, - ), - ); - } // Deduplicate submitted by (address + chain + nonce) and drop if already confirmed const seenSubmittedNonces = new Set(); const submittedTxsFiltered = submittedTxs.filter( @@ -355,10 +325,8 @@ const UnifiedTransactionsView = ({ selectedAccountGroupInternalAccountsAddresses, enabledEVMChainIds, enabledNonEVMChainIds, - isPopularNetwork, selectedInternalAccount, tokens, - currentEvmChainId, bridgeHistory, ]); @@ -370,18 +338,11 @@ const UnifiedTransactionsView = ({ const configBlockExplorerUrl = useMemo(() => { // When using the per-dapp/multiselect network selector, only return a block // explorer if exactly one EVM chain is selected. Otherwise, undefined. - if (isRemoveGlobalNetworkSelectorEnabled()) { - if (!enabledEVMChainIds?.length || enabledEVMChainIds.length !== 1) { - return undefined; - } - const selectedChainId = enabledEVMChainIds[0]; - const config = evmNetworkConfigurationsByChainId?.[selectedChainId]; - if (!config) return undefined; - const index = config.defaultBlockExplorerUrlIndex ?? 0; - return config.blockExplorerUrls?.[index]; + if (!enabledEVMChainIds?.length || enabledEVMChainIds.length !== 1) { + return undefined; } - - const config = evmNetworkConfigurationsByChainId?.[enabledEVMChainIds[0]]; + const selectedChainId = enabledEVMChainIds[0]; + const config = evmNetworkConfigurationsByChainId?.[selectedChainId]; if (!config) return undefined; const index = config.defaultBlockExplorerUrlIndex ?? 0; return config.blockExplorerUrls?.[index]; diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index d06969bb48e..833c34fa049 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -572,7 +572,6 @@ const Wallet = ({ } return false; } - return enabledNetworks.some((network) => isTestNet(network)); }, [enabledNetworks, isMultichainAccountsState2Enabled, allEnabledNetworks]); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts index 458de4fbe27..b711f41152d 100644 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.styles.ts @@ -6,6 +6,10 @@ const styleSheet = () => paddingBottom: 4, paddingHorizontal: 8, }, + alertRowOverride: { + marginLeft: 0, + paddingLeft: 0, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx index 4c3c4a33262..7364c13e41c 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx @@ -6,6 +6,7 @@ import { Severity } from '../../../../types/alerts'; import { IconName } from '../../../../../../../component-library/components/Icons/Icon'; import { useConfirmationAlertMetrics } from '../../../../hooks/metrics/useConfirmationAlertMetrics'; import { InfoRowVariant } from '../info-row'; +import styleSheet from './alert-row.styles'; jest.mock('../../../../context/alert-system-context', () => ({ useAlerts: jest.fn(), @@ -135,4 +136,19 @@ describe('AlertRow', () => { expect(getByText(CHILDREN_MOCK)).toBeDefined(); expect(queryByTestId('inline-alert')).toBeNull(); }); + + it('renders with the given style if provided', () => { + const props = { ...baseProps, style: { backgroundColor: 'red' } }; + const { getByTestId } = render(); + const infoRow = getByTestId('info-row'); + expect(infoRow.props.style.backgroundColor).toBe('red'); + }); + + it('renders with styles.infoRowOverride if no style is provided', () => { + const styles = styleSheet(); + const { getByTestId } = render(); + const infoRow = getByTestId('info-row'); + + expect(infoRow.props.style).toMatchObject(styles.infoRowOverride); + }); }); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx index df3f82862ab..78684ad61b6 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx @@ -44,7 +44,7 @@ const AlertRow = ({ const { fieldAlerts } = useAlerts(); const alertSelected = fieldAlerts.find((a) => a.field === alertField); const { styles } = useStyles(styleSheet, {}); - const { rowVariant } = props; + const { rowVariant, style } = props; if (!alertSelected && isShownWithAlertsOnly) { return null; @@ -66,7 +66,7 @@ const AlertRow = ({ return ( ); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts index adae95330f7..2567101efa7 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts @@ -9,4 +9,5 @@ export enum RowAlertKey { PayWithFee = 'payWithFee', PendingTransaction = 'pendingTransaction', RequestFrom = 'requestFrom', + IncomingTokens = 'incomingTokens', } diff --git a/app/components/Views/confirmations/components/send/amount/amount.styles.ts b/app/components/Views/confirmations/components/send/amount/amount.styles.ts index c2f36fc2956..85bf4053f24 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.styles.ts +++ b/app/components/Views/confirmations/components/send/amount/amount.styles.ts @@ -49,7 +49,7 @@ export const styleSheet = (params: { }, currencyTag: { alignSelf: 'center', - backgroundColor: theme.colors.background.alternative, + backgroundColor: theme.colors.background.section, color: theme.colors.text.alternative, flexDirection: FlexDirection.Row, justifyContent: JustifyContent.center, diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts index b16baefc8d2..e975afe2e2d 100644 --- a/app/components/Views/confirmations/constants/alerts.ts +++ b/app/components/Views/confirmations/constants/alerts.ts @@ -13,4 +13,6 @@ export enum AlertKeys { PerpsDepositMinimum = 'perps_deposit_minimum', PerpsHardwareAccount = 'perps_hardware_account', SignedOrSubmitted = 'signed_or_submitted', + TokenTrustSignalMalicious = 'token_trust_signal_malicious', + TokenTrustSignalWarning = 'token_trust_signal_warning', } diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts index 36e111f040a..09f95eed771 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts @@ -17,6 +17,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert'; import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert'; import { useBurnAddressAlert } from './useBurnAddressAlert'; +import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; jest.mock('./useBlockaidAlerts'); jest.mock('./useDomainMismatchAlerts'); @@ -29,6 +30,7 @@ jest.mock('./useInsufficientPayTokenBalanceAlert'); jest.mock('./useNoPayTokenQuotesAlert'); jest.mock('./useInsufficientPredictBalanceAlert'); jest.mock('./useBurnAddressAlert'); +jest.mock('./useTokenTrustSignalAlerts'); describe('useConfirmationAlerts', () => { const ALERT_MESSAGE_MOCK = 'This is a test alert message.'; @@ -133,6 +135,15 @@ describe('useConfirmationAlerts', () => { }, ]; + const mockTokenTrustSignalAlerts: Alert[] = [ + { + key: 'TokenTrustSignalAlert', + title: 'Test Token Trust Signal Alert', + message: ALERT_MESSAGE_MOCK, + severity: Severity.Danger, + }, + ]; + beforeEach(() => { jest.clearAllMocks(); (useBlockaidAlerts as jest.Mock).mockReturnValue([]); @@ -146,6 +157,7 @@ describe('useConfirmationAlerts', () => { (useNoPayTokenQuotesAlert as jest.Mock).mockReturnValue([]); (useInsufficientPredictBalanceAlert as jest.Mock).mockReturnValue([]); (useBurnAddressAlert as jest.Mock).mockReturnValue([]); + (useTokenTrustSignalAlerts as jest.Mock).mockReturnValue([]); }); it('returns empty array if no alerts', () => { @@ -211,6 +223,9 @@ describe('useConfirmationAlerts', () => { mockInsufficientPredictBalanceAlert, ); (useBurnAddressAlert as jest.Mock).mockReturnValue(mockBurnAddressAlert); + (useTokenTrustSignalAlerts as jest.Mock).mockReturnValue( + mockTokenTrustSignalAlerts, + ); const { result } = renderHookWithProvider(() => useConfirmationAlerts(), { state: siweSignatureConfirmationState, }); @@ -225,6 +240,7 @@ describe('useConfirmationAlerts', () => { ...mockNoPayTokenQuotesAlert, ...mockInsufficientPredictBalanceAlert, ...mockBurnAddressAlert, + ...mockTokenTrustSignalAlerts, ...mockUpgradeAccountAlert, ]); }); diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts index 981a76bf5f9..6341378dc37 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts @@ -11,6 +11,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa import { useNoPayTokenQuotesAlert } from './useNoPayTokenQuotesAlert'; import { useInsufficientPredictBalanceAlert } from './useInsufficientPredictBalanceAlert'; import { useBurnAddressAlert } from './useBurnAddressAlert'; +import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; function useSignatureAlerts(): Alert[] { const domainMismatchAlerts = useDomainMismatchAlerts(); @@ -28,6 +29,7 @@ function useTransactionAlerts(): Alert[] { const noPayTokenQuotesAlert = useNoPayTokenQuotesAlert(); const insufficientPredictBalanceAlert = useInsufficientPredictBalanceAlert(); const burnAddressAlert = useBurnAddressAlert(); + const tokenTrustSignalAlerts = useTokenTrustSignalAlerts(); return useMemo( () => [ @@ -39,6 +41,7 @@ function useTransactionAlerts(): Alert[] { ...noPayTokenQuotesAlert, ...insufficientPredictBalanceAlert, ...burnAddressAlert, + ...tokenTrustSignalAlerts, ], [ insufficientBalanceAlert, @@ -49,6 +52,7 @@ function useTransactionAlerts(): Alert[] { noPayTokenQuotesAlert, insufficientPredictBalanceAlert, burnAddressAlert, + tokenTrustSignalAlerts, ], ); } diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts new file mode 100644 index 00000000000..68fa932da15 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.test.ts @@ -0,0 +1,348 @@ +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; +import { Severity } from '../../types/alerts'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { TransactionMeta } from '@metamask/transaction-controller'; + +jest.mock('../transactions/useTransactionMetadataRequest', () => ({ + useTransactionMetadataRequest: jest.fn(), +})); + +describe('useTokenTrustSignalAlerts', () => { + const mockUseTransactionMetadataRequest = jest.mocked( + useTransactionMetadataRequest, + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x1234567890123456789012345678901234567890', + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + }); + + it('returns a malicious alert if the token scan result is malicious', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalMalicious, + field: RowAlertKey.IncomingTokens, + message: + 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.', + title: 'Malicious token', + severity: Severity.Danger, + isBlocking: false, + }, + ]); + }); + + it('returns a warning alert if the token scan result is warning', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Warning', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalWarning, + field: RowAlertKey.IncomingTokens, + message: + 'This token shows strong signs of malicious behavior. Continuing may result in loss of funds.', + title: 'Suspicious token', + severity: Severity.Warning, + isBlocking: false, + }, + ]); + }); + + it('returns no alerts if the token scan result is benign', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Benign', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([]); + }); + + it('returns no alerts if the token scan result does not exist', () => { + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: {}, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([]); + }); + + it('returns no alerts if the transaction metadata is undefined', () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Benign', + }, + }, + }, + }, + }, + }, + }, + }, + ); + expect(result.current).toEqual([]); + }); + + it('detects malicious token when it is not the first incoming token', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x0000000000000000000000000000000000000001', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000002', + isDecrease: false, + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x0000000000000000000000000000000000000001': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Benign', + }, + }, + '0x1:0x0000000000000000000000000000000000000002': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalMalicious, + field: RowAlertKey.IncomingTokens, + message: + 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.', + title: 'Malicious token', + severity: Severity.Danger, + isBlocking: false, + }, + ]); + }); + + it('returns the highest severity alert if there are multiple tokens that are flagged as malicious or warning', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x0000000000000000000000000000000000000001', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000002', + isDecrease: false, + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x0000000000000000000000000000000000000001': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Warning', + }, + }, + '0x1:0x0000000000000000000000000000000000000002': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current).toEqual([ + { + key: AlertKeys.TokenTrustSignalMalicious, + field: RowAlertKey.IncomingTokens, + message: + 'This token has been identified as malicious. Interacting with this token may result in a loss of funds.', + title: 'Malicious token', + severity: Severity.Danger, + isBlocking: false, + }, + ]); + }); + + it('returns exactly one alert if there are multiple tokens that are flagged as malicious or warning', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + simulationData: { + tokenBalanceChanges: [ + { + address: '0x0000000000000000000000000000000000000001', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000002', + isDecrease: false, + }, + { + address: '0x0000000000000000000000000000000000000003', + isDecrease: false, + }, + ], + }, + chainId: '0x1', + } as unknown as TransactionMeta); + + const { result } = renderHookWithProvider( + () => useTokenTrustSignalAlerts(), + { + state: { + engine: { + backgroundState: { + PhishingController: { + tokenScanCache: { + '0x1:0x0000000000000000000000000000000000000001': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Warning', + }, + }, + '0x1:0x0000000000000000000000000000000000000002': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + '0x1:0x0000000000000000000000000000000000000003': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(result.current.length).toBe(1); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts new file mode 100644 index 00000000000..d5a5607e00d --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenTrustSignalAlerts.ts @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Alert, Severity } from '../../types/alerts'; +import { AlertKeys } from '../../constants/alerts'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { selectMultipleTokenScanResults } from '../../../../../selectors/phishingController'; +import { RootState } from '../../../../../reducers'; +import { strings } from '../../../../../../locales/i18n'; + +export function useTokenTrustSignalAlerts(): Alert[] { + const transactionMetadata = useTransactionMetadataRequest(); + + const incomingTokens = useMemo(() => { + const tokens: { address: string; chainId: string }[] = []; + const tokenBalanceChanges = + transactionMetadata?.simulationData?.tokenBalanceChanges; + + if ( + !tokenBalanceChanges || + !Array.isArray(tokenBalanceChanges) || + !transactionMetadata?.chainId + ) { + return tokens; + } + + const chainId = transactionMetadata.chainId; + + tokenBalanceChanges.forEach((change) => { + if (!change.isDecrease) { + tokens.push({ + address: change.address || '', + chainId, + }); + } + }); + + return tokens; + }, [transactionMetadata]); + + const tokenScanResults = useSelector((state: RootState) => + selectMultipleTokenScanResults(state, { tokens: incomingTokens }), + ); + + const alerts = useMemo(() => { + const alertsList: Alert[] = []; + let highestSeverity: Severity | null = null; + + tokenScanResults.forEach(({ scanResult }) => { + if (!scanResult) { + return; + } + + const resultType = scanResult.result_type; + let severity: Severity | null = null; + + if (resultType === 'Malicious') { + severity = Severity.Danger; + } else if (resultType === 'Warning') { + severity = Severity.Warning; + } + + if (!severity) { + return; + } + + if (!highestSeverity || severity === Severity.Danger) { + highestSeverity = severity; + } + }); + + if (highestSeverity) { + const isDanger = highestSeverity === Severity.Danger; + + const alertKey = isDanger + ? AlertKeys.TokenTrustSignalMalicious + : AlertKeys.TokenTrustSignalWarning; + + const message = isDanger + ? strings('alert_system.token_trust_signal.malicious.message') + : strings('alert_system.token_trust_signal.warning.message'); + + const title = isDanger + ? strings('alert_system.token_trust_signal.malicious.title') + : strings('alert_system.token_trust_signal.warning.title'); + + alertsList.push({ + key: alertKey, + field: RowAlertKey.IncomingTokens, + message, + title, + severity: highestSeverity, + isBlocking: false, + }); + } + + return alertsList; + }, [tokenScanResults]); + + return alerts; +} diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index 63fb70b9084..a998f0a08fc 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -120,6 +120,8 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.PerpsDepositMinimum]: 'minimum_deposit', [AlertKeys.PerpsHardwareAccount]: 'perps_hardware_account', [AlertKeys.SignedOrSubmitted]: 'signed_or_submitted', + [AlertKeys.TokenTrustSignalMalicious]: 'token_trust_signal_malicious', + [AlertKeys.TokenTrustSignalWarning]: 'token_trust_signal_warning', }; function getAlertName(alertKey: string): string { diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx index 13d6b7e4167..a98a6cf9a54 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx +++ b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.test.tsx @@ -18,16 +18,6 @@ const mockedNetworkControllerState = mockNetworkState({ ticker: 'ETH', }); -const mockIsRemoveGlobalNetworkSelectorEnabled = jest - .fn() - .mockReturnValue(false); - -jest.mock('../../../../../../util/networks', () => ({ - ...jest.requireActual('../../../../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: () => - mockIsRemoveGlobalNetworkSelectorEnabled(), -})); - jest.mock('../../../../../../core/Engine', () => { const { MOCK_ACCOUNTS_CONTROLLER_STATE } = jest.requireActual( '../../../../../../util/test/accountsControllerTestUtils', @@ -102,8 +92,7 @@ describe('AddressElement', () => { expect(addressText).toBeDefined(); }); - it('renders the network badge when displayNetworkBadge is true and the isRemoveGlobalNetworkSelectorEnabled feature flag is enabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); + it('renders the network badge when displayNetworkBadge is true', () => { const { getByTestId } = renderComponent( { ...initialState, diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx index 8c32051465b..8c236907379 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx +++ b/app/components/Views/confirmations/legacy/SendFlow/AddressElement/AddressElement.tsx @@ -33,7 +33,6 @@ import Badge, { BadgeVariant, } from '../../../../../../component-library/components/Badges/Badge'; import { NetworkBadgeSource } from '../../../../../UI/AssetOverview/Balance/Balance'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks'; const AddressElement: React.FC = ({ name, @@ -54,7 +53,7 @@ const AddressElement: React.FC = ({ const addressElementNetwork = allNetworks[chainId]; const shouldDisplayNetworkBadge = useMemo( - () => isRemoveGlobalNetworkSelectorEnabled() && displayNetworkBadge, + () => displayNetworkBadge, [displayNetworkBadge], ); diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx index 595bf1bfddd..91e9e85e9ba 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx +++ b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.test.tsx @@ -15,17 +15,6 @@ const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([ MOCK_ADDRESS, ]); -// Mock isRemoveGlobalNetworkSelectorEnabled utility -const mockIsRemoveGlobalNetworkSelectorEnabled = jest - .fn() - .mockReturnValue(false); - -jest.mock('../../../../../../util/networks', () => ({ - ...jest.requireActual('../../../../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: () => - mockIsRemoveGlobalNetworkSelectorEnabled(), -})); - // Mock isSmartContractAddress to avoid actual network calls during tests jest.mock('../../../../../../util/transactions', () => ({ ...jest.requireActual('../../../../../../util/transactions'), @@ -119,7 +108,6 @@ const renderComponent = ( describe('AddressList', () => { beforeEach(() => { jest.clearAllMocks(); - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); }); it('renders correctly', () => { @@ -147,52 +135,26 @@ describe('AddressList', () => { }); }); - it('filters contacts by current chainId when isRemoveGlobalNetworkSelectorEnabled is false', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const { queryByText } = renderComponent(initialState); - - await waitFor(() => { - // Contact from chainId 0x1 should be visible - expect(queryByText(textElements.firstContact)).toBeTruthy(); - // Contact from chainId 0x5 should not be visible - expect(queryByText(textElements.secondContact)).toBeNull(); - }); - }); - - it('shows contacts from all chains when onlyRenderAddressBook is true and isRemoveGlobalNetworkSelectorEnabled is true', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); + it('shows contacts from all chains when rendering address book', async () => { const { queryByText } = renderComponent(initialState, { onlyRenderAddressBook: true, }); - // Wait for contacts to be processed await waitFor(() => { - // Both contacts from different chains should be visible + // Both contacts from different chains are visible expect(queryByText(textElements.firstContact)).toBeTruthy(); expect(queryByText(textElements.secondContact)).toBeTruthy(); }); }); - it('only shows contacts from current chain when onlyRenderAddressBook is true but isRemoveGlobalNetworkSelectorEnabled is false', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const { queryByText } = renderComponent(initialState, { - onlyRenderAddressBook: true, - }); - - await waitFor(() => { - // Only contact from current chain should be visible - expect(queryByText(textElements.firstContact)).toBeTruthy(); - expect(queryByText(textElements.secondContact)).toBeNull(); - }); - }); - - it('sets displayNetworkBadge to true when rendering address elements', async () => { - const { findByTestId } = renderComponent(initialState); + it('renders address elements with network badges', async () => { + const { findAllByTestId } = renderComponent(initialState); - const addressElement = await findByTestId('address-book-account'); - expect(addressElement).toBeTruthy(); + const addressElements = await findAllByTestId('address-book-account'); + expect(addressElements.length).toBeGreaterThan(0); - // This implicitly tests that renderAddressElementWithNetworkBadge is setting displayNetworkBadge to true - // The actual rendering of the badge is tested in AddressElement.test.tsx + // Verify network badges are present + const networkBadges = await findAllByTestId('badgenetwork'); + expect(networkBadges.length).toBeGreaterThan(0); }); }); diff --git a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx index b2b64a2be04..858795b01bd 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx +++ b/app/components/Views/confirmations/legacy/SendFlow/AddressList/AddressList.tsx @@ -23,7 +23,6 @@ import { AddressBookEntryWithRelaxedChainId, InternalAddressBookEntry, } from './AddressList.types'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks'; const LabelElement = (styles: ReturnType, label: string) => ( @@ -142,22 +141,11 @@ const AddressList = ({ return fuse.search(inputSearch); } - if (isRemoveGlobalNetworkSelectorEnabled()) { - return completeAndFlattenedAddressBook; - } - - return completeAndFlattenedAddressBookFilteredByCurrentChainId; - }, [ - fuse, - inputSearch, - completeAndFlattenedAddressBook, - completeAndFlattenedAddressBookFilteredByCurrentChainId, - ]); + return completeAndFlattenedAddressBook; + }, [fuse, inputSearch, completeAndFlattenedAddressBook]); useEffect(() => { - const fuseAddressBook = isRemoveGlobalNetworkSelectorEnabled() - ? completeAndFlattenedAddressBook - : completeAndFlattenedAddressBookFilteredByCurrentChainId; + const fuseAddressBook = completeAndFlattenedAddressBook; const newFuse = new Fuse(fuseAddressBook, { shouldSort: true, diff --git a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js index 832fe8eaf03..7559e05aa18 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js +++ b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js @@ -10,10 +10,7 @@ import WarningMessage from '../WarningMessage'; import { getSendFlowTitle } from '../../../../../UI/Navbar'; import StyledButton from '../../../../../UI/StyledButton'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; -import { - getDecimalChainId, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../../../../util/networks'; +import { getDecimalChainId } from '../../../../../../util/networks'; import { handleNetworkSwitch } from '../../../../../../util/networks/handleNetworkSwitch'; import { isENS, @@ -408,19 +405,16 @@ class SendFlow extends PureComponent { }; getAddressNameFromBookOrInternalAccounts = (toAccount) => { - const { addressBook, internalAccounts, globalChainId } = this.props; + const { addressBook, internalAccounts } = this.props; if (!toAccount) return; - let filteredAddressBook = addressBook[globalChainId] || {}; - if (isRemoveGlobalNetworkSelectorEnabled()) { - filteredAddressBook = Object.values(addressBook).reduce( - (acc, networkAddressBook) => ({ - ...acc, - ...networkAddressBook, - }), - {}, - ); - } + const filteredAddressBook = Object.values(addressBook).reduce( + (acc, networkAddressBook) => ({ + ...acc, + ...networkAddressBook, + }), + {}, + ); const checksummedAddress = this.safeChecksumAddress(toAccount); const matchingAccount = internalAccounts.find((account) => @@ -588,13 +582,11 @@ class SendFlow extends PureComponent { style={styles.wrapper} {...generateTestId(Platform, SendViewSelectorsIDs.CONTAINER_ID)} > - {isRemoveGlobalNetworkSelectorEnabled() ? ( - - ) : null} + { network: rpcUrl, shouldNetworkSwitchPopToWallet: false, shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, }, ); }); @@ -258,6 +259,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:1', rpc_endpoint_url: 'mainnet.infura.io', + rpc_domain: 'mainnet.infura.io', }); }); @@ -289,6 +291,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'unavailable', chain_id_caip: 'eip155:1', rpc_endpoint_url: 'mainnet.infura.io', + rpc_domain: 'mainnet.infura.io', }); }); @@ -323,6 +326,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:1', rpc_endpoint_url: 'custom', + rpc_domain: 'custom', }); }); @@ -597,6 +601,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:137', rpc_endpoint_url: 'polygon-rpc.com', + rpc_domain: 'polygon-rpc.com', }); }); @@ -625,6 +630,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'unavailable', chain_id_caip: 'eip155:137', rpc_endpoint_url: 'polygon-rpc.com', + rpc_domain: 'polygon-rpc.com', }); }); @@ -779,6 +785,7 @@ describe('useNetworkConnectionBanner', () => { banner_type: 'degraded', chain_id_caip: 'eip155:137', rpc_endpoint_url: 'polygon-rpc.com', + rpc_domain: 'polygon-rpc.com', }); }); diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts index 4cae292f929..f7a8ac0684e 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts @@ -65,8 +65,10 @@ const useNetworkConnectionBanner = (): { network: rpcUrl, shouldNetworkSwitchPopToWallet: false, shouldShowPopularNetworks: false, + trackRpcUpdateFromBanner: true, }); + const sanitizedUrl = sanitizeRpcUrl(rpcUrl); trackEvent( createEventBuilder( MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, @@ -74,7 +76,9 @@ const useNetworkConnectionBanner = (): { .addProperties({ banner_type: status, chain_id_caip: `eip155:${hexToNumber(chainId)}`, - rpc_endpoint_url: sanitizeRpcUrl(rpcUrl), + // @deprecated: will be removed in a future release + rpc_endpoint_url: sanitizedUrl, + rpc_domain: sanitizedUrl, }) .build(), ); @@ -196,6 +200,7 @@ const useNetworkConnectionBanner = (): { useEffect(() => { if (networkConnectionBannerState.visible) { + const sanitizedUrl = sanitizeRpcUrl(networkConnectionBannerState.rpcUrl); trackEvent( createEventBuilder(MetaMetricsEvents.NETWORK_CONNECTION_BANNER_SHOWN) .addProperties({ @@ -203,9 +208,8 @@ const useNetworkConnectionBanner = (): { chain_id_caip: `eip155:${hexToNumber( networkConnectionBannerState.chainId, )}`, - rpc_endpoint_url: sanitizeRpcUrl( - networkConnectionBannerState.rpcUrl, - ), + rpc_endpoint_url: sanitizedUrl, + rpc_domain: sanitizedUrl, }) .build(), ); diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 74a47afa24a..16a1203c0ca 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -79,7 +79,7 @@ const Routes = { REWARDS_SETTINGS_VIEW: 'RewardsSettingsView', REWARDS_DASHBOARD: 'RewardsDashboard', TRENDING_VIEW: 'TrendingView', - SITES_LIST_VIEW: 'SitesListView', + SITES_FULL_VIEW: 'SitesFullView', EXPLORE_SEARCH: 'ExploreSearch', REWARDS_ONBOARDING_FLOW: 'RewardsOnboardingFlow', REWARDS_ONBOARDING_INTRO: 'RewardsOnboardingIntro', @@ -264,6 +264,11 @@ const Routes = { CLOSE_POSITION: 'PerpsClosePosition', HIP3_DEBUG: 'PerpsHIP3Debug', TPSL: 'PerpsTPSL', + ADJUST_MARGIN: 'PerpsAdjustMargin', + SELECT_MODIFY_ACTION: 'PerpsSelectModifyAction', + SELECT_ADJUST_MARGIN_ACTION: 'PerpsSelectAdjustMarginAction', + SELECT_ORDER_TYPE: 'PerpsSelectOrderType', + ORDER_DETAILS: 'PerpsOrderDetailsView', PNL_HERO_CARD: 'PerpsPnlHeroCard', ACTIVITY: 'PerpsActivity', // Stack-based activity view for proper back navigation MODALS: { diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 3c3783c2035..104af28f0f0 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -514,6 +514,7 @@ enum EVENT_NAME { // NETWORK CONNECTION BANNER NETWORK_CONNECTION_BANNER_SHOWN = 'Network Connection Banner Shown', NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED = 'Network Connection Banner Update RPC Clicked', + NetworkConnectionBannerRpcUpdated = 'Network Connection Banner RPC Updated', // Deep Link Modal Viewed DEEP_LINK_PRIVATE_MODAL_VIEWED = 'Deep Link Private Modal Viewed', @@ -1332,6 +1333,9 @@ const events = { NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED: generateOpt( EVENT_NAME.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, ), + NetworkConnectionBannerRpcUpdated: generateOpt( + EVENT_NAME.NetworkConnectionBannerRpcUpdated, + ), // Multi SRP IMPORT_SECRET_RECOVERY_PHRASE_CLICKED: generateOpt( diff --git a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts index 29113a49e4b..90318b1fe70 100644 --- a/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts +++ b/app/core/Engine/controllers/network-controller/messenger-action-handlers.test.ts @@ -79,6 +79,7 @@ describe('onRpcEndpointUnavailable', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -109,6 +110,7 @@ describe('onRpcEndpointUnavailable', () => { chain_id_caip: 'eip155:11155111', http_status: 420, rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -138,6 +140,7 @@ describe('onRpcEndpointUnavailable', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'custom', + rpc_domain: 'custom', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -236,6 +239,7 @@ describe('onRpcEndpointDegraded', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -266,6 +270,7 @@ describe('onRpcEndpointDegraded', () => { chain_id_caip: 'eip155:11155111', http_status: 420, rpc_endpoint_url: 'example.com', + rpc_domain: 'example.com', }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -295,6 +300,7 @@ describe('onRpcEndpointDegraded', () => { properties: { chain_id_caip: 'eip155:11155111', rpc_endpoint_url: 'custom', + rpc_domain: 'custom', }, }); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts b/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts index d2917d59e07..0156be74b18 100644 --- a/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts +++ b/app/core/Engine/controllers/network-controller/messenger-action-handlers.ts @@ -146,13 +146,15 @@ export function trackRpcEndpointEvent( return; } + const isPublicEndpoint = isPublicEndpointUrl(endpointUrl, infuraProjectId); + const rpcDomain = isPublicEndpoint ? onlyKeepHost(endpointUrl) : 'custom'; // The names of Segment properties have a particular case. /* eslint-disable @typescript-eslint/naming-convention */ const properties = { chain_id_caip: `eip155:${hexToNumber(chainId)}`, - rpc_endpoint_url: isPublicEndpointUrl(endpointUrl, infuraProjectId) - ? onlyKeepHost(endpointUrl) - : 'custom', + // @deprecated: will be removed in a future release + rpc_endpoint_url: rpcDomain, + rpc_domain: rpcDomain, ...(isObject(error) && 'httpStatus' in error && isValidJson(error.httpStatus) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index c8fb768f467..4cd8a5cddf6 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -283,6 +283,7 @@ const createTestSeasonStatus = ( startDate: new Date(Date.now() - 86400000), // 1 day ago endDate: new Date(Date.now() + 86400000), // 1 day from now tiers: createTestTiers(), + activityTypes: [], }; return { @@ -311,6 +312,7 @@ const createTestSeasonStatusState = ( startDate: Date.now() - 86400000, endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 100, @@ -802,27 +804,25 @@ describe('RewardsController', () => { tiers: createTestTiers(), }; - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now - 86400000), - endDate: new Date(now + 86400000), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - if (method === 'RewardsDataService:estimatePoints') { - return Promise.resolve(mockResponse); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); const result = await controller.estimatePoints(mockRequest); @@ -909,27 +909,25 @@ describe('RewardsController', () => { tiers: createTestTiers(), }; - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now - 86400000), - endDate: new Date(now + 86400000), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - if (method === 'RewardsDataService:estimatePoints') { - return Promise.resolve(mockResponse); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); const result = await controller.estimatePoints(mockRequest); @@ -949,17 +947,15 @@ describe('RewardsController', () => { // Mock getSeasonMetadata to return null (no active season) // This simulates getSeasonMetadata('current') returning null - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: null, - next: null, - }); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: null, + next: null, + }); + } + return Promise.resolve(null); + }); const result = await controller.estimatePoints(mockRequest); @@ -994,27 +990,25 @@ describe('RewardsController', () => { // Mock getSeasonMetadata to return valid season metadata // This simulates getSeasonMetadata('current') returning a valid season - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now - 86400000), - endDate: new Date(now + 86400000), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - if (method === 'RewardsDataService:estimatePoints') { - return Promise.resolve(mockResponse); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); const result = await controller.estimatePoints(mockRequest); @@ -1043,17 +1037,15 @@ describe('RewardsController', () => { it('should return false when getSeasonMetadata returns null', async () => { // Mock getSeasonMetadata to return null by having getDiscoverSeasons return null for current - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: null, - next: null, - }); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: null, + next: null, + }); + } + return Promise.resolve(null); + }); const result = await controller.hasActiveSeason(); @@ -1071,24 +1063,22 @@ describe('RewardsController', () => { tiers: createTestTiers(), }; - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now + 86400000), - endDate: new Date(now + 172800000), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now + 86400000), + endDate: new Date(now + 172800000), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + return Promise.resolve(null); + }); const result = await controller.hasActiveSeason(); @@ -1106,24 +1096,22 @@ describe('RewardsController', () => { tiers: createTestTiers(), }; - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now - 172800000), - endDate: new Date(now - 86400000), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 172800000), + endDate: new Date(now - 86400000), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + return Promise.resolve(null); + }); const result = await controller.hasActiveSeason(); @@ -1141,24 +1129,22 @@ describe('RewardsController', () => { tiers: createTestTiers(), }; - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now - 86400000), - endDate: new Date(now + 86400000), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + return Promise.resolve(null); + }); const result = await controller.hasActiveSeason(); @@ -1176,24 +1162,22 @@ describe('RewardsController', () => { tiers: createTestTiers(), }; - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now), - endDate: new Date(now + 86400000), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now), + endDate: new Date(now + 86400000), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + return Promise.resolve(null); + }); const result = await controller.hasActiveSeason(); @@ -1211,24 +1195,22 @@ describe('RewardsController', () => { tiers: createTestTiers(), }; - mockMessenger.call.mockImplementation( - (method: string, ..._: unknown[]) => { - if (method === 'RewardsDataService:getDiscoverSeasons') { - return Promise.resolve({ - current: { - id: mockSeasonId, - startDate: new Date(now - 86400000), - endDate: new Date(now), - }, - next: null, - }); - } - if (method === 'RewardsDataService:getSeasonMetadata') { - return Promise.resolve(mockSeasonMetadata); - } - return Promise.resolve(null); - }, - ); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now), + }, + next: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + return Promise.resolve(null); + }); const result = await controller.hasActiveSeason(); @@ -3121,6 +3103,7 @@ describe('RewardsController', () => { startDate: Date.now() - 86400000, // 1 day ago endDate: Date.now() + 86400000, // 1 day from now tiers: createTestTiers(), + activityTypes: [], }; const mockSeasonStatus: SeasonStatusState = { @@ -3240,6 +3223,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z'), endDate: new Date('2024-12-31T23:59:59Z'), tiers: createTestTiers(), + activityTypes: [], }; const mockApiResponse = createTestSeasonStatus({ season: mockSeasonMetadata, @@ -3264,6 +3248,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now() - 7200000, // 2 hours ago (stale) }, }, @@ -3313,6 +3298,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z'), endDate: new Date('2024-12-31T23:59:59Z'), tiers: createTestTiers(), + activityTypes: [], }; const mockApiResponse = createTestSeasonStatus({ season: mockSeasonMetadata, @@ -3339,6 +3325,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }, @@ -3430,6 +3417,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }, @@ -3481,6 +3469,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -3527,6 +3516,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z'), endDate: new Date('2024-12-31T23:59:59Z'), tiers: createTestTiers(), + activityTypes: [], }; const mockSeasonStatus = createTestSeasonStatus({ season: mockSeasonMetadata, @@ -3571,6 +3561,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -3691,6 +3682,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -3783,6 +3775,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z'), endDate: new Date('2024-12-31T23:59:59Z'), tiers: createTestTiers(), + activityTypes: [], }; const mockSeasonStatus = createTestSeasonStatus({ season: mockSeasonMetadata, @@ -3836,6 +3829,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -3930,6 +3924,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z'), endDate: new Date('2024-12-31T23:59:59Z'), tiers: createTestTiers(), + activityTypes: [], }; const mockSeasonStatus = createTestSeasonStatus({ season: mockSeasonMetadata, @@ -3991,6 +3986,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4111,6 +4107,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4207,6 +4204,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4292,6 +4290,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4368,6 +4367,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4440,6 +4440,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4498,6 +4499,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4555,6 +4557,7 @@ describe('RewardsController', () => { startDate: new Date('2024-01-01T00:00:00Z').getTime(), endDate: new Date('2024-12-31T23:59:59Z').getTime(), tiers: createTestTiers(), + activityTypes: [], lastFetched: Date.now(), }, }; @@ -4610,6 +4613,7 @@ describe('RewardsController', () => { startDate: Date.now() - 86400000, endDate: Date.now() + 86400000, tiers: createTestTiers(), + activityTypes: [], lastFetched: recentTime, }; @@ -4645,6 +4649,7 @@ describe('RewardsController', () => { startDate: Date.now() + 86400000, endDate: Date.now() + 172800000, tiers: createTestTiers(), + activityTypes: [], lastFetched: recentTime, }; @@ -4680,6 +4685,7 @@ describe('RewardsController', () => { startDate: Date.now() - 86400000, endDate: Date.now() + 86400000, tiers: createTestTiers(), + activityTypes: [], lastFetched: staleTime, }; @@ -6790,6 +6796,7 @@ describe('RewardsController', () => { startDate: 1609459200000, // 2021-01-01 endDate: 1640995200000, // 2022-01-01 tiers, + activityTypes: [], }; const seasonState: SeasonStateDto = { @@ -6827,6 +6834,7 @@ describe('RewardsController', () => { startDate: startTimestamp, endDate: endTimestamp, tiers: createTestTiers(), + activityTypes: [], }; const seasonState: SeasonStateDto = { @@ -6856,6 +6864,7 @@ describe('RewardsController', () => { startDate: Date.now() - 86400000, endDate: Date.now() + 86400000, tiers: createTestTiers(), + activityTypes: [], }; const updatedAtDate = new Date('2025-10-20T10:30:00.000Z'); @@ -6909,6 +6918,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 1000000, tiers: customTiers, + activityTypes: [], }; const seasonState: SeasonStateDto = { @@ -6940,6 +6950,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: createTestTiers(), + activityTypes: [], }; const seasonState: SeasonStateDto = { @@ -6967,6 +6978,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: createTestTiers(), + activityTypes: [], }; const largeBalance = 999999999; @@ -7582,6 +7594,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 1000, tiers: [], + activityTypes: [], }, }, subscriptionReferralDetails: { @@ -7600,6 +7613,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now(), tiers: [], + activityTypes: [], }, balance: { total: 100 }, tier: { @@ -10847,6 +10861,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 1000 }, tier: { @@ -11000,6 +11015,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 1000 }, tier: { @@ -11110,6 +11126,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 500 }, tier: { @@ -11133,6 +11150,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 1000 }, tier: { @@ -11156,6 +11174,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 1500 }, tier: { @@ -11179,6 +11198,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 2000 }, tier: { @@ -13239,6 +13259,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 1000 }, tier: { @@ -13441,6 +13462,7 @@ describe('RewardsController', () => { startDate: Date.now(), endDate: Date.now() + 86400000, tiers: [], + activityTypes: [], }, balance: { total: 500 }, tier: { diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 72cfe14da88..1815d75a89e 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -305,6 +305,7 @@ export class RewardsController extends BaseController< startDate: season.startDate.getTime(), endDate: season.endDate.getTime(), tiers: season.tiers, + activityTypes: season.activityTypes, }; } @@ -322,6 +323,7 @@ export class RewardsController extends BaseController< startDate: new Date(seasonMetadata.startDate), endDate: new Date(seasonMetadata.endDate), tiers: seasonMetadata.tiers, + activityTypes: seasonMetadata.activityTypes, }, balance: { total: seasonState.balance, @@ -1655,7 +1657,7 @@ export class RewardsController extends BaseController< /** * Get season metadata with caching. This fetches and caches the season metadata - * including id, name, dates, and tiers. + * including id, name, dates, tiers, and activity types. * @param type - The type of season to get * @returns Promise - The season metadata */ @@ -1714,6 +1716,7 @@ export class RewardsController extends BaseController< startDate: seasonMetadata.startDate, endDate: seasonMetadata.endDate, tiers: seasonMetadata.tiers, + activityTypes: seasonMetadata.activityTypes, }); // Add lastFetched timestamp diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index a9ccfa98857..9a7fdebf20e 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -1298,6 +1298,7 @@ describe('RewardsDataService', () => { rewards: [], }, ], + activityTypes: [], }; beforeEach(() => { diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index ef84531a7bb..2e6ae4d026c 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -972,6 +972,11 @@ export class RewardsDataService { data.endDate = new Date(data.endDate); } + // Ensure activityTypes is always an array per SeasonMetadataDto + if (!Array.isArray(data.activityTypes)) { + data.activityTypes = []; + } + return data as SeasonMetadataDto; } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 55ec7244770..c870b175389 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -384,6 +384,7 @@ export type PointsEventDto = BasePointsEventDto & type: 'REFERRAL' | 'SIGN_UP_BONUS' | 'LOYALTY_BONUS' | 'ONE_TIME_BONUS'; payload: null; } + | { type: string; payload: Record | null } ); export interface EstimatePointsDto { @@ -454,6 +455,7 @@ export interface SeasonDto { startDate: Date; endDate: Date; tiers: SeasonTierDto[]; + activityTypes: SeasonActivityTypeDto[]; } export interface SeasonStatusBalanceDto { @@ -576,6 +578,7 @@ export type SeasonDtoState = { startDate: number; // timestamp endDate: number; // timestamp tiers: SeasonTierDtoState[]; + activityTypes: SeasonActivityTypeDto[]; lastFetched?: number; }; @@ -1150,6 +1153,11 @@ export interface SeasonMetadataDto { * The tiers for the season */ tiers: SeasonTierDto[]; + + /** + * Activity types for the season + */ + activityTypes: SeasonActivityTypeDto[]; } /** @@ -1174,3 +1182,30 @@ export interface SeasonStateDto { */ updatedAt: Date; } + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SeasonActivityTypeDto = { + /** + * The activity type + * @example 'SWAP' + */ + type: string; + + /** + * The name of the activity type + * @example 'Swap' + */ + title: string; + + /** + * The description of the activity type + * @example 'Stake your M$D to earn points' + */ + description: string; + + /** + * The icon for the activity type + * @example 'Rocket' + */ + icon: string; +}; diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 3a184f5b36a..b46de930318 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -34,6 +34,10 @@ import { } from '../../core/Engine/controllers/rewards-controller/types'; import { AccountGroupId } from '@metamask/account-api'; +const initialState: RewardsState = rewardsReducer(undefined, { + type: 'unknown', +} as Action); + describe('rewardsReducer', () => { const initialState: RewardsState = { activeTab: 'overview', @@ -45,6 +49,7 @@ describe('rewardsReducer', () => { seasonStartDate: null, seasonEndDate: null, seasonTiers: [], + seasonActivityTypes: [], referralDetailsLoading: false, referralDetailsError: false, @@ -309,6 +314,7 @@ describe('rewardsReducer', () => { rewards: [], }, ], + activityTypes: [], }, balance: { total: 500, @@ -370,1633 +376,943 @@ describe('rewardsReducer', () => { expect(state.balanceUpdatedAt).toBe(null); }); - describe('setReferralDetails', () => { - it('should update referral code when provided', () => { - // Arrange - const action = setReferralDetails({ referralCode: 'NEW123' }); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.referralCode).toBe('NEW123'); - expect(state.refereeCount).toBe(0); // Should remain unchanged - }); + it('should set seasonActivityTypes from season data', () => { + const mockSeasonStatus = { + season: { + id: 'season-activity', + name: 'Season Activity', + startDate: new Date('2024-02-01').getTime(), + endDate: new Date('2024-03-01').getTime(), + tiers: [], + activityTypes: [ + { + type: 'SWAP', + title: 'Swap', + description: 'Swap desc', + icon: 'SwapVertical', + }, + { + type: 'CARD', + title: 'Card spend', + description: 'Spend', + icon: 'Card', + }, + ], + }, + } as unknown as SeasonStatusState; + const action = setSeasonStatus(mockSeasonStatus); - it('should update referee count when provided', () => { - // Arrange - const action = setReferralDetails({ refereeCount: 5 }); + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(initialState, action); + expect(state.seasonActivityTypes).toEqual( + mockSeasonStatus.season.activityTypes, + ); + }); - // Assert - expect(state.refereeCount).toBe(5); - expect(state.referralCode).toBe(null); // Should remain unchanged - }); + it('should clear seasonActivityTypes when season status is null', () => { + const stateWithActivities = { + ...initialState, + seasonActivityTypes: [ + { + type: 'REFERRAL', + title: 'Referral', + description: 'Refer a friend', + icon: 'UserCircleAdd', + }, + ], + }; + const action = setSeasonStatus(null); - it('should update multiple referral fields when provided', () => { - // Arrange - const action = setReferralDetails({ - referralCode: 'MULTI123', - refereeCount: 10, - }); + const state = rewardsReducer(stateWithActivities, action); - // Act - const state = rewardsReducer(initialState, action); + expect(state.seasonActivityTypes).toEqual([]); + }); + }); - // Assert - expect(state.referralCode).toBe('MULTI123'); - expect(state.refereeCount).toBe(10); - }); + describe('setReferralDetails', () => { + it('should update referral code when provided', () => { + // Arrange + const action = setReferralDetails({ referralCode: 'NEW123' }); - it('should handle empty payload without updating any fields', () => { - // Arrange - const stateWithData = { - ...initialState, - referralCode: 'EXISTING', - refereeCount: 3, - }; - const action = setReferralDetails({}); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(stateWithData, action); + // Assert + expect(state.referralCode).toBe('NEW123'); + expect(state.refereeCount).toBe(0); // Should remain unchanged + }); - // Assert - expect(state.referralCode).toBe('EXISTING'); - expect(state.refereeCount).toBe(3); - }); + it('should update referee count when provided', () => { + // Arrange + const action = setReferralDetails({ refereeCount: 5 }); - it('should handle zero referee count', () => { - // Arrange - const stateWithReferees = { ...initialState, refereeCount: 5 }; - const action = setReferralDetails({ refereeCount: 0 }); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(stateWithReferees, action); + // Assert + expect(state.refereeCount).toBe(5); + expect(state.referralCode).toBe(null); // Should remain unchanged + }); - // Assert - expect(state.refereeCount).toBe(0); + it('should update multiple referral fields when provided', () => { + // Arrange + const action = setReferralDetails({ + referralCode: 'MULTI123', + refereeCount: 10, }); - it('should set referralDetailsLoading to false', () => { - // Arrange - const stateWithLoading = { - ...initialState, - referralDetailsLoading: true, - }; - const action = setReferralDetails({ referralCode: 'TEST123' }); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(stateWithLoading, action); + // Assert + expect(state.referralCode).toBe('MULTI123'); + expect(state.refereeCount).toBe(10); + }); - // Assert - expect(state.referralDetailsLoading).toBe(false); - expect(state.referralCode).toBe('TEST123'); - }); + it('should handle empty payload without updating any fields', () => { + // Arrange + const stateWithData = { + ...initialState, + referralCode: 'EXISTING', + refereeCount: 3, + }; + const action = setReferralDetails({}); - it('should handle null referralCode explicitly', () => { - // Arrange - const stateWithCode = { ...initialState, referralCode: 'EXISTING' }; - const action = setReferralDetails({ - referralCode: null as unknown as string, - }); + // Act + const state = rewardsReducer(stateWithData, action); - // Act - const state = rewardsReducer(stateWithCode, action); + // Assert + expect(state.referralCode).toBe('EXISTING'); + expect(state.refereeCount).toBe(3); + }); - // Assert - expect(state.referralCode).toBe(null); - expect(state.referralDetailsLoading).toBe(false); - }); + it('should handle zero referee count', () => { + // Arrange + const stateWithReferees = { ...initialState, refereeCount: 5 }; + const action = setReferralDetails({ refereeCount: 0 }); - it('should handle undefined referralCode in payload', () => { - // Arrange - const stateWithCode = { ...initialState, referralCode: 'EXISTING' }; - const action = setReferralDetails({ - referralCode: undefined, - refereeCount: 5, - }); + // Act + const state = rewardsReducer(stateWithReferees, action); - // Act - const state = rewardsReducer(stateWithCode, action); + // Assert + expect(state.refereeCount).toBe(0); + }); - // Assert - expect(state.referralCode).toBe('EXISTING'); // Should remain unchanged - expect(state.refereeCount).toBe(5); - expect(state.referralDetailsLoading).toBe(false); - }); + it('should set referralDetailsLoading to false', () => { + // Arrange + const stateWithLoading = { + ...initialState, + referralDetailsLoading: true, + }; + const action = setReferralDetails({ referralCode: 'TEST123' }); - it('should handle negative referee count', () => { - // Arrange - const action = setReferralDetails({ refereeCount: -1 }); + // Act + const state = rewardsReducer(stateWithLoading, action); - // Act - const state = rewardsReducer(initialState, action); + // Assert + expect(state.referralDetailsLoading).toBe(false); + expect(state.referralCode).toBe('TEST123'); + }); - // Assert - expect(state.refereeCount).toBe(-1); // Should accept negative values - expect(state.referralDetailsLoading).toBe(false); + it('should handle null referralCode explicitly', () => { + // Arrange + const stateWithCode = { ...initialState, referralCode: 'EXISTING' }; + const action = setReferralDetails({ + referralCode: null as unknown as string, }); - it('updates balanceRefereePortion when referralPoints is provided', () => { - // Arrange - const action = setReferralDetails({ referralPoints: 500 }); + // Act + const state = rewardsReducer(stateWithCode, action); - // Act - const state = rewardsReducer(initialState, action); + // Assert + expect(state.referralCode).toBe(null); + expect(state.referralDetailsLoading).toBe(false); + }); - // Assert - expect(state.balanceRefereePortion).toBe(500); - expect(state.referralDetailsLoading).toBe(false); + it('should handle undefined referralCode in payload', () => { + // Arrange + const stateWithCode = { ...initialState, referralCode: 'EXISTING' }; + const action = setReferralDetails({ + referralCode: undefined, + refereeCount: 5, }); - it('updates balanceRefereePortion with zero value', () => { - // Arrange - const stateWithPoints = { - ...initialState, - balanceRefereePortion: 300, - }; - const action = setReferralDetails({ referralPoints: 0 }); + // Act + const state = rewardsReducer(stateWithCode, action); - // Act - const state = rewardsReducer(stateWithPoints, action); + // Assert + expect(state.referralCode).toBe('EXISTING'); // Should remain unchanged + expect(state.refereeCount).toBe(5); + expect(state.referralDetailsLoading).toBe(false); + }); - // Assert - expect(state.balanceRefereePortion).toBe(0); - }); + it('should handle negative referee count', () => { + // Arrange + const action = setReferralDetails({ refereeCount: -1 }); - it('updates all fields including referralPoints when provided together', () => { - // Arrange - const action = setReferralDetails({ - referralCode: 'COMBO123', - refereeCount: 15, - referralPoints: 750, - }); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(initialState, action); + // Assert + expect(state.refereeCount).toBe(-1); // Should accept negative values + expect(state.referralDetailsLoading).toBe(false); + }); - // Assert - expect(state.referralCode).toBe('COMBO123'); - expect(state.refereeCount).toBe(15); - expect(state.balanceRefereePortion).toBe(750); - expect(state.referralDetailsLoading).toBe(false); - }); + it('updates balanceRefereePortion when referralPoints is provided', () => { + // Arrange + const action = setReferralDetails({ referralPoints: 500 }); - it('preserves balanceRefereePortion when referralPoints is not provided', () => { - // Arrange - const stateWithPoints = { - ...initialState, - balanceRefereePortion: 200, - }; - const action = setReferralDetails({ referralCode: 'TEST456' }); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(stateWithPoints, action); + // Assert + expect(state.balanceRefereePortion).toBe(500); + expect(state.referralDetailsLoading).toBe(false); + }); - // Assert - expect(state.balanceRefereePortion).toBe(200); - expect(state.referralCode).toBe('TEST456'); - }); + it('updates balanceRefereePortion with zero value', () => { + // Arrange + const stateWithPoints = { + ...initialState, + balanceRefereePortion: 300, + }; + const action = setReferralDetails({ referralPoints: 0 }); - it('handles negative referralPoints value', () => { - // Arrange - const action = setReferralDetails({ referralPoints: -50 }); + // Act + const state = rewardsReducer(stateWithPoints, action); - // Act - const state = rewardsReducer(initialState, action); + // Assert + expect(state.balanceRefereePortion).toBe(0); + }); - // Assert - expect(state.balanceRefereePortion).toBe(-50); + it('updates all fields including referralPoints when provided together', () => { + // Arrange + const action = setReferralDetails({ + referralCode: 'COMBO123', + refereeCount: 15, + referralPoints: 750, }); - it('handles large referralPoints value', () => { - // Arrange - const action = setReferralDetails({ referralPoints: 999999 }); - - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.balanceRefereePortion).toBe(999999); - }); + // Assert + expect(state.referralCode).toBe('COMBO123'); + expect(state.refereeCount).toBe(15); + expect(state.balanceRefereePortion).toBe(750); + expect(state.referralDetailsLoading).toBe(false); + }); - it('handles decimal referralPoints value', () => { - // Arrange - const action = setReferralDetails({ referralPoints: 125.75 }); + it('preserves balanceRefereePortion when referralPoints is not provided', () => { + // Arrange + const stateWithPoints = { + ...initialState, + balanceRefereePortion: 200, + }; + const action = setReferralDetails({ referralCode: 'TEST456' }); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(stateWithPoints, action); - // Assert - expect(state.balanceRefereePortion).toBe(125.75); - }); + // Assert + expect(state.balanceRefereePortion).toBe(200); + expect(state.referralCode).toBe('TEST456'); }); - describe('setReferralDetailsError', () => { - it('should set referral details error to true', () => { - // Arrange - const action = setReferralDetailsError(true); + it('handles negative referralPoints value', () => { + // Arrange + const action = setReferralDetails({ referralPoints: -50 }); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.referralDetailsError).toBe(true); - }); + // Assert + expect(state.balanceRefereePortion).toBe(-50); + }); - it('should set referral details error to false', () => { - // Arrange - const stateWithError = { - ...initialState, - referralDetailsError: true, - }; - const action = setReferralDetailsError(false); + it('handles large referralPoints value', () => { + // Arrange + const action = setReferralDetails({ referralPoints: 999999 }); - // Act - const state = rewardsReducer(stateWithError, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.referralDetailsError).toBe(false); - }); + // Assert + expect(state.balanceRefereePortion).toBe(999999); + }); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - referralCode: 'TEST123', - refereeCount: 5, - referralDetailsLoading: true, - }; - const action = setReferralDetailsError(true); + it('handles decimal referralPoints value', () => { + // Arrange + const action = setReferralDetails({ referralPoints: 125.75 }); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.referralDetailsError).toBe(true); - expect(state.referralCode).toBe('TEST123'); - expect(state.refereeCount).toBe(5); - expect(state.referralDetailsLoading).toBe(true); - }); + // Assert + expect(state.balanceRefereePortion).toBe(125.75); }); + }); - describe('setSeasonStatusLoading', () => { - it('should set season status loading to true when no season data exists', () => { - // Arrange - const action = setSeasonStatusLoading(true); + describe('setReferralDetailsError', () => { + it('should set referral details error to true', () => { + // Arrange + const action = setReferralDetailsError(true); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.seasonStatusLoading).toBe(true); - }); + // Assert + expect(state.referralDetailsError).toBe(true); + }); - it('should not set season status loading to true when season data already exists', () => { - // Arrange - const stateWithSeasonData = { - ...initialState, - seasonStartDate: new Date('2024-01-01'), - seasonStatusLoading: false, - }; - const action = setSeasonStatusLoading(true); + it('should set referral details error to false', () => { + // Arrange + const stateWithError = { + ...initialState, + referralDetailsError: true, + }; + const action = setReferralDetailsError(false); - // Act - const state = rewardsReducer(stateWithSeasonData, action); + // Act + const state = rewardsReducer(stateWithError, action); - // Assert - expect(state.seasonStatusLoading).toBe(false); // Should remain false due to guard clause - }); + // Assert + expect(state.referralDetailsError).toBe(false); + }); - it('should set season status loading to false', () => { - // Arrange - const stateWithLoading = { ...initialState, seasonStatusLoading: true }; - const action = setSeasonStatusLoading(false); + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + referralCode: 'TEST123', + refereeCount: 5, + referralDetailsLoading: true, + }; + const action = setReferralDetailsError(true); - // Act - const state = rewardsReducer(stateWithLoading, action); + // Act + const state = rewardsReducer(stateWithData, action); - // Assert - expect(state.seasonStatusLoading).toBe(false); - }); + // Assert + expect(state.referralDetailsError).toBe(true); + expect(state.referralCode).toBe('TEST123'); + expect(state.refereeCount).toBe(5); + expect(state.referralDetailsLoading).toBe(true); + }); + }); - it('should set season status loading to false even when season data exists', () => { - // Arrange - const stateWithSeasonDataAndLoading = { - ...initialState, - seasonStartDate: new Date('2024-01-01'), - seasonStatusLoading: true, - }; - const action = setSeasonStatusLoading(false); + describe('setSeasonStatusLoading', () => { + it('should set season status loading to true when no season data exists', () => { + // Arrange + const action = setSeasonStatusLoading(true); - // Act - const state = rewardsReducer(stateWithSeasonDataAndLoading, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.seasonStatusLoading).toBe(false); - }); + // Assert + expect(state.seasonStatusLoading).toBe(true); }); - describe('setSeasonStatusError', () => { - it('should set season status error to a string message', () => { - // Arrange - const errorMessage = 'Failed to fetch season status'; - const action = setSeasonStatusError(errorMessage); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.seasonStatusError).toBe(errorMessage); - }); - - it('should clear season status error when set to null', () => { - // Arrange - const stateWithError = { - ...initialState, - seasonStatusError: 'Previous error message', - }; - const action = setSeasonStatusError(null); - - // Act - const state = rewardsReducer(stateWithError, action); - - // Assert - expect(state.seasonStatusError).toBe(null); - }); - - it('should replace existing error with new error message', () => { - // Arrange - const stateWithError = { - ...initialState, - seasonStatusError: 'Old error message', - }; - const newErrorMessage = 'New error message'; - const action = setSeasonStatusError(newErrorMessage); - - // Act - const state = rewardsReducer(stateWithError, action); - - // Assert - expect(state.seasonStatusError).toBe(newErrorMessage); - }); - - it('should handle network timeout error message', () => { - // Arrange - const timeoutError = 'Request timed out while fetching season status'; - const action = setSeasonStatusError(timeoutError); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.seasonStatusError).toBe(timeoutError); - }); - - it('should handle API error response message', () => { - // Arrange - const apiError = 'API returned 500: Internal server error'; - const action = setSeasonStatusError(apiError); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.seasonStatusError).toBe(apiError); - }); - - it('should not affect other state properties when setting error', () => { - // Arrange - const stateWithData = { - ...initialState, - seasonName: 'Test Season', - seasonId: 'season-123', - balanceTotal: 1000, - seasonStatusLoading: false, - }; - const errorMessage = 'Something went wrong'; - const action = setSeasonStatusError(errorMessage); + it('should not set season status loading to true when season data already exists', () => { + // Arrange + const stateWithSeasonData = { + ...initialState, + seasonStartDate: new Date('2024-01-01'), + seasonStatusLoading: false, + }; + const action = setSeasonStatusLoading(true); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithSeasonData, action); - // Assert - expect(state.seasonStatusError).toBe(errorMessage); - expect(state.seasonName).toBe('Test Season'); - expect(state.seasonId).toBe('season-123'); - expect(state.balanceTotal).toBe(1000); - expect(state.seasonStatusLoading).toBe(false); - }); + // Assert + expect(state.seasonStatusLoading).toBe(false); // Should remain false due to guard clause }); - describe('setReferralDetailsLoading', () => { - it('should set referral details loading to true when no referral code exists', () => { - // Arrange - const action = setReferralDetailsLoading(true); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.referralDetailsLoading).toBe(true); - }); - - it('should not set referral details loading to true when referral code already exists', () => { - // Arrange - const stateWithReferralCode = { - ...initialState, - referralCode: 'EXISTING123', - referralDetailsLoading: false, - }; - const action = setReferralDetailsLoading(true); + it('should set season status loading to false', () => { + // Arrange + const stateWithLoading = { ...initialState, seasonStatusLoading: true }; + const action = setSeasonStatusLoading(false); - // Act - const state = rewardsReducer(stateWithReferralCode, action); + // Act + const state = rewardsReducer(stateWithLoading, action); - // Assert - expect(state.referralDetailsLoading).toBe(false); // Should remain false due to guard clause - }); + // Assert + expect(state.seasonStatusLoading).toBe(false); + }); - it('should set referral details loading to false', () => { - // Arrange - const stateWithLoading = { - ...initialState, - referralDetailsLoading: true, - }; - const action = setReferralDetailsLoading(false); + it('should set season status loading to false even when season data exists', () => { + // Arrange + const stateWithSeasonDataAndLoading = { + ...initialState, + seasonStartDate: new Date('2024-01-01'), + seasonStatusLoading: true, + }; + const action = setSeasonStatusLoading(false); - // Act - const state = rewardsReducer(stateWithLoading, action); + // Act + const state = rewardsReducer(stateWithSeasonDataAndLoading, action); - // Assert - expect(state.referralDetailsLoading).toBe(false); - }); + // Assert + expect(state.seasonStatusLoading).toBe(false); + }); + }); - it('should set referral details loading to false even when referral code exists', () => { - // Arrange - const stateWithReferralCodeAndLoading = { - ...initialState, - referralCode: 'EXISTING123', - referralDetailsLoading: true, - }; - const action = setReferralDetailsLoading(false); + describe('setSeasonStatusError', () => { + it('should set season status error to a string message', () => { + // Arrange + const errorMessage = 'Failed to fetch season status'; + const action = setSeasonStatusError(errorMessage); - // Act - const state = rewardsReducer(stateWithReferralCodeAndLoading, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.referralDetailsLoading).toBe(false); - }); + // Assert + expect(state.seasonStatusError).toBe(errorMessage); }); - describe('setOnboardingActiveStep', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it.each([ - OnboardingStep.INTRO, - OnboardingStep.STEP_1, - OnboardingStep.STEP_2, - OnboardingStep.STEP_3, - OnboardingStep.STEP_4, - ])('should set onboarding active step to %s', (step) => { - // Arrange - const action = setOnboardingActiveStep(step); + it('should clear season status error when set to null', () => { + // Arrange + const stateWithError = { + ...initialState, + seasonStatusError: 'Previous error message', + }; + const action = setSeasonStatusError(null); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(stateWithError, action); - // Assert - expect(state.onboardingActiveStep).toBe(step); - }); + // Assert + expect(state.seasonStatusError).toBe(null); + }); - it('should update from different onboarding step', () => { - // Arrange - const stateWithStep = { - ...initialState, - onboardingActiveStep: OnboardingStep.STEP_2, - }; - const action = setOnboardingActiveStep(OnboardingStep.STEP_4); + it('should replace existing error with new error message', () => { + // Arrange + const stateWithError = { + ...initialState, + seasonStatusError: 'Old error message', + }; + const newErrorMessage = 'New error message'; + const action = setSeasonStatusError(newErrorMessage); - // Act - const state = rewardsReducer(stateWithStep, action); + // Act + const state = rewardsReducer(stateWithError, action); - // Assert - expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_4); - }); + // Assert + expect(state.seasonStatusError).toBe(newErrorMessage); + }); - it('should call logger even when step is the same', () => { - // Arrange - const stateWithStep = { - ...initialState, - onboardingActiveStep: OnboardingStep.STEP_1, - }; - const action = setOnboardingActiveStep(OnboardingStep.STEP_1); + it('should handle network timeout error message', () => { + // Arrange + const timeoutError = 'Request timed out while fetching season status'; + const action = setSeasonStatusError(timeoutError); - // Act - const state = rewardsReducer(stateWithStep, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_1); - }); + // Assert + expect(state.seasonStatusError).toBe(timeoutError); }); - describe('resetOnboarding', () => { - it('should reset onboarding to INTRO step and clear referral code', () => { - // Arrange - const stateWithStep = { - ...initialState, - onboardingActiveStep: OnboardingStep.STEP_3, - onboardingReferralCode: 'REF123', - }; - const action = resetOnboarding(); + it('should handle API error response message', () => { + // Arrange + const apiError = 'API returned 500: Internal server error'; + const action = setSeasonStatusError(apiError); - // Act - const state = rewardsReducer(stateWithStep, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO); - expect(state.onboardingReferralCode).toBeNull(); - }); + // Assert + expect(state.seasonStatusError).toBe(apiError); + }); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - onboardingActiveStep: OnboardingStep.STEP_4, - onboardingReferralCode: 'REF456', - referralCode: 'KEEP123', - balanceTotal: 1500, - }; - const action = resetOnboarding(); + it('should not affect other state properties when setting error', () => { + // Arrange + const stateWithData = { + ...initialState, + seasonName: 'Test Season', + seasonId: 'season-123', + balanceTotal: 1000, + seasonStatusLoading: false, + }; + const errorMessage = 'Something went wrong'; + const action = setSeasonStatusError(errorMessage); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithData, action); - // Assert - expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO); - expect(state.onboardingReferralCode).toBeNull(); - expect(state.referralCode).toBe('KEEP123'); - expect(state.balanceTotal).toBe(1500); - }); + // Assert + expect(state.seasonStatusError).toBe(errorMessage); + expect(state.seasonName).toBe('Test Season'); + expect(state.seasonId).toBe('season-123'); + expect(state.balanceTotal).toBe(1000); + expect(state.seasonStatusLoading).toBe(false); }); + }); - describe('setOnboardingReferralCode', () => { - it('should set onboarding referral code', () => { - // Arrange - const action = setOnboardingReferralCode('REF123'); + describe('setReferralDetailsLoading', () => { + it('should set referral details loading to true when no referral code exists', () => { + // Arrange + const action = setReferralDetailsLoading(true); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.onboardingReferralCode).toBe('REF123'); - }); + // Assert + expect(state.referralDetailsLoading).toBe(true); + }); - it('should update existing onboarding referral code', () => { - // Arrange - const stateWithCode = { - ...initialState, - onboardingReferralCode: 'OLD_REF', - }; - const action = setOnboardingReferralCode('NEW_REF'); + it('should not set referral details loading to true when referral code already exists', () => { + // Arrange + const stateWithReferralCode = { + ...initialState, + referralCode: 'EXISTING123', + referralDetailsLoading: false, + }; + const action = setReferralDetailsLoading(true); - // Act - const state = rewardsReducer(stateWithCode, action); + // Act + const state = rewardsReducer(stateWithReferralCode, action); - // Assert - expect(state.onboardingReferralCode).toBe('NEW_REF'); - }); + // Assert + expect(state.referralDetailsLoading).toBe(false); // Should remain false due to guard clause + }); - it('should set onboarding referral code to null', () => { - // Arrange - const stateWithCode = { - ...initialState, - onboardingReferralCode: 'REF123', - }; - const action = setOnboardingReferralCode(null); + it('should set referral details loading to false', () => { + // Arrange + const stateWithLoading = { + ...initialState, + referralDetailsLoading: true, + }; + const action = setReferralDetailsLoading(false); - // Act - const state = rewardsReducer(stateWithCode, action); + // Act + const state = rewardsReducer(stateWithLoading, action); - // Assert - expect(state.onboardingReferralCode).toBeNull(); - }); + // Assert + expect(state.referralDetailsLoading).toBe(false); + }); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - onboardingActiveStep: OnboardingStep.STEP_2, - referralCode: 'KEEP123', - balanceTotal: 1500, - }; - const action = setOnboardingReferralCode('REF789'); + it('should set referral details loading to false even when referral code exists', () => { + // Arrange + const stateWithReferralCodeAndLoading = { + ...initialState, + referralCode: 'EXISTING123', + referralDetailsLoading: true, + }; + const action = setReferralDetailsLoading(false); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithReferralCodeAndLoading, action); - // Assert - expect(state.onboardingReferralCode).toBe('REF789'); - expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2); - expect(state.referralCode).toBe('KEEP123'); - expect(state.balanceTotal).toBe(1500); - }); + // Assert + expect(state.referralDetailsLoading).toBe(false); }); + }); - describe('setGeoRewardsMetadata', () => { - it('should update geo metadata when payload is provided', () => { - // Arrange - const geoMetadata = { - geoLocation: 'US', - optinAllowedForGeo: true, - }; - const action = setGeoRewardsMetadata(geoMetadata); - - // Act - const state = rewardsReducer(initialState, action); + describe('setOnboardingActiveStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - // Assert - expect(state.geoLocation).toBe('US'); - expect(state.optinAllowedForGeo).toBe(true); - expect(state.optinAllowedForGeoLoading).toBe(false); - }); + afterEach(() => { + jest.restoreAllMocks(); + }); - it('should update geo metadata with different location', () => { - // Arrange - const geoMetadata = { - geoLocation: 'CA', - optinAllowedForGeo: false, - }; - const action = setGeoRewardsMetadata(geoMetadata); + it.each([ + OnboardingStep.INTRO, + OnboardingStep.STEP_1, + OnboardingStep.STEP_2, + OnboardingStep.STEP_3, + OnboardingStep.STEP_4, + ])('should set onboarding active step to %s', (step) => { + // Arrange + const action = setOnboardingActiveStep(step); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.geoLocation).toBe('CA'); - expect(state.optinAllowedForGeo).toBe(false); - expect(state.optinAllowedForGeoLoading).toBe(false); - }); + // Assert + expect(state.onboardingActiveStep).toBe(step); + }); - it('should clear geo metadata when payload is null', () => { - // Arrange - const stateWithGeoData = { - ...initialState, - geoLocation: 'EU', - optinAllowedForGeo: true, - optinAllowedForGeoLoading: true, - }; - const action = setGeoRewardsMetadata(null); + it('should update from different onboarding step', () => { + // Arrange + const stateWithStep = { + ...initialState, + onboardingActiveStep: OnboardingStep.STEP_2, + }; + const action = setOnboardingActiveStep(OnboardingStep.STEP_4); - // Act - const state = rewardsReducer(stateWithGeoData, action); + // Act + const state = rewardsReducer(stateWithStep, action); - // Assert - expect(state.geoLocation).toBe(null); - expect(state.optinAllowedForGeo).toBe(null); - expect(state.optinAllowedForGeoLoading).toBe(false); - }); + // Assert + expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_4); + }); - it('should reset loading state when metadata is set', () => { - // Arrange - const stateWithLoading = { - ...initialState, - optinAllowedForGeoLoading: true, - }; - const geoMetadata = { - geoLocation: 'UK', - optinAllowedForGeo: true, - }; - const action = setGeoRewardsMetadata(geoMetadata); + it('should call logger even when step is the same', () => { + // Arrange + const stateWithStep = { + ...initialState, + onboardingActiveStep: OnboardingStep.STEP_1, + }; + const action = setOnboardingActiveStep(OnboardingStep.STEP_1); - // Act - const state = rewardsReducer(stateWithLoading, action); + // Act + const state = rewardsReducer(stateWithStep, action); - // Assert - expect(state.geoLocation).toBe('UK'); - expect(state.optinAllowedForGeo).toBe(true); - expect(state.optinAllowedForGeoLoading).toBe(false); - }); + // Assert + expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_1); }); + }); - describe('setGeoRewardsMetadataLoading', () => { - it('should set geo rewards metadata loading to true', () => { - // Arrange - const action = setGeoRewardsMetadataLoading(true); + describe('resetOnboarding', () => { + it('should reset onboarding to INTRO step and clear referral code', () => { + // Arrange + const stateWithStep = { + ...initialState, + onboardingActiveStep: OnboardingStep.STEP_3, + onboardingReferralCode: 'REF123', + }; + const action = resetOnboarding(); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(stateWithStep, action); - // Assert - expect(state.optinAllowedForGeoLoading).toBe(true); - }); + // Assert + expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO); + expect(state.onboardingReferralCode).toBeNull(); + }); - it('should set geo rewards metadata loading to false', () => { - // Arrange - const stateWithLoading = { - ...initialState, - optinAllowedForGeoLoading: true, - }; - const action = setGeoRewardsMetadataLoading(false); + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + onboardingActiveStep: OnboardingStep.STEP_4, + onboardingReferralCode: 'REF456', + referralCode: 'KEEP123', + balanceTotal: 1500, + }; + const action = resetOnboarding(); - // Act - const state = rewardsReducer(stateWithLoading, action); + // Act + const state = rewardsReducer(stateWithData, action); - // Assert - expect(state.optinAllowedForGeoLoading).toBe(false); - }); + // Assert + expect(state.onboardingActiveStep).toBe(OnboardingStep.INTRO); + expect(state.onboardingReferralCode).toBeNull(); + expect(state.referralCode).toBe('KEEP123'); + expect(state.balanceTotal).toBe(1500); }); + }); - describe('setGeoRewardsMetadataError', () => { - it('should set geo rewards metadata error to true', () => { - // Arrange - const action = setGeoRewardsMetadataError(true); + describe('setOnboardingReferralCode', () => { + it('should set onboarding referral code', () => { + // Arrange + const action = setOnboardingReferralCode('REF123'); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.optinAllowedForGeoError).toBe(true); - }); + // Assert + expect(state.onboardingReferralCode).toBe('REF123'); + }); - it('should set geo rewards metadata error to false', () => { - // Arrange - const stateWithError = { - ...initialState, - optinAllowedForGeoError: true, - }; - const action = setGeoRewardsMetadataError(false); + it('should update existing onboarding referral code', () => { + // Arrange + const stateWithCode = { + ...initialState, + onboardingReferralCode: 'OLD_REF', + }; + const action = setOnboardingReferralCode('NEW_REF'); - // Act - const state = rewardsReducer(stateWithError, action); + // Act + const state = rewardsReducer(stateWithCode, action); - // Assert - expect(state.optinAllowedForGeoError).toBe(false); - }); + // Assert + expect(state.onboardingReferralCode).toBe('NEW_REF'); + }); - it('should not affect other geo metadata properties', () => { - // Arrange - const stateWithGeoData = { - ...initialState, - geoLocation: 'US', - optinAllowedForGeo: true, - optinAllowedForGeoLoading: true, - }; - const action = setGeoRewardsMetadataError(true); + it('should set onboarding referral code to null', () => { + // Arrange + const stateWithCode = { + ...initialState, + onboardingReferralCode: 'REF123', + }; + const action = setOnboardingReferralCode(null); - // Act - const state = rewardsReducer(stateWithGeoData, action); + // Act + const state = rewardsReducer(stateWithCode, action); - // Assert - expect(state.optinAllowedForGeoError).toBe(true); - expect(state.geoLocation).toBe('US'); - expect(state.optinAllowedForGeo).toBe(true); - expect(state.optinAllowedForGeoLoading).toBe(true); - }); + // Assert + expect(state.onboardingReferralCode).toBeNull(); }); - describe('setCandidateSubscriptionId', () => { - it('should set candidate subscription ID to a string value', () => { - // Arrange - const action = setCandidateSubscriptionId('sub-12345'); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('sub-12345'); - }); + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + onboardingActiveStep: OnboardingStep.STEP_2, + referralCode: 'KEEP123', + balanceTotal: 1500, + }; + const action = setOnboardingReferralCode('REF789'); - it('should set candidate subscription ID to pending', () => { - // Arrange - const stateWithId = { - ...initialState, - candidateSubscriptionId: 'existing-id' as const, - }; - const action = setCandidateSubscriptionId('pending'); + // Act + const state = rewardsReducer(stateWithData, action); - // Act - const state = rewardsReducer(stateWithId, action); + // Assert + expect(state.onboardingReferralCode).toBe('REF789'); + expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2); + expect(state.referralCode).toBe('KEEP123'); + expect(state.balanceTotal).toBe(1500); + }); + }); - // Assert - expect(state.candidateSubscriptionId).toBe('pending'); - }); + describe('setGeoRewardsMetadata', () => { + it('should update geo metadata when payload is provided', () => { + // Arrange + const geoMetadata = { + geoLocation: 'US', + optinAllowedForGeo: true, + }; + const action = setGeoRewardsMetadata(geoMetadata); - it('should set candidate subscription ID to error', () => { - // Arrange - const action = setCandidateSubscriptionId('error'); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(initialState, action); + // Assert + expect(state.geoLocation).toBe('US'); + expect(state.optinAllowedForGeo).toBe(true); + expect(state.optinAllowedForGeoLoading).toBe(false); + }); - // Assert - expect(state.candidateSubscriptionId).toBe('error'); - }); + it('should update geo metadata with different location', () => { + // Arrange + const geoMetadata = { + geoLocation: 'CA', + optinAllowedForGeo: false, + }; + const action = setGeoRewardsMetadata(geoMetadata); - it('should set candidate subscription ID to retry', () => { - // Arrange - const action = setCandidateSubscriptionId('retry'); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(initialState, action); + // Assert + expect(state.geoLocation).toBe('CA'); + expect(state.optinAllowedForGeo).toBe(false); + expect(state.optinAllowedForGeoLoading).toBe(false); + }); - // Assert - expect(state.candidateSubscriptionId).toBe('retry'); - }); + it('should clear geo metadata when payload is null', () => { + // Arrange + const stateWithGeoData = { + ...initialState, + geoLocation: 'EU', + optinAllowedForGeo: true, + optinAllowedForGeoLoading: true, + }; + const action = setGeoRewardsMetadata(null); - it('should set candidate subscription ID to null', () => { - // Arrange - const stateWithId = { - ...initialState, - candidateSubscriptionId: 'existing-id' as const, - }; - const action = setCandidateSubscriptionId(null); + // Act + const state = rewardsReducer(stateWithGeoData, action); - // Act - const state = rewardsReducer(stateWithId, action); + // Assert + expect(state.geoLocation).toBe(null); + expect(state.optinAllowedForGeo).toBe(null); + expect(state.optinAllowedForGeoLoading).toBe(false); + }); - // Assert - expect(state.candidateSubscriptionId).toBe(null); - }); + it('should reset loading state when metadata is set', () => { + // Arrange + const stateWithLoading = { + ...initialState, + optinAllowedForGeoLoading: true, + }; + const geoMetadata = { + geoLocation: 'UK', + optinAllowedForGeo: true, + }; + const action = setGeoRewardsMetadata(geoMetadata); - it('should not affect other state properties when changing from non-valid state', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'pending' as const, - referralCode: 'KEEP123', - balanceTotal: 1500, - }; - const action = setCandidateSubscriptionId('new-id'); + // Act + const state = rewardsReducer(stateWithLoading, action); - // Act - const state = rewardsReducer(stateWithData, action); + // Assert + expect(state.geoLocation).toBe('UK'); + expect(state.optinAllowedForGeo).toBe(true); + expect(state.optinAllowedForGeoLoading).toBe(false); + }); + }); - // Assert - expect(state.candidateSubscriptionId).toBe('new-id'); - expect(state.referralCode).toBe('KEEP123'); - expect(state.balanceTotal).toBe(1500); - }); + describe('setGeoRewardsMetadataLoading', () => { + it('should set geo rewards metadata loading to true', () => { + // Arrange + const action = setGeoRewardsMetadataLoading(true); - describe('state reset logic when candidate ID changes', () => { - it('should reset UI state when changing from valid ID to different valid ID', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'old-subscription-id', - seasonId: 'season-123', - seasonName: 'Test Season', - seasonStartDate: new Date('2024-01-01'), - seasonEndDate: new Date('2024-12-31'), - seasonTiers: [ - { - id: 'tier-1', - name: 'Tier 1', - pointsNeeded: 100, - image: { - lightModeUrl: 'tier1.png', - darkModeUrl: 'tier1-dark.png', - }, - levelNumber: '1', - rewards: [], - }, - ], - referralCode: 'REF123', - refereeCount: 5, - currentTier: { - id: 'current-tier', - name: 'Current Tier', - pointsNeeded: 1000, - image: { - lightModeUrl: 'current.png', - darkModeUrl: 'current-dark.png', - }, - levelNumber: '2', - rewards: [], - }, - nextTier: { - id: 'next-tier', - name: 'Next Tier', - pointsNeeded: 2000, - image: { - lightModeUrl: 'next.png', - darkModeUrl: 'next-dark.png', - }, - levelNumber: '3', - rewards: [], - }, - nextTierPointsNeeded: 1000, - balanceTotal: 1500, - balanceRefereePortion: 300, - balanceUpdatedAt: new Date('2024-06-01'), - onboardingActiveStep: OnboardingStep.STEP_2, - onboardingReferralCode: 'ONBOARDING_REF', - activeBoosts: [ - { - id: 'boost-1', - name: 'Test Boost', - icon: { - lightModeUrl: 'boost.png', - darkModeUrl: 'boost-dark.png', - }, - boostBips: 1000, - seasonLong: true, - backgroundColor: '#FF0000', - }, - ], - pointsEvents: [ - { - id: 'event-1', - type: 'SWAP' as const, - timestamp: new Date('2024-01-01'), - value: 100, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-01'), - payload: null, - }, - ], - unlockedRewards: [ - { - id: 'reward-1', - seasonRewardId: 'season-reward-1', - claimStatus: RewardClaimStatus.CLAIMED, - }, - ], - }; - const action = setCandidateSubscriptionId('new-subscription-id'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('new-subscription-id'); - // All UI state should be reset to initial values - expect(state.seasonId).toBe(initialState.seasonId); - expect(state.seasonName).toBe(initialState.seasonName); - expect(state.seasonStartDate).toBe(initialState.seasonStartDate); - expect(state.seasonEndDate).toBe(initialState.seasonEndDate); - expect(state.seasonTiers).toEqual(initialState.seasonTiers); - expect(state.referralCode).toBe(initialState.referralCode); - expect(state.refereeCount).toBe(initialState.refereeCount); - expect(state.currentTier).toBe(initialState.currentTier); - expect(state.nextTier).toBe(initialState.nextTier); - expect(state.nextTierPointsNeeded).toBe( - initialState.nextTierPointsNeeded, - ); - expect(state.balanceTotal).toBe(initialState.balanceTotal); - expect(state.balanceRefereePortion).toBe( - initialState.balanceRefereePortion, - ); - expect(state.balanceUpdatedAt).toBe(initialState.balanceUpdatedAt); - expect(state.activeBoosts).toBe(initialState.activeBoosts); - expect(state.pointsEvents).toBe(initialState.pointsEvents); - expect(state.unlockedRewards).toBe(initialState.unlockedRewards); - // Onboarding state should NOT be reset - expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2); - expect(state.onboardingReferralCode).toBe('ONBOARDING_REF'); - }); - - it('should NOT reset UI state when changing from pending to valid ID', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'pending' as const, - seasonId: 'season-123', - seasonName: 'Test Season', - referralCode: 'REF123', - balanceTotal: 1500, - }; - const action = setCandidateSubscriptionId('new-subscription-id'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('new-subscription-id'); - // UI state should NOT be reset when coming from pending - expect(state.seasonId).toBe('season-123'); - expect(state.seasonName).toBe('Test Season'); - expect(state.referralCode).toBe('REF123'); - expect(state.balanceTotal).toBe(1500); - }); - - it('should NOT reset UI state when changing from error to valid ID', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'error' as const, - seasonId: 'season-456', - seasonName: 'Error Season', - referralCode: 'ERROR123', - balanceTotal: 2000, - }; - const action = setCandidateSubscriptionId('new-subscription-id'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('new-subscription-id'); - // UI state should NOT be reset when coming from error - expect(state.seasonId).toBe('season-456'); - expect(state.seasonName).toBe('Error Season'); - expect(state.referralCode).toBe('ERROR123'); - expect(state.balanceTotal).toBe(2000); - }); - - it('should NOT reset UI state when changing from retry to valid ID', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'retry' as const, - seasonId: 'season-789', - seasonName: 'Retry Season', - referralCode: 'RETRY123', - balanceTotal: 3000, - }; - const action = setCandidateSubscriptionId('new-subscription-id'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('new-subscription-id'); - // UI state should NOT be reset when coming from retry - expect(state.seasonId).toBe('season-789'); - expect(state.seasonName).toBe('Retry Season'); - expect(state.referralCode).toBe('RETRY123'); - expect(state.balanceTotal).toBe(3000); - }); - - it('should NOT reset UI state when changing from null to valid ID', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: null, - seasonId: 'season-null', - seasonName: 'Null Season', - referralCode: 'NULL123', - balanceTotal: 4000, - }; - const action = setCandidateSubscriptionId('new-subscription-id'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('new-subscription-id'); - // UI state should NOT be reset when coming from null - expect(state.seasonId).toBe('season-null'); - expect(state.seasonName).toBe('Null Season'); - expect(state.referralCode).toBe('NULL123'); - expect(state.balanceTotal).toBe(4000); - }); - - it('should NOT reset UI state when changing to same valid ID', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'same-subscription-id', - seasonId: 'season-same', - seasonName: 'Same Season', - referralCode: 'SAME123', - balanceTotal: 5000, - }; - const action = setCandidateSubscriptionId('same-subscription-id'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('same-subscription-id'); - // UI state should NOT be reset when ID doesn't change - expect(state.seasonId).toBe('season-same'); - expect(state.seasonName).toBe('Same Season'); - expect(state.referralCode).toBe('SAME123'); - expect(state.balanceTotal).toBe(5000); - }); - - it('should reset UI state when changing from valid ID to pending', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'valid-subscription-id', - seasonId: 'season-valid', - seasonName: 'Valid Season', - referralCode: 'VALID123', - balanceTotal: 6000, - }; - const action = setCandidateSubscriptionId('pending'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('pending'); - // UI state should be reset when changing from valid ID to pending - expect(state.seasonId).toBe(initialState.seasonId); - expect(state.seasonName).toBe(initialState.seasonName); - expect(state.referralCode).toBe(initialState.referralCode); - expect(state.balanceTotal).toBe(initialState.balanceTotal); - }); - - it('should reset UI state when changing from valid ID to error', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'valid-subscription-id', - seasonId: 'season-valid', - seasonName: 'Valid Season', - referralCode: 'VALID123', - balanceTotal: 6000, - }; - const action = setCandidateSubscriptionId('error'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('error'); - // UI state should be reset when changing from valid ID to error - expect(state.seasonId).toBe(initialState.seasonId); - expect(state.seasonName).toBe(initialState.seasonName); - expect(state.referralCode).toBe(initialState.referralCode); - expect(state.balanceTotal).toBe(initialState.balanceTotal); - }); - - it('should reset UI state when changing from valid ID to retry', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'valid-subscription-id', - seasonId: 'season-valid', - seasonName: 'Valid Season', - referralCode: 'VALID123', - balanceTotal: 6000, - }; - const action = setCandidateSubscriptionId('retry'); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('retry'); - // UI state should be reset when changing from valid ID to retry - expect(state.seasonId).toBe(initialState.seasonId); - expect(state.seasonName).toBe(initialState.seasonName); - expect(state.referralCode).toBe(initialState.referralCode); - expect(state.balanceTotal).toBe(initialState.balanceTotal); - }); - - it('should reset UI state when changing from valid ID to null', () => { - // Arrange - const stateWithData = { - ...initialState, - candidateSubscriptionId: 'valid-subscription-id', - seasonId: 'season-valid', - seasonName: 'Valid Season', - referralCode: 'VALID123', - balanceTotal: 6000, - }; - const action = setCandidateSubscriptionId(null); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.candidateSubscriptionId).toBe(null); - // UI state should be reset when changing from valid ID to null - expect(state.seasonId).toBe(initialState.seasonId); - expect(state.seasonName).toBe(initialState.seasonName); - expect(state.referralCode).toBe(initialState.referralCode); - expect(state.balanceTotal).toBe(initialState.balanceTotal); - }); - }); + // Act + const state = rewardsReducer(initialState, action); - describe('state transitions between special states', () => { - it('should handle transition from pending to error', () => { - // Arrange - const stateWithPending = { - ...initialState, - candidateSubscriptionId: 'pending' as const, - seasonId: 'season-pending', - referralCode: 'PENDING123', - }; - const action = setCandidateSubscriptionId('error'); - - // Act - const state = rewardsReducer(stateWithPending, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('error'); - expect(state.seasonId).toBe('season-pending'); // Should not reset - expect(state.referralCode).toBe('PENDING123'); // Should not reset - }); - - it('should handle transition from error to retry', () => { - // Arrange - const stateWithError = { - ...initialState, - candidateSubscriptionId: 'error' as const, - seasonId: 'season-error', - referralCode: 'ERROR123', - }; - const action = setCandidateSubscriptionId('retry'); - - // Act - const state = rewardsReducer(stateWithError, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('retry'); - expect(state.seasonId).toBe('season-error'); // Should not reset - expect(state.referralCode).toBe('ERROR123'); // Should not reset - }); - - it('should handle transition from retry to pending', () => { - // Arrange - const stateWithRetry = { - ...initialState, - candidateSubscriptionId: 'retry' as const, - seasonId: 'season-retry', - referralCode: 'RETRY123', - }; - const action = setCandidateSubscriptionId('pending'); - - // Act - const state = rewardsReducer(stateWithRetry, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('pending'); - expect(state.seasonId).toBe('season-retry'); // Should not reset - expect(state.referralCode).toBe('RETRY123'); // Should not reset - }); - - it('should handle transition from null to pending', () => { - // Arrange - const stateWithNull = { - ...initialState, - candidateSubscriptionId: null, - seasonId: 'season-null', - referralCode: 'NULL123', - }; - const action = setCandidateSubscriptionId('pending'); - - // Act - const state = rewardsReducer(stateWithNull, action); - - // Assert - expect(state.candidateSubscriptionId).toBe('pending'); - expect(state.seasonId).toBe('season-null'); // Should not reset - expect(state.referralCode).toBe('NULL123'); // Should not reset - }); - - it('should handle transition from pending to null', () => { - // Arrange - const stateWithPending = { - ...initialState, - candidateSubscriptionId: 'pending' as const, - seasonId: 'season-pending', - referralCode: 'PENDING123', - }; - const action = setCandidateSubscriptionId(null); - - // Act - const state = rewardsReducer(stateWithPending, action); - - // Assert - expect(state.candidateSubscriptionId).toBe(null); - expect(state.seasonId).toBe('season-pending'); // Should not reset - expect(state.referralCode).toBe('PENDING123'); // Should not reset - }); - }); + // Assert + expect(state.optinAllowedForGeoLoading).toBe(true); }); - describe('setHideUnlinkedAccountsBanner', () => { - it('should set hide unlinked accounts banner to true', () => { - // Arrange - const action = setHideUnlinkedAccountsBanner(true); + it('should set geo rewards metadata loading to false', () => { + // Arrange + const stateWithLoading = { + ...initialState, + optinAllowedForGeoLoading: true, + }; + const action = setGeoRewardsMetadataLoading(false); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(stateWithLoading, action); - // Assert - expect(state.hideUnlinkedAccountsBanner).toBe(true); - }); + // Assert + expect(state.optinAllowedForGeoLoading).toBe(false); + }); + }); - it('should set hide unlinked accounts banner to false', () => { - // Arrange - const stateWithBannerHidden = { - ...initialState, - hideUnlinkedAccountsBanner: true, - }; - const action = setHideUnlinkedAccountsBanner(false); + describe('setGeoRewardsMetadataError', () => { + it('should set geo rewards metadata error to true', () => { + // Arrange + const action = setGeoRewardsMetadataError(true); - // Act - const state = rewardsReducer(stateWithBannerHidden, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.hideUnlinkedAccountsBanner).toBe(false); - }); + // Assert + expect(state.optinAllowedForGeoError).toBe(true); + }); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - hideUnlinkedAccountsBanner: false, - referralCode: 'KEEP123', - balanceTotal: 1500, - }; - const action = setHideUnlinkedAccountsBanner(true); + it('should set geo rewards metadata error to false', () => { + // Arrange + const stateWithError = { + ...initialState, + optinAllowedForGeoError: true, + }; + const action = setGeoRewardsMetadataError(false); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithError, action); - // Assert - expect(state.hideUnlinkedAccountsBanner).toBe(true); - expect(state.referralCode).toBe('KEEP123'); - expect(state.balanceTotal).toBe(1500); - }); + // Assert + expect(state.optinAllowedForGeoError).toBe(false); }); - describe('setHideCurrentAccountNotOptedInBanner', () => { - it('should add new account banner entry when it does not exist', () => { - // Arrange - const accountGroupId: AccountGroupId = 'keyring:wallet1/1'; - const action = setHideCurrentAccountNotOptedInBanner({ - accountGroupId, - hide: true, - }); + it('should not affect other geo metadata properties', () => { + // Arrange + const stateWithGeoData = { + ...initialState, + geoLocation: 'US', + optinAllowedForGeo: true, + optinAllowedForGeoLoading: true, + }; + const action = setGeoRewardsMetadataError(true); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(stateWithGeoData, action); - // Assert - expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1); - expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ - accountGroupId, - hide: true, - }); - }); + // Assert + expect(state.optinAllowedForGeoError).toBe(true); + expect(state.geoLocation).toBe('US'); + expect(state.optinAllowedForGeo).toBe(true); + expect(state.optinAllowedForGeoLoading).toBe(true); + }); + }); - it('should update existing account banner entry', () => { - // Arrange - const accountGroupId: AccountGroupId = 'keyring:wallet1/1'; - const stateWithExistingEntry = { - ...initialState, - hideCurrentAccountNotOptedInBanner: [ - { - accountGroupId, - hide: false, - }, - ], - }; - const action = setHideCurrentAccountNotOptedInBanner({ - accountGroupId, - hide: true, - }); + describe('setCandidateSubscriptionId', () => { + it('should set candidate subscription ID to a string value', () => { + // Arrange + const action = setCandidateSubscriptionId('sub-12345'); - // Act - const state = rewardsReducer(stateWithExistingEntry, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1); - expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ - accountGroupId, - hide: true, - }); - }); + // Assert + expect(state.candidateSubscriptionId).toBe('sub-12345'); + }); - it('should add multiple different account entries', () => { - // Arrange - const accountGroupId1: AccountGroupId = 'keyring:wallet1/1'; - const accountGroupId2: AccountGroupId = 'keyring:wallet2/2'; + it('should set candidate subscription ID to pending', () => { + // Arrange + const stateWithId = { + ...initialState, + candidateSubscriptionId: 'existing-id' as const, + }; + const action = setCandidateSubscriptionId('pending'); - let currentState = initialState; + // Act + const state = rewardsReducer(stateWithId, action); - // Add first account - const action1 = setHideCurrentAccountNotOptedInBanner({ - accountGroupId: accountGroupId1, - hide: true, - }); - currentState = rewardsReducer(currentState, action1); + // Assert + expect(state.candidateSubscriptionId).toBe('pending'); + }); - // Add second account - const action2 = setHideCurrentAccountNotOptedInBanner({ - accountGroupId: accountGroupId2, - hide: false, - }); + it('should set candidate subscription ID to error', () => { + // Arrange + const action = setCandidateSubscriptionId('error'); - // Act - const state = rewardsReducer(currentState, action2); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2); - expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ - accountGroupId: accountGroupId1, - hide: true, - }); - expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({ - accountGroupId: accountGroupId2, - hide: false, - }); - }); + // Assert + expect(state.candidateSubscriptionId).toBe('error'); + }); - it('should update specific account without affecting others', () => { - // Arrange - const accountGroupId1: AccountGroupId = 'keyring:wallet1/1'; - const accountGroupId2: AccountGroupId = 'keyring:wallet2/2'; - const stateWithMultipleEntries = { - ...initialState, - hideCurrentAccountNotOptedInBanner: [ - { - accountGroupId: accountGroupId1, - hide: true, - }, - { - accountGroupId: accountGroupId2, - hide: false, - }, - ], - }; - const action = setHideCurrentAccountNotOptedInBanner({ - accountGroupId: accountGroupId1, - hide: false, - }); + it('should set candidate subscription ID to retry', () => { + // Arrange + const action = setCandidateSubscriptionId('retry'); - // Act - const state = rewardsReducer(stateWithMultipleEntries, action); + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2); - expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ - accountGroupId: accountGroupId1, - hide: false, // Updated - }); - expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({ - accountGroupId: accountGroupId2, - hide: false, // Unchanged - }); - }); + // Assert + expect(state.candidateSubscriptionId).toBe('retry'); + }); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - activeTab: 'activity' as const, - referralCode: 'TEST123', - hideUnlinkedAccountsBanner: true, - }; - const accountGroupId: AccountGroupId = 'keyring:wallet1/1'; - const action = setHideCurrentAccountNotOptedInBanner({ - accountGroupId, - hide: true, - }); + it('should set candidate subscription ID to null', () => { + // Arrange + const stateWithId = { + ...initialState, + candidateSubscriptionId: 'existing-id' as const, + }; + const action = setCandidateSubscriptionId(null); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithId, action); - // Assert - expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1); - expect(state.activeTab).toBe('activity'); - expect(state.referralCode).toBe('TEST123'); - expect(state.hideUnlinkedAccountsBanner).toBe(true); - }); + // Assert + expect(state.candidateSubscriptionId).toBe(null); }); - describe('resetRewardsState', () => { - it('should reset all state to initial values', () => { - // Arrange - const stateWithData: RewardsState = { - activeTab: 'activity' as const, - seasonStatusLoading: true, - seasonId: 'test-season-id', - referralDetailsLoading: false, - referralCode: 'TEST123', - refereeCount: 10, - currentTier: { - id: 'tier-platinum', - name: 'Platinum', - pointsNeeded: 1000, - image: { - lightModeUrl: 'platinum.png', - darkModeUrl: 'platinum-dark.png', - }, - levelNumber: 'Level 10', - rewards: [], - }, - seasonStatusError: null, - nextTier: { - id: 'tier-diamond', - name: 'Diamond', - pointsNeeded: 2000, - image: { - lightModeUrl: 'diamond.png', - darkModeUrl: 'diamond-dark.png', - }, - levelNumber: 'Level 20', - rewards: [], - }, - nextTierPointsNeeded: 1000, - balanceTotal: 5000, - balanceRefereePortion: 1000, - balanceUpdatedAt: new Date('2024-01-01'), - seasonName: 'Test Season', - seasonStartDate: new Date('2024-01-01'), - seasonEndDate: new Date('2024-12-31'), - seasonTiers: [ - { - id: 'tier-1', - name: 'Tier 1', - pointsNeeded: 100, - image: { - lightModeUrl: 'tier-1.png', - darkModeUrl: 'tier-1-dark.png', - }, - levelNumber: 'Level 1', - rewards: [], - }, - ], - onboardingActiveStep: OnboardingStep.STEP_1, - onboardingReferralCode: 'REF123', - candidateSubscriptionId: 'some-id', - geoLocation: 'US', - optinAllowedForGeo: true, - optinAllowedForGeoLoading: false, - hideUnlinkedAccountsBanner: true, - hideCurrentAccountNotOptedInBanner: [ - { - accountGroupId: 'keyring:wallet1/1' as AccountGroupId, - hide: true, - }, - ], - activeBoosts: [ - { - id: 'boost-1', - name: 'Test Boost 1', - icon: { - lightModeUrl: 'light1.png', - darkModeUrl: 'dark1.png', - }, - boostBips: 1000, - seasonLong: true, - backgroundColor: '#FF0000', - }, - ], - pointsEvents: null, - activeBoostsLoading: false, - activeBoostsError: false, - unlockedRewards: [], - unlockedRewardLoading: false, - unlockedRewardError: false, - referralDetailsError: false, - optinAllowedForGeoError: false, - }; - const action = resetRewardsState(); + it('should not affect other state properties when changing from non-valid state', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'pending' as const, + referralCode: 'KEEP123', + balanceTotal: 1500, + }; + const action = setCandidateSubscriptionId('new-id'); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithData, action); - // Assert - expect(state).toEqual(initialState); - }); + // Assert + expect(state.candidateSubscriptionId).toBe('new-id'); + expect(state.referralCode).toBe('KEEP123'); + expect(state.balanceTotal).toBe(1500); }); - describe('persist/REHYDRATE', () => { - it('should restore persisted UI state while resetting non-persistent state', () => { + describe('state reset logic when candidate ID changes', () => { + it('should reset UI state when changing from valid ID to different valid ID', () => { // Arrange - const persistedRewardsState: RewardsState = { - activeTab: 'activity', - seasonStatusLoading: true, - seasonId: 'test-season-id', - referralDetailsLoading: false, - referralCode: 'PERSISTED123', - refereeCount: 15, - currentTier: { - id: 'tier-diamond', - name: 'Diamond', - pointsNeeded: 1000, - image: { - lightModeUrl: 'https://example.com/diamond-light.png', - darkModeUrl: 'https://example.com/diamond-dark.png', - }, - levelNumber: '4', - rewards: [], - }, - nextTier: null, - nextTierPointsNeeded: null, - balanceTotal: 2000, - balanceRefereePortion: 400, - balanceUpdatedAt: new Date('2024-05-01'), - seasonName: 'Persisted Season', + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'old-subscription-id', + seasonId: 'season-123', + seasonName: 'Test Season', seasonStartDate: new Date('2024-01-01'), seasonEndDate: new Date('2024-12-31'), seasonTiers: [ @@ -2005,111 +1321,15 @@ describe('rewardsReducer', () => { name: 'Tier 1', pointsNeeded: 100, image: { - lightModeUrl: 'https://example.com/tier1-light.png', - darkModeUrl: 'https://example.com/tier1-dark.png', + lightModeUrl: 'tier1.png', + darkModeUrl: 'tier1-dark.png', }, levelNumber: '1', rewards: [], }, ], - onboardingActiveStep: OnboardingStep.STEP_2, - onboardingReferralCode: 'PERSISTED_REF', - candidateSubscriptionId: 'some-id', - geoLocation: 'CA', - optinAllowedForGeo: true, - optinAllowedForGeoLoading: false, - hideUnlinkedAccountsBanner: true, - hideCurrentAccountNotOptedInBanner: [ - { - accountGroupId: 'keyring:wallet1/1' as AccountGroupId, - hide: true, - }, - ], - activeBoosts: [ - { - id: 'boost-1', - name: 'Test Boost 1', - icon: { - lightModeUrl: 'light1.png', - darkModeUrl: 'dark1.png', - }, - boostBips: 1000, - seasonLong: true, - backgroundColor: '#FF0000', - }, - ], - pointsEvents: null, - seasonStatusError: null, - activeBoostsLoading: false, - activeBoostsError: false, - unlockedRewards: [], - unlockedRewardLoading: false, - unlockedRewardError: false, - referralDetailsError: false, - optinAllowedForGeoError: false, - }; - const rehydrateAction = { - type: 'persist/REHYDRATE', - payload: { - rewards: persistedRewardsState, - }, - }; - - // Act - const state = rewardsReducer(initialState, rehydrateAction); - - // Assert - Should restore persisted UI state while keeping current non-persistent state - const expectedState = { - ...initialState, - // Restored from persisted state - seasonId: persistedRewardsState.seasonId, - seasonName: persistedRewardsState.seasonName, - seasonStartDate: persistedRewardsState.seasonStartDate, - seasonEndDate: persistedRewardsState.seasonEndDate, - seasonTiers: persistedRewardsState.seasonTiers, - referralCode: persistedRewardsState.referralCode, - refereeCount: persistedRewardsState.refereeCount, - currentTier: persistedRewardsState.currentTier, - nextTier: persistedRewardsState.nextTier, - balanceTotal: persistedRewardsState.balanceTotal, - balanceUpdatedAt: persistedRewardsState.balanceUpdatedAt, - activeBoosts: persistedRewardsState.activeBoosts, - pointsEvents: persistedRewardsState.pointsEvents, - unlockedRewards: persistedRewardsState.unlockedRewards, - hideUnlinkedAccountsBanner: - persistedRewardsState.hideUnlinkedAccountsBanner, - hideCurrentAccountNotOptedInBanner: - persistedRewardsState.hideCurrentAccountNotOptedInBanner, - // These fields are restored from persisted state - nextTierPointsNeeded: persistedRewardsState.nextTierPointsNeeded, - balanceRefereePortion: persistedRewardsState.balanceRefereePortion, - }; - expect(state).toEqual(expectedState); - }); - - it('should preserve all persisted UI state fields', () => { - // Arrange - const persistedRewardsState: RewardsState = { - ...initialState, - seasonId: 'persisted-season-id', - seasonName: 'Persisted Season Name', - seasonStartDate: new Date('2024-01-01'), - seasonEndDate: new Date('2024-12-31'), - seasonTiers: [ - { - id: 'tier-persisted', - name: 'Persisted Tier', - pointsNeeded: 500, - image: { - lightModeUrl: 'persisted.png', - darkModeUrl: 'persisted-dark.png', - }, - levelNumber: '2', - rewards: [], - }, - ], - referralCode: 'PERSISTED_CODE', - refereeCount: 25, + referralCode: 'REF123', + refereeCount: 5, currentTier: { id: 'current-tier', name: 'Current Tier', @@ -2118,7 +1338,7 @@ describe('rewardsReducer', () => { lightModeUrl: 'current.png', darkModeUrl: 'current-dark.png', }, - levelNumber: '3', + levelNumber: '2', rewards: [], }, nextTier: { @@ -2129,1086 +1349,1871 @@ describe('rewardsReducer', () => { lightModeUrl: 'next.png', darkModeUrl: 'next-dark.png', }, - levelNumber: '4', + levelNumber: '3', rewards: [], }, - balanceTotal: 3000, + nextTierPointsNeeded: 1000, + balanceTotal: 1500, + balanceRefereePortion: 300, balanceUpdatedAt: new Date('2024-06-01'), + onboardingActiveStep: OnboardingStep.STEP_2, + onboardingReferralCode: 'ONBOARDING_REF', activeBoosts: [ { - id: 'persisted-boost', - name: 'Persisted Boost', + id: 'boost-1', + name: 'Test Boost', icon: { lightModeUrl: 'boost.png', darkModeUrl: 'boost-dark.png', }, - boostBips: 1500, + boostBips: 1000, seasonLong: true, - backgroundColor: '#00FF00', + backgroundColor: '#FF0000', + }, + ], + pointsEvents: [ + { + id: 'event-1', + type: 'SWAP' as const, + timestamp: new Date('2024-01-01'), + value: 100, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-01'), + payload: null, }, ], - pointsEvents: [], unlockedRewards: [ { - id: 'unlocked-reward', - seasonRewardId: 'season-reward-id', - claimStatus: RewardClaimStatus.UNCLAIMED, + id: 'reward-1', + seasonRewardId: 'season-reward-1', + claimStatus: RewardClaimStatus.CLAIMED, }, ], - hideUnlinkedAccountsBanner: true, - hideCurrentAccountNotOptedInBanner: [ + seasonActivityTypes: [ { - accountGroupId: 'keyring:wallet1/1' as AccountGroupId, - hide: true, + type: 'PREDICT', + title: 'Predict', + description: 'Prediction', + icon: 'Speedometer', }, ], }; - const rehydrateAction = { - type: 'persist/REHYDRATE', - payload: { - rewards: persistedRewardsState, - }, + const action = setCandidateSubscriptionId('new-subscription-id'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('new-subscription-id'); + // All UI state should be reset to initial values + expect(state.seasonId).toBe(initialState.seasonId); + expect(state.seasonName).toBe(initialState.seasonName); + expect(state.seasonStartDate).toBe(initialState.seasonStartDate); + expect(state.seasonEndDate).toBe(initialState.seasonEndDate); + expect(state.seasonTiers).toEqual(initialState.seasonTiers); + expect(state.referralCode).toBe(initialState.referralCode); + expect(state.refereeCount).toBe(initialState.refereeCount); + expect(state.currentTier).toBe(initialState.currentTier); + expect(state.nextTier).toBe(initialState.nextTier); + expect(state.nextTierPointsNeeded).toBe( + initialState.nextTierPointsNeeded, + ); + expect(state.balanceTotal).toBe(initialState.balanceTotal); + expect(state.balanceRefereePortion).toBe( + initialState.balanceRefereePortion, + ); + expect(state.balanceUpdatedAt).toBe(initialState.balanceUpdatedAt); + expect(state.activeBoosts).toBe(initialState.activeBoosts); + expect(state.pointsEvents).toBe(initialState.pointsEvents); + expect(state.unlockedRewards).toBe(initialState.unlockedRewards); + expect(state.seasonActivityTypes).toEqual( + initialState.seasonActivityTypes, + ); + // Onboarding state should NOT be reset + expect(state.onboardingActiveStep).toBe(OnboardingStep.STEP_2); + expect(state.onboardingReferralCode).toBe('ONBOARDING_REF'); + }); + + it('should NOT reset UI state when changing from pending to valid ID', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'pending' as const, + seasonId: 'season-123', + seasonName: 'Test Season', + referralCode: 'REF123', + balanceTotal: 1500, + }; + const action = setCandidateSubscriptionId('new-subscription-id'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('new-subscription-id'); + // UI state should NOT be reset when coming from pending + expect(state.seasonId).toBe('season-123'); + expect(state.seasonName).toBe('Test Season'); + expect(state.referralCode).toBe('REF123'); + expect(state.balanceTotal).toBe(1500); + }); + + it('should NOT reset UI state when changing from error to valid ID', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'error' as const, + seasonId: 'season-456', + seasonName: 'Error Season', + referralCode: 'ERROR123', + balanceTotal: 2000, + }; + const action = setCandidateSubscriptionId('new-subscription-id'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('new-subscription-id'); + // UI state should NOT be reset when coming from error + expect(state.seasonId).toBe('season-456'); + expect(state.seasonName).toBe('Error Season'); + expect(state.referralCode).toBe('ERROR123'); + expect(state.balanceTotal).toBe(2000); + }); + + it('should NOT reset UI state when changing from retry to valid ID', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'retry' as const, + seasonId: 'season-789', + seasonName: 'Retry Season', + referralCode: 'RETRY123', + balanceTotal: 3000, + }; + const action = setCandidateSubscriptionId('new-subscription-id'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('new-subscription-id'); + // UI state should NOT be reset when coming from retry + expect(state.seasonId).toBe('season-789'); + expect(state.seasonName).toBe('Retry Season'); + expect(state.referralCode).toBe('RETRY123'); + expect(state.balanceTotal).toBe(3000); + }); + + it('should NOT reset UI state when changing from null to valid ID', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: null, + seasonId: 'season-null', + seasonName: 'Null Season', + referralCode: 'NULL123', + balanceTotal: 4000, + }; + const action = setCandidateSubscriptionId('new-subscription-id'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('new-subscription-id'); + // UI state should NOT be reset when coming from null + expect(state.seasonId).toBe('season-null'); + expect(state.seasonName).toBe('Null Season'); + expect(state.referralCode).toBe('NULL123'); + expect(state.balanceTotal).toBe(4000); + }); + + it('should NOT reset UI state when changing to same valid ID', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'same-subscription-id', + seasonId: 'season-same', + seasonName: 'Same Season', + referralCode: 'SAME123', + balanceTotal: 5000, }; + const action = setCandidateSubscriptionId('same-subscription-id'); // Act - const state = rewardsReducer(initialState, rehydrateAction); + const state = rewardsReducer(stateWithData, action); - // Assert - All persisted UI state should be preserved - expect(state.seasonId).toBe(persistedRewardsState.seasonId); - expect(state.seasonName).toBe(persistedRewardsState.seasonName); - expect(state.seasonStartDate).toEqual( - persistedRewardsState.seasonStartDate, - ); - expect(state.seasonEndDate).toEqual( - persistedRewardsState.seasonEndDate, - ); - expect(state.seasonTiers).toEqual(persistedRewardsState.seasonTiers); - expect(state.referralCode).toBe(persistedRewardsState.referralCode); - expect(state.refereeCount).toBe(persistedRewardsState.refereeCount); - expect(state.currentTier).toEqual(persistedRewardsState.currentTier); - expect(state.nextTier).toEqual(persistedRewardsState.nextTier); - expect(state.balanceTotal).toBe(persistedRewardsState.balanceTotal); - expect(state.balanceUpdatedAt).toEqual( - persistedRewardsState.balanceUpdatedAt, - ); - expect(state.activeBoosts).toEqual(persistedRewardsState.activeBoosts); - expect(state.pointsEvents).toEqual(persistedRewardsState.pointsEvents); - expect(state.unlockedRewards).toEqual( - persistedRewardsState.unlockedRewards, - ); - expect(state.hideUnlinkedAccountsBanner).toBe( - persistedRewardsState.hideUnlinkedAccountsBanner, - ); - expect(state.hideCurrentAccountNotOptedInBanner).toEqual( - persistedRewardsState.hideCurrentAccountNotOptedInBanner, - ); + // Assert + expect(state.candidateSubscriptionId).toBe('same-subscription-id'); + // UI state should NOT be reset when ID doesn't change + expect(state.seasonId).toBe('season-same'); + expect(state.seasonName).toBe('Same Season'); + expect(state.referralCode).toBe('SAME123'); + expect(state.balanceTotal).toBe(5000); + }); - // Non-persistent state should remain from current state - expect(state.nextTierPointsNeeded).toBe( - initialState.nextTierPointsNeeded, - ); - expect(state.balanceRefereePortion).toBe( - initialState.balanceRefereePortion, - ); + it('should reset UI state when changing from valid ID to pending', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'valid-subscription-id', + seasonId: 'season-valid', + seasonName: 'Valid Season', + referralCode: 'VALID123', + balanceTotal: 6000, + }; + const action = setCandidateSubscriptionId('pending'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('pending'); + // UI state should be reset when changing from valid ID to pending + expect(state.seasonId).toBe(initialState.seasonId); + expect(state.seasonName).toBe(initialState.seasonName); + expect(state.referralCode).toBe(initialState.referralCode); + expect(state.balanceTotal).toBe(initialState.balanceTotal); }); - it('should preserve current non-persistent state while restoring persisted UI state', () => { + it('should reset UI state when changing from valid ID to error', () => { // Arrange - const currentState = { + const stateWithData = { ...initialState, - nextTierPointsNeeded: 500, // This should be preserved - balanceRefereePortion: 100, // This should be preserved - activeTab: 'levels' as const, // This should be reset to initial - seasonStatusLoading: true, // This should be reset to initial - onboardingActiveStep: OnboardingStep.STEP_3, // This should be reset to initial - onboardingReferralCode: 'CURRENT_REF', // This should be reset to initial + candidateSubscriptionId: 'valid-subscription-id', + seasonId: 'season-valid', + seasonName: 'Valid Season', + referralCode: 'VALID123', + balanceTotal: 6000, }; - const persistedRewardsState: RewardsState = { + const action = setCandidateSubscriptionId('error'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('error'); + // UI state should be reset when changing from valid ID to error + expect(state.seasonId).toBe(initialState.seasonId); + expect(state.seasonName).toBe(initialState.seasonName); + expect(state.referralCode).toBe(initialState.referralCode); + expect(state.balanceTotal).toBe(initialState.balanceTotal); + }); + + it('should reset UI state when changing from valid ID to retry', () => { + // Arrange + const stateWithData = { ...initialState, - seasonId: 'persisted-season', - seasonName: 'Persisted Season', - referralCode: 'PERSISTED123', - balanceTotal: 2000, - hideUnlinkedAccountsBanner: true, - onboardingActiveStep: OnboardingStep.STEP_4, // This should NOT be persisted - onboardingReferralCode: 'PERSISTED_REF', // This should NOT be persisted + candidateSubscriptionId: 'valid-subscription-id', + seasonId: 'season-valid', + seasonName: 'Valid Season', + referralCode: 'VALID123', + balanceTotal: 6000, }; - const rehydrateAction = { - type: 'persist/REHYDRATE', - payload: { - rewards: persistedRewardsState, - }, + const action = setCandidateSubscriptionId('retry'); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('retry'); + // UI state should be reset when changing from valid ID to retry + expect(state.seasonId).toBe(initialState.seasonId); + expect(state.seasonName).toBe(initialState.seasonName); + expect(state.referralCode).toBe(initialState.referralCode); + expect(state.balanceTotal).toBe(initialState.balanceTotal); + }); + + it('should reset UI state when changing from valid ID to null', () => { + // Arrange + const stateWithData = { + ...initialState, + candidateSubscriptionId: 'valid-subscription-id', + seasonId: 'season-valid', + seasonName: 'Valid Season', + referralCode: 'VALID123', + balanceTotal: 6000, }; + const action = setCandidateSubscriptionId(null); // Act - const state = rewardsReducer(currentState, rehydrateAction); + const state = rewardsReducer(stateWithData, action); - // Assert - Non-persistent state should be preserved from current state - expect(state.nextTierPointsNeeded).toBe(null); // Restored from persisted state (initialState) - expect(state.balanceRefereePortion).toBe(0); // Restored from persisted state (initialState) + // Assert + expect(state.candidateSubscriptionId).toBe(null); + // UI state should be reset when changing from valid ID to null + expect(state.seasonId).toBe(initialState.seasonId); + expect(state.seasonName).toBe(initialState.seasonName); + expect(state.referralCode).toBe(initialState.referralCode); + expect(state.balanceTotal).toBe(initialState.balanceTotal); + }); + }); - // Persisted UI state should be restored - expect(state.seasonId).toBe('persisted-season'); - expect(state.seasonName).toBe('Persisted Season'); - expect(state.referralCode).toBe('PERSISTED123'); - expect(state.balanceTotal).toBe(2000); - expect(state.hideUnlinkedAccountsBanner).toBe(true); + describe('state transitions between special states', () => { + it('should handle transition from pending to error', () => { + // Arrange + const stateWithPending = { + ...initialState, + candidateSubscriptionId: 'pending' as const, + seasonId: 'season-pending', + referralCode: 'PENDING123', + }; + const action = setCandidateSubscriptionId('error'); - // Non-persistent state should be reset to initial - expect(state.activeTab).toBe(initialState.activeTab); - expect(state.seasonStatusLoading).toBe( - initialState.seasonStatusLoading, - ); - expect(state.onboardingActiveStep).toBe( - initialState.onboardingActiveStep, - ); - expect(state.onboardingReferralCode).toBe( - initialState.onboardingReferralCode, - ); + // Act + const state = rewardsReducer(stateWithPending, action); + + // Assert + expect(state.candidateSubscriptionId).toBe('error'); + expect(state.seasonId).toBe('season-pending'); // Should not reset + expect(state.referralCode).toBe('PENDING123'); // Should not reset }); - it('should return current state when no rewards data in rehydrate payload', () => { + it('should handle transition from error to retry', () => { // Arrange - const currentState = { ...initialState, referralCode: 'CURRENT123' }; - const rehydrateAction = { - type: 'persist/REHYDRATE', - payload: { - someOtherReducer: {}, - }, + const stateWithError = { + ...initialState, + candidateSubscriptionId: 'error' as const, + seasonId: 'season-error', + referralCode: 'ERROR123', }; + const action = setCandidateSubscriptionId('retry'); // Act - const state = rewardsReducer(currentState, rehydrateAction); + const state = rewardsReducer(stateWithError, action); // Assert - expect(state).toEqual(currentState); + expect(state.candidateSubscriptionId).toBe('retry'); + expect(state.seasonId).toBe('season-error'); // Should not reset + expect(state.referralCode).toBe('ERROR123'); // Should not reset }); - it('should return current state when rehydrate payload is empty', () => { + it('should handle transition from retry to pending', () => { // Arrange - const currentState = { ...initialState, referralCode: 'CURRENT123' }; - const rehydrateAction = { - type: 'persist/REHYDRATE', - payload: undefined, + const stateWithRetry = { + ...initialState, + candidateSubscriptionId: 'retry' as const, + seasonId: 'season-retry', + referralCode: 'RETRY123', }; + const action = setCandidateSubscriptionId('pending'); // Act - const state = rewardsReducer(currentState, rehydrateAction); + const state = rewardsReducer(stateWithRetry, action); // Assert - expect(state).toEqual(currentState); + expect(state.candidateSubscriptionId).toBe('pending'); + expect(state.seasonId).toBe('season-retry'); // Should not reset + expect(state.referralCode).toBe('RETRY123'); // Should not reset }); - }); - describe('unknown actions', () => { - it('should return unchanged state for unknown actions', () => { + it('should handle transition from null to pending', () => { // Arrange - const stateWithData = { + const stateWithNull = { ...initialState, - referralCode: 'SOME_CODE', - balanceTotal: 1000, - activeTab: 'activity' as const, + candidateSubscriptionId: null, + seasonId: 'season-null', + referralCode: 'NULL123', }; - const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' }; + const action = setCandidateSubscriptionId('pending'); // Act - const state = rewardsReducer( - stateWithData, - unknownAction as unknown as Action, - ); + const state = rewardsReducer(stateWithNull, action); // Assert - expect(state).toEqual(stateWithData); - expect(state).toBe(stateWithData); // Should be the same reference + expect(state.candidateSubscriptionId).toBe('pending'); + expect(state.seasonId).toBe('season-null'); // Should not reset + expect(state.referralCode).toBe('NULL123'); // Should not reset }); - it('should return initial state for unknown action when state is undefined', () => { + it('should handle transition from pending to null', () => { // Arrange - const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' }; + const stateWithPending = { + ...initialState, + candidateSubscriptionId: 'pending' as const, + seasonId: 'season-pending', + referralCode: 'PENDING123', + }; + const action = setCandidateSubscriptionId(null); // Act - const state = rewardsReducer( - undefined, - unknownAction as unknown as Action, - ); + const state = rewardsReducer(stateWithPending, action); // Assert - expect(state).toEqual(initialState); + expect(state.candidateSubscriptionId).toBe(null); + expect(state.seasonId).toBe('season-pending'); // Should not reset + expect(state.referralCode).toBe('PENDING123'); // Should not reset }); }); }); - describe('setActiveBoosts', () => { - it('should set active boosts array', () => { + describe('setHideUnlinkedAccountsBanner', () => { + it('should set hide unlinked accounts banner to true', () => { // Arrange - const mockBoosts = [ - { - id: 'boost-1', - name: 'Test Boost 1', - icon: { - lightModeUrl: 'light1.png', - darkModeUrl: 'dark1.png', - }, - boostBips: 1000, - seasonLong: true, - backgroundColor: '#FF0000', - }, - { - id: 'boost-2', - name: 'Test Boost 2', - icon: { - lightModeUrl: 'light2.png', - darkModeUrl: 'dark2.png', - }, - boostBips: 500, - seasonLong: false, - startDate: '2024-01-01', - endDate: '2024-01-31', - backgroundColor: '#00FF00', - }, - ]; - const action = setActiveBoosts(mockBoosts); + const action = setHideUnlinkedAccountsBanner(true); // Act const state = rewardsReducer(initialState, action); // Assert - expect(state.activeBoosts).toEqual(mockBoosts); - expect(state.activeBoosts).toHaveLength(2); - expect(state.activeBoosts?.[0]?.id).toBe('boost-1'); - expect(state.activeBoosts?.[1]?.seasonLong).toBe(false); + expect(state.hideUnlinkedAccountsBanner).toBe(true); }); - it('should replace existing active boosts', () => { + it('should set hide unlinked accounts banner to false', () => { // Arrange - const existingBoosts = [ - { - id: 'old-boost', - name: 'Old Boost', - icon: { lightModeUrl: 'old.png', darkModeUrl: 'old.png' }, - boostBips: 100, - seasonLong: true, - backgroundColor: '#000000', - }, - ]; - const stateWithBoosts = { + const stateWithBannerHidden = { ...initialState, - activeBoosts: existingBoosts, + hideUnlinkedAccountsBanner: true, }; - const newBoosts = [ - { - id: 'new-boost', - name: 'New Boost', - icon: { lightModeUrl: 'new.png', darkModeUrl: 'new.png' }, - boostBips: 2000, - seasonLong: false, - backgroundColor: '#FFFFFF', - }, - ]; - const action = setActiveBoosts(newBoosts); + const action = setHideUnlinkedAccountsBanner(false); // Act - const state = rewardsReducer(stateWithBoosts, action); + const state = rewardsReducer(stateWithBannerHidden, action); // Assert - expect(state.activeBoosts).toEqual(newBoosts); - expect(state.activeBoosts).toHaveLength(1); - expect(state.activeBoosts?.[0]?.id).toBe('new-boost'); + expect(state.hideUnlinkedAccountsBanner).toBe(false); }); - it('should set empty array when no boosts provided', () => { + it('should not affect other state properties', () => { // Arrange - const stateWithBoosts = { + const stateWithData = { ...initialState, - activeBoosts: [ + hideUnlinkedAccountsBanner: false, + referralCode: 'KEEP123', + balanceTotal: 1500, + }; + const action = setHideUnlinkedAccountsBanner(true); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.hideUnlinkedAccountsBanner).toBe(true); + expect(state.referralCode).toBe('KEEP123'); + expect(state.balanceTotal).toBe(1500); + }); + }); + + describe('setHideCurrentAccountNotOptedInBanner', () => { + it('should add new account banner entry when it does not exist', () => { + // Arrange + const accountGroupId: AccountGroupId = 'keyring:wallet1/1'; + const action = setHideCurrentAccountNotOptedInBanner({ + accountGroupId, + hide: true, + }); + + // Act + const state = rewardsReducer(initialState, action); + + // Assert + expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1); + expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ + accountGroupId, + hide: true, + }); + }); + + it('should update existing account banner entry', () => { + // Arrange + const accountGroupId: AccountGroupId = 'keyring:wallet1/1'; + const stateWithExistingEntry = { + ...initialState, + hideCurrentAccountNotOptedInBanner: [ { - id: 'existing-boost', - name: 'Existing', - icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, - boostBips: 500, - seasonLong: true, - backgroundColor: '#123456', + accountGroupId, + hide: false, }, ], }; - const action = setActiveBoosts([]); + const action = setHideCurrentAccountNotOptedInBanner({ + accountGroupId, + hide: true, + }); // Act - const state = rewardsReducer(stateWithBoosts, action); + const state = rewardsReducer(stateWithExistingEntry, action); // Assert - expect(state.activeBoosts).toEqual([]); - expect(state.activeBoosts).toHaveLength(0); + expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1); + expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ + accountGroupId, + hide: true, + }); }); - it('should reset activeBoostsError to false when setting active boosts', () => { + it('should add multiple different account entries', () => { // Arrange - const stateWithError = { + const accountGroupId1: AccountGroupId = 'keyring:wallet1/1'; + const accountGroupId2: AccountGroupId = 'keyring:wallet2/2'; + + let currentState = initialState; + + // Add first account + const action1 = setHideCurrentAccountNotOptedInBanner({ + accountGroupId: accountGroupId1, + hide: true, + }); + currentState = rewardsReducer(currentState, action1); + + // Add second account + const action2 = setHideCurrentAccountNotOptedInBanner({ + accountGroupId: accountGroupId2, + hide: false, + }); + + // Act + const state = rewardsReducer(currentState, action2); + + // Assert + expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2); + expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ + accountGroupId: accountGroupId1, + hide: true, + }); + expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({ + accountGroupId: accountGroupId2, + hide: false, + }); + }); + + it('should update specific account without affecting others', () => { + // Arrange + const accountGroupId1: AccountGroupId = 'keyring:wallet1/1'; + const accountGroupId2: AccountGroupId = 'keyring:wallet2/2'; + const stateWithMultipleEntries = { ...initialState, - activeBoostsError: true, - }; - const mockBoosts = [ - { - id: 'boost-1', - name: 'Test Boost', - icon: { - lightModeUrl: 'light.png', - darkModeUrl: 'dark.png', + hideCurrentAccountNotOptedInBanner: [ + { + accountGroupId: accountGroupId1, + hide: true, }, - boostBips: 1000, - seasonLong: true, - backgroundColor: '#FF0000', - }, - ]; - const action = setActiveBoosts(mockBoosts); + { + accountGroupId: accountGroupId2, + hide: false, + }, + ], + }; + const action = setHideCurrentAccountNotOptedInBanner({ + accountGroupId: accountGroupId1, + hide: false, + }); // Act - const state = rewardsReducer(stateWithError, action); + const state = rewardsReducer(stateWithMultipleEntries, action); + + // Assert + expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(2); + expect(state.hideCurrentAccountNotOptedInBanner[0]).toEqual({ + accountGroupId: accountGroupId1, + hide: false, // Updated + }); + expect(state.hideCurrentAccountNotOptedInBanner[1]).toEqual({ + accountGroupId: accountGroupId2, + hide: false, // Unchanged + }); + }); + + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + activeTab: 'activity' as const, + referralCode: 'TEST123', + hideUnlinkedAccountsBanner: true, + }; + const accountGroupId: AccountGroupId = 'keyring:wallet1/1'; + const action = setHideCurrentAccountNotOptedInBanner({ + accountGroupId, + hide: true, + }); + + // Act + const state = rewardsReducer(stateWithData, action); // Assert - expect(state.activeBoosts).toEqual(mockBoosts); - expect(state.activeBoostsError).toBe(false); // Should be reset when successful + expect(state.hideCurrentAccountNotOptedInBanner).toHaveLength(1); + expect(state.activeTab).toBe('activity'); + expect(state.referralCode).toBe('TEST123'); + expect(state.hideUnlinkedAccountsBanner).toBe(true); }); }); - describe('setActiveBoostsLoading', () => { - it('should set activeBoostsLoading to true when no active boosts exist', () => { + describe('resetRewardsState', () => { + it('should reset all state to initial values', () => { // Arrange - const action = setActiveBoostsLoading(true); + const stateWithData: RewardsState = { + activeTab: 'activity' as const, + seasonStatusLoading: true, + seasonId: 'test-season-id', + referralDetailsLoading: false, + referralCode: 'TEST123', + refereeCount: 10, + currentTier: { + id: 'tier-platinum', + name: 'Platinum', + pointsNeeded: 1000, + image: { + lightModeUrl: 'platinum.png', + darkModeUrl: 'platinum-dark.png', + }, + levelNumber: 'Level 10', + rewards: [], + }, + seasonStatusError: null, + nextTier: { + id: 'tier-diamond', + name: 'Diamond', + pointsNeeded: 2000, + image: { + lightModeUrl: 'diamond.png', + darkModeUrl: 'diamond-dark.png', + }, + levelNumber: 'Level 20', + rewards: [], + }, + nextTierPointsNeeded: 1000, + balanceTotal: 5000, + balanceRefereePortion: 1000, + balanceUpdatedAt: new Date('2024-01-01'), + seasonName: 'Test Season', + seasonStartDate: new Date('2024-01-01'), + seasonEndDate: new Date('2024-12-31'), + seasonTiers: [ + { + id: 'tier-1', + name: 'Tier 1', + pointsNeeded: 100, + image: { + lightModeUrl: 'tier-1.png', + darkModeUrl: 'tier-1-dark.png', + }, + levelNumber: 'Level 1', + rewards: [], + }, + ], + seasonActivityTypes: [], + onboardingActiveStep: OnboardingStep.STEP_1, + onboardingReferralCode: 'REF123', + candidateSubscriptionId: 'some-id', + geoLocation: 'US', + optinAllowedForGeo: true, + optinAllowedForGeoLoading: false, + hideUnlinkedAccountsBanner: true, + hideCurrentAccountNotOptedInBanner: [ + { + accountGroupId: 'keyring:wallet1/1' as AccountGroupId, + hide: true, + }, + ], + activeBoosts: [ + { + id: 'boost-1', + name: 'Test Boost 1', + icon: { + lightModeUrl: 'light1.png', + darkModeUrl: 'dark1.png', + }, + boostBips: 1000, + seasonLong: true, + backgroundColor: '#FF0000', + }, + ], + pointsEvents: null, + activeBoostsLoading: false, + activeBoostsError: false, + unlockedRewards: [], + unlockedRewardLoading: false, + unlockedRewardError: false, + referralDetailsError: false, + optinAllowedForGeoError: false, + }; + const action = resetRewardsState(); // Act - const state = rewardsReducer(initialState, action); + const state = rewardsReducer(stateWithData, action); // Assert - expect(state.activeBoostsLoading).toBe(true); + expect(state).toEqual(initialState); }); + }); - it('should not set activeBoostsLoading to true when active boosts already exist', () => { + describe('persist/REHYDRATE', () => { + it('should restore persisted UI state while resetting non-persistent state', () => { // Arrange - const stateWithBoosts = { - ...initialState, + const persistedRewardsState: RewardsState = { + activeTab: 'activity', + seasonStatusLoading: true, + seasonId: 'test-season-id', + referralDetailsLoading: false, + referralCode: 'PERSISTED123', + refereeCount: 15, + currentTier: { + id: 'tier-diamond', + name: 'Diamond', + pointsNeeded: 1000, + image: { + lightModeUrl: 'https://example.com/diamond-light.png', + darkModeUrl: 'https://example.com/diamond-dark.png', + }, + levelNumber: '4', + rewards: [], + }, + nextTier: null, + nextTierPointsNeeded: null, + balanceTotal: 2000, + balanceRefereePortion: 400, + balanceUpdatedAt: new Date('2024-05-01'), + seasonName: 'Persisted Season', + seasonStartDate: new Date('2024-01-01'), + seasonEndDate: new Date('2024-12-31'), + seasonTiers: [ + { + id: 'tier-1', + name: 'Tier 1', + pointsNeeded: 100, + image: { + lightModeUrl: 'https://example.com/tier1-light.png', + darkModeUrl: 'https://example.com/tier1-dark.png', + }, + levelNumber: '1', + rewards: [], + }, + ], + seasonActivityTypes: [], + onboardingActiveStep: OnboardingStep.STEP_2, + onboardingReferralCode: 'PERSISTED_REF', + candidateSubscriptionId: 'some-id', + geoLocation: 'CA', + optinAllowedForGeo: true, + optinAllowedForGeoLoading: false, + hideUnlinkedAccountsBanner: true, + hideCurrentAccountNotOptedInBanner: [ + { + accountGroupId: 'keyring:wallet1/1' as AccountGroupId, + hide: true, + }, + ], activeBoosts: [ { - id: 'existing-boost', - name: 'Existing Boost', - icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, + id: 'boost-1', + name: 'Test Boost 1', + icon: { + lightModeUrl: 'light1.png', + darkModeUrl: 'dark1.png', + }, boostBips: 1000, seasonLong: true, backgroundColor: '#FF0000', }, ], + pointsEvents: null, + seasonStatusError: null, activeBoostsLoading: false, + activeBoostsError: false, + unlockedRewards: [], + unlockedRewardLoading: false, + unlockedRewardError: false, + referralDetailsError: false, + optinAllowedForGeoError: false, + }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { + rewards: persistedRewardsState, + }, }; - const action = setActiveBoostsLoading(true); // Act - const state = rewardsReducer(stateWithBoosts, action); + const state = rewardsReducer(initialState, rehydrateAction); - // Assert - expect(state.activeBoostsLoading).toBe(false); // Should remain false due to guard clause + // Assert - Should restore persisted UI state while keeping current non-persistent state + const expectedState = { + ...initialState, + // Restored from persisted state + seasonId: persistedRewardsState.seasonId, + seasonName: persistedRewardsState.seasonName, + seasonStartDate: persistedRewardsState.seasonStartDate, + seasonEndDate: persistedRewardsState.seasonEndDate, + seasonTiers: persistedRewardsState.seasonTiers, + seasonActivityTypes: persistedRewardsState.seasonActivityTypes, + referralCode: persistedRewardsState.referralCode, + refereeCount: persistedRewardsState.refereeCount, + currentTier: persistedRewardsState.currentTier, + nextTier: persistedRewardsState.nextTier, + balanceTotal: persistedRewardsState.balanceTotal, + balanceUpdatedAt: persistedRewardsState.balanceUpdatedAt, + activeBoosts: persistedRewardsState.activeBoosts, + pointsEvents: persistedRewardsState.pointsEvents, + unlockedRewards: persistedRewardsState.unlockedRewards, + hideUnlinkedAccountsBanner: + persistedRewardsState.hideUnlinkedAccountsBanner, + hideCurrentAccountNotOptedInBanner: + persistedRewardsState.hideCurrentAccountNotOptedInBanner, + // These fields are restored from persisted state + nextTierPointsNeeded: persistedRewardsState.nextTierPointsNeeded, + balanceRefereePortion: persistedRewardsState.balanceRefereePortion, + }; + expect(state).toEqual(expectedState); }); - it('should set activeBoostsLoading to false', () => { - // Arrange - const stateWithLoading = { + it('should restore seasonActivityTypes from persisted state', () => { + const persistedRewardsState: RewardsState = { ...initialState, - activeBoostsLoading: true, + seasonId: 'persisted-season-id', + seasonActivityTypes: [ + { + type: 'MUSD_DEPOSIT', + title: 'mUSD deposit', + description: 'Deposit mUSD', + icon: 'Coin', + }, + ], + }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { + rewards: persistedRewardsState, + }, }; - const action = setActiveBoostsLoading(false); - // Act - const state = rewardsReducer(stateWithLoading, action); + const state = rewardsReducer(initialState, rehydrateAction); - // Assert - expect(state.activeBoostsLoading).toBe(false); + expect(state.seasonActivityTypes).toEqual( + persistedRewardsState.seasonActivityTypes, + ); }); - it('should set activeBoostsLoading to false even when active boosts exist', () => { + it('should preserve all persisted UI state fields', () => { // Arrange - const stateWithBoostsAndLoading = { + const persistedRewardsState: RewardsState = { ...initialState, + seasonId: 'persisted-season-id', + seasonName: 'Persisted Season Name', + seasonStartDate: new Date('2024-01-01'), + seasonEndDate: new Date('2024-12-31'), + seasonTiers: [ + { + id: 'tier-persisted', + name: 'Persisted Tier', + pointsNeeded: 500, + image: { + lightModeUrl: 'persisted.png', + darkModeUrl: 'persisted-dark.png', + }, + levelNumber: '2', + rewards: [], + }, + ], + referralCode: 'PERSISTED_CODE', + refereeCount: 25, + currentTier: { + id: 'current-tier', + name: 'Current Tier', + pointsNeeded: 1000, + image: { + lightModeUrl: 'current.png', + darkModeUrl: 'current-dark.png', + }, + levelNumber: '3', + rewards: [], + }, + nextTier: { + id: 'next-tier', + name: 'Next Tier', + pointsNeeded: 2000, + image: { + lightModeUrl: 'next.png', + darkModeUrl: 'next-dark.png', + }, + levelNumber: '4', + rewards: [], + }, + balanceTotal: 3000, + balanceUpdatedAt: new Date('2024-06-01'), activeBoosts: [ { - id: 'existing-boost', - name: 'Existing Boost', - icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, - boostBips: 1000, + id: 'persisted-boost', + name: 'Persisted Boost', + icon: { + lightModeUrl: 'boost.png', + darkModeUrl: 'boost-dark.png', + }, + boostBips: 1500, seasonLong: true, - backgroundColor: '#FF0000', + backgroundColor: '#00FF00', + }, + ], + pointsEvents: [], + unlockedRewards: [ + { + id: 'unlocked-reward', + seasonRewardId: 'season-reward-id', + claimStatus: RewardClaimStatus.UNCLAIMED, }, ], - activeBoostsLoading: true, + hideUnlinkedAccountsBanner: true, + hideCurrentAccountNotOptedInBanner: [ + { + accountGroupId: 'keyring:wallet1/1' as AccountGroupId, + hide: true, + }, + ], + }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { + rewards: persistedRewardsState, + }, }; - const action = setActiveBoostsLoading(false); // Act - const state = rewardsReducer(stateWithBoostsAndLoading, action); + const state = rewardsReducer(initialState, rehydrateAction); - // Assert - expect(state.activeBoostsLoading).toBe(false); + // Assert - All persisted UI state should be preserved + expect(state.seasonId).toBe(persistedRewardsState.seasonId); + expect(state.seasonName).toBe(persistedRewardsState.seasonName); + expect(state.seasonStartDate).toEqual( + persistedRewardsState.seasonStartDate, + ); + expect(state.seasonEndDate).toEqual(persistedRewardsState.seasonEndDate); + expect(state.seasonTiers).toEqual(persistedRewardsState.seasonTiers); + expect(state.referralCode).toBe(persistedRewardsState.referralCode); + expect(state.refereeCount).toBe(persistedRewardsState.refereeCount); + expect(state.currentTier).toEqual(persistedRewardsState.currentTier); + expect(state.nextTier).toEqual(persistedRewardsState.nextTier); + expect(state.balanceTotal).toBe(persistedRewardsState.balanceTotal); + expect(state.balanceUpdatedAt).toEqual( + persistedRewardsState.balanceUpdatedAt, + ); + expect(state.activeBoosts).toEqual(persistedRewardsState.activeBoosts); + expect(state.pointsEvents).toEqual(persistedRewardsState.pointsEvents); + expect(state.unlockedRewards).toEqual( + persistedRewardsState.unlockedRewards, + ); + expect(state.hideUnlinkedAccountsBanner).toBe( + persistedRewardsState.hideUnlinkedAccountsBanner, + ); + expect(state.hideCurrentAccountNotOptedInBanner).toEqual( + persistedRewardsState.hideCurrentAccountNotOptedInBanner, + ); + + // Non-persistent state should remain from current state + expect(state.nextTierPointsNeeded).toBe( + initialState.nextTierPointsNeeded, + ); + expect(state.balanceRefereePortion).toBe( + initialState.balanceRefereePortion, + ); }); - it('should not affect other state properties', () => { + it('should preserve current non-persistent state while restoring persisted UI state', () => { // Arrange - const stateWithData = { + const currentState = { ...initialState, - activeTab: 'activity' as const, - referralCode: 'TEST123', + nextTierPointsNeeded: 500, // This should be preserved + balanceRefereePortion: 100, // This should be preserved + activeTab: 'levels' as const, // This should be reset to initial + seasonStatusLoading: true, // This should be reset to initial + onboardingActiveStep: OnboardingStep.STEP_3, // This should be reset to initial + onboardingReferralCode: 'CURRENT_REF', // This should be reset to initial + }; + const persistedRewardsState: RewardsState = { + ...initialState, + seasonId: 'persisted-season', + seasonName: 'Persisted Season', + referralCode: 'PERSISTED123', + balanceTotal: 2000, + hideUnlinkedAccountsBanner: true, + onboardingActiveStep: OnboardingStep.STEP_4, // This should NOT be persisted + onboardingReferralCode: 'PERSISTED_REF', // This should NOT be persisted + }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { + rewards: persistedRewardsState, + }, }; - const action = setActiveBoostsLoading(true); // Act - const state = rewardsReducer(stateWithData, action); + const state = rewardsReducer(currentState, rehydrateAction); - // Assert - expect(state.activeBoostsLoading).toBe(true); - expect(state.activeTab).toBe('activity'); - expect(state.referralCode).toBe('TEST123'); - expect(state.activeBoosts).toBeNull(); + // Assert - Non-persistent state should be preserved from current state + expect(state.nextTierPointsNeeded).toBe(null); // Restored from persisted state (initialState) + expect(state.balanceRefereePortion).toBe(0); // Restored from persisted state (initialState) + + // Persisted UI state should be restored + expect(state.seasonId).toBe('persisted-season'); + expect(state.seasonName).toBe('Persisted Season'); + expect(state.referralCode).toBe('PERSISTED123'); + expect(state.balanceTotal).toBe(2000); + expect(state.hideUnlinkedAccountsBanner).toBe(true); + + // Non-persistent state should be reset to initial + expect(state.activeTab).toBe(initialState.activeTab); + expect(state.seasonStatusLoading).toBe(initialState.seasonStatusLoading); + expect(state.onboardingActiveStep).toBe( + initialState.onboardingActiveStep, + ); + expect(state.onboardingReferralCode).toBe( + initialState.onboardingReferralCode, + ); }); - }); - describe('setActiveBoostsError', () => { - it('should set activeBoostsError to true', () => { + it('should return current state when no rewards data in rehydrate payload', () => { // Arrange - const action = setActiveBoostsError(true); + const currentState = { ...initialState, referralCode: 'CURRENT123' }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { + someOtherReducer: {}, + }, + }; // Act - const state = rewardsReducer(initialState, action); + const state = rewardsReducer(currentState, rehydrateAction); // Assert - expect(state.activeBoostsError).toBe(true); + expect(state).toEqual(currentState); }); - it('should set activeBoostsError to false', () => { + it('should return current state when rehydrate payload is empty', () => { // Arrange - const stateWithError = { - ...initialState, - activeBoostsError: true, + const currentState = { ...initialState, referralCode: 'CURRENT123' }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: undefined, }; - const action = setActiveBoostsError(false); // Act - const state = rewardsReducer(stateWithError, action); + const state = rewardsReducer(currentState, rehydrateAction); // Assert - expect(state.activeBoostsError).toBe(false); + expect(state).toEqual(currentState); }); + }); - it('should not affect other state properties', () => { + describe('unknown actions', () => { + it('should return unchanged state for unknown actions', () => { // Arrange const stateWithData = { ...initialState, + referralCode: 'SOME_CODE', + balanceTotal: 1000, activeTab: 'activity' as const, - referralCode: 'TEST123', - activeBoosts: [ - { - id: 'test-boost', - name: 'Test', - icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, - boostBips: 1000, - seasonLong: true, - backgroundColor: '#FF0000', - }, - ], - activeBoostsLoading: true, }; - const action = setActiveBoostsError(true); + const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' }; // Act - const state = rewardsReducer(stateWithData, action); + const state = rewardsReducer( + stateWithData, + unknownAction as unknown as Action, + ); // Assert - expect(state.activeBoostsError).toBe(true); - expect(state.activeTab).toBe('activity'); - expect(state.referralCode).toBe('TEST123'); - expect(state.activeBoosts).toEqual(stateWithData.activeBoosts); - expect(state.activeBoostsLoading).toBe(true); // Should remain unchanged + expect(state).toEqual(stateWithData); + expect(state).toBe(stateWithData); // Should be the same reference }); - it('should handle multiple error state changes', () => { + it('should return initial state for unknown action when state is undefined', () => { // Arrange - let currentState = initialState; - - // Act & Assert - Set error to true - let action = setActiveBoostsError(true); - currentState = rewardsReducer(currentState, action); - expect(currentState.activeBoostsError).toBe(true); + const unknownAction = { type: 'UNKNOWN_ACTION', payload: 'some data' }; - // Act & Assert - Set error back to false - action = setActiveBoostsError(false); - currentState = rewardsReducer(currentState, action); - expect(currentState.activeBoostsError).toBe(false); + // Act + const state = rewardsReducer( + undefined, + unknownAction as unknown as Action, + ); - // Act & Assert - Set error to true again - action = setActiveBoostsError(true); - currentState = rewardsReducer(currentState, action); - expect(currentState.activeBoostsError).toBe(true); + // Assert + expect(state).toEqual(initialState); }); }); +}); - describe('setUnlockedRewards', () => { - it('should set unlocked rewards in state', () => { - // Arrange - const mockUnlockedRewards = [ - { - id: 'reward-1', - seasonRewardId: 'season-reward-1', - claimStatus: RewardClaimStatus.CLAIMED, +describe('setActiveBoosts', () => { + it('should set active boosts array', () => { + // Arrange + const mockBoosts = [ + { + id: 'boost-1', + name: 'Test Boost 1', + icon: { + lightModeUrl: 'light1.png', + darkModeUrl: 'dark1.png', }, - { - id: 'reward-2', - seasonRewardId: 'season-reward-2', - claimStatus: RewardClaimStatus.UNCLAIMED, + boostBips: 1000, + seasonLong: true, + backgroundColor: '#FF0000', + }, + { + id: 'boost-2', + name: 'Test Boost 2', + icon: { + lightModeUrl: 'light2.png', + darkModeUrl: 'dark2.png', }, - ]; - const action = setUnlockedRewards(mockUnlockedRewards); + boostBips: 500, + seasonLong: false, + startDate: '2024-01-01', + endDate: '2024-01-31', + backgroundColor: '#00FF00', + }, + ]; + const action = setActiveBoosts(mockBoosts); + + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(initialState, action); + // Assert + expect(state.activeBoosts).toEqual(mockBoosts); + expect(state.activeBoosts).toHaveLength(2); + expect(state.activeBoosts?.[0]?.id).toBe('boost-1'); + expect(state.activeBoosts?.[1]?.seasonLong).toBe(false); + }); - // Assert - expect(state.unlockedRewards).toEqual(mockUnlockedRewards); - expect(state.unlockedRewards).toHaveLength(2); - expect(state.unlockedRewards?.[0]?.id).toBe('reward-1'); - expect(state.unlockedRewards?.[1]?.claimStatus).toBe( - RewardClaimStatus.UNCLAIMED, - ); - }); + it('should replace existing active boosts', () => { + // Arrange + const existingBoosts = [ + { + id: 'old-boost', + name: 'Old Boost', + icon: { lightModeUrl: 'old.png', darkModeUrl: 'old.png' }, + boostBips: 100, + seasonLong: true, + backgroundColor: '#000000', + }, + ]; + const stateWithBoosts = { + ...initialState, + activeBoosts: existingBoosts, + }; + const newBoosts = [ + { + id: 'new-boost', + name: 'New Boost', + icon: { lightModeUrl: 'new.png', darkModeUrl: 'new.png' }, + boostBips: 2000, + seasonLong: false, + backgroundColor: '#FFFFFF', + }, + ]; + const action = setActiveBoosts(newBoosts); + + // Act + const state = rewardsReducer(stateWithBoosts, action); - it('should replace existing unlocked rewards', () => { - // Arrange - const existingRewards = [ + // Assert + expect(state.activeBoosts).toEqual(newBoosts); + expect(state.activeBoosts).toHaveLength(1); + expect(state.activeBoosts?.[0]?.id).toBe('new-boost'); + }); + + it('should set empty array when no boosts provided', () => { + // Arrange + const stateWithBoosts = { + ...initialState, + activeBoosts: [ { - id: 'old-reward', - seasonRewardId: 'old-season-reward', - claimStatus: RewardClaimStatus.CLAIMED, + id: 'existing-boost', + name: 'Existing', + icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, + boostBips: 500, + seasonLong: true, + backgroundColor: '#123456', }, - ]; - const stateWithRewards = { - ...initialState, - unlockedRewards: existingRewards, - }; - const newRewards = [ - { - id: 'new-reward-1', - seasonRewardId: 'new-season-reward-1', - claimStatus: RewardClaimStatus.UNCLAIMED, + ], + }; + const action = setActiveBoosts([]); + + // Act + const state = rewardsReducer(stateWithBoosts, action); + + // Assert + expect(state.activeBoosts).toEqual([]); + expect(state.activeBoosts).toHaveLength(0); + }); + + it('should reset activeBoostsError to false when setting active boosts', () => { + // Arrange + const stateWithError = { + ...initialState, + activeBoostsError: true, + }; + const mockBoosts = [ + { + id: 'boost-1', + name: 'Test Boost', + icon: { + lightModeUrl: 'light.png', + darkModeUrl: 'dark.png', }, + boostBips: 1000, + seasonLong: true, + backgroundColor: '#FF0000', + }, + ]; + const action = setActiveBoosts(mockBoosts); + + // Act + const state = rewardsReducer(stateWithError, action); + + // Assert + expect(state.activeBoosts).toEqual(mockBoosts); + expect(state.activeBoostsError).toBe(false); // Should be reset when successful + }); +}); + +describe('setActiveBoostsLoading', () => { + it('should set activeBoostsLoading to true when no active boosts exist', () => { + // Arrange + const action = setActiveBoostsLoading(true); + + // Act + const state = rewardsReducer(initialState, action); + + // Assert + expect(state.activeBoostsLoading).toBe(true); + }); + + it('should not set activeBoostsLoading to true when active boosts already exist', () => { + // Arrange + const stateWithBoosts = { + ...initialState, + activeBoosts: [ { - id: 'new-reward-2', - seasonRewardId: 'new-season-reward-2', - claimStatus: RewardClaimStatus.CLAIMED, + id: 'existing-boost', + name: 'Existing Boost', + icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, + boostBips: 1000, + seasonLong: true, + backgroundColor: '#FF0000', }, - ]; - const action = setUnlockedRewards(newRewards); + ], + activeBoostsLoading: false, + }; + const action = setActiveBoostsLoading(true); - // Act - const state = rewardsReducer(stateWithRewards, action); + // Act + const state = rewardsReducer(stateWithBoosts, action); - // Assert - expect(state.unlockedRewards).toEqual(newRewards); - expect(state.unlockedRewards).toHaveLength(2); - expect(state.unlockedRewards?.[0]?.id).toBe('new-reward-1'); - expect(state.unlockedRewards?.[1]?.id).toBe('new-reward-2'); - }); + // Assert + expect(state.activeBoostsLoading).toBe(false); // Should remain false due to guard clause + }); - it('should set empty array when no rewards provided', () => { - // Arrange - const stateWithRewards = { - ...initialState, - unlockedRewards: [ - { - id: 'existing-reward', - seasonRewardId: 'existing-season-reward', - claimStatus: RewardClaimStatus.CLAIMED, - }, - ], - }; - const action = setUnlockedRewards([]); + it('should set activeBoostsLoading to false', () => { + // Arrange + const stateWithLoading = { + ...initialState, + activeBoostsLoading: true, + }; + const action = setActiveBoostsLoading(false); - // Act - const state = rewardsReducer(stateWithRewards, action); + // Act + const state = rewardsReducer(stateWithLoading, action); - // Assert - expect(state.unlockedRewards).toEqual([]); - expect(state.unlockedRewards).toHaveLength(0); - }); + // Assert + expect(state.activeBoostsLoading).toBe(false); + }); - it('should reset unlockedRewardError to false when setting unlocked rewards', () => { - // Arrange - const stateWithError = { - ...initialState, - unlockedRewardError: true, - }; - const mockRewards = [ + it('should set activeBoostsLoading to false even when active boosts exist', () => { + // Arrange + const stateWithBoostsAndLoading = { + ...initialState, + activeBoosts: [ { - id: 'test-reward', - seasonRewardId: 'test-season-reward', - claimStatus: RewardClaimStatus.CLAIMED, + id: 'existing-boost', + name: 'Existing Boost', + icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, + boostBips: 1000, + seasonLong: true, + backgroundColor: '#FF0000', }, - ]; - const action = setUnlockedRewards(mockRewards); + ], + activeBoostsLoading: true, + }; + const action = setActiveBoostsLoading(false); - // Act - const state = rewardsReducer(stateWithError, action); + // Act + const state = rewardsReducer(stateWithBoostsAndLoading, action); - // Assert - expect(state.unlockedRewards).toEqual(mockRewards); - expect(state.unlockedRewardError).toBe(false); // Should be reset when successful - }); + // Assert + expect(state.activeBoostsLoading).toBe(false); + }); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - activeTab: 'levels' as const, - referralCode: 'TEST123', - balanceTotal: 1000, - activeBoostsLoading: true, - }; - const mockRewards = [ + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + activeTab: 'activity' as const, + referralCode: 'TEST123', + }; + const action = setActiveBoostsLoading(true); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.activeBoostsLoading).toBe(true); + expect(state.activeTab).toBe('activity'); + expect(state.referralCode).toBe('TEST123'); + expect(state.activeBoosts).toBeNull(); + }); +}); + +describe('setActiveBoostsError', () => { + it('should set activeBoostsError to true', () => { + // Arrange + const action = setActiveBoostsError(true); + + // Act + const state = rewardsReducer(initialState, action); + + // Assert + expect(state.activeBoostsError).toBe(true); + }); + + it('should set activeBoostsError to false', () => { + // Arrange + const stateWithError = { + ...initialState, + activeBoostsError: true, + }; + const action = setActiveBoostsError(false); + + // Act + const state = rewardsReducer(stateWithError, action); + + // Assert + expect(state.activeBoostsError).toBe(false); + }); + + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + activeTab: 'activity' as const, + referralCode: 'TEST123', + activeBoosts: [ { - id: 'test-reward', - seasonRewardId: 'test-season-reward', - claimStatus: RewardClaimStatus.CLAIMED, + id: 'test-boost', + name: 'Test', + icon: { lightModeUrl: 'test.png', darkModeUrl: 'test.png' }, + boostBips: 1000, + seasonLong: true, + backgroundColor: '#FF0000', }, - ]; - const action = setUnlockedRewards(mockRewards); + ], + activeBoostsLoading: true, + }; + const action = setActiveBoostsError(true); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithData, action); - // Assert - expect(state.unlockedRewards).toEqual(mockRewards); - expect(state.activeTab).toBe('levels'); - expect(state.referralCode).toBe('TEST123'); - expect(state.balanceTotal).toBe(1000); - expect(state.activeBoostsLoading).toBe(true); - }); + // Assert + expect(state.activeBoostsError).toBe(true); + expect(state.activeTab).toBe('activity'); + expect(state.referralCode).toBe('TEST123'); + expect(state.activeBoosts).toEqual(stateWithData.activeBoosts); + expect(state.activeBoostsLoading).toBe(true); // Should remain unchanged }); - describe('setUnlockedRewardLoading', () => { - it('should set unlocked reward loading to true when no unlocked rewards exist', () => { - // Arrange - const action = setUnlockedRewardLoading(true); + it('should handle multiple error state changes', () => { + // Arrange + let currentState = initialState; - // Act - const state = rewardsReducer(initialState, action); + // Act & Assert - Set error to true + let action = setActiveBoostsError(true); + currentState = rewardsReducer(currentState, action); + expect(currentState.activeBoostsError).toBe(true); - // Assert - expect(state.unlockedRewardLoading).toBe(true); - }); + // Act & Assert - Set error back to false + action = setActiveBoostsError(false); + currentState = rewardsReducer(currentState, action); + expect(currentState.activeBoostsError).toBe(false); - it('should not set unlocked reward loading to true when unlocked rewards already exist', () => { - // Arrange - const stateWithRewards = { - ...initialState, - unlockedRewards: [ - { - id: 'existing-reward', - seasonRewardId: 'existing-season-reward', - claimStatus: RewardClaimStatus.CLAIMED, - }, - ], - unlockedRewardLoading: false, - }; - const action = setUnlockedRewardLoading(true); + // Act & Assert - Set error to true again + action = setActiveBoostsError(true); + currentState = rewardsReducer(currentState, action); + expect(currentState.activeBoostsError).toBe(true); + }); +}); - // Act - const state = rewardsReducer(stateWithRewards, action); +describe('setUnlockedRewards', () => { + it('should set unlocked rewards in state', () => { + // Arrange + const mockUnlockedRewards = [ + { + id: 'reward-1', + seasonRewardId: 'season-reward-1', + claimStatus: RewardClaimStatus.CLAIMED, + }, + { + id: 'reward-2', + seasonRewardId: 'season-reward-2', + claimStatus: RewardClaimStatus.UNCLAIMED, + }, + ]; + const action = setUnlockedRewards(mockUnlockedRewards); + + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.unlockedRewardLoading).toBe(false); // Should remain false due to guard clause - }); + // Assert + expect(state.unlockedRewards).toEqual(mockUnlockedRewards); + expect(state.unlockedRewards).toHaveLength(2); + expect(state.unlockedRewards?.[0]?.id).toBe('reward-1'); + expect(state.unlockedRewards?.[1]?.claimStatus).toBe( + RewardClaimStatus.UNCLAIMED, + ); + }); - it('should set unlocked reward loading to false', () => { - // Arrange - const stateWithLoading = { - ...initialState, - unlockedRewardLoading: true, - }; - const action = setUnlockedRewardLoading(false); + it('should replace existing unlocked rewards', () => { + // Arrange + const existingRewards = [ + { + id: 'old-reward', + seasonRewardId: 'old-season-reward', + claimStatus: RewardClaimStatus.CLAIMED, + }, + ]; + const stateWithRewards = { + ...initialState, + unlockedRewards: existingRewards, + }; + const newRewards = [ + { + id: 'new-reward-1', + seasonRewardId: 'new-season-reward-1', + claimStatus: RewardClaimStatus.UNCLAIMED, + }, + { + id: 'new-reward-2', + seasonRewardId: 'new-season-reward-2', + claimStatus: RewardClaimStatus.CLAIMED, + }, + ]; + const action = setUnlockedRewards(newRewards); + + // Act + const state = rewardsReducer(stateWithRewards, action); - // Act - const state = rewardsReducer(stateWithLoading, action); + // Assert + expect(state.unlockedRewards).toEqual(newRewards); + expect(state.unlockedRewards).toHaveLength(2); + expect(state.unlockedRewards?.[0]?.id).toBe('new-reward-1'); + expect(state.unlockedRewards?.[1]?.id).toBe('new-reward-2'); + }); - // Assert - expect(state.unlockedRewardLoading).toBe(false); - }); + it('should set empty array when no rewards provided', () => { + // Arrange + const stateWithRewards = { + ...initialState, + unlockedRewards: [ + { + id: 'existing-reward', + seasonRewardId: 'existing-season-reward', + claimStatus: RewardClaimStatus.CLAIMED, + }, + ], + }; + const action = setUnlockedRewards([]); - it('should set unlocked reward loading to false even when unlocked rewards exist', () => { - // Arrange - const stateWithRewardsAndLoading = { - ...initialState, - unlockedRewards: [ - { - id: 'existing-reward', - seasonRewardId: 'existing-season-reward', - claimStatus: RewardClaimStatus.CLAIMED, - }, - ], - unlockedRewardLoading: true, - }; - const action = setUnlockedRewardLoading(false); + // Act + const state = rewardsReducer(stateWithRewards, action); + + // Assert + expect(state.unlockedRewards).toEqual([]); + expect(state.unlockedRewards).toHaveLength(0); + }); - // Act - const state = rewardsReducer(stateWithRewardsAndLoading, action); + it('should reset unlockedRewardError to false when setting unlocked rewards', () => { + // Arrange + const stateWithError = { + ...initialState, + unlockedRewardError: true, + }; + const mockRewards = [ + { + id: 'test-reward', + seasonRewardId: 'test-season-reward', + claimStatus: RewardClaimStatus.CLAIMED, + }, + ]; + const action = setUnlockedRewards(mockRewards); + + // Act + const state = rewardsReducer(stateWithError, action); - // Assert - expect(state.unlockedRewardLoading).toBe(false); - }); + // Assert + expect(state.unlockedRewards).toEqual(mockRewards); + expect(state.unlockedRewardError).toBe(false); // Should be reset when successful + }); - it('should toggle loading state correctly when no rewards exist', () => { - // Arrange - Start with false and no rewards - let currentState = initialState; - expect(currentState.unlockedRewardLoading).toBe(false); - expect(currentState.unlockedRewards).toBeNull(); + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + activeTab: 'levels' as const, + referralCode: 'TEST123', + balanceTotal: 1000, + activeBoostsLoading: true, + }; + const mockRewards = [ + { + id: 'test-reward', + seasonRewardId: 'test-season-reward', + claimStatus: RewardClaimStatus.CLAIMED, + }, + ]; + const action = setUnlockedRewards(mockRewards); + + // Act + const state = rewardsReducer(stateWithData, action); - // Act - Set to true (should work since no rewards exist) - currentState = rewardsReducer( - currentState, - setUnlockedRewardLoading(true), - ); - expect(currentState.unlockedRewardLoading).toBe(true); + // Assert + expect(state.unlockedRewards).toEqual(mockRewards); + expect(state.activeTab).toBe('levels'); + expect(state.referralCode).toBe('TEST123'); + expect(state.balanceTotal).toBe(1000); + expect(state.activeBoostsLoading).toBe(true); + }); +}); - // Act - Set back to false - currentState = rewardsReducer( - currentState, - setUnlockedRewardLoading(false), - ); - expect(currentState.unlockedRewardLoading).toBe(false); - }); +describe('setUnlockedRewardLoading', () => { + it('should set unlocked reward loading to true when no unlocked rewards exist', () => { + // Arrange + const action = setUnlockedRewardLoading(true); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - activeTab: 'activity' as const, - referralCode: 'TEST456', - activeBoostsLoading: false, - }; - const action = setUnlockedRewardLoading(true); + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(stateWithData, action); + // Assert + expect(state.unlockedRewardLoading).toBe(true); + }); - // Assert - expect(state.unlockedRewardLoading).toBe(true); - expect(state.activeTab).toBe('activity'); - expect(state.referralCode).toBe('TEST456'); - expect(state.unlockedRewards).toBeNull(); - expect(state.activeBoostsLoading).toBe(false); - }); + it('should not set unlocked reward loading to true when unlocked rewards already exist', () => { + // Arrange + const stateWithRewards = { + ...initialState, + unlockedRewards: [ + { + id: 'existing-reward', + seasonRewardId: 'existing-season-reward', + claimStatus: RewardClaimStatus.CLAIMED, + }, + ], + unlockedRewardLoading: false, + }; + const action = setUnlockedRewardLoading(true); + + // Act + const state = rewardsReducer(stateWithRewards, action); + + // Assert + expect(state.unlockedRewardLoading).toBe(false); // Should remain false due to guard clause }); - describe('setUnlockedRewardError', () => { - it('should set unlockedRewardError to true', () => { - // Arrange - const action = setUnlockedRewardError(true); + it('should set unlocked reward loading to false', () => { + // Arrange + const stateWithLoading = { + ...initialState, + unlockedRewardLoading: true, + }; + const action = setUnlockedRewardLoading(false); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(stateWithLoading, action); - // Assert - expect(state.unlockedRewardError).toBe(true); - }); + // Assert + expect(state.unlockedRewardLoading).toBe(false); + }); - it('should set unlockedRewardError to false', () => { - // Arrange - const stateWithError = { - ...initialState, - unlockedRewardError: true, - }; - const action = setUnlockedRewardError(false); + it('should set unlocked reward loading to false even when unlocked rewards exist', () => { + // Arrange + const stateWithRewardsAndLoading = { + ...initialState, + unlockedRewards: [ + { + id: 'existing-reward', + seasonRewardId: 'existing-season-reward', + claimStatus: RewardClaimStatus.CLAIMED, + }, + ], + unlockedRewardLoading: true, + }; + const action = setUnlockedRewardLoading(false); - // Act - const state = rewardsReducer(stateWithError, action); + // Act + const state = rewardsReducer(stateWithRewardsAndLoading, action); - // Assert - expect(state.unlockedRewardError).toBe(false); - }); + // Assert + expect(state.unlockedRewardLoading).toBe(false); + }); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - activeTab: 'levels' as const, - referralCode: 'TEST789', - balanceTotal: 2000, - unlockedRewardLoading: true, - }; - const action = setUnlockedRewardError(true); + it('should toggle loading state correctly when no rewards exist', () => { + // Arrange - Start with false and no rewards + let currentState = initialState; + expect(currentState.unlockedRewardLoading).toBe(false); + expect(currentState.unlockedRewards).toBeNull(); + + // Act - Set to true (should work since no rewards exist) + currentState = rewardsReducer(currentState, setUnlockedRewardLoading(true)); + expect(currentState.unlockedRewardLoading).toBe(true); + + // Act - Set back to false + currentState = rewardsReducer( + currentState, + setUnlockedRewardLoading(false), + ); + expect(currentState.unlockedRewardLoading).toBe(false); + }); - // Act - const state = rewardsReducer(stateWithData, action); + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + activeTab: 'activity' as const, + referralCode: 'TEST456', + activeBoostsLoading: false, + }; + const action = setUnlockedRewardLoading(true); - // Assert - expect(state.unlockedRewardError).toBe(true); - expect(state.activeTab).toBe('levels'); - expect(state.referralCode).toBe('TEST789'); - expect(state.balanceTotal).toBe(2000); - expect(state.unlockedRewardLoading).toBe(true); // Should remain unchanged - }); + // Act + const state = rewardsReducer(stateWithData, action); - it('should handle multiple error state changes', () => { - // Arrange - let currentState = initialState; + // Assert + expect(state.unlockedRewardLoading).toBe(true); + expect(state.activeTab).toBe('activity'); + expect(state.referralCode).toBe('TEST456'); + expect(state.unlockedRewards).toBeNull(); + expect(state.activeBoostsLoading).toBe(false); + }); +}); - // Act & Assert - Set error to true - let action = setUnlockedRewardError(true); - currentState = rewardsReducer(currentState, action); - expect(currentState.unlockedRewardError).toBe(true); +describe('setUnlockedRewardError', () => { + it('should set unlockedRewardError to true', () => { + // Arrange + const action = setUnlockedRewardError(true); - // Act & Assert - Set error back to false - action = setUnlockedRewardError(false); - currentState = rewardsReducer(currentState, action); - expect(currentState.unlockedRewardError).toBe(false); + // Act + const state = rewardsReducer(initialState, action); - // Act & Assert - Set error to true again - action = setUnlockedRewardError(true); - currentState = rewardsReducer(currentState, action); - expect(currentState.unlockedRewardError).toBe(true); - }); + // Assert + expect(state.unlockedRewardError).toBe(true); }); - describe('setPointsEvents', () => { - it('should set points events array', () => { - // Arrange - const mockPointsEvents: PointsEventDto[] = [ - { - id: 'event-1', - type: 'SWAP' as const, - timestamp: new Date('2024-01-01T00:00:00Z'), - value: 100, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-01T00:00:00Z'), - payload: { - srcAsset: { - amount: '1000000000000000000', - type: 'eip155:1/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - destAsset: { - amount: '3000000000', - type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C', - decimals: 6, - name: 'USD Coin', - symbol: 'USDC', - }, - }, - }, - { - id: 'event-2', - type: 'REFERRAL' as const, - timestamp: new Date('2024-01-02T00:00:00Z'), - value: 50, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-02T00:00:00Z'), - payload: null, - }, - ]; - const action = setPointsEvents(mockPointsEvents); + it('should set unlockedRewardError to false', () => { + // Arrange + const stateWithError = { + ...initialState, + unlockedRewardError: true, + }; + const action = setUnlockedRewardError(false); - // Act - const state = rewardsReducer(initialState, action); + // Act + const state = rewardsReducer(stateWithError, action); - // Assert - expect(state.pointsEvents).toEqual(mockPointsEvents); - expect(state.pointsEvents).toHaveLength(2); - expect(state.pointsEvents?.[0]?.id).toBe('event-1'); - expect(state.pointsEvents?.[0]?.type).toBe('SWAP'); - expect(state.pointsEvents?.[1]?.type).toBe('REFERRAL'); - }); + // Assert + expect(state.unlockedRewardError).toBe(false); + }); - it('should replace existing points events', () => { - // Arrange - const existingEvents: PointsEventDto[] = [ - { - id: 'old-event', - type: 'SIGN_UP_BONUS' as const, - timestamp: new Date('2024-01-01T00:00:00Z'), - value: 200, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-01T00:00:00Z'), - payload: null, - }, - ]; - const stateWithEvents = { - ...initialState, - pointsEvents: existingEvents, - }; - const newEvents: PointsEventDto[] = [ - { - id: 'new-event-1', - type: 'PERPS' as const, - timestamp: new Date('2024-01-02T00:00:00Z'), - value: 300, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-02T00:00:00Z'), - payload: { - type: 'OPEN_POSITION', - direction: 'LONG', - asset: { - amount: '1000000000000000000', - type: 'eip155:1/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - }, - }, - { - id: 'new-event-2', - type: 'LOYALTY_BONUS' as const, - timestamp: new Date('2024-01-03T00:00:00Z'), - value: 75, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-03T00:00:00Z'), - payload: null, - }, - ]; - const action = setPointsEvents(newEvents); + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + activeTab: 'levels' as const, + referralCode: 'TEST789', + balanceTotal: 2000, + unlockedRewardLoading: true, + }; + const action = setUnlockedRewardError(true); - // Act - const state = rewardsReducer(stateWithEvents, action); + // Act + const state = rewardsReducer(stateWithData, action); - // Assert - expect(state.pointsEvents).toEqual(newEvents); - expect(state.pointsEvents).toHaveLength(2); - expect(state.pointsEvents?.[0]?.id).toBe('new-event-1'); - expect(state.pointsEvents?.[1]?.id).toBe('new-event-2'); - }); + // Assert + expect(state.unlockedRewardError).toBe(true); + expect(state.activeTab).toBe('levels'); + expect(state.referralCode).toBe('TEST789'); + expect(state.balanceTotal).toBe(2000); + expect(state.unlockedRewardLoading).toBe(true); // Should remain unchanged + }); - it('should set empty array when no events provided', () => { - // Arrange - const stateWithEvents = { - ...initialState, - pointsEvents: [ - { - id: 'existing-event', - type: 'ONE_TIME_BONUS' as const, - timestamp: new Date('2024-01-01T00:00:00Z'), - value: 500, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-01T00:00:00Z'), - payload: null, - }, - ], - }; - const action = setPointsEvents([]); + it('should handle multiple error state changes', () => { + // Arrange + let currentState = initialState; - // Act - const state = rewardsReducer(stateWithEvents, action); + // Act & Assert - Set error to true + let action = setUnlockedRewardError(true); + currentState = rewardsReducer(currentState, action); + expect(currentState.unlockedRewardError).toBe(true); - // Assert - expect(state.pointsEvents).toEqual([]); - expect(state.pointsEvents).toHaveLength(0); - }); + // Act & Assert - Set error back to false + action = setUnlockedRewardError(false); + currentState = rewardsReducer(currentState, action); + expect(currentState.unlockedRewardError).toBe(false); - it('should set points events to null', () => { - // Arrange - const stateWithEvents = { - ...initialState, - pointsEvents: [ - { - id: 'existing-event', - type: 'SWAP' as const, - timestamp: new Date('2024-01-01T00:00:00Z'), - value: 100, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-01T00:00:00Z'), - payload: { - srcAsset: { - amount: '1000000000000000000', - type: 'eip155:1/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - destAsset: { - amount: '3000000000', - type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C', - decimals: 6, - name: 'USD Coin', - symbol: 'USDC', - }, - }, + // Act & Assert - Set error to true again + action = setUnlockedRewardError(true); + currentState = rewardsReducer(currentState, action); + expect(currentState.unlockedRewardError).toBe(true); + }); +}); + +describe('setPointsEvents', () => { + it('should set points events array', () => { + // Arrange + const mockPointsEvents: PointsEventDto[] = [ + { + id: 'event-1', + type: 'SWAP' as const, + timestamp: new Date('2024-01-01T00:00:00Z'), + value: 100, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-01T00:00:00Z'), + payload: { + srcAsset: { + amount: '1000000000000000000', + type: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', }, - ], - }; - const action = setPointsEvents(null); + destAsset: { + amount: '3000000000', + type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + }, + }, + { + id: 'event-2', + type: 'REFERRAL' as const, + timestamp: new Date('2024-01-02T00:00:00Z'), + value: 50, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-02T00:00:00Z'), + payload: null, + }, + ]; + const action = setPointsEvents(mockPointsEvents); + + // Act + const state = rewardsReducer(initialState, action); - // Act - const state = rewardsReducer(stateWithEvents, action); + // Assert + expect(state.pointsEvents).toEqual(mockPointsEvents); + expect(state.pointsEvents).toHaveLength(2); + expect(state.pointsEvents?.[0]?.id).toBe('event-1'); + expect(state.pointsEvents?.[0]?.type).toBe('SWAP'); + expect(state.pointsEvents?.[1]?.type).toBe('REFERRAL'); + }); - // Assert - expect(state.pointsEvents).toBeNull(); - }); + it('should replace existing points events', () => { + // Arrange + const existingEvents: PointsEventDto[] = [ + { + id: 'old-event', + type: 'SIGN_UP_BONUS' as const, + timestamp: new Date('2024-01-01T00:00:00Z'), + value: 200, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-01T00:00:00Z'), + payload: null, + }, + ]; + const stateWithEvents = { + ...initialState, + pointsEvents: existingEvents, + }; + const newEvents: PointsEventDto[] = [ + { + id: 'new-event-1', + type: 'PERPS' as const, + timestamp: new Date('2024-01-02T00:00:00Z'), + value: 300, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-02T00:00:00Z'), + payload: { + type: 'OPEN_POSITION', + direction: 'LONG', + asset: { + amount: '1000000000000000000', + type: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + }, + }, + { + id: 'new-event-2', + type: 'LOYALTY_BONUS' as const, + timestamp: new Date('2024-01-03T00:00:00Z'), + value: 75, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-03T00:00:00Z'), + payload: null, + }, + ]; + const action = setPointsEvents(newEvents); + + // Act + const state = rewardsReducer(stateWithEvents, action); - it('should not affect other state properties', () => { - // Arrange - const stateWithData = { - ...initialState, - activeTab: 'activity' as const, - referralCode: 'TEST123', - balanceTotal: 1000, - activeBoostsLoading: true, - }; - const mockEvents: PointsEventDto[] = [ + // Assert + expect(state.pointsEvents).toEqual(newEvents); + expect(state.pointsEvents).toHaveLength(2); + expect(state.pointsEvents?.[0]?.id).toBe('new-event-1'); + expect(state.pointsEvents?.[1]?.id).toBe('new-event-2'); + }); + + it('should set empty array when no events provided', () => { + // Arrange + const stateWithEvents = { + ...initialState, + pointsEvents: [ { - id: 'test-event', - type: 'SWAP' as const, + id: 'existing-event', + type: 'ONE_TIME_BONUS' as const, timestamp: new Date('2024-01-01T00:00:00Z'), - value: 150, + value: 500, bonus: null, accountAddress: '0x1234567890abcdef1234567890abcdef12345678', updatedAt: new Date('2024-01-01T00:00:00Z'), - payload: { - srcAsset: { - amount: '10000000', - type: 'eip155:1/slip44:0', - decimals: 8, - name: 'Bitcoin', - symbol: 'BTC', - }, - destAsset: { - amount: '2500000000000000000', - type: 'eip155:1/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - }, + payload: null, }, - ]; - const action = setPointsEvents(mockEvents); + ], + }; + const action = setPointsEvents([]); - // Act - const state = rewardsReducer(stateWithData, action); + // Act + const state = rewardsReducer(stateWithEvents, action); - // Assert - expect(state.pointsEvents).toEqual(mockEvents); - expect(state.activeTab).toBe('activity'); - expect(state.referralCode).toBe('TEST123'); - expect(state.balanceTotal).toBe(1000); - expect(state.activeBoostsLoading).toBe(true); - }); + // Assert + expect(state.pointsEvents).toEqual([]); + expect(state.pointsEvents).toHaveLength(0); + }); - it('should handle mixed event types', () => { - // Arrange - const mixedEvents: PointsEventDto[] = [ + it('should set points events to null', () => { + // Arrange + const stateWithEvents = { + ...initialState, + pointsEvents: [ { - id: 'swap-event', + id: 'existing-event', type: 'SWAP' as const, timestamp: new Date('2024-01-01T00:00:00Z'), value: 100, @@ -3232,81 +3237,168 @@ describe('rewardsReducer', () => { }, }, }, - { - id: 'perps-event', - type: 'PERPS' as const, - timestamp: new Date('2024-01-02T00:00:00Z'), - value: 200, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-02T00:00:00Z'), - payload: { - type: 'CLOSE_POSITION', - direction: 'SHORT', - asset: { - amount: '5000000000000000000', - type: 'eip155:1/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, + ], + }; + const action = setPointsEvents(null); + + // Act + const state = rewardsReducer(stateWithEvents, action); + + // Assert + expect(state.pointsEvents).toBeNull(); + }); + + it('should not affect other state properties', () => { + // Arrange + const stateWithData = { + ...initialState, + activeTab: 'activity' as const, + referralCode: 'TEST123', + balanceTotal: 1000, + activeBoostsLoading: true, + }; + const mockEvents: PointsEventDto[] = [ + { + id: 'test-event', + type: 'SWAP' as const, + timestamp: new Date('2024-01-01T00:00:00Z'), + value: 150, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-01T00:00:00Z'), + payload: { + srcAsset: { + amount: '10000000', + type: 'eip155:1/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + destAsset: { + amount: '2500000000000000000', + type: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', }, }, - { - id: 'referral-event', - type: 'REFERRAL' as const, - timestamp: new Date('2024-01-03T00:00:00Z'), - value: 50, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-03T00:00:00Z'), - payload: null, - }, - { - id: 'signup-event', - type: 'SIGN_UP_BONUS' as const, - timestamp: new Date('2024-01-04T00:00:00Z'), - value: 1000, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-04T00:00:00Z'), - payload: null, - }, - { - id: 'loyalty-event', - type: 'LOYALTY_BONUS' as const, - timestamp: new Date('2024-01-05T00:00:00Z'), - value: 75, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-05T00:00:00Z'), - payload: null, + }, + ]; + const action = setPointsEvents(mockEvents); + + // Act + const state = rewardsReducer(stateWithData, action); + + // Assert + expect(state.pointsEvents).toEqual(mockEvents); + expect(state.activeTab).toBe('activity'); + expect(state.referralCode).toBe('TEST123'); + expect(state.balanceTotal).toBe(1000); + expect(state.activeBoostsLoading).toBe(true); + }); + + it('should handle mixed event types', () => { + // Arrange + const mixedEvents: PointsEventDto[] = [ + { + id: 'swap-event', + type: 'SWAP' as const, + timestamp: new Date('2024-01-01T00:00:00Z'), + value: 100, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-01T00:00:00Z'), + payload: { + srcAsset: { + amount: '1000000000000000000', + type: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + destAsset: { + amount: '3000000000', + type: 'eip155:1/erc20:0xA0b86a33E6441b8c4C8C0C0C0C0C0C0C0C0C0C0C', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, }, - { - id: 'onetime-event', - type: 'ONE_TIME_BONUS' as const, - timestamp: new Date('2024-01-06T00:00:00Z'), - value: 500, - bonus: null, - accountAddress: '0x1234567890abcdef1234567890abcdef12345678', - updatedAt: new Date('2024-01-06T00:00:00Z'), - payload: null, + }, + { + id: 'perps-event', + type: 'PERPS' as const, + timestamp: new Date('2024-01-02T00:00:00Z'), + value: 200, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-02T00:00:00Z'), + payload: { + type: 'CLOSE_POSITION', + direction: 'SHORT', + asset: { + amount: '5000000000000000000', + type: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, }, - ]; - const action = setPointsEvents(mixedEvents); - - // Act - const state = rewardsReducer(initialState, action); + }, + { + id: 'referral-event', + type: 'REFERRAL' as const, + timestamp: new Date('2024-01-03T00:00:00Z'), + value: 50, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-03T00:00:00Z'), + payload: null, + }, + { + id: 'signup-event', + type: 'SIGN_UP_BONUS' as const, + timestamp: new Date('2024-01-04T00:00:00Z'), + value: 1000, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-04T00:00:00Z'), + payload: null, + }, + { + id: 'loyalty-event', + type: 'LOYALTY_BONUS' as const, + timestamp: new Date('2024-01-05T00:00:00Z'), + value: 75, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-05T00:00:00Z'), + payload: null, + }, + { + id: 'onetime-event', + type: 'ONE_TIME_BONUS' as const, + timestamp: new Date('2024-01-06T00:00:00Z'), + value: 500, + bonus: null, + accountAddress: '0x1234567890abcdef1234567890abcdef12345678', + updatedAt: new Date('2024-01-06T00:00:00Z'), + payload: null, + }, + ]; + const action = setPointsEvents(mixedEvents); + + // Act + const state = rewardsReducer(initialState, action); - // Assert - expect(state.pointsEvents).toEqual(mixedEvents); - expect(state.pointsEvents).toHaveLength(6); - expect(state.pointsEvents?.[0]?.type).toBe('SWAP'); - expect(state.pointsEvents?.[1]?.type).toBe('PERPS'); - expect(state.pointsEvents?.[2]?.type).toBe('REFERRAL'); - expect(state.pointsEvents?.[3]?.type).toBe('SIGN_UP_BONUS'); - expect(state.pointsEvents?.[4]?.type).toBe('LOYALTY_BONUS'); - expect(state.pointsEvents?.[5]?.type).toBe('ONE_TIME_BONUS'); - }); + // Assert + expect(state.pointsEvents).toEqual(mixedEvents); + expect(state.pointsEvents).toHaveLength(6); + expect(state.pointsEvents?.[0]?.type).toBe('SWAP'); + expect(state.pointsEvents?.[1]?.type).toBe('PERPS'); + expect(state.pointsEvents?.[2]?.type).toBe('REFERRAL'); + expect(state.pointsEvents?.[3]?.type).toBe('SIGN_UP_BONUS'); + expect(state.pointsEvents?.[4]?.type).toBe('LOYALTY_BONUS'); + expect(state.pointsEvents?.[5]?.type).toBe('ONE_TIME_BONUS'); }); }); diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index 286c926e8e0..a0ed7f783ee 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -6,6 +6,7 @@ import { PointsBoostDto, RewardDto, PointsEventDto, + SeasonActivityTypeDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { OnboardingStep } from './types'; import { AccountGroupId } from '@metamask/account-api'; @@ -26,6 +27,7 @@ export interface RewardsState { seasonStartDate: Date | null; seasonEndDate: Date | null; seasonTiers: SeasonTierDto[]; + seasonActivityTypes: SeasonActivityTypeDto[]; // Subscription Referral state referralDetailsLoading: boolean; @@ -84,6 +86,7 @@ export const initialState: RewardsState = { seasonStartDate: null, seasonEndDate: null, seasonTiers: [], + seasonActivityTypes: [], referralDetailsLoading: false, referralDetailsError: false, @@ -153,6 +156,7 @@ const rewardsSlice = createSlice({ ? new Date(action.payload.season.endDate) : null; state.seasonTiers = action.payload?.season.tiers || []; + state.seasonActivityTypes = action.payload?.season.activityTypes || []; // Season Balance state state.balanceTotal = @@ -257,6 +261,7 @@ const rewardsSlice = createSlice({ state.seasonStartDate = initialState.seasonStartDate; state.seasonEndDate = initialState.seasonEndDate; state.seasonTiers = initialState.seasonTiers; + state.seasonActivityTypes = initialState.seasonActivityTypes; state.referralCode = initialState.referralCode; state.refereeCount = initialState.refereeCount; state.currentTier = initialState.currentTier; @@ -370,6 +375,7 @@ const rewardsSlice = createSlice({ seasonStartDate: action.payload.rewards.seasonStartDate, seasonEndDate: action.payload.rewards.seasonEndDate, seasonTiers: action.payload.rewards.seasonTiers, + seasonActivityTypes: action.payload.rewards.seasonActivityTypes, referralCode: action.payload.rewards.referralCode, refereeCount: action.payload.rewards.refereeCount, currentTier: action.payload.rewards.currentTier, diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index 06d6ec5fd3f..2777a5cb302 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -17,6 +17,7 @@ import { selectSeasonStartDate, selectSeasonEndDate, selectSeasonTiers, + selectSeasonActivityTypes, selectOnboardingActiveStep, selectOnboardingReferralCode, selectGeoLocation, @@ -41,6 +42,7 @@ import { OnboardingStep } from './types'; import { RewardDto, SeasonTierDto, + SeasonActivityTypeDto, PointsEventDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { RootState } from '..'; @@ -521,6 +523,42 @@ describe('Rewards selectors', () => { }); }); + describe('selectSeasonActivityTypes', () => { + it('returns empty array when season activity types are not set', () => { + const mockState = { rewards: { seasonActivityTypes: [] } }; + mockedUseSelector.mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => + useSelector(selectSeasonActivityTypes), + ); + expect(result.current).toEqual([]); + }); + + it('returns season activity types when set', () => { + const mockActivityTypes: SeasonActivityTypeDto[] = [ + { + type: 'SWAP', + title: 'Swap', + description: 'Swap tokens', + icon: 'SwapVertical', + }, + { + type: 'CARD', + title: 'Card spend', + description: 'Spend with card', + icon: 'Card', + }, + ]; + const mockState = { rewards: { seasonActivityTypes: mockActivityTypes } }; + mockedUseSelector.mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => + useSelector(selectSeasonActivityTypes), + ); + expect(result.current).toEqual(mockActivityTypes); + }); + }); + describe('selectOnboardingActiveStep', () => { it('returns INTRO step when set', () => { const mockState = { diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index 09560d171cc..e24134f5f65 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -46,6 +46,9 @@ export const selectSeasonEndDate = (state: RootState) => export const selectSeasonTiers = (state: RootState) => state.rewards.seasonTiers; +export const selectSeasonActivityTypes = (state: RootState) => + state.rewards.seasonActivityTypes; + export const selectOnboardingActiveStep = (state: RootState): OnboardingStep => state.rewards.onboardingActiveStep; diff --git a/app/selectors/phishingController.test.ts b/app/selectors/phishingController.test.ts new file mode 100644 index 00000000000..f5c9230bbf7 --- /dev/null +++ b/app/selectors/phishingController.test.ts @@ -0,0 +1,124 @@ +import { PhishingControllerState } from '@metamask/phishing-controller'; +import { RootState } from '../reducers'; +import { selectMultipleTokenScanResults } from './phishingController'; + +describe('PhishingController Selectors', () => { + const createMockRootState = ( + phishingControllerState: Partial = {}, + ): RootState => + ({ + engine: { + backgroundState: { + PhishingController: phishingControllerState, + }, + }, + }) as RootState; + + describe('selectMultipleTokenScanResults', () => { + it('returns the scan result for one token', () => { + const state = createMockRootState({ + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }); + + const result = selectMultipleTokenScanResults(state, { + tokens: [ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + }, + ], + }); + + expect(result).toEqual([ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + scanResult: { + result_type: 'Malicious', + }, + }, + ]); + }); + + it('returns multiple scan results for multiple tokens', () => { + const state = createMockRootState({ + tokenScanCache: { + '0x1:0x1234567890123456789012345678901234567890': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + '0x1:0x1234567890123456789012345678901234567891': { + data: { + // @ts-expect-error - TokenScanResultType is not exported in PhishingController + result_type: 'Malicious', + }, + }, + }, + }); + + const result = selectMultipleTokenScanResults(state, { + tokens: [ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + }, + { + address: '0x1234567890123456789012345678901234567891', + chainId: '0x1', + }, + ], + }); + + expect(result).toEqual([ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + scanResult: { + result_type: 'Malicious', + }, + }, + { + address: '0x1234567890123456789012345678901234567891', + chainId: '0x1', + scanResult: { + result_type: 'Malicious', + }, + }, + ]); + }); + + it('returns an empty array if no tokens are provided', () => { + const state = createMockRootState(); + const result = selectMultipleTokenScanResults(state, { tokens: [] }); + expect(result).toEqual([]); + }); + + it('returns an empty array if no scan results are found', () => { + const state = createMockRootState(); + const result = selectMultipleTokenScanResults(state, { + tokens: [ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + }, + ], + }); + expect(result).toEqual([ + { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + scanResult: undefined, + }, + ]); + }); + }); +}); diff --git a/app/selectors/phishingController.ts b/app/selectors/phishingController.ts new file mode 100644 index 00000000000..fa913eb9222 --- /dev/null +++ b/app/selectors/phishingController.ts @@ -0,0 +1,54 @@ +import type { TokenScanCacheData } from '@metamask/phishing-controller'; +import { RootState } from '../reducers'; +import { createDeepEqualSelector } from './util'; + +const selectPhishingControllerState = (state: RootState) => + state.engine.backgroundState.PhishingController; + +/** + * Select the scan results for multiple token addresses + * + * @param state - Redux root state + * @param params - Parameters object + * @param params.tokens - Array of token objects with address and chainId + * @returns Array of scan results with their addresses + */ +export const selectMultipleTokenScanResults = createDeepEqualSelector( + selectPhishingControllerState, + ( + _state: RootState, + params: { tokens: { address: string; chainId: string }[] }, + ) => params.tokens, + (phishingControllerState, tokens) => { + if (!tokens || tokens.length === 0) { + return []; + } + + const tokenScanCache = phishingControllerState?.tokenScanCache || {}; + + return tokens.reduce< + { + address: string; + chainId: string; + scanResult: TokenScanCacheData; + }[] + >((acc, token) => { + const { address, chainId } = token; + + if (!address || !chainId) { + return acc; + } + + const cacheKey = `${chainId}:${address.toLowerCase()}`; + const cacheEntry = tokenScanCache[cacheKey]; + + acc.push({ + address: address.toLowerCase(), + chainId, + scanResult: cacheEntry?.data, + }); + + return acc; + }, []); + }, +); diff --git a/app/util/networks/index.js b/app/util/networks/index.js index 1bb430035e0..a945a16910f 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -672,8 +672,6 @@ export const getIsNetworkOnboarded = (chainId, networkOnboardedState) => export const isPermissionsSettingsV1Enabled = process.env.MM_PERMISSIONS_SETTINGS_V1_ENABLED === 'true'; -export const isRemoveGlobalNetworkSelectorEnabled = () => true; - // The whitelisted network names for the given chain IDs to prevent showing warnings on Network Settings. export const WHILELIST_NETWORK_NAME = { [ChainId.mainnet]: 'Mainnet', diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index f516cba2fff..2632734f468 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -271,6 +271,7 @@ } }, "PhishingController": { + "addressScanCache": {}, "c2DomainBlocklistLastFetched": 0, "phishingLists": [], "whitelist": [], diff --git a/app/util/trace.ts b/app/util/trace.ts index 3d7f5f3fba8..617be9ae5f1 100644 --- a/app/util/trace.ts +++ b/app/util/trace.ts @@ -141,12 +141,17 @@ export enum TraceName { PerpsEditOrder = 'Perps Edit Order', PerpsCancelOrder = 'Perps Cancel Order', PerpsUpdateTPSL = 'Perps Update TP/SL', + PerpsUpdateMargin = 'Perps Update Margin', + PerpsFlipPosition = 'Perps Flip Position', PerpsOrderSubmissionToast = 'Perps Order Submission Toast', PerpsMarketDataUpdate = 'Perps Market Data Update', PerpsOrderView = 'Perps Order View', PerpsTabView = 'Perps Tab View', PerpsMarketListView = 'Perps Market List View', PerpsPositionDetailsView = 'Perps Position Details View', + PerpsAdjustMarginView = 'Perps Adjust Margin View', + PerpsOrderDetailsView = 'Perps Order Details View', + PerpsFlipPositionSheet = 'Perps Flip Position Sheet', PerpsTransactionsView = 'Perps Transactions View', PerpsOrderFillsFetch = 'Perps Order Fills Fetch', PerpsOrdersFetch = 'Perps Orders Fetch', diff --git a/bitrise.yml b/bitrise.yml index f33d29b64f6..0563c419dcf 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -905,13 +905,11 @@ workflows: ios_run_regression_network_abstraction_tests: envs: - TEST_SUITE_TAG: 'RegressionNetworkAbstractions' - - MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'true' after_run: - ios_e2e_test ios_run_regression_network_abstraction_tests_gns_disabled: envs: - TEST_SUITE_TAG: 'RegressionNetworkAbstractions' - - MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'false' after_run: - ios_e2e_test ios_run_regression_network_expansion_tests: @@ -3255,7 +3253,6 @@ workflows: - APP_NAME: "MetaMask" - INFO_PLIST_NAME: "Info.plist" - COMMAND_YARN: 'build:ios:main:e2e' - - MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'false' after_run: - _ios_build_template build_ios_release_and_upload_sourcemaps: @@ -3614,9 +3611,6 @@ app: - opts: is_expand: false SEEDLESS_ONBOARDING_ENABLED: true - - opts: - is_expand: false - MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'true' meta: bitrise.io: stack: osx-xcode-16.3.x diff --git a/docs/perps/perps-screens.md b/docs/perps/perps-screens.md index 5f08dfdd613..339ec82228c 100644 --- a/docs/perps/perps-screens.md +++ b/docs/perps/perps-screens.md @@ -1,6 +1,6 @@ # Perps Screens & Views Documentation -Complete architectural reference for all 16 Perps screens in MetaMask Mobile. +Complete architectural reference for all 17 Perps screens in MetaMask Mobile. ## Table of Contents @@ -11,15 +11,16 @@ Complete architectural reference for all 16 Perps screens in MetaMask Mobile. 5. [PerpsOrderView](#perpsorderview) - Order entry 6. [PerpsPositionsView](#perpspositionsview) - Positions list 7. [PerpsClosePositionView](#perpsclosepositio nview) - Close position -8. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all -9. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all -10. [PerpsTPSLView](#perpstpslview) - TP/SL management -11. [PerpsTransactionsView](#perpstransactionsview) - Transaction history -12. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal -13. [PerpsHeroCardView](#perpsherocardview) - Hero cards -14. [PerpsEmptyState](#perpsemptystate) - Empty states -15. [PerpsRedirect](#perpsredirect) - Routing logic -16. [HIP3DebugView](#hip3debugview) - Debug tools +8. [PerpsAdjustMarginView](#perpsadjustmarginview) - Adjust margin +9. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all +10. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all +11. [PerpsTPSLView](#perpstpslview) - TP/SL management +12. [PerpsTransactionsView](#perpstransactionsview) - Transaction history +13. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal +14. [PerpsHeroCardView](#perpsherocardview) - Hero cards +15. [PerpsEmptyState](#perpsemptystate) - Empty states +16. [PerpsRedirect](#perpsredirect) - Routing logic +17. [HIP3DebugView](#hip3debugview) - Debug tools --- @@ -171,16 +172,17 @@ Detailed market view with TradingView chart, market stats, and trading interface ### Key Components Used -| Component | Purpose | -| --------------------------- | ---------------------------------- | -| `PerpsMarketHeader` | Title, price, 24h change | -| `TradingViewChart` | Chart with multiple timeframes | -| `PerpsCandlePeriodSelector` | Candle period (1m, 5m, 1h, 4h, 1d) | -| `PerpsMarketTabs` | Info/Orders/Positions tabs | -| `PerpsNavigationCard` | Quick action buttons | -| `PerpsOICapWarning` | OI capacity warning | -| `PerpsMarketHoursBanner` | Trading hours status | -| `PerpsMarketBalanceActions` | Balance info | +| Component | Purpose | +| ------------------------------- | ---------------------------------- | +| `PerpsMarketHeader` | Title, price, 24h change | +| `TradingViewChart` | Chart with multiple timeframes | +| `PerpsCandlePeriodSelector` | Candle period (1m, 5m, 1h, 4h, 1d) | +| `PerpsMarketTabs` | Info/Orders/Positions tabs | +| `PerpsNavigationCard` | Quick action buttons | +| `PerpsOICapWarning` | OI capacity warning | +| `PerpsMarketHoursBanner` | Trading hours status | +| `PerpsMarketBalanceActions` | Balance info | +| `PerpsFlipPositionConfirmSheet` | Flip position confirmation modal | ### Hooks Consumed @@ -547,6 +549,50 @@ User action: Confirm → onConfirm(tpPrice, slPrice, trackingData) --- +## PerpsAdjustMarginView + +**Location:** `app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx` + +### Purpose & User Journey + +Unified view for adjusting position margin (add or remove). Mode parameter determines behavior: add mode increases margin to reduce leverage; remove mode decreases margin to free collateral. Slider-based selection with live impact preview and risk warnings for remove mode. + +### Key Components Used + +| Component | Purpose | +| ------------------ | ------------------ | +| `Slider` | Amount selector | +| `PerpsOrderHeader` | Asset info & price | + +### Hooks Consumed + +| Hook | Purpose | +| -------------------------- | ------------------------------------- | +| `usePerpsMarginAdjustment` | Unified margin adjustment with toasts | +| `usePerpsLiveAccount` | Available balance (add mode) | +| `usePerpsMarkets` | Max leverage (remove mode) | +| `usePerpsLivePrices` | Current market price | +| `usePerpsMeasurement` | Performance tracking with mode tag | + +### Data Flow + +``` +Route params: { position, mode: 'add' | 'remove' } +Add mode: availableBalance → maxAmount +Remove mode: calculateMaxRemovableMargin() → maxAmount +User slides → Preview new margin/leverage/liq price +Remove mode: assessMarginRemovalRisk() → risk level (safe/warning/danger) +Confirm → handleAddMargin() or handleRemoveMargin() +``` + +### Navigation + +- **From:** PerpsMarketDetailsView (position card → Adjust Margin action sheet → mode selection) +- **To:** Navigates back on success +- **Full screen:** SafeAreaView-based + +--- + ## PerpsTransactionsView **Location:** `app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx` diff --git a/docs/perps/perps-sentry-reference.md b/docs/perps/perps-sentry-reference.md index f60fa9f0538..f8d2148b65e 100644 --- a/docs/perps/perps-sentry-reference.md +++ b/docs/perps/perps-sentry-reference.md @@ -135,7 +135,7 @@ setMeasurement( ## Event Catalog -### UI Screen Measurements (14 events) +### UI Screen Measurements (16 events) **Purpose:** Track screen load times and user-perceived performance. @@ -146,6 +146,8 @@ setMeasurement( | `PerpsPositionDetailsView` | Position data, market stats, history loaded | Position details | | `PerpsOrderView` | Current price, market data, account available | Trade entry | | `PerpsClosePositionView` | Position data, current price | Position exit | +| `PerpsAdjustMarginView` | Position data, balance/max removable (mode) | Adjust margin (add/remove) - differentiated by mode tag | +| `PerpsFlipPositionSheet` | Position data, fees, current price | Flip position confirmation bottom sheet | | `PerpsWithdrawView` | Account balance, destination token | Withdrawal form | | `PerpsTransactionsView` | Order fills loaded | History view | | `PerpsOrderSubmissionToast` | Immediate (shows when toast appears) | Order feedback | @@ -168,7 +170,7 @@ setMeasurement( | `PERPS_CLOSE_ORDER_CONFIRMATION_TOAST_LOADED` | ms | Close confirmation | | `PERPS_LEVERAGE_BOTTOM_SHEET_LOADED` | ms | Leverage picker | -### Trading Operations (7 events) +### Trading Operations (9 events) **Purpose:** Track order execution, position management, and transaction completion. @@ -179,6 +181,8 @@ setMeasurement( | `PerpsCancelOrder` | `PerpsOrderSubmission` | provider, market, isTestnet, **isBatch** (batch ops only) | orderId, success, **coinCount** (batch), **successCount** (batch) | | `PerpsClosePosition` | `PerpsPositionManagement` | provider, coin, closeSize, isTestnet, **isBatch** (batch) | success, filledSize, **closeAll** (batch), **coinCount** (batch) | | `PerpsUpdateTPSL` | `PerpsPositionManagement` | provider, market, isTestnet | takeProfitPrice, stopLossPrice, success | +| `PerpsUpdateMargin` | `PerpsPositionManagement` | provider, coin, action, isTestnet | amount, success | +| `PerpsFlipPosition` | `PerpsPositionManagement` | provider, coin, fromDirection, toDirection, isTestnet | size, success | | `PerpsWithdraw` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash, withdrawalId | | `PerpsDeposit` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash | diff --git a/e2e/pages/Perps/PerpsMarketDetailsView.ts b/e2e/pages/Perps/PerpsMarketDetailsView.ts index 66a0e4b8322..7da4009dc56 100644 --- a/e2e/pages/Perps/PerpsMarketDetailsView.ts +++ b/e2e/pages/Perps/PerpsMarketDetailsView.ts @@ -4,7 +4,6 @@ import { PerpsCandlestickChartSelectorsIDs, PerpsMarketTabsSelectorsIDs, PerpsOpenOrderCardSelectorsIDs, - PerpsPositionCardSelectorsIDs, } from '../../selectors/Perps/Perps.selectors'; import Gestures from '../../framework/Gestures'; import Matchers from '../../framework/Matchers'; @@ -252,7 +251,7 @@ class PerpsMarketDetailsView { // Ensure Close Position button is visible by performing best-effort scrolls, then assert async expectClosePositionButtonVisible() { const closeBtn = Matchers.getElementByID( - PerpsPositionCardSelectorsIDs.CLOSE_BUTTON, + PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON, ) as DetoxElement; for (let i = 0; i < 3; i++) { diff --git a/e2e/pages/Perps/PerpsView.ts b/e2e/pages/Perps/PerpsView.ts index 9acf0ecbd27..e2ac474c19b 100644 --- a/e2e/pages/Perps/PerpsView.ts +++ b/e2e/pages/Perps/PerpsView.ts @@ -1,10 +1,10 @@ import { - PerpsPositionCardSelectorsIDs, PerpsGeneralSelectorsIDs, PerpsOrderViewSelectorsIDs, PerpsMarketListViewSelectorsIDs, PerpsClosePositionViewSelectorsIDs, PerpsPositionDetailsViewSelectorsIDs, + PerpsMarketDetailsViewSelectorsIDs, getPerpsTPSLViewSelector, } from '../../selectors/Perps/Perps.selectors'; import Gestures from '../../framework/Gestures'; @@ -14,7 +14,9 @@ import Utilities from '../../framework/Utilities'; class PerpsView { get closePositionButton() { - return Matchers.getElementByID(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON); + return Matchers.getElementByID( + PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON, + ); } getPositionItem( diff --git a/e2e/selectors/Perps/Perps.selectors.ts b/e2e/selectors/Perps/Perps.selectors.ts index 82dd30d44d2..1711d410308 100644 --- a/e2e/selectors/Perps/Perps.selectors.ts +++ b/e2e/selectors/Perps/Perps.selectors.ts @@ -68,17 +68,21 @@ export const PerpsChartFullscreenModalSelectorsIDs = { export const PerpsPositionCardSelectorsIDs = { CARD: 'PerpsPositionCard', - // Test mock selectors (for component testing) - COIN: 'position-card-coin', - SIZE: 'position-card-size', - PNL: 'position-card-pnl', - CLOSE_BUTTON: 'position-card-close', - EDIT_BUTTON: 'position-card-edit', + HEADER: 'position-card-header', SHARE_BUTTON: 'position-card-share', - TPSL_COUNT_WARNING_TOOLTIP_VIEW_ORDERS_BUTTON: - 'position-card-tpsl-count-warning-tooltip-view-orders', - TPSL_COUNT_WARNING_TOOLTIP_GOT_IT_BUTTON: - 'position-card-tpsl-count-warning-tooltip-got-it', + PNL_CARD: 'position-card-pnl', + PNL_VALUE: 'position-card-pnl-value', + RETURN_CARD: 'position-card-return', + RETURN_VALUE: 'position-card-return-value', + SIZE_CONTAINER: 'position-card-size', + SIZE_VALUE: 'position-card-size-value', + FLIP_ICON: 'position-card-flip-icon', + MARGIN_CONTAINER: 'position-card-margin', + MARGIN_VALUE: 'position-card-margin-value', + MARGIN_CHEVRON: 'position-card-margin-chevron', + AUTO_CLOSE_TOGGLE: 'position-card-auto-close-toggle', + DETAILS_SECTION: 'position-card-details', + DIRECTION_VALUE: 'position-card-direction-value', }; // ======================================== @@ -333,6 +337,12 @@ export const PerpsMarketDetailsViewSelectorsIDs = { ADD_FUNDS_BUTTON: 'perps-market-details-add-funds-button', LONG_BUTTON: 'perps-market-details-long-button', SHORT_BUTTON: 'perps-market-details-short-button', + CLOSE_BUTTON: 'perps-market-details-close-button', + MODIFY_BUTTON: 'perps-market-details-modify-button', + SHARE_BUTTON: 'perps-market-details-share-button', + ADD_TPSL_BUTTON: 'perps-market-details-add-tpsl-button', + MODIFY_ACTION_SHEET: 'perps-market-details-modify-action-sheet', + ADJUST_MARGIN_ACTION_SHEET: 'perps-market-details-adjust-margin-action-sheet', CANDLE_PERIOD_BOTTOM_SHEET: 'perps-market-candle-period-bottom-sheet', OPEN_INTEREST_INFO_ICON: 'perps-market-details-open-interest-info-icon', FUNDING_RATE_INFO_ICON: 'perps-market-details-funding-rate-info-icon', diff --git a/jest.config.js b/jest.config.js index 7b0c32abce8..fa804826a36 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,6 @@ process.env.MM_FOX_CODE = 'EXAMPLE_FOX_CODE'; process.env.MM_SECURITY_ALERTS_API_ENABLED = 'true'; process.env.SECURITY_ALERTS_API_URL = 'https://example.com'; -process.env.MM_REMOVE_GLOBAL_NETWORK_SELECTOR = 'true'; process.env.LAUNCH_DARKLY_URL = 'https://client-config.dev-api.cx.metamask.io/v1'; diff --git a/locales/languages/en.json b/locales/languages/en.json index 54f24dcaed1..680e9a1688e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -104,6 +104,16 @@ "burn_address": { "message": "You're sending your assets to a burn address. If you continue, you'll lose your assets.", "title": "Sending Assets to Burn Address" + }, + "token_trust_signal": { + "malicious": { + "title": "Malicious token", + "message": "This token has been identified as malicious. Interacting with this token may result in a loss of funds." + }, + "warning": { + "title": "Suspicious token", + "message": "This token shows strong signs of malicious behavior. Continuing may result in loss of funds." + } } }, "blockaid_banner": { @@ -950,6 +960,12 @@ "loading_positions": "Loading positions...", "refreshing_positions": "Refreshing positions...", "no_open_orders": "No open orders", + "auto_close": { + "title": "Auto close", + "description": "Protect your margin, lock in gains", + "set_button": "Set", + "edit_button": "Edit" + }, "deposit": { "title": "Amount to deposit", "get_usdc_hyperliquid": "Get USDC • Hyperliquid", @@ -1138,6 +1154,8 @@ "tp_sl": "Auto close", "tp": "TP", "sl": "SL", + "long_label": "Long", + "short_label": "Short", "button": { "long": "Long {{asset}}", "short": "Short {{asset}}" @@ -1216,6 +1234,21 @@ "your_funds_have_been_returned_to_you": "Your funds have been returned to you", "order_cancelled_success": "{{detailedOrderType}} order cancelled" }, + "order_details": { + "title": "Order Details", + "cancel_order": "Cancel Order", + "date": "Date", + "fee": "Fee", + "limit_buy": "Limit Long", + "limit_price": "Limit Price", + "limit_sell": "Limit Short", + "market_buy": "Market Long", + "market_sell": "Market Short", + "open": "Open", + "size": "Size", + "status": "Status", + "view_explorer": "View on Explorer" + }, "close_position": { "title": "Close position", "button": "Close position", @@ -1255,6 +1288,36 @@ "you_need_set_price_limit_order": "You need to set a price for a limit order.", "your_pnl_is": "Your P&L is" }, + "modify": { + "title": "Modify", + "add_to_position": "Increase exposure", + "add_to_position_description": "Increase the size of your {{direction}} position", + "reduce_position": "Reduce exposure", + "reduce_position_description": "Decrease the size of your {{direction}} position by closing it partially", + "flip_position": "Reverse position", + "flip_position_description": "Flip your {{fromDirection}} to a {{toDirection}}" + }, + "flip_position": { + "title": "Flip Position", + "direction": "Direction", + "est_size": "Est. Size", + "flip": "Flip", + "flipping": "Flipping...", + "cancel": "Cancel" + }, + "adjust_margin": { + "title": "Adjust Margin", + "add_margin": "Add Margin", + "add_margin_description": "Increase margin to reduce liquidation risk", + "reduce_margin": "Reduce Margin", + "reduce_margin_description": "Withdraw excess margin from position", + "add_title": "Add Margin", + "remove_title": "Reduce Margin", + "margin_in_position": "Margin in position", + "perps_balance": "Perps balance", + "liquidation_price": "Liquidation price", + "liquidation_distance": "Liquidation distance" + }, "tpsl": { "title": "Auto close", "description": "Pick a percentage gain or loss, or enter a custom trigger price to automatically close your position.", @@ -1294,6 +1357,9 @@ "minimumDeposit": "Minimum deposit amount is {{amount}} USDC", "tokenNotSupported": "Token {{token}} not supported for deposits", "unknownError": "Unknown error occurred", + "unknown": "Unknown error occurred", + "position_not_found": "Position not found", + "order_not_found": "Order not found", "clientNotInitialized": "HyperLiquid SDK clients not properly initialized", "exchangeClientNotAvailable": "ExchangeClient not available after initialization", "infoClientNotAvailable": "InfoClient not available after initialization", @@ -1358,11 +1424,26 @@ "title": "Something Went Wrong", "description": "An unexpected error occurred. Please try again later.", "retry": "Retry" + }, + "marginValidation": { + "exceedsMaxRemovable": "Amount exceeds maximum removable margin", + "insufficientMargin": "Position does not have sufficient margin for this reduction" } }, "position": { "title": "Positions", "card": { + "position_title": "Position", + "pnl_label": "P&L", + "return_label": "Return", + "size_label": "Size", + "margin_label": "Margin", + "direction_label": "Direction", + "entry_label": "Entry price", + "liquidation_price_label": "Liquidation price", + "funding_payments_label": "Funding payments", + "oracle_price_label": "Oracle price", + "details_title": "Details", "entry_price": "Entry price", "funding_cost": "Funding", "liquidation_price": "Liq. Price", @@ -1403,6 +1484,11 @@ "tpsl": { "update_success": "TP/SL updated successfully", "update_failed": "Failed to update TP/SL" + }, + "margin": { + "add_success": "Added ${{amount}} margin to {{asset}} position", + "remove_success": "Removed ${{amount}} margin from {{asset}} position", + "adjustment_failed": "Margin adjustment failed" } }, "markets": { @@ -1414,16 +1500,21 @@ "error_message": "Market data not found. Please go back and try again." }, "statistics": "Overview", + "stats": "Stats", "24h_high": "24h high", "24h_low": "24h low", "24h_volume": "24h volume", "open_interest": "Open interest", "funding_rate": "Funding rate", + "oracle_price": "Oracle price", "countdown": "Countdown", "long": "Long", "short": "Short", "long_lowercase": "long", "short_lowercase": "short", + "modify": "Modify", + "close_long": "Close long", + "close_short": "Close short", "add_funds": "Add funds", "add_funds_to_start_trading_perps": "Add funds to start trading perps", "position": "Position", @@ -1460,6 +1551,10 @@ "title": "Liquidation price", "content": "If the price hits this point, you'll be liquidated and lose your margin. Higher leverage makes this more likely." }, + "liquidation_distance": { + "title": "Liquidation distance", + "content": "The percentage the price needs to move against your position before liquidation. A higher percentage means more room before liquidation." + }, "margin": { "title": "Margin", "content": "Margin is the money you put in to open a trade. It acts as collateral, and it's the most you can lose on that trade." @@ -6705,7 +6800,8 @@ "points": "Points", "points_base": "Base", "points_boost": "Boost", - "points_total": "Total" + "points_total": "Total", + "description": "Description" }, "onboarding": { "not_supported_region_title": "Region not supported", @@ -7003,6 +7099,9 @@ "no_results": "No results found", "sites": "Sites", "popular_sites": "Popular Sites", - "search_sites": "Search sites" + "search_sites": "Search sites", + "enable_basic_functionality": "Enable basic functionality", + "basic_functionality_disabled_title": "Explore is not available", + "basic_functionality_disabled_description": "We can't fetch the required metadata when basic functionality is disabled." } } diff --git a/package.json b/package.json index d93573cf1b9..c1167de5e5c 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,7 @@ "@metamask/approval-controller": "^8.0.0", "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch", "@metamask/base-controller": "^9.0.0", - "@metamask/bitcoin-wallet-snap": "^1.6.0", + "@metamask/bitcoin-wallet-snap": "^1.7.0", "@metamask/bridge-controller": "^61.0.0", "@metamask/bridge-status-controller": "^61.0.0", "@metamask/chain-agnostic-permission": "^1.2.2", @@ -223,7 +223,7 @@ "@metamask/eth-qr-keyring": "^1.1.0", "@metamask/eth-query": "^4.0.0", "@metamask/eth-sig-util": "^8.0.0", - "@metamask/eth-snap-keyring": "^18.0.0", + "@metamask/eth-snap-keyring": "^18.0.2", "@metamask/etherscan-link": "^2.0.0", "@metamask/ethjs-contract": "^0.4.1", "@metamask/ethjs-query": "^0.7.1", @@ -253,7 +253,7 @@ "@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch", "@metamask/notification-services-controller": "^20.0.0", "@metamask/permission-controller": "^12.1.0", - "@metamask/phishing-controller": "^15.0.0", + "@metamask/phishing-controller": "^16.1.0", "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^21.0.0", "@metamask/preinstalled-example-snap": "^0.7.2", @@ -280,7 +280,7 @@ "@metamask/snaps-rpc-methods": "^14.1.1", "@metamask/snaps-sdk": "^10.1.0", "@metamask/snaps-utils": "^11.6.1", - "@metamask/solana-wallet-snap": "^2.4.7", + "@metamask/solana-wallet-snap": "^2.5.0", "@metamask/solana-wallet-standard": "^0.6.0", "@metamask/stake-sdk": "^3.2.0", "@metamask/swappable-obj-proxy": "^2.1.0", @@ -288,7 +288,7 @@ "@metamask/token-search-discovery-controller": "^4.0.0", "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", "@metamask/transaction-pay-controller": "^10.1.0", - "@metamask/tron-wallet-snap": "^1.12.1", + "@metamask/tron-wallet-snap": "^1.13.0", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", "@nktkas/hyperliquid": "^0.25.9", diff --git a/yarn.lock b/yarn.lock index 37ac84c0315..a2e2ad99328 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7574,10 +7574,10 @@ __metadata: languageName: node linkType: hard -"@metamask/bitcoin-wallet-snap@npm:^1.6.0": - version: 1.6.0 - resolution: "@metamask/bitcoin-wallet-snap@npm:1.6.0" - checksum: 10/e5d391ecc88c52fa56b888e0a341331da8c8fec18a228ae3238f9ace9c0216012ef2af06134cab25fe251e6e829a14db706aa8d01ed70976fe47fd40017a6c8d +"@metamask/bitcoin-wallet-snap@npm:^1.7.0": + version: 1.7.0 + resolution: "@metamask/bitcoin-wallet-snap@npm:1.7.0" + checksum: 10/34943af054bdceeaf11ca6ed876f582194257c1bdc06e37cca06aabf650c6f541fe0f9bfaef07c8fe5e4179354f0ae81445194ce6c77a526f53221d3deb6a26c languageName: node linkType: hard @@ -8160,16 +8160,16 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^18.0.0": - version: 18.0.0 - resolution: "@metamask/eth-snap-keyring@npm:18.0.0" +"@metamask/eth-snap-keyring@npm:^18.0.0, @metamask/eth-snap-keyring@npm:^18.0.2": + version: 18.0.2 + resolution: "@metamask/eth-snap-keyring@npm:18.0.2" dependencies: "@ethereumjs/tx": "npm:^5.4.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-api": "npm:^21.1.0" - "@metamask/keyring-internal-api": "npm:^9.1.0" - "@metamask/keyring-internal-snap-client": "npm:^8.0.0" - "@metamask/keyring-snap-sdk": "npm:^7.1.0" + "@metamask/keyring-api": "npm:^21.2.0" + "@metamask/keyring-internal-api": "npm:^9.1.1" + "@metamask/keyring-internal-snap-client": "npm:^8.0.1" + "@metamask/keyring-snap-sdk": "npm:^7.1.1" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/superstruct": "npm:^3.1.0" @@ -8177,8 +8177,8 @@ __metadata: "@types/uuid": "npm:^9.0.8" uuid: "npm:^9.0.1" peerDependencies: - "@metamask/keyring-api": ^21.1.0 - checksum: 10/39a6380e351997e53776c8db9d1558769517a1a12ec1431c40cedb516d90ae447a81b7b1c21bc8d8ffcbc31188cf52f17057a1416d509013cfe8b2f46b314e02 + "@metamask/keyring-api": ^21.2.0 + checksum: 10/2c37e55cf4b56089fb5a081d3809b9004b8bbe2822267fbe5b8884cd687da4a43e122b053ebbc418173353232066a4763edc90002f51ce55a84e53a7009c16e6 languageName: node linkType: hard @@ -8391,7 +8391,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.1.0, @metamask/keyring-api@npm:^21.2.0": +"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.2.0": version: 21.2.0 resolution: "@metamask/keyring-api@npm:21.2.0" dependencies: @@ -8426,35 +8426,35 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-internal-api@npm:^9.0.0, @metamask/keyring-internal-api@npm:^9.1.0": - version: 9.1.0 - resolution: "@metamask/keyring-internal-api@npm:9.1.0" +"@metamask/keyring-internal-api@npm:^9.0.0, @metamask/keyring-internal-api@npm:^9.1.0, @metamask/keyring-internal-api@npm:^9.1.1": + version: 9.1.1 + resolution: "@metamask/keyring-internal-api@npm:9.1.1" dependencies: - "@metamask/keyring-api": "npm:^21.1.0" + "@metamask/keyring-api": "npm:^21.2.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/6b19f35f57bc1b5dc73957d7f3185236780c93e6292678e22d84f9eb2fe92e15a98437a9bc4fbe5e5e10143d4db36afa2c420636f2cca4bd984e8455ca4332c6 + checksum: 10/ab0fb8e153a02d3d0acf739d77356a1c60e0a7bf998dcbba9468f9f231605beaed472d8bff27dc56323d0a2529167336499e23dcad911fa8c3e37999ed14d2d1 languageName: node linkType: hard -"@metamask/keyring-internal-snap-client@npm:^8.0.0": - version: 8.0.0 - resolution: "@metamask/keyring-internal-snap-client@npm:8.0.0" +"@metamask/keyring-internal-snap-client@npm:^8.0.1": + version: 8.0.1 + resolution: "@metamask/keyring-internal-snap-client@npm:8.0.1" dependencies: - "@metamask/keyring-api": "npm:^21.1.0" - "@metamask/keyring-internal-api": "npm:^9.1.0" - "@metamask/keyring-snap-client": "npm:^8.1.0" + "@metamask/keyring-api": "npm:^21.2.0" + "@metamask/keyring-internal-api": "npm:^9.1.1" + "@metamask/keyring-snap-client": "npm:^8.1.1" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/7a4aa08ac6ac1bda064182420af01b785aaaff37068d14577007ce40e53f4da33b3bbc1a18625ebd75cee6d08c34de8dc860e6c927477335d5f1df72328b563a + checksum: 10/40a686cd3d1f49accde83bb2a983ac9e897498e1de5a0ccb0768e382d44dd4c273230db95bcd6eace4ad8a184e7ab4fc780770f617994a2ca29b4302890f31b6 languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^8.0.0, @metamask/keyring-snap-client@npm:^8.1.0": - version: 8.1.0 - resolution: "@metamask/keyring-snap-client@npm:8.1.0" +"@metamask/keyring-snap-client@npm:^8.0.0, @metamask/keyring-snap-client@npm:^8.1.0, @metamask/keyring-snap-client@npm:^8.1.1": + version: 8.1.1 + resolution: "@metamask/keyring-snap-client@npm:8.1.1" dependencies: - "@metamask/keyring-api": "npm:^21.1.0" + "@metamask/keyring-api": "npm:^21.2.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/superstruct": "npm:^3.1.0" "@types/uuid": "npm:^9.0.8" @@ -8462,13 +8462,13 @@ __metadata: webextension-polyfill: "npm:^0.12.0" peerDependencies: "@metamask/providers": ^19.0.0 - checksum: 10/e92aa7f6e1454150870e8e0a6d9cf4fac7bbc22280d85a252ca7ee428842dfbaaaccae78dfc5ad773e21d757febfcbe6933a72b966c4478f1a2b3fc0088419a1 + checksum: 10/dcdc9a286137a4ae884b709e565b988fb2e555a8a80db5d2ed3e93ee5262c81567a4efac6ff663b6751caf5b1173f92bc8437a395696058018a3b6e93fc30b35 languageName: node linkType: hard -"@metamask/keyring-snap-sdk@npm:^7.1.0": - version: 7.1.0 - resolution: "@metamask/keyring-snap-sdk@npm:7.1.0" +"@metamask/keyring-snap-sdk@npm:^7.1.1": + version: 7.1.1 + resolution: "@metamask/keyring-snap-sdk@npm:7.1.1" dependencies: "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/snaps-sdk": "npm:^9.0.0" @@ -8476,9 +8476,9 @@ __metadata: "@metamask/utils": "npm:^11.1.0" webextension-polyfill: "npm:^0.12.0" peerDependencies: - "@metamask/keyring-api": ^21.1.0 + "@metamask/keyring-api": ^21.2.0 "@metamask/providers": ^19.0.0 - checksum: 10/1a1809733c1f21af87f3491d292c499c5441afa0780e848718ec2b6aff50d76bb03ea44ee93ecaa80d79453a98926d84cd13ff406256ab6a2136d9e31250faa8 + checksum: 10/ac4ce050f4647096ef66ebd04d99d1423c002ca0fb05bd83e11caec59754b56d73bb8a95ac3a76f64472713256205e889d6785003dfe2c35f5f1d67c2f2efd12 languageName: node linkType: hard @@ -8892,11 +8892,11 @@ __metadata: linkType: hard "@metamask/phishing-controller@npm:^15.0.0": - version: 15.0.0 - resolution: "@metamask/phishing-controller@npm:15.0.0" + version: 15.0.1 + resolution: "@metamask/phishing-controller@npm:15.0.1" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/controller-utils": "npm:^11.15.0" "@metamask/messenger": "npm:^0.3.0" "@noble/hashes": "npm:^1.8.0" "@types/punycode": "npm:^2.1.0" @@ -8905,7 +8905,25 @@ __metadata: punycode: "npm:^2.1.1" peerDependencies: "@metamask/transaction-controller": ^61.0.0 - checksum: 10/84e10ddcba9bb1351538c2de1105863dda030ad5f6dfa54bb17d731e436e948e6bcc4630fa914162046bda1b925514de37224f34cc00145e102b2f7f3f83059e + checksum: 10/2f3bc2946f8231256c4a17af8369637f9fc4e3beef31b30b45372059e899fedfa22261cf7b526db62fe607e752e74c63de4a0dea6bd811fae046aa677e4929d0 + languageName: node + linkType: hard + +"@metamask/phishing-controller@npm:^16.1.0": + version: 16.1.0 + resolution: "@metamask/phishing-controller@npm:16.1.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/messenger": "npm:^0.3.0" + "@noble/hashes": "npm:^1.8.0" + "@types/punycode": "npm:^2.1.0" + ethereum-cryptography: "npm:^2.1.2" + fastest-levenshtein: "npm:^1.0.16" + punycode: "npm:^2.1.1" + peerDependencies: + "@metamask/transaction-controller": ^62.0.0 + checksum: 10/af956177cd1a3dd10150cefd8895cc479bb35bddd4ae751031985a89d929a9f63febf55462d09d9e6970612b00f5b90e27ff84dbb82f5ce503f8d429a4b0803b languageName: node linkType: hard @@ -9436,10 +9454,10 @@ __metadata: languageName: node linkType: hard -"@metamask/solana-wallet-snap@npm:^2.4.7": - version: 2.4.7 - resolution: "@metamask/solana-wallet-snap@npm:2.4.7" - checksum: 10/3867ddf07c5cf2cdd50cd000b39c8e97a1fd6ef8d8270820c07f7b4d2edcc0fed383ac9015afe8827c0a46dc94ae9623c447dec32980219c5cd83a20cae145a0 +"@metamask/solana-wallet-snap@npm:^2.5.0": + version: 2.5.0 + resolution: "@metamask/solana-wallet-snap@npm:2.5.0" + checksum: 10/cee4cbece192269fb02a59a90cbb8369dd6af3dab33eaecbb40fdb9723568c2da1dcd98b214063f34268696074a438a895cff40a421231e05cfab0afb1c71ea6 languageName: node linkType: hard @@ -9703,10 +9721,10 @@ __metadata: languageName: node linkType: hard -"@metamask/tron-wallet-snap@npm:^1.12.1": - version: 1.12.1 - resolution: "@metamask/tron-wallet-snap@npm:1.12.1" - checksum: 10/6f48c8dd6f625d7bb290bf3d39978839a0f4b905c14883e43fb35538b5ffa822f9611b8977fc54e9cb83711a95a9cbce93ad6a0149c4c31cfd1272af4b7055b0 +"@metamask/tron-wallet-snap@npm:^1.13.0": + version: 1.13.0 + resolution: "@metamask/tron-wallet-snap@npm:1.13.0" + checksum: 10/de3fc0ab146e0fab5f8d2f69e6dda918c22f40158ec770b24850ddb424114116dd74b1efdae119ef3bb716e6b2c96b12eb131ea2d63da89f692a756208f6be90 languageName: node linkType: hard @@ -35639,7 +35657,7 @@ __metadata: "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch" "@metamask/auto-changelog": "npm:^5.1.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bitcoin-wallet-snap": "npm:^1.6.0" + "@metamask/bitcoin-wallet-snap": "npm:^1.7.0" "@metamask/bridge-controller": "npm:^61.0.0" "@metamask/bridge-status-controller": "npm:^61.0.0" "@metamask/browser-passworder": "npm:^5.0.0" @@ -35667,7 +35685,7 @@ __metadata: "@metamask/eth-qr-keyring": "npm:^1.1.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/eth-sig-util": "npm:^8.0.0" - "@metamask/eth-snap-keyring": "npm:^18.0.0" + "@metamask/eth-snap-keyring": "npm:^18.0.2" "@metamask/etherscan-link": "npm:^2.0.0" "@metamask/ethjs-contract": "npm:^0.4.1" "@metamask/ethjs-query": "npm:^0.7.1" @@ -35700,7 +35718,7 @@ __metadata: "@metamask/notification-services-controller": "npm:^20.0.0" "@metamask/object-multiplex": "npm:^1.1.0" "@metamask/permission-controller": "npm:^12.1.0" - "@metamask/phishing-controller": "npm:^15.0.0" + "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/preferences-controller": "npm:^21.0.0" "@metamask/preinstalled-example-snap": "npm:^0.7.2" @@ -35728,7 +35746,7 @@ __metadata: "@metamask/snaps-rpc-methods": "npm:^14.1.1" "@metamask/snaps-sdk": "npm:^10.1.0" "@metamask/snaps-utils": "npm:^11.6.1" - "@metamask/solana-wallet-snap": "npm:^2.4.7" + "@metamask/solana-wallet-snap": "npm:^2.5.0" "@metamask/solana-wallet-standard": "npm:^0.6.0" "@metamask/stake-sdk": "npm:^3.2.0" "@metamask/swappable-obj-proxy": "npm:^2.1.0" @@ -35739,7 +35757,7 @@ __metadata: "@metamask/token-search-discovery-controller": "npm:^4.0.0" "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" "@metamask/transaction-pay-controller": "npm:^10.1.0" - "@metamask/tron-wallet-snap": "npm:^1.12.1" + "@metamask/tron-wallet-snap": "npm:^1.13.0" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" "@nktkas/hyperliquid": "npm:^0.25.9"