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 ? (
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+ {/* Tooltip Bottom Sheet */}
+ {selectedTooltip && (
+
+ )}
+
+ );
+};
+
+export default PerpsAdjustMarginView;
diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts b/app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts
new file mode 100644
index 00000000000..9d524418a93
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsAdjustMarginView';
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts
index aade887f696..94dadfc6520 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts
@@ -34,9 +34,12 @@ export const createStyles = ({ theme }: { theme: Theme }) =>
backgroundColor: theme.colors.background.default,
},
section: {
- paddingVertical: 8,
+ paddingVertical: 16,
paddingHorizontal: 16,
},
+ sectionTitle: {
+ marginBottom: 12,
+ },
chartSection: {
paddingTop: 0,
marginTop: 16,
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
index a7c0ccee3c8..9c4153ecc54 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
@@ -319,6 +319,37 @@ jest.mock('../../hooks', () => ({
navigateBack: mockNavigateBack,
canGoBack: mockCanGoBack(),
})),
+ usePositionManagement: jest.fn(() => ({
+ showModifyActionSheet: false,
+ showAdjustMarginActionSheet: false,
+ showReversePositionSheet: false,
+ modifyActionSheetRef: { current: null },
+ adjustMarginActionSheetRef: { current: null },
+ reversePositionSheetRef: { current: null },
+ openModifySheet: jest.fn(),
+ closeModifySheet: jest.fn(),
+ openAdjustMarginSheet: jest.fn(),
+ closeAdjustMarginSheet: jest.fn(),
+ openReversePositionSheet: jest.fn(),
+ closeReversePositionSheet: jest.fn(),
+ handleReversePosition: jest.fn(),
+ })),
+}));
+
+// Mock usePerpsABTest to return default variant
+jest.mock('../../utils/abTesting/usePerpsABTest', () => ({
+ usePerpsABTest: () => ({
+ variantName: 'semantic',
+ isEnabled: false,
+ }),
+}));
+
+// Mock usePerpsOICap to return not at cap by default
+jest.mock('../../hooks/usePerpsOICap', () => ({
+ usePerpsOICap: () => ({
+ isAtCap: false,
+ capPercentage: 50,
+ }),
}));
// Mock PerpsMarketStatisticsCard to simplify the test
@@ -686,7 +717,7 @@ describe('PerpsMarketDetailsView', () => {
).toBeNull();
});
- it('renders long/short buttons when user has balance and existing position', () => {
+ it('renders modify/close buttons when user has balance and existing position', () => {
// Override with non-zero balance and existing position
mockUsePerpsAccount.mockReturnValue({
account: {
@@ -723,7 +754,7 @@ describe('PerpsMarketDetailsView', () => {
refreshPosition: jest.fn(),
});
- const { getByTestId, queryByText } = renderWithProvider(
+ const { getByTestId, queryByText, queryByTestId } = renderWithProvider(
,
@@ -732,14 +763,22 @@ describe('PerpsMarketDetailsView', () => {
},
);
- // Shows long/short buttons even with existing position
+ // Shows modify/close buttons when existing position exists (not long/short buttons)
expect(
- getByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON),
+ getByTestId(PerpsMarketDetailsViewSelectorsIDs.MODIFY_BUTTON),
).toBeTruthy();
expect(
- getByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON),
+ getByTestId(PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON),
).toBeTruthy();
+ // Long/short buttons should NOT be shown when position exists
+ expect(
+ queryByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON),
+ ).toBeNull();
+ expect(
+ queryByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON),
+ ).toBeNull();
+
// Does not show add funds message
expect(queryByText('Add funds to start trading perps')).toBeNull();
});
@@ -829,7 +868,7 @@ describe('PerpsMarketDetailsView', () => {
expect(mockRefreshPosition).not.toHaveBeenCalled();
});
- it('refreshes statistics data when statistics tab is active', async () => {
+ it('refreshes statistics data via WebSocket', async () => {
// Arrange
const mockRefreshPosition = jest.fn();
mockUseHasExistingPosition.mockReturnValue({
@@ -856,7 +895,7 @@ describe('PerpsMarketDetailsView', () => {
refreshPosition: mockRefreshPosition,
});
- const { getByTestId, getByText } = renderWithProvider(
+ const { getByTestId } = renderWithProvider(
,
@@ -865,10 +904,6 @@ describe('PerpsMarketDetailsView', () => {
},
);
- // Act - Switch to statistics tab
- const statisticsTab = getByText('Overview');
- fireEvent.press(statisticsTab);
-
const scrollView = getByTestId(
PerpsMarketDetailsViewSelectorsIDs.SCROLL_VIEW,
);
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
index 2658544ec85..e1238b13bcd 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
@@ -31,12 +31,12 @@ import {
PerpsMarketDetailsViewSelectorsIDs,
PerpsOrderViewSelectorsIDs,
PerpsTutorialSelectorsIDs,
- PerpsMarketTabsSelectorsIDs,
} from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import PerpsMarketHeader from '../../components/PerpsMarketHeader';
import type {
PerpsMarketData,
PerpsNavigationParamList,
+ TPSLTrackingData,
} from '../../controllers/types';
import { usePerpsLiveCandles } from '../../hooks/stream/usePerpsLiveCandles';
import { usePerpsMarketStats } from '../../hooks/usePerpsMarketStats';
@@ -46,10 +46,7 @@ import {
TimeDuration,
PERPS_CHART_CONFIG,
} from '../../constants/chartConfig';
-import {
- PERFORMANCE_CONFIG,
- PERPS_CONSTANTS,
-} from '../../constants/perpsConfig';
+import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
import { createStyles } from './PerpsMarketDetailsView.styles';
import type { PerpsMarketDetailsViewProps } from './PerpsMarketDetailsView.types';
import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
@@ -64,6 +61,7 @@ import {
usePerpsTrading,
usePerpsNetworkManagement,
usePerpsNavigation,
+ usePositionManagement,
} from '../../hooks';
import { usePerpsOICap } from '../../hooks/usePerpsOICap';
import {
@@ -71,18 +69,25 @@ import {
type DataMonitorParams,
} from '../../hooks/usePerpsDataMonitor';
import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
-import { usePerpsLiveOrders, usePerpsLiveAccount } from '../../hooks/stream';
+import {
+ usePerpsLiveAccount,
+ usePerpsLivePrices,
+ usePerpsLiveOrders,
+} from '../../hooks/stream';
import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest';
import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests';
import { selectPerpsButtonColorTestVariant } from '../../selectors/featureFlags';
-import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs';
-import type { PerpsTabId } from '../../components/PerpsMarketTabs/PerpsMarketTabs.types';
+import PerpsPositionCard from '../../components/PerpsPositionCard';
+import PerpsMarketStatisticsCard from '../../components/PerpsMarketStatisticsCard';
+import type { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
import PerpsOICapWarning from '../../components/PerpsOICapWarning';
import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip';
import PerpsNavigationCard, {
type NavigationItem,
} from '../../components/PerpsNavigationCard/PerpsNavigationCard';
import PerpsMarketTradesList from '../../components/PerpsMarketTradesList';
+import PerpsCompactOrderRow from '../../components/PerpsCompactOrderRow';
+import PerpsFlipPositionConfirmSheet from '../../components/PerpsFlipPositionConfirmSheet';
import { isNotificationsFeatureEnabled } from '../../../../../util/notifications';
import Logger from '../../../../../util/Logger';
import { ensureError } from '../../utils/perpsErrorHandler';
@@ -111,10 +116,12 @@ import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useC
import Engine from '../../../../../core/Engine';
import { setPerpsChartPreferredCandlePeriod } from '../../../../../actions/settings';
import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences';
+import PerpsSelectAdjustMarginActionView from '../PerpsSelectAdjustMarginActionView';
+import PerpsSelectModifyActionView from '../PerpsSelectModifyActionView';
+import { usePerpsTPSLUpdate } from '../../hooks/usePerpsTPSLUpdate';
interface MarketDetailsRouteParams {
market: PerpsMarketData;
- initialTab?: PerpsTabId;
monitoringIntent?: Partial;
isNavigationFromOrderSuccess?: boolean;
source?: string;
@@ -124,18 +131,37 @@ const PerpsMarketDetailsView: React.FC = () => {
// Use centralized navigation hook for all Perps navigation
const {
navigateToHome,
- navigateToActivity,
navigateToOrder,
navigateToTutorial,
+ navigateToClosePosition,
navigateBack,
canGoBack,
} = usePerpsNavigation();
+ // Use position management hook for bottom sheet state and handlers
+ const {
+ showModifyActionSheet,
+ showAdjustMarginActionSheet,
+ showReversePositionSheet,
+ modifyActionSheetRef,
+ adjustMarginActionSheetRef,
+ reversePositionSheetRef,
+ openModifySheet,
+ openAdjustMarginSheet,
+ closeModifySheet,
+ closeAdjustMarginSheet,
+ closeReversePositionSheet,
+ handleReversePosition,
+ } = usePositionManagement();
+
+ // Hook for updating TP/SL on existing positions
+ const { handleUpdateTPSL } = usePerpsTPSLUpdate();
+
// Keep direct navigation for configuration methods (setOptions, setParams)
const navigation = useNavigation>();
const route =
useRoute>();
- const { market, initialTab, monitoringIntent, source } = route.params || {};
+ const { market, monitoringIntent, source } = route.params || {};
const { track } = usePerpsEventTracking();
const dispatch = useDispatch();
@@ -143,6 +169,8 @@ const PerpsMarketDetailsView: React.FC = () => {
useState(false);
const [isMarketHoursModalVisible, setIsMarketHoursModalVisible] =
useState(false);
+ const [selectedTooltip, setSelectedTooltip] =
+ useState(null);
const isEligible = useSelector(selectPerpsEligibility);
@@ -200,6 +228,48 @@ const PerpsMarketDetailsView: React.FC = () => {
const { account } = usePerpsLiveAccount();
+ // Get real-time open orders via WebSocket
+ const { orders: ordersData } = usePerpsLiveOrders({});
+
+ // Filter orders for the current market
+ const openOrders = useMemo(() => {
+ if (!ordersData?.length || !market?.symbol) return [];
+ return ordersData.filter((order) => order.symbol === market.symbol);
+ }, [ordersData, market?.symbol]);
+
+ // Sort orders by time
+ const sortedOrders = useMemo(
+ () =>
+ [...openOrders].sort((a, b) => {
+ const timeA = a.timestamp || 0;
+ const timeB = b.timestamp || 0;
+ return timeB - timeA;
+ }),
+ [openOrders],
+ );
+
+ // Filter out TP/SL (reduceOnly) orders
+ const nonTPSLOrders = useMemo(
+ () => sortedOrders.filter((order) => !order.reduceOnly),
+ [sortedOrders],
+ );
+
+ // Subscribe to live prices for current position price
+ const livePrices = usePerpsLivePrices({
+ symbols: market?.symbol ? [market.symbol] : [],
+ throttleMs: 1000,
+ });
+
+ // Get current price for the symbol
+ const currentPrice = useMemo(() => {
+ if (!market?.symbol) return 0;
+ const priceData = livePrices[market.symbol];
+ if (priceData?.price) {
+ return parseFloat(priceData.price);
+ }
+ return 0;
+ }, [livePrices, market?.symbol]);
+
// A/B Testing: Button color test (TAT-1937)
const {
variantName: buttonColorVariant,
@@ -209,10 +279,6 @@ const PerpsMarketDetailsView: React.FC = () => {
featureFlagSelector: selectPerpsButtonColorTestVariant,
});
- // TP/SL order selection state - track TP and SL separately
- const [activeTPOrderId, setActiveTPOrderId] = useState(null);
- const [activeSLOrderId, setActiveSLOrderId] = useState(null);
-
usePerpsConnection();
const { depositWithConfirmation } = usePerpsTrading();
const { ensureArbitrumNetworkExists } = usePerpsNetworkManagement();
@@ -220,35 +286,12 @@ const PerpsMarketDetailsView: React.FC = () => {
// Check if market is at open interest cap
const { isAtCap: isAtOICap } = usePerpsOICap(market?.symbol);
- // Programmatic tab control state for data-driven navigation
- const [programmaticActiveTab, setProgrammaticActiveTab] = useState<
- string | null
- >(null);
-
- // Callback to handle data detection from monitoring hook
- const handleDataDetected = useCallback(
- ({
- detectedData,
- }: {
- detectedData: 'positions' | 'orders';
- asset: string;
- reason: string;
- }) => {
- const targetTab = detectedData === 'positions' ? 'position' : 'orders';
- setProgrammaticActiveTab(targetTab);
-
- // Reset programmatic tab control after a brief delay to prevent render loops
- setTimeout(() => {
- setProgrammaticActiveTab(null);
- }, PERFORMANCE_CONFIG.TAB_CONTROL_RESET_DELAY_MS);
-
- // Clear monitoringIntent to allow fresh monitoring next time
- navigation.setParams({ monitoringIntent: undefined });
- },
- [navigation],
- );
+ // Handle data-driven monitoring when coming from order success
+ // Clear monitoringIntent after processing to allow fresh monitoring next time
+ const handleDataDetected = useCallback(() => {
+ navigation.setParams({ monitoringIntent: undefined });
+ }, [navigation]);
- // Handle data-driven monitoring when coming from order success (declarative API)
usePerpsDataMonitor({
asset: monitoringIntent?.asset,
monitorOrders: monitoringIntent?.monitorOrders,
@@ -257,94 +300,6 @@ const PerpsMarketDetailsView: React.FC = () => {
onDataDetected: handleDataDetected,
enabled: !!(monitoringIntent && market && monitoringIntent.asset),
});
- // Get real-time open orders via WebSocket
- const { orders: ordersData } = usePerpsLiveOrders({});
- // Filter orders for the current market
- const openOrders = useMemo(() => {
- if (!ordersData?.length || !market?.symbol) return [];
- return ordersData.filter((order) => order.symbol === market.symbol);
- }, [ordersData, market?.symbol]);
-
- // Filter orders that have TP/SL data for chart integration
- const ordersWithTPSL = useMemo(
- () =>
- openOrders.filter((order) => {
- // Check if order has TP/SL prices directly
- if (order.takeProfitPrice || order.stopLossPrice) return true;
-
- // Check if it's a trigger order (TP/SL orders are stored as trigger orders)
- if (order.isTrigger && order.detailedOrderType) {
- const orderType = order.detailedOrderType.toLowerCase();
- return (
- orderType.includes('take profit') || orderType.includes('stop')
- );
- }
-
- return false;
- }),
- [openOrders],
- );
-
- const orderChildOrderIds = useMemo(
- () =>
- openOrders
- .filter((order) => order.takeProfitOrderId || order.stopLossOrderId)
- .reduce((acc, order) => {
- if (order.takeProfitOrderId) {
- acc.push(order.takeProfitOrderId);
- }
- if (order.stopLossOrderId) {
- acc.push(order.stopLossOrderId);
- }
- return acc;
- }, [] as string[]),
- [openOrders],
- );
-
- // Determine which TP/SL lines to show on the chart
- const selectedOrderTPSL = useMemo(() => {
- // Find the active TP order
- let activeTPOrder = ordersWithTPSL.find(
- (order) => order.orderId === activeTPOrderId,
- );
- // Only use default TP if no TP has ever been explicitly selected
- if (!activeTPOrder && activeTPOrderId === null) {
- activeTPOrder = ordersWithTPSL.find((order) => {
- if (
- order.isTrigger &&
- order.detailedOrderType?.toLowerCase().includes('take profit') &&
- !orderChildOrderIds.includes(order.orderId)
- )
- return true;
- return false;
- });
- }
-
- // Find the active SL order
- let activeSLOrder = ordersWithTPSL.find(
- (order) => order.orderId === activeSLOrderId,
- );
- // Only use default SL if no SL has ever been explicitly selected
- if (!activeSLOrder && activeSLOrderId === null) {
- activeSLOrder = ordersWithTPSL.find((order) => {
- if (
- order.isTrigger &&
- order.detailedOrderType?.toLowerCase().includes('stop') &&
- !orderChildOrderIds.includes(order.orderId)
- )
- return true;
- return false;
- });
- }
-
- const result = {
- takeProfitPrice: activeTPOrder?.takeProfitPrice || activeTPOrder?.price,
- stopLossPrice: activeSLOrder?.stopLossPrice || activeSLOrder?.price,
- activeTPOrderId: activeTPOrder?.orderId,
- activeSLOrderId: activeSLOrder?.orderId,
- };
- return result;
- }, [ordersWithTPSL, activeTPOrderId, activeSLOrderId, orderChildOrderIds]);
const hasZeroBalance = useMemo(
() => parseFloat(account?.availableBalance || '0') === 0,
@@ -394,28 +349,19 @@ const PerpsMarketDetailsView: React.FC = () => {
loadOnMount: true,
});
- // Compute TP/SL lines for the chart based on existing position and selected orders
+ // Compute TP/SL lines for the chart based on existing position
const tpslLines = useMemo(() => {
if (existingPosition) {
return {
entryPrice: existingPosition.entryPrice,
- takeProfitPrice:
- selectedOrderTPSL.takeProfitPrice || existingPosition.takeProfitPrice,
- stopLossPrice:
- selectedOrderTPSL.stopLossPrice || existingPosition.stopLossPrice,
+ takeProfitPrice: existingPosition.takeProfitPrice,
+ stopLossPrice: existingPosition.stopLossPrice,
liquidationPrice: existingPosition.liquidationPrice || undefined,
};
}
- if (selectedOrderTPSL.takeProfitPrice || selectedOrderTPSL.stopLossPrice) {
- return {
- takeProfitPrice: selectedOrderTPSL.takeProfitPrice,
- stopLossPrice: selectedOrderTPSL.stopLossPrice,
- };
- }
-
return undefined;
- }, [existingPosition, selectedOrderTPSL]);
+ }, [existingPosition]);
// Track Perps asset screen load performance with simplified API
usePerpsMeasurement({
@@ -504,53 +450,6 @@ const PerpsMarketDetailsView: React.FC = () => {
}
}, []);
- // Handle order selection for chart integration
- const handleOrderSelect = useCallback(
- (orderId: string) => {
- const selectedOrder = ordersWithTPSL.find(
- (order) => order.orderId === orderId,
- );
-
- if (selectedOrder) {
- const hasBothTPSL =
- selectedOrder.takeProfitPrice && selectedOrder.stopLossPrice;
-
- if (hasBothTPSL) {
- setActiveTPOrderId(orderId);
- setActiveSLOrderId(orderId);
- } else if (selectedOrder.isTrigger && selectedOrder.detailedOrderType) {
- const orderType = selectedOrder.detailedOrderType.toLowerCase();
- if (orderType.includes('take profit')) {
- setActiveTPOrderId(orderId);
- } else if (orderType.includes('stop')) {
- setActiveSLOrderId(orderId);
- }
- } else if (selectedOrder.takeProfitPrice) {
- setActiveTPOrderId(orderId);
- } else if (selectedOrder.stopLossPrice) {
- setActiveSLOrderId(orderId);
- }
- }
- },
- [ordersWithTPSL],
- );
-
- // Handle order cancellation to update chart
- const handleOrderCancelled = useCallback(
- (cancelledOrderId: string) => {
- // If the cancelled order was the active TP order, clear it
- if (activeTPOrderId === cancelledOrderId) {
- setActiveTPOrderId(null);
- }
-
- // If the cancelled order was the active SL order, clear it
- if (activeSLOrderId === cancelledOrderId) {
- setActiveSLOrderId(null);
- }
- },
- [activeTPOrderId, activeSLOrderId],
- );
-
// Check if notifications feature is enabled once
const isNotificationsEnabled = isNotificationsFeatureEnabled();
@@ -693,6 +592,79 @@ const PerpsMarketDetailsView: React.FC = () => {
setIsMarketHoursModalVisible(true);
}, []);
+ // Position card handlers
+ const handleAutoClosePress = useCallback(() => {
+ if (!existingPosition) return;
+
+ navigation.navigate(Routes.PERPS.TPSL, {
+ asset: existingPosition.coin,
+ currentPrice,
+ position: existingPosition,
+ initialTakeProfitPrice: existingPosition.takeProfitPrice,
+ initialStopLossPrice: existingPosition.stopLossPrice,
+ onConfirm: async (
+ takeProfitPrice?: string,
+ stopLossPrice?: string,
+ trackingData?: TPSLTrackingData,
+ ) => {
+ await handleUpdateTPSL(
+ existingPosition,
+ takeProfitPrice,
+ stopLossPrice,
+ trackingData,
+ );
+ },
+ });
+ }, [existingPosition, currentPrice, navigation, handleUpdateTPSL]);
+
+ const handleMarginPress = useCallback(() => {
+ if (!existingPosition) return;
+ openAdjustMarginSheet();
+ }, [existingPosition, openAdjustMarginSheet]);
+
+ const handleSharePress = useCallback(() => {
+ if (!existingPosition) return;
+
+ navigation.navigate(Routes.PERPS.PNL_HERO_CARD, {
+ position: existingPosition,
+ marketPrice: currentPrice.toString(),
+ });
+ }, [existingPosition, currentPrice, navigation]);
+
+ // Stats card tooltip handler
+ const handleTooltipPress = useCallback(
+ (contentKey: PerpsTooltipContentKey) => {
+ setSelectedTooltip(contentKey);
+ },
+ [],
+ );
+
+ const handleTooltipClose = useCallback(() => {
+ setSelectedTooltip(null);
+ }, []);
+
+ // Close position handler
+ const handleClosePosition = useCallback(() => {
+ if (!existingPosition) return;
+ navigateToClosePosition(existingPosition);
+ }, [existingPosition, navigateToClosePosition]);
+
+ // Modify position handler - opens the modify action sheet
+ const handleModifyPress = useCallback(() => {
+ if (!existingPosition) return;
+ openModifySheet();
+ }, [existingPosition, openModifySheet]);
+
+ // Handler for order selection - navigates to order details
+ const handleOrderSelect = useCallback(
+ (order: (typeof nonTPSLOrders)[number]) => {
+ navigation.navigate(Routes.PERPS.ORDER_DETAILS, {
+ order,
+ });
+ },
+ [navigation],
+ );
+
const handleFullscreenChartOpen = useCallback(() => {
setIsFullscreenChartVisible(true);
}, []);
@@ -741,13 +713,8 @@ const PerpsMarketDetailsView: React.FC = () => {
onPress: () => navigateToTutorial(),
testID: PerpsTutorialSelectorsIDs.TUTORIAL_CARD,
},
- {
- label: strings('perps.market.go_to_activity'),
- onPress: () => navigateToActivity(),
- testID: PerpsMarketTabsSelectorsIDs.ACTIVITY_LINK,
- },
],
- [navigateToTutorial, navigateToActivity],
+ [navigateToTutorial],
);
// Simplified styles - no complex calculations needed
@@ -862,18 +829,45 @@ const PerpsMarketDetailsView: React.FC = () => {
/>
)}
- {/* Market Tabs Section */}
-
-
+
+
+ )}
+
+ {/* Orders Section - Compact view (TP/SL orders excluded) */}
+ {nonTPSLOrders.length > 0 && (
+
+
+ {strings('perps.market.orders')}
+
+ {nonTPSLOrders.map((order) => (
+ handleOrderSelect(order)}
+ testID={`compact-order-${order.orderId}`}
+ />
+ ))}
+
+ )}
+
+ {/* Statistics Section - Always shown */}
+
+
@@ -884,6 +878,11 @@ const PerpsMarketDetailsView: React.FC = () => {
)}
+ {/* Navigation Card Section */}
+
+
+
+
{/* Risk Disclaimer Section */}
= () => {
-
- {/* Navigation Card Section */}
-
-
-
{/* Fixed Actions Footer */}
- {(hasAddFundsButton || (hasLongShortButtons && !isAtOICap)) && (
+ {(hasAddFundsButton || hasLongShortButtons) && (
{hasAddFundsButton && (
@@ -928,7 +922,39 @@ const PerpsMarketDetailsView: React.FC = () => {
)}
- {hasLongShortButtons && !isAtOICap && (
+ {/* Show Modify/Close buttons when position exists */}
+ {hasLongShortButtons && existingPosition && (
+
+
+
+
+
+
+ = 0
+ ? strings('perps.market.close_long')
+ : strings('perps.market.close_short')
+ }
+ onPress={handleClosePosition}
+ testID={PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON}
+ />
+
+
+ )}
+
+ {/* Show Long/Short buttons when no position exists */}
+ {hasLongShortButtons && !existingPosition && !isAtOICap && (
{buttonColorVariant === 'monochrome' ? (
@@ -1019,6 +1045,16 @@ const PerpsMarketDetailsView: React.FC = () => {
/>
)}
+ {/* Statistics Tooltip Bottom Sheet */}
+ {selectedTooltip && (
+
+ )}
+
{/* Notification Tooltip - Shows after first successful order */}
{isNotificationsEnabled && !!monitoringIntent && (
= () => {
onClose={handleFullscreenChartClose}
onIntervalChange={handleCandlePeriodChange}
/>
+
+ {/* Modify Action Bottom Sheet - Rendered conditionally using PerpsHomeView pattern */}
+ {showModifyActionSheet && (
+
+ )}
+
+ {/* Adjust Margin Action Bottom Sheet - Rendered conditionally using PerpsHomeView pattern */}
+ {showAdjustMarginActionSheet && (
+
+ )}
+
+ {/* Flip Position Confirm Bottom Sheet - Rendered conditionally using PerpsHomeView pattern */}
+ {showReversePositionSheet && existingPosition && (
+
+ )}
);
};
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts
new file mode 100644
index 00000000000..f7b8e5d4494
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.styles.ts
@@ -0,0 +1,107 @@
+import { StyleSheet } from 'react-native';
+import type { 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',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border.muted,
+ },
+ headerBackButton: {
+ marginRight: 12,
+ },
+ headerTitleContainer: {
+ flex: 1,
+ alignItems: 'center',
+ marginRight: 40, // Compensate for back button width to center title
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingBottom: 100,
+ },
+ headerSection: {
+ alignItems: 'center',
+ paddingVertical: 24,
+ paddingHorizontal: 16,
+ },
+ assetLogoContainer: {
+ marginBottom: 12,
+ },
+ assetName: {
+ marginBottom: 4,
+ },
+ orderTypeLabel: {
+ marginBottom: 8,
+ },
+ section: {
+ paddingHorizontal: 16,
+ marginBottom: 16,
+ },
+ detailsCard: {
+ borderRadius: 8,
+ padding: 16,
+ },
+ detailRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 12,
+ },
+ detailLabel: {
+ flex: 1,
+ },
+ detailValue: {
+ flex: 1,
+ alignItems: 'flex-end',
+ },
+ separator: {
+ height: 1,
+ marginVertical: 4,
+ },
+ statusContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ statusFilled: {
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 4,
+ backgroundColor: colors.success.muted,
+ },
+ footer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ paddingHorizontal: 16,
+ paddingTop: 12,
+ paddingBottom: 24,
+ backgroundColor: colors.background.default,
+ borderTopWidth: 1,
+ borderTopColor: colors.border.muted,
+ gap: 8,
+ },
+ errorContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 24,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx
new file mode 100644
index 00000000000..ecddd218a3e
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx
@@ -0,0 +1,320 @@
+import React from 'react';
+import {
+ render,
+ screen,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react-native';
+import PerpsOrderDetailsView from './PerpsOrderDetailsView';
+import type { Order } from '../../controllers/types';
+
+let mockRouteParams: { order?: Order } = {};
+const mockCancelOrder = jest.fn();
+const mockShowToast = jest.fn();
+const mockGetExplorerUrl = jest.fn();
+
+// Mock dependencies
+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 mockGoBack = jest.fn();
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ goBack: mockGoBack,
+ setOptions: jest.fn(),
+ }),
+ useRoute: () => ({
+ params: mockRouteParams,
+ key: 'test-route',
+ name: 'PerpsOrderDetails',
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsTrading', () => ({
+ usePerpsTrading: () => ({
+ cancelOrder: mockCancelOrder,
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsMeasurement', () => ({
+ usePerpsMeasurement: jest.fn(),
+}));
+
+jest.mock('../../hooks/usePerpsOrderFees', () => ({
+ usePerpsOrderFees: () => ({
+ totalFee: 0.5,
+ makerFee: 0.2,
+ takerFee: 0.3,
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsBlockExplorerUrl', () => ({
+ usePerpsBlockExplorerUrl: () => ({
+ getExplorerUrl: mockGetExplorerUrl,
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsToasts', () => ({
+ __esModule: true,
+ default: () => ({
+ showToast: mockShowToast,
+ PerpsToastOptions: {
+ orderManagement: {
+ shared: {
+ cancellationInProgress: jest
+ .fn()
+ .mockReturnValue({ type: 'progress' }),
+ cancellationSuccess: jest.fn().mockReturnValue({ type: 'success' }),
+ cancellationFailed: { type: 'error' },
+ },
+ },
+ },
+ }),
+}));
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: () => ({ address: '0x1234' }),
+}));
+
+jest.mock('../../../../../util/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ success: { default: '#00FF00' },
+ error: { default: '#FF0000' },
+ border: { muted: '#CCCCCC' },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => key),
+}));
+
+jest.mock('../../components/PerpsTokenLogo', () => 'PerpsTokenLogo');
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatPerpsFiat: jest.fn((value) => `$${value.toFixed(2)}`),
+ formatPositionSize: jest.fn((value) => value.toFixed(4)),
+ formatOrderCardDate: jest.fn(() => 'Nov 25, 2025'),
+}));
+
+// Mock component-library Button to be testable
+jest.mock('../../../../../component-library/components/Buttons/Button', () => {
+ const ReactModule = jest.requireActual('react');
+ const { TouchableOpacity, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockButton({
+ label,
+ onPress,
+ testID,
+ }: {
+ label: string;
+ onPress?: () => void;
+ testID?: string;
+ }) {
+ return ReactModule.createElement(
+ TouchableOpacity,
+ { onPress, testID },
+ ReactModule.createElement(Text, null, label),
+ );
+ },
+ ButtonVariants: { Primary: 'Primary', Secondary: 'Secondary' },
+ ButtonWidthTypes: { Full: 'Full' },
+ ButtonSize: { Lg: 'Lg' },
+ };
+});
+
+// Mock ButtonIcon for back button
+jest.mock(
+ '../../../../../component-library/components/Buttons/ButtonIcon',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { TouchableOpacity, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockButtonIcon({
+ onPress,
+ testID,
+ }: {
+ onPress?: () => void;
+ testID?: string;
+ }) {
+ return ReactModule.createElement(
+ TouchableOpacity,
+ { onPress, testID: testID || 'back-button' },
+ ReactModule.createElement(Text, null, 'Back'),
+ );
+ },
+ ButtonIconSizes: { Md: 'Md' },
+ };
+ },
+);
+
+describe('PerpsOrderDetailsView', () => {
+ const mockOrder: Order = {
+ orderId: 'order-123',
+ symbol: 'BTC',
+ size: '0.5',
+ originalSize: '0.5',
+ filledSize: '0',
+ remainingSize: '0.5',
+ price: '50000',
+ side: 'buy',
+ orderType: 'limit',
+ timestamp: Date.now(),
+ status: 'open',
+ reduceOnly: false,
+ };
+
+ const mockPartiallyFilledOrder: Order = {
+ ...mockOrder,
+ orderId: 'order-456',
+ filledSize: '0.25',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouteParams = { order: mockOrder };
+ mockCancelOrder.mockResolvedValue({ success: true });
+ mockGetExplorerUrl.mockReturnValue('https://explorer.test.com');
+ });
+
+ afterEach(() => {
+ mockRouteParams = {};
+ });
+
+ it('renders order details view with order data', () => {
+ render();
+
+ expect(screen.getByText('BTC')).toBeOnTheScreen();
+ });
+
+ it('renders error state when no order is provided', () => {
+ mockRouteParams = {};
+
+ render();
+
+ expect(screen.getByText('perps.errors.order_not_found')).toBeOnTheScreen();
+ });
+
+ it('renders cancel order button', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.order_details.cancel_order'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders cancel order button label', () => {
+ render();
+
+ // Verify cancel button is rendered (this is one of the key actions)
+ expect(
+ screen.getByText('perps.order_details.cancel_order'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders order date', () => {
+ render();
+
+ expect(screen.getByText('Nov 25, 2025')).toBeOnTheScreen();
+ });
+
+ it('renders limit price label', () => {
+ render();
+
+ expect(
+ screen.getByText('perps.order_details.limit_price'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls goBack when back button is pressed', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('back-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('cancels order successfully when cancel button is pressed', async () => {
+ render();
+
+ fireEvent.press(screen.getByText('perps.order_details.cancel_order'));
+
+ await waitFor(() => {
+ expect(mockCancelOrder).toHaveBeenCalledWith({
+ orderId: 'order-123',
+ coin: 'BTC',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+ });
+
+ it('shows error toast when cancel order fails', async () => {
+ mockCancelOrder.mockResolvedValue({ success: false });
+ render();
+
+ fireEvent.press(screen.getByText('perps.order_details.cancel_order'));
+
+ await waitFor(() => {
+ expect(mockCancelOrder).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+ });
+
+ it('shows error toast when cancel order throws exception', async () => {
+ mockCancelOrder.mockRejectedValue(new Error('Network error'));
+ render();
+
+ fireEvent.press(screen.getByText('perps.order_details.cancel_order'));
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+ });
+
+ it('shows fill percentage for partially filled orders', () => {
+ mockRouteParams = { order: mockPartiallyFilledOrder };
+ render();
+
+ expect(screen.getByText('50% filled')).toBeOnTheScreen();
+ });
+
+ it('shows open status for unfilled orders', () => {
+ render();
+
+ expect(screen.getByText('perps.order_details.open')).toBeOnTheScreen();
+ });
+
+ it('renders short direction for sell orders', () => {
+ const sellOrder = { ...mockOrder, side: 'sell' as const };
+ mockRouteParams = { order: sellOrder };
+ render();
+
+ expect(screen.getByText('BTC')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx
new file mode 100644
index 00000000000..bc163e669d9
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx
@@ -0,0 +1,348 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import { View, ScrollView } 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 ButtonIcon, {
+ ButtonIconSizes,
+} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import {
+ IconColor,
+ IconName,
+} from '../../../../../component-library/components/Icons/Icon';
+import { strings } from '../../../../../../locales/i18n';
+import { usePerpsTrading } from '../../hooks/usePerpsTrading';
+import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
+import { usePerpsOrderFees } from '../../hooks/usePerpsOrderFees';
+import usePerpsToasts from '../../hooks/usePerpsToasts';
+import { TraceName } from '../../../../../util/trace';
+import type { Order } from '../../controllers/types';
+import styleSheet from './PerpsOrderDetailsView.styles';
+import PerpsTokenLogo from '../../components/PerpsTokenLogo';
+import {
+ formatPerpsFiat,
+ formatPositionSize,
+ formatOrderCardDate,
+} from '../../utils/formatUtils';
+import { useTheme } from '../../../../../util/theme';
+
+interface OrderDetailsRouteParams {
+ order: Order;
+}
+
+const PerpsOrderDetailsView: React.FC = () => {
+ const navigation = useNavigation();
+ const route =
+ useRoute>();
+ const { order } = route.params || {};
+ const { styles } = useStyles(styleSheet, {});
+ const { colors } = useTheme();
+ const { cancelOrder } = usePerpsTrading();
+ const { showToast, PerpsToastOptions } = usePerpsToasts();
+
+ const [isCanceling, setIsCanceling] = useState(false);
+
+ // Calculate size in USD for fee calculation
+ const sizeInUSD = useMemo(() => {
+ if (!order) return '0';
+ return (parseFloat(order.size) * parseFloat(order.price)).toString();
+ }, [order]);
+
+ // Get order fees
+ const { totalFee } = usePerpsOrderFees({
+ orderType: order?.orderType ?? 'market',
+ amount: sizeInUSD,
+ });
+
+ // Add performance measurement
+ usePerpsMeasurement({
+ traceName: TraceName.PerpsOrderDetailsView,
+ conditions: [!!order],
+ });
+
+ // Handle back button press
+ const handleBack = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ // Calculate order details
+ const orderDetails = useMemo(() => {
+ if (!order) return null;
+
+ const isLong = order.side === 'buy';
+ const directionLabel = isLong
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label');
+ const orderTypeLabel = strings(
+ `perps.order_details.${order.orderType}_${order.side}`,
+ );
+
+ // Calculate fill percentage
+ const fillPercentage =
+ parseFloat(order.originalSize) > 0
+ ? (parseFloat(order.filledSize) / parseFloat(order.originalSize)) * 100
+ : 0;
+
+ // Calculate size in USD (size * price)
+ const orderSizeUSD = parseFloat(order.size) * parseFloat(order.price);
+
+ // Format date using formatOrderCardDate
+ const dateString = formatOrderCardDate(order.timestamp);
+
+ return {
+ isLong,
+ directionLabel,
+ orderTypeLabel,
+ fillPercentage,
+ sizeInUSD: orderSizeUSD,
+ dateString,
+ directionColor: isLong ? colors.success.default : colors.error.default,
+ };
+ }, [order, colors]);
+
+ const handleCancelOrder = useCallback(async () => {
+ if (!order) return;
+
+ setIsCanceling(true);
+
+ // Show in-progress toast
+ showToast(
+ PerpsToastOptions.orderManagement.shared.cancellationInProgress(
+ order.side === 'buy' ? 'long' : 'short',
+ order.size,
+ order.symbol,
+ order.orderType,
+ ),
+ );
+
+ try {
+ const result = await cancelOrder({
+ orderId: order.orderId,
+ coin: order.symbol,
+ });
+
+ // Show success/failure toast
+ if (result.success) {
+ showToast(
+ PerpsToastOptions.orderManagement.shared.cancellationSuccess(
+ order.reduceOnly,
+ order.orderType,
+ order.side === 'buy' ? 'long' : 'short',
+ order.size,
+ order.symbol,
+ ),
+ );
+ navigation.goBack();
+ } else {
+ showToast(PerpsToastOptions.orderManagement.shared.cancellationFailed);
+ }
+ } catch (error) {
+ showToast(PerpsToastOptions.orderManagement.shared.cancellationFailed);
+ } finally {
+ setIsCanceling(false);
+ }
+ }, [order, cancelOrder, navigation, showToast, PerpsToastOptions]);
+
+ if (!order) {
+ return (
+
+
+
+ {strings('perps.errors.order_not_found')}
+
+
+
+ );
+ }
+
+ if (!orderDetails) {
+ return null;
+ }
+
+ return (
+
+ {/* Header with back button */}
+
+
+
+
+
+
+ {orderDetails.orderTypeLabel}
+
+
+
+
+ {/* Header Section */}
+
+
+
+
+
+ {order.symbol}
+
+
+
+ {/* Order Details Card */}
+
+
+ {/* Date */}
+
+
+ {strings('perps.order_details.date')}
+
+
+
+ {orderDetails.dateString}
+
+
+
+
+
+
+ {/* Limit Price */}
+
+
+ {strings('perps.order_details.limit_price')}
+
+
+
+ {formatPerpsFiat(parseFloat(order.price))}
+
+
+
+
+
+
+ {/* Size */}
+
+
+ {strings('perps.order_details.size')}
+
+
+
+ {formatPositionSize(parseFloat(order.size))} {order.symbol} •{' '}
+ {formatPerpsFiat(orderDetails.sizeInUSD)}
+
+
+
+
+
+
+ {/* Fee */}
+
+
+ {strings('perps.order_details.fee')}
+
+
+
+ {formatPerpsFiat(totalFee)}
+
+
+
+
+
+
+ {/* Status */}
+
+
+ {strings('perps.order_details.status')}
+
+
+
+ {orderDetails.fillPercentage > 0 && (
+
+
+ {Math.round(orderDetails.fillPercentage)}% filled
+
+
+ )}
+ {orderDetails.fillPercentage === 0 && (
+
+ {strings('perps.order_details.open')}
+
+ )}
+
+
+
+
+
+
+
+ {/* Footer Actions */}
+
+
+
+
+ );
+};
+
+export default PerpsOrderDetailsView;
diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts b/app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts
new file mode 100644
index 00000000000..d123b1134d9
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsOrderDetailsView';
diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
index 1ebb9c4c661..050f4eaf113 100644
--- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
@@ -79,6 +79,7 @@ import type {
OrderParams,
OrderType,
PerpsNavigationParamList,
+ Position,
} from '../../controllers/types';
import {
useHasExistingPosition,
@@ -124,6 +125,8 @@ interface OrderRouteParams {
asset?: string;
amount?: string;
leverage?: number;
+ // Existing position param
+ existingPosition?: Position;
// Modal return values
leverageUpdate?: number;
orderTypeUpdate?: OrderType;
@@ -132,6 +135,12 @@ interface OrderRouteParams {
stopLossPrice?: string;
};
limitPriceUpdate?: string;
+ // Hide TP/SL when modifying existing position
+ hideTPSL?: boolean;
+}
+
+interface PerpsOrderViewContentProps {
+ hideTPSL?: boolean;
}
/**
@@ -145,7 +154,9 @@ interface OrderRouteParams {
* - Auto-opening limit price modal when switching order types
* - Comprehensive order validation
*/
-const PerpsOrderViewContentBase: React.FC = () => {
+const PerpsOrderViewContentBase: React.FC = ({
+ hideTPSL = false,
+}) => {
const navigation = useNavigation>();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
@@ -203,6 +214,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
handlePercentageAmount,
handleMaxAmount,
maxPossibleAmount,
+ // existingPosition is available in context but not used in this component
} = usePerpsOrderContext();
/**
@@ -233,7 +245,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
});
// Check if user has an existing position for this market
- const { existingPosition } = useHasExistingPosition({
+ const { existingPosition: currentMarketPosition } = useHasExistingPosition({
asset: orderForm.asset || '',
loadOnMount: true,
});
@@ -554,7 +566,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
// Get existing position leverage for validation (protocol constraint)
// Note: This is the same value used for initial form state, but needed here for validation
const existingPositionLeverageForValidation =
- existingPosition?.leverage?.value;
+ currentMarketPosition?.leverage?.value;
// Order validation using new hook
const orderValidation = usePerpsOrderValidation({
@@ -706,7 +718,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
}
// Check for cross-margin position (MetaMask only supports isolated margin)
- if (existingPosition?.leverage?.type === 'cross') {
+ if (currentMarketPosition?.leverage?.type === 'cross') {
navigation.navigate(Routes.PERPS.MODALS.ROOT, {
screen: Routes.PERPS.MODALS.CROSS_MARGIN_WARNING,
});
@@ -795,9 +807,9 @@ const PerpsOrderViewContentBase: React.FC = () => {
// Check if TP/SL should be handled separately (for new positions or position flips)
const shouldHandleTPSLSeparately =
(orderForm.takeProfitPrice || orderForm.stopLossPrice) &&
- ((!existingPosition && orderForm.type === 'market') ||
- (existingPosition &&
- willFlipPosition(existingPosition, orderParams)));
+ ((!currentMarketPosition && orderForm.type === 'market') ||
+ (currentMarketPosition &&
+ willFlipPosition(currentMarketPosition, orderParams)));
if (shouldHandleTPSLSeparately) {
// Execute order without TP/SL first, then update position TP/SL
@@ -834,7 +846,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
assetData.price,
navigation,
navigationMarketData,
- existingPosition,
+ currentMarketPosition,
executeOrder,
showToast,
PerpsToastOptions.formValidation.orderForm,
@@ -1011,46 +1023,48 @@ const PerpsOrderViewContentBase: React.FC = () => {
)}
- {/* Combined TP/SL row */}
-
-
-
-
-
+ {/* Combined TP/SL row - Hidden when modifying existing position */}
+ {!hideTPSL && (
+
+
+
+
+
+
+ {strings('perps.order.tp_sl')}
+
+ handleTooltipPress('tp_sl')}
+ style={styles.infoIcon}
+ >
+
+
+
+
+
- {strings('perps.order.tp_sl')}
+ {tpSlDisplayText}
- handleTooltipPress('tp_sl')}
- style={styles.infoIcon}
- >
-
-
-
-
-
-
- {tpSlDisplayText}
-
-
-
-
-
- {doesStopLossRiskLiquidation && (
+
+
+
+
+ )}
+ {!hideTPSL && doesStopLossRiskLiquidation && (
{strings('perps.tpsl.stop_loss_order_view_warning', {
@@ -1444,6 +1458,8 @@ const PerpsOrderView: React.FC = () => {
asset = 'BTC',
amount: paramAmount,
leverage: paramLeverage,
+ existingPosition,
+ hideTPSL = false,
} = route.params || {};
return (
@@ -1452,8 +1468,9 @@ const PerpsOrderView: React.FC = () => {
initialDirection={direction}
initialAmount={paramAmount}
initialLeverage={paramLeverage}
+ existingPosition={existingPosition}
>
-
+
);
};
diff --git a/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx
new file mode 100644
index 00000000000..6824a4fc1ae
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.test.tsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsSelectAdjustMarginActionView from './PerpsSelectAdjustMarginActionView';
+import type { Position } from '../../controllers/types';
+
+let mockRouteParams: { position?: Position } = {};
+const mockGoBack = jest.fn();
+const mockNavigateToAdjustMargin = jest.fn();
+const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.());
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ }),
+ useRoute: () => ({
+ params: mockRouteParams,
+ key: 'test-route',
+ name: 'SELECT_ADJUST_MARGIN_ACTION',
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsNavigation', () => ({
+ usePerpsNavigation: () => ({
+ navigateToAdjustMargin: mockNavigateToAdjustMargin,
+ }),
+}));
+
+// Mock the PerpsAdjustMarginActionSheet component
+jest.mock(
+ '../../components/PerpsAdjustMarginActionSheet',
+ () =>
+ function MockPerpsAdjustMarginActionSheet({
+ onClose,
+ onSelectAction,
+ }: {
+ onClose: () => void;
+ onSelectAction: (action: string) => void;
+ }) {
+ const ReactModule = jest.requireActual('react');
+ const { View, Text, TouchableOpacity } =
+ jest.requireActual('react-native');
+ return ReactModule.createElement(
+ View,
+ { testID: 'adjust-margin-action-sheet' },
+ ReactModule.createElement(Text, null, 'Adjust Margin Action Sheet'),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: () => onSelectAction('add_margin'), testID: 'add-margin' },
+ ReactModule.createElement(Text, null, 'Add Margin'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onSelectAction('reduce_margin'),
+ testID: 'reduce-margin',
+ },
+ ReactModule.createElement(Text, null, 'Reduce Margin'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: onClose, testID: 'close-button' },
+ ReactModule.createElement(Text, null, 'Close'),
+ ),
+ );
+ },
+);
+
+describe('PerpsSelectAdjustMarginActionView', () => {
+ 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: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouteParams = { position: mockPosition };
+ });
+
+ afterEach(() => {
+ mockRouteParams = {};
+ });
+
+ it('renders the adjust margin action sheet', () => {
+ render();
+
+ expect(screen.getByTestId('adjust-margin-action-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders add margin option', () => {
+ render();
+
+ expect(screen.getByText('Add Margin')).toBeOnTheScreen();
+ });
+
+ it('renders reduce margin option', () => {
+ render();
+
+ expect(screen.getByText('Reduce Margin')).toBeOnTheScreen();
+ });
+
+ it('renders with position from props', () => {
+ render();
+
+ expect(screen.getByTestId('adjust-margin-action-sheet')).toBeOnTheScreen();
+ });
+
+ it('navigates to add margin when add_margin action is selected', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('add-margin'));
+
+ expect(mockNavigateToAdjustMargin).toHaveBeenCalledWith(
+ mockPosition,
+ 'add',
+ );
+ });
+
+ it('navigates to reduce margin when reduce_margin action is selected', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('reduce-margin'));
+
+ expect(mockNavigateToAdjustMargin).toHaveBeenCalledWith(
+ mockPosition,
+ 'remove',
+ );
+ });
+
+ it('does not navigate when position is not available', () => {
+ mockRouteParams = {};
+ render();
+
+ fireEvent.press(screen.getByTestId('add-margin'));
+
+ expect(mockNavigateToAdjustMargin).not.toHaveBeenCalled();
+ });
+
+ it('calls goBack when close button is pressed without external sheetRef', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('calls onClose callback when close button is pressed with external sheetRef', () => {
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+
+ it('uses position from route params when not provided via props', () => {
+ mockRouteParams = { position: mockPosition };
+ render();
+
+ fireEvent.press(screen.getByTestId('add-margin'));
+
+ expect(mockNavigateToAdjustMargin).toHaveBeenCalledWith(
+ mockPosition,
+ 'add',
+ );
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx
new file mode 100644
index 00000000000..beebb578673
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/PerpsSelectAdjustMarginActionView.tsx
@@ -0,0 +1,82 @@
+import React, { useCallback, useRef } from 'react';
+import {
+ useNavigation,
+ useRoute,
+ type NavigationProp,
+ type RouteProp,
+} from '@react-navigation/native';
+import type { Position } from '../../controllers/types';
+import type { PerpsNavigationParamList } from '../../types/navigation';
+import PerpsAdjustMarginActionSheet, {
+ type AdjustMarginAction,
+} from '../../components/PerpsAdjustMarginActionSheet';
+import { usePerpsNavigation } from '../../hooks/usePerpsNavigation';
+import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+interface PerpsSelectAdjustMarginActionViewProps {
+ sheetRef?: React.RefObject;
+ position?: Position;
+ onClose?: () => void;
+}
+
+const PerpsSelectAdjustMarginActionView: React.FC<
+ PerpsSelectAdjustMarginActionViewProps
+> = ({
+ sheetRef: externalSheetRef,
+ position: positionProp,
+ onClose: onExternalClose,
+}) => {
+ const navigation = useNavigation>();
+ const route =
+ useRoute<
+ RouteProp
+ >();
+
+ // Support both props and route params
+ const position = positionProp || route.params?.position;
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+ const { navigateToAdjustMargin } = usePerpsNavigation();
+
+ const handleActionSelect = useCallback(
+ (action: AdjustMarginAction) => {
+ if (!position) return;
+
+ // Navigate BEFORE closing (prevents navigation loss from component unmounting)
+ switch (action) {
+ case 'add_margin':
+ navigateToAdjustMargin(position, 'add');
+ break;
+ case 'reduce_margin':
+ navigateToAdjustMargin(position, 'remove');
+ break;
+ }
+
+ // Close bottom sheet AFTER navigation is triggered
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ },
+ [position, sheetRef, onExternalClose, navigateToAdjustMargin],
+ );
+
+ const handleClose = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ } else {
+ navigation.goBack();
+ }
+ }, [navigation, externalSheetRef, sheetRef, onExternalClose]);
+
+ return (
+
+ );
+};
+
+export default PerpsSelectAdjustMarginActionView;
diff --git a/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts
new file mode 100644
index 00000000000..65e68435f80
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectAdjustMarginActionView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsSelectAdjustMarginActionView';
diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx
new file mode 100644
index 00000000000..ee8cd3b1ecb
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx
@@ -0,0 +1,276 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsSelectModifyActionView from './PerpsSelectModifyActionView';
+import type { Position } from '../../controllers/types';
+
+let mockRouteParams: { position?: Position } = {};
+const mockGoBack = jest.fn();
+const mockNavigateToOrder = jest.fn();
+const mockNavigateToClosePosition = jest.fn();
+const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.());
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ }),
+ useRoute: () => ({
+ params: mockRouteParams,
+ key: 'test-route',
+ name: 'SELECT_MODIFY_ACTION',
+ }),
+}));
+
+jest.mock('../../hooks/usePerpsNavigation', () => ({
+ usePerpsNavigation: () => ({
+ navigateToOrder: mockNavigateToOrder,
+ navigateToClosePosition: mockNavigateToClosePosition,
+ }),
+}));
+
+// Mock the PerpsModifyActionSheet component
+jest.mock(
+ '../../components/PerpsModifyActionSheet',
+ () =>
+ function MockPerpsModifyActionSheet({
+ onClose,
+ onActionSelect,
+ position,
+ }: {
+ onClose: () => void;
+ onActionSelect: (action: string) => void;
+ position?: Position;
+ }) {
+ const ReactModule = jest.requireActual('react');
+ const { View, Text, TouchableOpacity } =
+ jest.requireActual('react-native');
+ return ReactModule.createElement(
+ View,
+ { testID: 'modify-action-sheet' },
+ ReactModule.createElement(Text, null, 'Modify Position'),
+ position &&
+ ReactModule.createElement(
+ Text,
+ { testID: 'position-coin' },
+ position.coin,
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onActionSelect('add_to_position'),
+ testID: 'add-to-position',
+ },
+ ReactModule.createElement(Text, null, 'Add to Position'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onActionSelect('reduce_position'),
+ testID: 'reduce-position',
+ },
+ ReactModule.createElement(Text, null, 'Reduce Position'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ onPress: () => onActionSelect('flip_position'),
+ testID: 'flip-position',
+ },
+ ReactModule.createElement(Text, null, 'Flip Position'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: onClose, testID: 'close-button' },
+ ReactModule.createElement(Text, null, 'Close'),
+ ),
+ );
+ },
+);
+
+describe('PerpsSelectModifyActionView', () => {
+ const mockLongPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockShortPosition: Position = {
+ ...mockLongPosition,
+ size: '-2.5',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouteParams = { position: mockLongPosition };
+ });
+
+ afterEach(() => {
+ mockRouteParams = {};
+ });
+
+ it('renders the modify action sheet', () => {
+ render();
+
+ expect(screen.getByTestId('modify-action-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders add to position option', () => {
+ render();
+
+ expect(screen.getByText('Add to Position')).toBeOnTheScreen();
+ });
+
+ it('renders reduce position option', () => {
+ render();
+
+ expect(screen.getByText('Reduce Position')).toBeOnTheScreen();
+ });
+
+ it('renders flip position option', () => {
+ render();
+
+ expect(screen.getByText('Flip Position')).toBeOnTheScreen();
+ });
+
+ it('renders with position from props', () => {
+ render();
+
+ expect(screen.getByTestId('position-coin')).toBeOnTheScreen();
+ expect(screen.getByText('ETH')).toBeOnTheScreen();
+ });
+
+ it('navigates to order with long direction when add_to_position is selected for long position', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'long',
+ asset: 'ETH',
+ hideTPSL: true,
+ });
+ });
+
+ it('navigates to order with short direction when add_to_position is selected for short position', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'short',
+ asset: 'ETH',
+ hideTPSL: true,
+ });
+ });
+
+ it('navigates to close position when reduce_position is selected', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('reduce-position'));
+
+ expect(mockNavigateToClosePosition).toHaveBeenCalledWith(mockLongPosition);
+ });
+
+ it('calls onReversePosition when flip_position is selected with callback', () => {
+ const mockOnReversePosition = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('flip-position'));
+
+ expect(mockOnReversePosition).toHaveBeenCalledWith(mockLongPosition);
+ });
+
+ it('navigates to order with opposite direction when flip_position is selected without callback (long)', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('flip-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'short',
+ asset: 'ETH',
+ size: '2.5',
+ leverage: 10,
+ });
+ });
+
+ it('navigates to order with opposite direction when flip_position is selected without callback (short)', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('flip-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'long',
+ asset: 'ETH',
+ size: '2.5',
+ leverage: 10,
+ });
+ });
+
+ it('does not navigate when position is not available', () => {
+ mockRouteParams = {};
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).not.toHaveBeenCalled();
+ });
+
+ it('calls goBack when close button is pressed without external sheetRef', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('calls onClose callback when close button is pressed with external sheetRef', () => {
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+
+ it('uses position from route params when not provided via props', () => {
+ mockRouteParams = { position: mockLongPosition };
+ render();
+
+ fireEvent.press(screen.getByTestId('add-to-position'));
+
+ expect(mockNavigateToOrder).toHaveBeenCalledWith({
+ direction: 'long',
+ asset: 'ETH',
+ hideTPSL: true,
+ });
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx
new file mode 100644
index 00000000000..2659691349d
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx
@@ -0,0 +1,120 @@
+import React, { useCallback, useRef } from 'react';
+import {
+ useNavigation,
+ useRoute,
+ type NavigationProp,
+ type RouteProp,
+} from '@react-navigation/native';
+import type { Position } from '../../controllers/types';
+import type { PerpsNavigationParamList } from '../../types/navigation';
+import PerpsModifyActionSheet, {
+ type ModifyAction,
+} from '../../components/PerpsModifyActionSheet';
+import { usePerpsNavigation } from '../../hooks/usePerpsNavigation';
+import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+interface PerpsSelectModifyActionViewProps {
+ sheetRef?: React.RefObject;
+ position?: Position;
+ onClose?: () => void;
+ onReversePosition?: (position: Position) => void;
+}
+
+const PerpsSelectModifyActionView: React.FC<
+ PerpsSelectModifyActionViewProps
+> = ({
+ sheetRef: externalSheetRef,
+ position: positionProp,
+ onClose: onExternalClose,
+ onReversePosition,
+}) => {
+ const navigation = useNavigation>();
+ const route =
+ useRoute>();
+
+ // Support both props and route params
+ const position = positionProp || route.params?.position;
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+ const { navigateToOrder, navigateToClosePosition } = usePerpsNavigation();
+
+ const handleActionSelect = useCallback(
+ (action: ModifyAction) => {
+ if (!position) return;
+
+ // Navigate BEFORE closing (prevents navigation loss from component unmounting)
+ switch (action) {
+ case 'add_to_position':
+ // Open trade screen in same direction
+ {
+ const direction = parseFloat(position.size) > 0 ? 'long' : 'short';
+ navigateToOrder({
+ direction,
+ asset: position.coin,
+ hideTPSL: true, // Hide TP/SL when adding to existing position
+ });
+ }
+ break;
+
+ case 'reduce_position':
+ // Open close position screen
+ navigateToClosePosition(position);
+ break;
+
+ case 'flip_position':
+ // If parent provides onReversePosition callback, use it (shows confirmation sheet)
+ // Otherwise, navigate directly to order screen (legacy behavior)
+ if (onReversePosition) {
+ onReversePosition(position);
+ } else {
+ const oppositeDirection =
+ parseFloat(position.size) > 0 ? 'short' : 'long';
+ const positionSize = Math.abs(parseFloat(position.size));
+ const positionLeverage = position.leverage?.value;
+
+ navigateToOrder({
+ direction: oppositeDirection,
+ asset: position.coin,
+ size: positionSize.toString(),
+ leverage: positionLeverage,
+ });
+ }
+ break;
+ }
+
+ // Close bottom sheet AFTER navigation is triggered
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ },
+ [
+ position,
+ navigateToOrder,
+ navigateToClosePosition,
+ onReversePosition,
+ sheetRef,
+ onExternalClose,
+ ],
+ );
+
+ const handleClose = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ } else {
+ navigation.goBack();
+ }
+ }, [navigation, externalSheetRef, sheetRef, onExternalClose]);
+
+ return (
+
+ );
+};
+
+export default PerpsSelectModifyActionView;
diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts
new file mode 100644
index 00000000000..a84ae2cd3fe
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsSelectModifyActionView';
diff --git a/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx
new file mode 100644
index 00000000000..bc563597d21
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.test.tsx
@@ -0,0 +1,193 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsSelectOrderTypeView from './PerpsSelectOrderTypeView';
+
+const mockGoBack = jest.fn();
+const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.());
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ }),
+}));
+
+// Mock the PerpsOrderTypeBottomSheet component
+jest.mock(
+ '../../components/PerpsOrderTypeBottomSheet',
+ () =>
+ function MockPerpsOrderTypeBottomSheet({
+ onClose,
+ onSelect,
+ currentOrderType,
+ asset,
+ direction,
+ }: {
+ onClose: () => void;
+ onSelect: (type: string) => void;
+ currentOrderType: string;
+ asset?: string;
+ direction?: string;
+ }) {
+ const ReactModule = jest.requireActual('react');
+ const { View, Text, TouchableOpacity } =
+ jest.requireActual('react-native');
+ return ReactModule.createElement(
+ View,
+ { testID: 'order-type-bottom-sheet' },
+ ReactModule.createElement(Text, null, 'Select Order Type'),
+ ReactModule.createElement(
+ Text,
+ { testID: 'current-type' },
+ `Current: ${currentOrderType}`,
+ ),
+ asset &&
+ ReactModule.createElement(
+ Text,
+ { testID: 'asset' },
+ `Asset: ${asset}`,
+ ),
+ direction &&
+ ReactModule.createElement(
+ Text,
+ { testID: 'direction' },
+ `Direction: ${direction}`,
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: () => onSelect('market'), testID: 'select-market' },
+ ReactModule.createElement(Text, null, 'Market'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: () => onSelect('limit'), testID: 'select-limit' },
+ ReactModule.createElement(Text, null, 'Limit'),
+ ),
+ ReactModule.createElement(
+ TouchableOpacity,
+ { onPress: onClose, testID: 'close-button' },
+ ReactModule.createElement(Text, null, 'Close'),
+ ),
+ );
+ },
+);
+
+describe('PerpsSelectOrderTypeView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the order type bottom sheet', () => {
+ render();
+
+ expect(screen.getByTestId('order-type-bottom-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders with default market order type', () => {
+ render();
+
+ expect(screen.getByText('Current: market')).toBeOnTheScreen();
+ });
+
+ it('renders with custom order type', () => {
+ render();
+
+ expect(screen.getByText('Current: limit')).toBeOnTheScreen();
+ });
+
+ it('renders market option', () => {
+ render();
+
+ expect(screen.getByText('Market')).toBeOnTheScreen();
+ });
+
+ it('renders limit option', () => {
+ render();
+
+ expect(screen.getByText('Limit')).toBeOnTheScreen();
+ });
+
+ it('displays asset when provided', () => {
+ render();
+
+ expect(screen.getByText('Asset: BTC')).toBeOnTheScreen();
+ });
+
+ it('displays direction when provided', () => {
+ render();
+
+ expect(screen.getByText('Direction: long')).toBeOnTheScreen();
+ });
+
+ it('calls onSelect callback when order type is selected', () => {
+ const mockOnSelect = jest.fn();
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('select-market'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockOnSelect).toHaveBeenCalledWith('market');
+ });
+
+ it('calls onSelect with limit when limit is selected', () => {
+ const mockOnSelect = jest.fn();
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('select-limit'));
+
+ expect(mockOnSelect).toHaveBeenCalledWith('limit');
+ });
+
+ it('calls goBack when close button is pressed without external sheetRef', () => {
+ render();
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('calls onClose callback when close button is pressed with external sheetRef', () => {
+ const mockOnClose = jest.fn();
+ const mockSheetRef = {
+ current: { onCloseBottomSheet: mockOnCloseBottomSheet },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('close-button'));
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ expect(mockOnClose).toHaveBeenCalled();
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx
new file mode 100644
index 00000000000..a76bbe878cb
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/PerpsSelectOrderTypeView.tsx
@@ -0,0 +1,61 @@
+import React, { useCallback, useRef } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import type { OrderType } from '../../controllers/types';
+import PerpsOrderTypeBottomSheet from '../../components/PerpsOrderTypeBottomSheet';
+import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+interface PerpsSelectOrderTypeViewProps {
+ sheetRef?: React.RefObject;
+ currentOrderType?: OrderType;
+ asset?: string;
+ direction?: 'long' | 'short';
+ onSelect?: (type: OrderType) => void;
+ onClose?: () => void;
+}
+
+const PerpsSelectOrderTypeView: React.FC = ({
+ sheetRef: externalSheetRef,
+ currentOrderType,
+ asset,
+ direction,
+ onSelect,
+ onClose: onExternalClose,
+}) => {
+ const navigation = useNavigation();
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ const handleSelect = useCallback(
+ (type: OrderType) => {
+ // Close bottom sheet first, then call onSelect callback
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ onSelect?.(type);
+ });
+ },
+ [sheetRef, onExternalClose, onSelect],
+ );
+
+ const handleClose = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onExternalClose?.();
+ });
+ } else {
+ navigation.goBack();
+ }
+ }, [navigation, externalSheetRef, sheetRef, onExternalClose]);
+
+ return (
+
+ );
+};
+
+export default PerpsSelectOrderTypeView;
diff --git a/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts
new file mode 100644
index 00000000000..97de65f8588
--- /dev/null
+++ b/app/components/UI/Perps/Views/PerpsSelectOrderTypeView/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsSelectOrderTypeView';
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts
new file mode 100644
index 00000000000..3fd8ebaf4ad
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.styles.ts
@@ -0,0 +1,25 @@
+import { StyleSheet } from 'react-native';
+
+const styleSheet = () =>
+ StyleSheet.create({
+ container: {
+ paddingBottom: 16,
+ },
+ actionItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 16,
+ paddingHorizontal: 16,
+ gap: 12,
+ },
+ actionContent: {
+ flex: 1,
+ gap: 4,
+ },
+ separator: {
+ height: 1,
+ marginHorizontal: 16,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx
new file mode 100644
index 00000000000..50d3bc30fbf
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.test.tsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsAdjustMarginActionSheet from './PerpsAdjustMarginActionSheet';
+
+// Mock dependencies
+jest.mock('../../../../../component-library/hooks', () => ({
+ useStyles: () => ({
+ styles: {
+ container: {},
+ actionItem: {},
+ actionContent: {},
+ separator: {},
+ },
+ theme: {
+ colors: {
+ border: { muted: '#CCCCCC' },
+ },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => {
+ const translations: Record = {
+ 'perps.adjust_margin.title': 'Adjust Margin',
+ 'perps.adjust_margin.add_margin': 'Add Margin',
+ 'perps.adjust_margin.add_margin_description':
+ 'Increase margin to reduce liquidation risk',
+ 'perps.adjust_margin.reduce_margin': 'Reduce Margin',
+ 'perps.adjust_margin.reduce_margin_description':
+ 'Withdraw excess margin from position',
+ };
+ return translations[key] || key;
+ }),
+}));
+
+// Mock BottomSheet and BottomSheetHeader
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheet',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ReactModule.forwardRef(
+ (
+ { children, testID }: { children: React.ReactNode; testID?: string },
+ _ref: unknown,
+ ) => ReactModule.createElement(View, { testID }, children),
+ ),
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetHeader',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockBottomSheetHeader({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-header' },
+ children,
+ );
+ };
+ },
+);
+
+// Mock Icon component
+jest.mock('../../../../../component-library/components/Icons/Icon', () => ({
+ __esModule: true,
+ default: function MockIcon() {
+ return null;
+ },
+ IconName: {
+ Add: 'Add',
+ Minus: 'Minus',
+ Arrow2Right: 'Arrow2Right',
+ },
+ IconSize: {
+ Lg: 'Lg',
+ Md: 'Md',
+ },
+ IconColor: {
+ Primary: 'Primary',
+ Alternative: 'Alternative',
+ },
+}));
+
+describe('PerpsAdjustMarginActionSheet', () => {
+ const mockOnClose = jest.fn();
+ const mockOnSelectAction = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the adjust margin title', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Adjust Margin')).toBeOnTheScreen();
+ });
+
+ it('renders add margin option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Add Margin')).toBeOnTheScreen();
+ expect(
+ screen.getByText('Increase margin to reduce liquidation risk'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders reduce margin option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Reduce Margin')).toBeOnTheScreen();
+ expect(
+ screen.getByText('Withdraw excess margin from position'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls onSelectAction with add_margin when add margin is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add Margin'));
+
+ expect(mockOnSelectAction).toHaveBeenCalledWith('add_margin');
+ });
+
+ it('calls onSelectAction with reduce_margin when reduce margin is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Reduce Margin'));
+
+ expect(mockOnSelectAction).toHaveBeenCalledWith('reduce_margin');
+ });
+
+ it('calls onClose when action is selected', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add Margin'));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('renders with testID when provided', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('test-adjust-margin-sheet')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
new file mode 100644
index 00000000000..30260e0aff7
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
@@ -0,0 +1,129 @@
+import React, { useMemo, useCallback, useRef, useEffect } from 'react';
+import { View, TouchableOpacity } from 'react-native';
+import { useStyles } from '../../../../../component-library/hooks';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import Icon, {
+ IconName,
+ IconSize,
+ IconColor,
+} from '../../../../../component-library/components/Icons/Icon';
+import { strings } from '../../../../../../locales/i18n';
+import styleSheet from './PerpsAdjustMarginActionSheet.styles';
+import type {
+ PerpsAdjustMarginActionSheetProps,
+ AdjustMarginAction,
+} from './PerpsAdjustMarginActionSheet.types';
+
+interface ActionOption {
+ action: AdjustMarginAction;
+ label: string;
+ description: string;
+ iconName: IconName;
+}
+
+const PerpsAdjustMarginActionSheet: React.FC<
+ PerpsAdjustMarginActionSheetProps
+> = ({
+ isVisible = true,
+ onClose,
+ onSelectAction,
+ sheetRef: externalSheetRef,
+ testID,
+}) => {
+ const { styles, theme } = useStyles(styleSheet, {});
+ const { colors } = theme;
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ useEffect(() => {
+ if (isVisible && !externalSheetRef) {
+ sheetRef.current?.onOpenBottomSheet();
+ }
+ }, [isVisible, externalSheetRef, sheetRef]);
+
+ const actionOptions: ActionOption[] = useMemo(
+ () => [
+ {
+ action: 'add_margin',
+ label: strings('perps.adjust_margin.add_margin'),
+ description: strings('perps.adjust_margin.add_margin_description'),
+ iconName: IconName.Add,
+ },
+ {
+ action: 'reduce_margin',
+ label: strings('perps.adjust_margin.reduce_margin'),
+ description: strings('perps.adjust_margin.reduce_margin_description'),
+ iconName: IconName.Minus,
+ },
+ ],
+ [],
+ );
+
+ const handleActionPress = useCallback(
+ (action: AdjustMarginAction) => {
+ onSelectAction(action);
+ onClose();
+ },
+ [onSelectAction, onClose],
+ );
+
+ return (
+
+
+
+ {strings('perps.adjust_margin.title')}
+
+
+
+ {actionOptions.map((option, index) => (
+
+ handleActionPress(option.action)}
+ activeOpacity={0.7}
+ >
+
+
+ {option.label}
+
+ {option.description}
+
+
+
+ {index < actionOptions.length - 1 && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+PerpsAdjustMarginActionSheet.displayName = 'PerpsAdjustMarginActionSheet';
+
+export default PerpsAdjustMarginActionSheet;
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts
new file mode 100644
index 00000000000..1a708b66009
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.types.ts
@@ -0,0 +1,11 @@
+import type { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+
+export type AdjustMarginAction = 'add_margin' | 'reduce_margin';
+
+export interface PerpsAdjustMarginActionSheetProps {
+ isVisible?: boolean;
+ onClose: () => void;
+ onSelectAction: (action: AdjustMarginAction) => void;
+ sheetRef?: React.RefObject;
+ testID?: string;
+}
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts
new file mode 100644
index 00000000000..f7370184087
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/index.ts
@@ -0,0 +1,5 @@
+export { default } from './PerpsAdjustMarginActionSheet';
+export type {
+ PerpsAdjustMarginActionSheetProps,
+ AdjustMarginAction,
+} from './PerpsAdjustMarginActionSheet.types';
diff --git a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts
index a094e6553af..5037fa59f58 100644
--- a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts
+++ b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.styles.ts
@@ -15,11 +15,19 @@ export const styleSheet = (params: {
// Background uses .muted variant, text uses .default variant from same color family
// This ensures proper contrast while maintaining semantic color consistency
// Pattern inspired by AvatarIcon component (primary.muted background + primary.default icon)
- const badgeStyles: Record = {
+ const badgeStyles: Record<
+ BadgeType,
+ { background: string; text: string; border?: string }
+ > = {
experimental: {
background: theme.colors.primary.muted,
text: theme.colors.primary.default,
},
+ dex: {
+ background: theme.colors.background.default,
+ text: theme.colors.text.alternative,
+ border: theme.colors.border.default,
+ },
equity: {
background: theme.colors.info.muted,
text: theme.colors.info.default,
@@ -51,6 +59,10 @@ export const styleSheet = (params: {
borderRadius: 4,
backgroundColor: style.background,
alignSelf: 'flex-start',
+ ...(style.border && {
+ borderWidth: 1,
+ borderColor: style.border,
+ }),
},
badgeText: {
fontSize: 10,
diff --git a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts
index 8e4a357aa7a..f345ff32423 100644
--- a/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts
+++ b/app/components/UI/Perps/components/PerpsBadge/PerpsBadge.types.ts
@@ -1,6 +1,6 @@
import type { MarketType } from '../../controllers/types';
-export type BadgeType = MarketType | 'experimental';
+export type BadgeType = MarketType | 'experimental' | 'dex';
export interface PerpsBadgeProps {
/**
diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
index abdfa05a6b0..bdb0d40e032 100644
--- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
+++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
@@ -36,6 +36,7 @@ export interface PerpsBottomSheetTooltipProps {
export type PerpsTooltipContentKey =
| 'leverage'
| 'liquidation_price'
+ | 'liquidation_distance'
| 'margin'
| 'fees'
| 'closing_fees'
diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
index 002a5e669bc..0c7fe2975e3 100644
--- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
+++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
@@ -21,6 +21,7 @@ export const tooltipContentRegistry: ContentRegistry = {
receive: undefined,
leverage: undefined,
liquidation_price: undefined,
+ liquidation_distance: undefined,
margin: undefined,
open_interest: undefined,
funding_rate: undefined,
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts
new file mode 100644
index 00000000000..dafcab74d9d
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.styles.ts
@@ -0,0 +1,39 @@
+import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
+
+const createStyles = (params: { theme: Theme }) =>
+ StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ backgroundColor: params.theme.colors.background.section,
+ borderRadius: 8,
+ marginBottom: 8,
+ },
+ leftSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ marginRight: 16,
+ gap: 12,
+ },
+ infoContainer: {
+ flex: 1,
+ gap: 4,
+ },
+ rightSection: {
+ alignItems: 'flex-end',
+ gap: 4,
+ },
+ priceText: {
+ textAlign: 'right',
+ },
+ labelText: {
+ textAlign: 'right',
+ },
+ });
+
+export default createStyles;
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx
new file mode 100644
index 00000000000..8ca916d1836
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.test.tsx
@@ -0,0 +1,189 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsCompactOrderRow from './PerpsCompactOrderRow';
+import type { Order } from '../../controllers/types';
+
+// Mock dependencies
+jest.mock('../../../../../component-library/hooks', () => ({
+ useStyles: () => ({
+ styles: {
+ container: {},
+ leftSection: {},
+ infoContainer: {},
+ rightSection: {},
+ priceText: {},
+ labelText: {},
+ },
+ theme: {
+ colors: {
+ success: { default: '#00FF00' },
+ error: { default: '#FF0000' },
+ },
+ },
+ }),
+}));
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatPositionSize: jest.fn((value) => value),
+ formatPerpsFiat: jest.fn((value) => `$${value.toFixed(2)}`),
+ PRICE_RANGES_MINIMAL_VIEW: {},
+}));
+
+jest.mock('../../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+jest.mock('../PerpsTokenLogo', () => 'PerpsTokenLogo');
+
+describe('PerpsCompactOrderRow', () => {
+ const mockLimitBuyOrder: Order = {
+ orderId: 'order-123',
+ symbol: 'BTC',
+ size: '0.5',
+ originalSize: '0.5',
+ filledSize: '0',
+ remainingSize: '0.5',
+ price: '50000',
+ side: 'buy',
+ orderType: 'limit',
+ timestamp: Date.now(),
+ status: 'open',
+ reduceOnly: false,
+ detailedOrderType: 'Limit',
+ };
+
+ const mockLimitSellOrder: Order = {
+ ...mockLimitBuyOrder,
+ orderId: 'order-456',
+ side: 'sell',
+ };
+
+ const mockMarketOrder: Order = {
+ ...mockLimitBuyOrder,
+ orderId: 'order-789',
+ orderType: 'market',
+ detailedOrderType: 'Market',
+ };
+
+ const mockTriggerOrder: Order = {
+ ...mockLimitBuyOrder,
+ orderId: 'order-trigger',
+ orderType: 'limit',
+ isTrigger: true,
+ price: '48000',
+ detailedOrderType: 'Stop Market',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders order info for limit buy order', () => {
+ render();
+
+ expect(screen.getByText('Limit long')).toBeOnTheScreen();
+ expect(screen.getByText('0.5 BTC')).toBeOnTheScreen();
+ expect(screen.getByText('Limit price')).toBeOnTheScreen();
+ });
+
+ it('renders order info for limit sell order (short direction)', () => {
+ render();
+
+ expect(screen.getByText('Limit short')).toBeOnTheScreen();
+ });
+
+ it('renders market price label for market orders', () => {
+ render();
+
+ expect(screen.getByText('Market price')).toBeOnTheScreen();
+ expect(screen.getByText('Market long')).toBeOnTheScreen();
+ });
+
+ it('renders Stop Market order type for trigger orders', () => {
+ render();
+
+ expect(screen.getByText('Stop Market long')).toBeOnTheScreen();
+ });
+
+ it('uses price for trigger orders', () => {
+ const { formatPerpsFiat } = jest.requireMock('../../utils/formatUtils');
+ render();
+
+ // Should have called formatPerpsFiat with the order price value (48000)
+ // Note: The adapter maps triggerPx to price, so trigger orders use price field
+ expect(formatPerpsFiat).toHaveBeenCalledWith(48000, expect.any(Object));
+ });
+
+ it('uses order price for limit orders', () => {
+ const { formatPerpsFiat } = jest.requireMock('../../utils/formatUtils');
+ render();
+
+ // Should have called formatPerpsFiat with the order price value (50000)
+ expect(formatPerpsFiat).toHaveBeenCalledWith(50000, expect.any(Object));
+ });
+
+ it('calls onPress when tapped', () => {
+ const mockOnPress = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('compact-order-row'));
+
+ expect(mockOnPress).toHaveBeenCalled();
+ });
+
+ it('is disabled when no onPress handler is provided', () => {
+ render(
+ ,
+ );
+
+ // TouchableOpacity should be disabled - we verify by checking it renders
+ expect(screen.getByTestId('compact-order-row')).toBeOnTheScreen();
+ });
+
+ it('renders with custom testID', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('custom-test-id')).toBeOnTheScreen();
+ });
+
+ it('formats position size correctly', () => {
+ const { formatPositionSize } = jest.requireMock('../../utils/formatUtils');
+ render();
+
+ expect(formatPositionSize).toHaveBeenCalledWith('0.5');
+ });
+
+ it('gets display symbol for the order', () => {
+ const { getPerpsDisplaySymbol } = jest.requireMock(
+ '../../utils/marketUtils',
+ );
+ render();
+
+ expect(getPerpsDisplaySymbol).toHaveBeenCalledWith('BTC');
+ });
+
+ it('handles order without detailedOrderType', () => {
+ const orderWithoutDetailedType: Order = {
+ ...mockLimitBuyOrder,
+ detailedOrderType: undefined,
+ };
+ render();
+
+ // Falls back to 'Limit' for order type display
+ expect(screen.getByText('Limit long')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx
new file mode 100644
index 00000000000..554fde36f44
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx
@@ -0,0 +1,129 @@
+import React, { useMemo } from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import { useStyles } from '../../../../../component-library/hooks';
+import {
+ formatPositionSize,
+ formatPerpsFiat,
+ PRICE_RANGES_MINIMAL_VIEW,
+} from '../../utils/formatUtils';
+import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
+import styleSheet from './PerpsCompactOrderRow.styles';
+import type { Order } from '../../controllers/types';
+import PerpsTokenLogo from '../PerpsTokenLogo';
+
+interface PerpsCompactOrderRowProps {
+ order: Order;
+ onPress?: () => void;
+ testID?: string;
+}
+
+/**
+ * PerpsCompactOrderRow Component
+ *
+ * A compact, single-line representation of an open order.
+ * Designed for displaying non-TP/SL orders in a simplified list format.
+ *
+ * Shows:
+ * - Order direction indicator (circle)
+ * - Order type and direction (e.g., "Limit long")
+ * - Price
+ * - Size in asset units
+ * - Order type label
+ */
+const PerpsCompactOrderRow: React.FC = ({
+ order,
+ onPress,
+ testID,
+}) => {
+ const { styles, theme } = useStyles(styleSheet, {});
+
+ const orderInfo = useMemo(() => {
+ // Determine direction and color
+ const isLong = order.side === 'buy';
+ const direction = isLong ? 'long' : 'short';
+ const directionColor = isLong
+ ? theme.colors.success.default
+ : theme.colors.error.default;
+
+ // Format order type
+ let orderTypeLabel = 'Limit price';
+ if (order.detailedOrderType?.includes('Market')) {
+ orderTypeLabel = 'Market price';
+ } else if (order.isTrigger) {
+ orderTypeLabel = 'Trigger price';
+ }
+
+ // Format price - trigger orders already have triggerPx mapped to price by adapter
+ const priceValue = parseFloat(order.price || '0');
+ const formattedPrice = formatPerpsFiat(priceValue, {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ });
+
+ // Format size
+ const size = Math.abs(parseFloat(order.size));
+ const formattedSize = formatPositionSize(size.toString());
+ const symbol = getPerpsDisplaySymbol(order.symbol);
+
+ // Order type display (e.g., "Limit long", "Stop Market")
+ const orderTypeDisplay = order.detailedOrderType || 'Limit';
+
+ return {
+ direction,
+ directionColor,
+ orderTypeLabel,
+ formattedPrice,
+ formattedSize,
+ symbol,
+ orderTypeDisplay,
+ isLong,
+ };
+ }, [order, theme]);
+
+ return (
+
+
+ {/* Token icon */}
+
+
+ {/* Order info */}
+
+
+ {orderInfo.orderTypeDisplay} {orderInfo.direction}
+
+
+ {orderInfo.formattedSize} {orderInfo.symbol}
+
+
+
+
+
+
+ {orderInfo.formattedPrice}
+
+
+ {orderInfo.orderTypeLabel}
+
+
+
+ );
+};
+
+export default PerpsCompactOrderRow;
diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts b/app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts
new file mode 100644
index 00000000000..d652190d200
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/index.ts
@@ -0,0 +1 @@
+export { default } from './PerpsCompactOrderRow';
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts
new file mode 100644
index 00000000000..410fbc2f4eb
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.styles.ts
@@ -0,0 +1,64 @@
+import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
+
+const createStyles = (theme: Theme) =>
+ StyleSheet.create({
+ contentContainer: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ },
+ description: {
+ marginBottom: 24,
+ },
+ detailsWrapper: {
+ gap: 1,
+ marginBottom: 16,
+ },
+ detailItem: {
+ backgroundColor: theme.colors.background.alternative,
+ overflow: 'hidden',
+ },
+ detailItemFirst: {
+ borderTopLeftRadius: 12,
+ borderTopRightRadius: 12,
+ },
+ detailItemLast: {
+ borderBottomLeftRadius: 12,
+ borderBottomRightRadius: 12,
+ },
+ detailItemWrapper: {
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ },
+ directionContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ directionText: {
+ marginHorizontal: 8,
+ },
+ infoRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 12,
+ },
+ infoLabel: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ loadingContainer: {
+ paddingVertical: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ loadingText: {
+ marginTop: 16,
+ },
+ footerContainer: {
+ paddingTop: 16,
+ },
+ });
+
+export default createStyles;
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx
new file mode 100644
index 00000000000..a8a620b70f6
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx
@@ -0,0 +1,374 @@
+import React from 'react';
+import {
+ render,
+ screen,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react-native';
+import PerpsFlipPositionConfirmSheet from './PerpsFlipPositionConfirmSheet';
+import type { Position } from '../../controllers/types';
+
+const mockHandleFlipPosition = jest.fn();
+let mockIsFlipping = false;
+
+// Mock dependencies
+jest.mock('../../../../../util/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ primary: { default: '#0376C9' },
+ success: { default: '#00FF00' },
+ error: { default: '#FF0000' },
+ border: { muted: '#CCCCCC' },
+ background: { alternative: '#F5F5F5' },
+ },
+ }),
+}));
+
+jest.mock('./PerpsFlipPositionConfirmSheet.styles', () => () => ({
+ contentContainer: {},
+ loadingContainer: {},
+ loadingText: {},
+ detailsWrapper: {},
+ detailItem: {},
+ detailItemFirst: {},
+ detailItemLast: {},
+ detailItemWrapper: {},
+ infoRow: {},
+ directionContainer: {},
+ footerContainer: {},
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => {
+ const translations: Record = {
+ 'perps.flip_position.title': 'Flip Position',
+ 'perps.flip_position.direction': 'Direction',
+ 'perps.flip_position.est_size': 'Est. Size',
+ 'perps.flip_position.cancel': 'Cancel',
+ 'perps.flip_position.flip': 'Flip',
+ 'perps.flip_position.flipping': 'Flipping...',
+ 'perps.order.long_label': 'Long',
+ 'perps.order.short_label': 'Short',
+ 'perps.order.fees': 'Fees',
+ 'perps.estimated_points': 'Est. Points',
+ };
+ return translations[key] || key;
+ }),
+}));
+
+jest.mock('../../hooks', () => ({
+ usePerpsOrderFees: () => ({
+ totalFee: 0.5,
+ makerFee: 0.2,
+ takerFee: 0.3,
+ isLoadingMetamaskFee: false,
+ }),
+ usePerpsRewards: () => ({
+ shouldShowRewardsRow: false,
+ estimatedPoints: undefined,
+ accountOptedIn: false,
+ feeDiscountPercentage: 0,
+ hasError: false,
+ bonusBips: 0,
+ }),
+ usePerpsMeasurement: jest.fn(),
+}));
+
+jest.mock('../../hooks/usePerpsFlipPosition', () => ({
+ usePerpsFlipPosition: ({ onSuccess }: { onSuccess?: () => void }) => ({
+ handleFlipPosition: mockHandleFlipPosition.mockImplementation(async () => {
+ onSuccess?.();
+ }),
+ isFlipping: mockIsFlipping,
+ }),
+}));
+
+jest.mock('../../hooks/stream', () => ({
+ usePerpsLivePrices: () => ({
+ ETH: { price: '2500', markPrice: '2502' },
+ BTC: { price: '50000', markPrice: '50010' },
+ }),
+ usePerpsTopOfBook: () => ({
+ bestAsk: '2501',
+ bestBid: '2499',
+ }),
+}));
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatPerpsFiat: jest.fn((value) => `$${value.toFixed(2)}`),
+ PRICE_RANGES_MINIMAL_VIEW: {},
+}));
+
+jest.mock('../../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+jest.mock('../PerpsFeesDisplay', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return function MockPerpsFeesDisplay({
+ formatFeeText,
+ }: {
+ formatFeeText: string;
+ }) {
+ return ReactModule.createElement(Text, null, formatFeeText);
+ };
+});
+
+jest.mock('../../../Rewards/components/RewardPointsAnimation', () => ({
+ __esModule: true,
+ default: () => null,
+ RewardAnimationState: {
+ Idle: 'Idle',
+ Loading: 'Loading',
+ ErrorState: 'ErrorState',
+ },
+}));
+
+jest.mock('../../../../../util/trace', () => ({
+ TraceName: {
+ PerpsFlipPositionSheet: 'PerpsFlipPositionSheet',
+ },
+}));
+
+// Mock BottomSheet components
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheet',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ReactModule.forwardRef(
+ ({ children }: { children: React.ReactNode }, _ref: unknown) =>
+ ReactModule.createElement(View, { testID: 'bottom-sheet' }, children),
+ ),
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetHeader',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockBottomSheetHeader({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-header' },
+ children,
+ );
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetFooter',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View, TouchableOpacity, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ ButtonsAlignment: { Horizontal: 'Horizontal' },
+ default: function MockBottomSheetFooter({
+ buttonPropsArray,
+ }: {
+ buttonPropsArray: {
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ }[];
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-footer' },
+ buttonPropsArray.map(
+ (
+ button: {
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ },
+ index: number,
+ ) =>
+ ReactModule.createElement(
+ TouchableOpacity,
+ {
+ key: index,
+ onPress: button.onPress,
+ disabled: button.disabled,
+ testID: `footer-button-${index}`,
+ },
+ ReactModule.createElement(Text, null, button.label),
+ ),
+ ),
+ );
+ },
+ };
+ },
+);
+
+jest.mock('../../../../../component-library/components/Texts/Text', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockText({
+ children,
+ testID,
+ }: {
+ children: React.ReactNode;
+ testID?: string;
+ }) {
+ return ReactModule.createElement(Text, { testID }, children);
+ },
+ TextVariant: {
+ HeadingMD: 'HeadingMD',
+ BodyMD: 'BodyMD',
+ },
+ TextColor: {
+ Default: 'Default',
+ Alternative: 'Alternative',
+ },
+ };
+});
+
+jest.mock('../../../../../component-library/components/Icons/Icon', () => ({
+ __esModule: true,
+ default: () => null,
+ IconName: { Arrow2Right: 'Arrow2Right' },
+ IconSize: { Md: 'Md' },
+ IconColor: { Default: 'Default' },
+}));
+
+jest.mock('../../../../../component-library/components/Buttons/Button', () => ({
+ ButtonSize: { Lg: 'Lg' },
+ ButtonVariants: { Primary: 'Primary', Secondary: 'Secondary' },
+}));
+
+describe('PerpsFlipPositionConfirmSheet', () => {
+ const mockLongPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockShortPosition: Position = {
+ ...mockLongPosition,
+ size: '-2.5',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsFlipping = false;
+ });
+
+ it('renders the flip position title', () => {
+ render();
+
+ expect(screen.getByText('Flip Position')).toBeOnTheScreen();
+ });
+
+ it('renders direction labels', () => {
+ render();
+
+ expect(screen.getByText('Direction')).toBeOnTheScreen();
+ expect(screen.getByText('Long')).toBeOnTheScreen();
+ expect(screen.getByText('Short')).toBeOnTheScreen();
+ });
+
+ it('renders direction for short position', () => {
+ render();
+
+ expect(screen.getByText('Short')).toBeOnTheScreen();
+ expect(screen.getByText('Long')).toBeOnTheScreen();
+ });
+
+ it('renders estimated size', () => {
+ render();
+
+ expect(screen.getByText('Est. Size')).toBeOnTheScreen();
+ expect(screen.getByText('2.5 ETH')).toBeOnTheScreen();
+ });
+
+ it('renders fees label', () => {
+ render();
+
+ expect(screen.getByText('Fees')).toBeOnTheScreen();
+ });
+
+ it('renders cancel button', () => {
+ render();
+
+ expect(screen.getByText('Cancel')).toBeOnTheScreen();
+ });
+
+ it('renders flip button', () => {
+ render();
+
+ expect(screen.getByText('Flip')).toBeOnTheScreen();
+ });
+
+ it('calls handleFlipPosition when flip button is pressed', async () => {
+ render();
+
+ fireEvent.press(screen.getByText('Flip'));
+
+ await waitFor(() => {
+ expect(mockHandleFlipPosition).toHaveBeenCalledWith(mockLongPosition);
+ });
+ });
+
+ it('calls onClose when cancel button is pressed', () => {
+ const mockOnClose = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Cancel'));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onConfirm after successful flip', async () => {
+ const mockOnConfirm = jest.fn();
+ const mockOnClose = jest.fn();
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Flip'));
+
+ await waitFor(() => {
+ expect(mockOnConfirm).toHaveBeenCalled();
+ });
+ });
+
+ it('displays the position size correctly for short position', () => {
+ render();
+
+ // Math.abs(-2.5) = 2.5
+ expect(screen.getByText('2.5 ETH')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx
new file mode 100644
index 00000000000..6db237c6e63
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx
@@ -0,0 +1,284 @@
+import React, { useCallback, useMemo, useRef } from 'react';
+import { View, ActivityIndicator } from 'react-native';
+import { strings } from '../../../../../../locales/i18n';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import BottomSheetFooter, {
+ ButtonsAlignment,
+} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter';
+import Text, {
+ TextColor,
+ TextVariant,
+} from '../../../../../component-library/components/Texts/Text';
+import Icon, {
+ IconName,
+ IconSize,
+ IconColor,
+} from '../../../../../component-library/components/Icons/Icon';
+import {
+ ButtonSize,
+ ButtonVariants,
+} from '../../../../../component-library/components/Buttons/Button';
+import type { PerpsFlipPositionConfirmSheetProps } from './PerpsFlipPositionConfirmSheet.types';
+import createStyles from './PerpsFlipPositionConfirmSheet.styles';
+import { useTheme } from '../../../../../util/theme';
+import { TraceName } from '../../../../../util/trace';
+import {
+ usePerpsOrderFees,
+ usePerpsRewards,
+ usePerpsMeasurement,
+} from '../../hooks';
+import { usePerpsFlipPosition } from '../../hooks/usePerpsFlipPosition';
+import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream';
+import {
+ formatPerpsFiat,
+ PRICE_RANGES_MINIMAL_VIEW,
+} from '../../utils/formatUtils';
+import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
+import PerpsFeesDisplay from '../PerpsFeesDisplay';
+import RewardsAnimations, {
+ RewardAnimationState,
+} from '../../../Rewards/components/RewardPointsAnimation';
+
+const PerpsFlipPositionConfirmSheet: React.FC<
+ PerpsFlipPositionConfirmSheetProps
+> = ({ position, sheetRef: externalSheetRef, onClose, onConfirm }) => {
+ const theme = useTheme();
+ const styles = createStyles(theme);
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ // Measure bottom sheet display
+ usePerpsMeasurement({ traceName: TraceName.PerpsFlipPositionSheet });
+
+ // Determine current and opposite direction
+ const currentDirection = parseFloat(position.size) > 0 ? 'long' : 'short';
+ const oppositeDirection = currentDirection === 'long' ? 'short' : 'long';
+ const positionSize = Math.abs(parseFloat(position.size));
+
+ // Get current price
+ const prices = usePerpsLivePrices({
+ symbols: [position.coin],
+ throttleMs: 1000,
+ });
+ const currentPrice = prices[position.coin];
+ const price = parseFloat(currentPrice?.price || '0');
+ const markPrice = parseFloat(currentPrice?.markPrice || '0');
+
+ // Calculate USD amount for fee estimation
+ const usdAmount = useMemo(
+ () => (positionSize * (markPrice || price)).toString(),
+ [positionSize, markPrice, price],
+ );
+
+ // Get top of book for maker/taker fee determination
+ const topOfBook = usePerpsTopOfBook({ symbol: position.coin });
+
+ // Calculate estimated fees
+ const feeResults = usePerpsOrderFees({
+ orderType: 'market',
+ amount: usdAmount,
+ coin: position.coin,
+ isClosing: false,
+ direction: oppositeDirection,
+ currentAskPrice: topOfBook?.bestAsk
+ ? Number.parseFloat(topOfBook.bestAsk)
+ : undefined,
+ currentBidPrice: topOfBook?.bestBid
+ ? Number.parseFloat(topOfBook.bestBid)
+ : undefined,
+ });
+
+ const hasValidAmount = parseFloat(usdAmount) > 0;
+
+ // Get rewards state
+ const rewardsState = usePerpsRewards({
+ feeResults,
+ hasValidAmount,
+ isFeesLoading: feeResults.isLoadingMetamaskFee,
+ orderAmount: usdAmount,
+ });
+
+ // Determine reward animation state
+ let rewardAnimationState = RewardAnimationState.Idle;
+ if (feeResults.isLoadingMetamaskFee) {
+ rewardAnimationState = RewardAnimationState.Loading;
+ } else if (rewardsState.hasError) {
+ rewardAnimationState = RewardAnimationState.ErrorState;
+ }
+
+ // Define close handler first to avoid hoisting issues
+ const handleCloseInternal = useCallback(() => {
+ if (externalSheetRef) {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ onClose?.();
+ });
+ } else {
+ onClose?.();
+ }
+ }, [externalSheetRef, sheetRef, onClose]);
+
+ // Use flip position hook for handling position reversal
+ const { handleFlipPosition, isFlipping } = usePerpsFlipPosition({
+ onSuccess: () => {
+ handleCloseInternal();
+ onConfirm?.();
+ },
+ });
+
+ const handleReverse = useCallback(async () => {
+ await handleFlipPosition(position);
+ }, [position, handleFlipPosition]);
+
+ const footerButtons = useMemo(
+ () => [
+ {
+ label: strings('perps.flip_position.cancel'),
+ onPress: handleCloseInternal,
+ variant: ButtonVariants.Secondary,
+ size: ButtonSize.Lg,
+ disabled: isFlipping,
+ },
+ {
+ label: isFlipping
+ ? strings('perps.flip_position.flipping')
+ : strings('perps.flip_position.flip'),
+ onPress: handleReverse,
+ variant: ButtonVariants.Primary,
+ size: ButtonSize.Lg,
+ disabled: isFlipping || !hasValidAmount,
+ danger: true,
+ },
+ ],
+ [handleCloseInternal, handleReverse, isFlipping, hasValidAmount],
+ );
+
+ return (
+
+
+
+ {strings('perps.flip_position.title')}
+
+
+
+
+ {isFlipping ? (
+
+
+
+ {strings('perps.flip_position.flipping')}
+
+
+ ) : (
+ <>
+ {/* Grouped Details: Direction and Est. Size */}
+
+ {/* Direction Display */}
+
+
+
+ {strings('perps.flip_position.direction')}
+
+
+
+ {currentDirection === 'long'
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label')}
+
+
+
+ {oppositeDirection === 'long'
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label')}
+
+
+
+
+
+ {/* Est. Size */}
+
+
+
+ {strings('perps.flip_position.est_size')}
+
+
+ {positionSize} {getPerpsDisplaySymbol(position.coin)}
+
+
+
+
+
+ {/* Fees */}
+
+
+ {strings('perps.order.fees')}
+
+
+
+
+ {/* Est. Points */}
+ {rewardsState.shouldShowRewardsRow &&
+ rewardsState.estimatedPoints !== undefined &&
+ rewardsState.accountOptedIn && (
+
+
+ {strings('perps.estimated_points')}
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+};
+
+export default PerpsFlipPositionConfirmSheet;
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts
new file mode 100644
index 00000000000..4258b23124a
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.types.ts
@@ -0,0 +1,9 @@
+import type { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import type { Position } from '../../controllers/types';
+
+export interface PerpsFlipPositionConfirmSheetProps {
+ position: Position;
+ sheetRef?: React.RefObject;
+ onClose?: () => void;
+ onConfirm?: () => void;
+}
diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts
new file mode 100644
index 00000000000..242b97ef28e
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/index.ts
@@ -0,0 +1,2 @@
+export { default } from './PerpsFlipPositionConfirmSheet';
+export * from './PerpsFlipPositionConfirmSheet.types';
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts
index 79891385bac..916c39ed6fc 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts
@@ -1,22 +1,32 @@
import { StyleSheet } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
-const styleSheet = () =>
+const styleSheet = (params: { theme: Theme }) =>
StyleSheet.create({
- statisticsGrid: {
- gap: 24,
+ container: {
+ gap: 16,
},
- statisticsRow: {
+ header: {
flexDirection: 'row',
- justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: 8,
},
- statisticsItem: {
- flex: 1,
- borderRadius: 8,
+ statsRowsContainer: {
+ gap: 1,
},
- statisticsLabelContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
+ statsRow: {
+ padding: 12,
+ backgroundColor: params.theme.colors.background.section,
+ },
+ statsRowFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
+ },
+ statsRowLast: {
+ padding: 12,
+ backgroundColor: params.theme.colors.background.section,
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
fundingRateContainer: {
flexDirection: 'row',
@@ -26,6 +36,16 @@ const styleSheet = () =>
fundingCountdown: {
marginLeft: 2,
},
+ labelWithIcon: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ },
+ dexTag: {
+ backgroundColor: params.theme.colors.background.default,
+ borderWidth: 1,
+ borderColor: params.theme.colors.border.default,
+ },
});
export default styleSheet;
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx
index 5d39a414dbd..e3ccfab3b9d 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx
@@ -97,11 +97,8 @@ describe('PerpsMarketStatisticsCard', () => {
,
);
- // Check 24h high/low row
- expect(getByText('perps.market.24h_high')).toBeOnTheScreen();
- expect(getByText('perps.market.24h_low')).toBeOnTheScreen();
- expect(getByText('$50,000.00')).toBeOnTheScreen();
- expect(getByText('$45,000.00')).toBeOnTheScreen();
+ // Check stats title
+ expect(getByText('perps.market.stats')).toBeOnTheScreen();
// Check volume and open interest row
expect(getByText('perps.market.24h_volume')).toBeOnTheScreen();
@@ -112,6 +109,9 @@ describe('PerpsMarketStatisticsCard', () => {
// Check funding rate row
expect(getByText('perps.market.funding_rate')).toBeOnTheScreen();
expect(getByText('0.0125%')).toBeOnTheScreen();
+
+ // Check oracle price label
+ expect(getByText('perps.market.oracle_price')).toBeOnTheScreen();
});
it('displays positive funding rate in success color', () => {
@@ -191,19 +191,6 @@ describe('PerpsMarketStatisticsCard', () => {
expect(mockOnTooltipPress).toHaveBeenCalledWith('funding_rate');
});
- it('renders with correct test IDs for all statistics', () => {
- const { getByTestId } = render(
- ,
- );
-
- // Check all test IDs are present
- expect(getByTestId('perps-statistics-high-24h')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-low-24h')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-volume-24h')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-open-interest')).toBeOnTheScreen();
- expect(getByTestId('perps-statistics-funding-rate')).toBeOnTheScreen();
- });
-
it('handles edge case with very small funding rate values', () => {
const smallFundingStats = {
...mockMarketStats,
@@ -241,9 +228,7 @@ describe('PerpsMarketStatisticsCard', () => {
,
);
- // Verify all values are displayed
- expect(getByText('$50,000.00')).toBeOnTheScreen(); // high24h
- expect(getByText('$45,000.00')).toBeOnTheScreen(); // low24h
+ // Verify all values are displayed (component now shows volume, open interest, funding rate, oracle price)
expect(getByText('$1,234,567.89')).toBeOnTheScreen(); // volume24h
expect(getByText('$987,654.32')).toBeOnTheScreen(); // openInterest
expect(getByText('0.0125%')).toBeOnTheScreen(); // fundingRate
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx
index 445173a3a94..106627da95a 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx
@@ -10,14 +10,20 @@ import Text, {
TextColor,
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
+import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow';
import { useStyles } from '../../../../hooks/useStyles';
import styleSheet from './PerpsMarketStatisticsCard.styles';
import type { PerpsMarketStatisticsCardProps } from './PerpsMarketStatisticsCard.types';
import { PerpsMarketDetailsViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import FundingCountdown from '../FundingCountdown';
import { usePerpsLivePrices } from '../../hooks/stream';
-import { formatFundingRate } from '../../utils/formatUtils';
+import {
+ formatFundingRate,
+ formatPerpsFiat,
+ PRICE_RANGES_UNIVERSAL,
+} from '../../utils/formatUtils';
import { FUNDING_RATE_CONFIG } from '../../constants/perpsConfig';
+import Tag from '../../../../../component-library/components/Tags/Tag';
const PerpsMarketStatisticsCard: React.FC = ({
symbol,
@@ -25,6 +31,7 @@ const PerpsMarketStatisticsCard: React.FC = ({
onTooltipPress,
nextFundingTime,
fundingIntervalHours,
+ dexName,
}) => {
const { styles } = useStyles(styleSheet, {});
@@ -34,8 +41,10 @@ const PerpsMarketStatisticsCard: React.FC = ({
throttleMs: 2000, // Update every 2 seconds for funding rate
});
- // Get live funding rate from WebSocket subscription
+ // Get live funding rate and oracle price from WebSocket subscription
const liveFunding = symbol ? livePrices[symbol]?.funding : undefined;
+ // Use markPrice (oracle/mark price) for oracle price display, not price (mid price)
+ const liveOraclePrice = symbol ? livePrices[symbol]?.markPrice : undefined;
// Compute funding rate value and display once
const fundingRateData = useMemo(() => {
@@ -72,112 +81,151 @@ const PerpsMarketStatisticsCard: React.FC = ({
};
}, [liveFunding, marketStats.fundingRate]);
- return (
-
- {/* Row 1: 24h High/Low */}
-
-
-
- {strings('perps.market.24h_low')}
-
-
- {marketStats.low24h}
-
-
-
-
- {strings('perps.market.24h_high')}
-
-
- {marketStats.high24h}
-
-
+ // Render funding rate value with countdown
+ const fundingValueContent = useMemo(
+ () => (
+
+
+ {fundingRateData.displayText}
+
+
+ ),
+ [
+ fundingRateData,
+ nextFundingTime,
+ fundingIntervalHours,
+ styles.fundingRateContainer,
+ styles.fundingCountdown,
+ ],
+ );
- {/* Row 2: Volume and Open Interest */}
-
-
-
- {strings('perps.market.24h_volume')}
-
-
- {marketStats.volume24h}
-
-
-
-
-
- {strings('perps.market.open_interest')}
-
- onTooltipPress('open_interest')}>
-
-
-
-
- {marketStats.openInterest}
-
-
+ return (
+
+ {/* Header with title and DEX badge */}
+
+
+ {strings('perps.market.stats')}
+
+ {dexName && }
- {/* Row 3: Funding Rate */}
-
-
-
-
- {strings('perps.market.funding_rate')}
-
- onTooltipPress('funding_rate')}>
-
-
-
-
-
- {fundingRateData.displayText}
-
-
-
-
+ {/* Stats rows with card background */}
+
+ {/* 24h volume */}
+
+
+ {/* Open interest with tooltip */}
+
+
+ {strings('perps.market.open_interest')}
+
+ onTooltipPress('open_interest')}
+ testID="perps-market-details-open-interest-info-icon"
+ >
+
+
+
+ ),
+ }}
+ value={{
+ label: {
+ text: marketStats.openInterest,
+ variant: TextVariant.BodyMD,
+ color: TextColor.Default,
+ },
+ }}
+ style={styles.statsRow}
+ />
+
+ {/* Funding rate with tooltip and countdown */}
+
+
+ {strings('perps.market.funding_rate')}
+
+ onTooltipPress('funding_rate')}
+ testID="perps-market-details-funding-rate-info-icon"
+ >
+
+
+
+ ),
+ }}
+ value={{
+ label: fundingValueContent,
+ }}
+ style={styles.statsRow}
+ />
+
+ {/* Oracle price (markPrice) - last row without bottom border */}
+
);
diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts
index 3bc02b12ff5..17d4c81b4ea 100644
--- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts
+++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts
@@ -16,4 +16,8 @@ export interface PerpsMarketStatisticsCardProps {
* Funding interval in hours (optional, market-specific)
*/
fundingIntervalHours?: number;
+ /**
+ * DEX name for HIP-3 markets (e.g., "XYZ", "Hyperliquid")
+ */
+ dexName?: string;
}
diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx
index 536bcdd17ed..5e6d1b4d3ae 100644
--- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx
@@ -5,6 +5,7 @@ import React, {
useRef,
useMemo,
} from 'react';
+import { useNavigation, NavigationProp } from '@react-navigation/native';
import Text, {
TextVariant,
TextColor,
@@ -25,9 +26,17 @@ import PerpsMarketStatisticsCard from '../PerpsMarketStatisticsCard';
import PerpsPositionCard from '../PerpsPositionCard';
import { PerpsMarketTabsProps, PerpsTabId } from './PerpsMarketTabs.types';
import styleSheet from './PerpsMarketTabs.styles';
-import type { Position, Order } from '../../controllers/types';
+import type {
+ Position,
+ Order,
+ PerpsNavigationParamList,
+} from '../../controllers/types';
import { usePerpsMarketStats } from '../../hooks/usePerpsMarketStats';
-import { usePerpsLivePositions, usePerpsLiveOrders } from '../../hooks/stream';
+import {
+ usePerpsLivePositions,
+ usePerpsLiveOrders,
+ usePerpsLivePrices,
+} from '../../hooks/stream';
import type { PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip';
import {
@@ -40,23 +49,24 @@ import Engine from '../../../../../core/Engine';
import { getOrderDirection } from '../../utils/orderUtils';
import usePerpsToasts from '../../hooks/usePerpsToasts';
import { OrderDirection } from '../../types/perps-types';
+import Routes from '../../../../../constants/navigation/Routes';
// Tab content component for Position tab
interface PositionTabContentProps {
tabLabel: string;
position: Position | null;
- expanded: boolean;
showIcon: boolean;
- onTooltipPress: (contentKey: PerpsTooltipContentKey) => void;
- onTpslCountPress: (tabId: string) => void;
+ onAutoClosePress?: () => void;
+ onMarginPress?: () => void;
+ onSharePress?: () => void;
}
const PositionTabContent: React.FC = ({
position,
- expanded,
showIcon,
- onTooltipPress,
- onTpslCountPress,
+ onAutoClosePress,
+ onMarginPress,
+ onSharePress,
}) => {
const { styles } = useStyles(styleSheet, {});
@@ -70,10 +80,10 @@ const PositionTabContent: React.FC = ({
);
@@ -210,6 +220,7 @@ const PerpsMarketTabs: React.FC = ({
activeSLOrderId,
}) => {
const { styles } = useStyles(styleSheet, {});
+ const navigation = useNavigation>();
const hasUserInteracted = useRef(false);
const hasSetInitialTab = useRef(false);
const tabsListRef = useRef(null);
@@ -224,6 +235,12 @@ const PerpsMarketTabs: React.FC = ({
});
const { orders: allOrders } = usePerpsLiveOrders({ throttleMs: 0 });
+ // Subscribe to live prices for current position price
+ const livePrices = usePerpsLivePrices({
+ symbols: symbol ? [symbol] : [],
+ throttleMs: 1000,
+ });
+
const position = useMemo(
() => positions.find((p) => p.coin === symbol) || null,
[positions, symbol],
@@ -233,6 +250,19 @@ const PerpsMarketTabs: React.FC = ({
[allOrders, symbol],
);
+ // Get current price for the symbol
+ const currentPrice = useMemo(() => {
+ const priceData = livePrices[symbol];
+ if (priceData?.price) {
+ return parseFloat(priceData.price);
+ }
+ // Fallback to position entry price if available
+ if (position?.entryPrice) {
+ return parseFloat(position.entryPrice);
+ }
+ return 0;
+ }, [livePrices, symbol, position]);
+
// State to track which orders are being cancelled for UI display
const [cancellingOrderIds, setCancellingOrderIds] = useState>(
new Set(),
@@ -288,6 +318,40 @@ const PerpsMarketTabs: React.FC = ({
}
}, [unfilledOrders, successfullyCancelledOrderIds]);
+ // Position management callbacks
+ const handleAutoClosePress = useCallback(() => {
+ if (!position) return;
+
+ navigation.navigate(Routes.PERPS.TPSL, {
+ asset: position.coin,
+ currentPrice,
+ position,
+ initialTakeProfitPrice: position.takeProfitPrice,
+ initialStopLossPrice: position.stopLossPrice,
+ onConfirm: async () => {
+ // TP/SL is set directly on the position, no need to handle here
+ // The position will update via WebSocket
+ },
+ });
+ }, [position, currentPrice, navigation]);
+
+ const handleMarginPress = useCallback(() => {
+ if (!position) return;
+
+ navigation.navigate(Routes.PERPS.SELECT_ADJUST_MARGIN_ACTION, {
+ position,
+ });
+ }, [position, navigation]);
+
+ const handleSharePress = useCallback(() => {
+ if (!position) return;
+
+ navigation.navigate(Routes.PERPS.PNL_HERO_CARD, {
+ position,
+ marketPrice: currentPrice.toString(),
+ });
+ }, [position, currentPrice, navigation]);
+
const tabs = React.useMemo(() => {
const dynamicTabs = [];
@@ -552,35 +616,18 @@ const PerpsMarketTabs: React.FC = ({
],
);
- // Helper to switch tabs programmatically by tabId (for backwards compatibility)
- const handleTabSwitchByTabId = useCallback(
- (tabId: string) => {
- // Build current available tabs in same order as tabsToRender
- const availableTabIds: PerpsTabId[] = [];
- if (position) availableTabIds.push('position');
- if (sortedUnfilledOrders.length > 0) availableTabIds.push('orders');
- availableTabIds.push('statistics'); // Always available
-
- const targetIndex = availableTabIds.indexOf(tabId as PerpsTabId);
- if (targetIndex >= 0 && tabsListRef.current) {
- tabsListRef.current.goToTabIndex(targetIndex);
- }
- },
- [position, sortedUnfilledOrders.length],
- );
-
// Define tab props objects (similar to wallet's tokensTabProps, perpsTabProps pattern)
const positionTabProps = useMemo(
() => ({
key: 'position-tab',
tabLabel: strings('perps.market.position'),
position,
- expanded: true,
showIcon: true,
- onTooltipPress: handleTooltipPress,
- onTpslCountPress: handleTabSwitchByTabId,
+ onAutoClosePress: handleAutoClosePress,
+ onMarginPress: handleMarginPress,
+ onSharePress: handleSharePress,
}),
- [position, handleTooltipPress, handleTabSwitchByTabId],
+ [position, handleAutoClosePress, handleMarginPress, handleSharePress],
);
const ordersTabProps = useMemo(
diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts
index 02e3a86ddfb..f5bbe0b6e96 100644
--- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts
+++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.styles.ts
@@ -13,17 +13,24 @@ const styleSheet = (params: { theme: Theme }) => {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
- marginBottom: 12,
+ marginBottom: 16,
+ },
+ listContainer: {
+ gap: 1,
},
tradeItem: {
flexDirection: 'row',
alignItems: 'center',
- paddingVertical: 12,
- borderBottomWidth: 1,
- borderBottomColor: colors.border.muted,
+ padding: 12,
+ backgroundColor: colors.background.section,
+ },
+ tradeItemFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
},
- lastTradeItem: {
- borderBottomWidth: 0,
+ tradeItemLast: {
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
leftSection: {
flex: 1,
diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
index 6d126ff8ce3..0594fe73120 100644
--- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
@@ -88,11 +88,16 @@ const PerpsMarketTradesList: React.FC = ({
const renderItem = useCallback(
(props: { item: PerpsTransaction; index: number }) => {
const { item, index } = props;
+ const isFirstItem = index === 0;
const isLastItem = index === trades.length - 1;
return (
handleTradePress(item)}
activeOpacity={0.7}
>
@@ -132,7 +137,7 @@ const PerpsMarketTradesList: React.FC = ({
// Render header section
const renderHeader = () => (
-
+
{strings('perps.market.recent_trades')}
{!isLoading && trades.length > 0 && (
@@ -164,12 +169,14 @@ const PerpsMarketTradesList: React.FC = ({
}
return (
- `${item.id || index}`}
- scrollEnabled={false}
- />
+
+ `${item.id || index}`}
+ scrollEnabled={false}
+ />
+
);
};
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts
new file mode 100644
index 00000000000..6edc4683cb7
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.styles.ts
@@ -0,0 +1,38 @@
+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({
+ contentContainer: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ },
+ actionItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 16,
+ },
+ actionItemBorder: {
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border.muted,
+ },
+ actionIconContainer: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: 12,
+ },
+ iconColor: {
+ color: colors.icon.default,
+ },
+ actionTextContainer: {
+ flex: 1,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx
new file mode 100644
index 00000000000..0bab0b9bb28
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.test.tsx
@@ -0,0 +1,329 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react-native';
+import PerpsModifyActionSheet from './PerpsModifyActionSheet';
+import type { Position } from '../../controllers/types';
+
+// Mock dependencies
+jest.mock('../../../../../component-library/hooks', () => ({
+ useStyles: () => ({
+ styles: {
+ contentContainer: {},
+ actionItem: {},
+ actionItemBorder: {},
+ actionIconContainer: {},
+ actionTextContainer: {},
+ iconColor: { color: '#000000' },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => {
+ const translations: Record = {
+ 'perps.modify.title': 'Modify Position',
+ 'perps.modify.add_to_position': 'Add to Position',
+ 'perps.modify.add_to_position_description': 'Increase your position size',
+ 'perps.modify.reduce_position': 'Reduce Position',
+ 'perps.modify.reduce_position_description': 'Decrease your position size',
+ 'perps.modify.flip_position': 'Flip Position',
+ 'perps.modify.flip_position_description':
+ 'Reverse your position direction',
+ };
+ return translations[key] || key;
+ }),
+}));
+
+// Mock BottomSheet
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheet',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ReactModule.forwardRef(
+ (
+ { children, testID }: { children: React.ReactNode; testID?: string },
+ _ref: unknown,
+ ) => ReactModule.createElement(View, { testID }, children),
+ ),
+ };
+ },
+);
+
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetHeader',
+ () => {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockBottomSheetHeader({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) {
+ return ReactModule.createElement(
+ View,
+ { testID: 'bottom-sheet-header' },
+ children,
+ );
+ };
+ },
+);
+
+// Mock Icon component
+jest.mock('../../../../../component-library/components/Icons/Icon', () => ({
+ __esModule: true,
+ default: function MockIcon() {
+ return null;
+ },
+ IconName: {
+ Add: 'Add',
+ Minus: 'Minus',
+ SwapHorizontal: 'SwapHorizontal',
+ },
+ IconSize: {
+ Md: 'Md',
+ },
+}));
+
+// Mock Text component
+jest.mock('../../../../../component-library/components/Texts/Text', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: function MockText({ children }: { children: React.ReactNode }) {
+ return ReactModule.createElement(Text, null, children);
+ },
+ TextVariant: {
+ HeadingMD: 'HeadingMD',
+ BodyMDBold: 'BodyMDBold',
+ BodySM: 'BodySM',
+ },
+ TextColor: {
+ Alternative: 'Alternative',
+ },
+ };
+});
+
+// Mock Box component
+jest.mock('@metamask/design-system-react-native', () => ({
+ Box: function MockBox({ children }: { children: React.ReactNode }) {
+ const ReactModule = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return ReactModule.createElement(View, null, children);
+ },
+}));
+
+describe('PerpsModifyActionSheet', () => {
+ const mockOnClose = jest.fn();
+ const mockOnActionSelect = jest.fn();
+
+ 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: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the modify position title', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Modify Position')).toBeOnTheScreen();
+ });
+
+ it('renders add to position option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Add to Position')).toBeOnTheScreen();
+ expect(screen.getByText('Increase your position size')).toBeOnTheScreen();
+ });
+
+ it('renders reduce position option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Reduce Position')).toBeOnTheScreen();
+ expect(screen.getByText('Decrease your position size')).toBeOnTheScreen();
+ });
+
+ it('renders flip position option', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Flip Position')).toBeOnTheScreen();
+ expect(
+ screen.getByText('Reverse your position direction'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls onActionSelect with add_to_position when add option is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add to Position'));
+
+ expect(mockOnActionSelect).toHaveBeenCalledWith('add_to_position');
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onActionSelect with reduce_position when reduce option is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Reduce Position'));
+
+ expect(mockOnActionSelect).toHaveBeenCalledWith('reduce_position');
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onActionSelect with flip_position when flip option is pressed', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Flip Position'));
+
+ expect(mockOnActionSelect).toHaveBeenCalledWith('flip_position');
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('calls onClose when action is selected', () => {
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByText('Add to Position'));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('renders with testID when provided', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('test-modify-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders action items with correct testIDs', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId('modify-sheet-add_to_position'),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId('modify-sheet-reduce_position'),
+ ).toBeOnTheScreen();
+ expect(screen.getByTestId('modify-sheet-flip_position')).toBeOnTheScreen();
+ });
+
+ it('works with null position', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Modify Position')).toBeOnTheScreen();
+ });
+
+ it('renders with isVisible true by default', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('modify-sheet')).toBeOnTheScreen();
+ });
+
+ it('renders with external sheetRef', () => {
+ const mockSheetRef = { current: null };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('modify-sheet')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx
new file mode 100644
index 00000000000..c6b2460961d
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.tsx
@@ -0,0 +1,145 @@
+import React, { useRef, useEffect, useCallback } from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import { useStyles } from '../../../../../component-library/hooks';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../component-library/components/Texts/Text';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import Icon, {
+ IconSize,
+ IconName,
+} from '../../../../../component-library/components/Icons/Icon';
+import { Box } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../locales/i18n';
+import styleSheet from './PerpsModifyActionSheet.styles';
+import type { ModifyAction } from './PerpsModifyActionSheet.types';
+import type { Position } from '../../controllers/types';
+
+interface ActionOption {
+ action: ModifyAction;
+ label: string;
+ description: string;
+ iconName: IconName;
+}
+
+interface PerpsModifyActionSheetProps {
+ isVisible?: boolean;
+ onClose: () => void;
+ position: Position | null;
+ onActionSelect: (action: ModifyAction) => void;
+ sheetRef?: React.RefObject;
+ testID?: string;
+}
+
+const PerpsModifyActionSheet: React.FC = ({
+ isVisible = true,
+ onClose,
+ position,
+ onActionSelect,
+ sheetRef: externalSheetRef,
+ testID,
+}) => {
+ const { styles } = useStyles(styleSheet, {});
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
+
+ // Get direction labels for the position
+ const isLong = position?.size ? parseFloat(position.size) > 0 : true;
+ const direction = isLong
+ ? strings('perps.order.long_label')
+ : strings('perps.order.short_label');
+ const fromDirection = direction.toLowerCase();
+ const toDirection = isLong
+ ? strings('perps.order.short_label').toLowerCase()
+ : strings('perps.order.long_label').toLowerCase();
+
+ useEffect(() => {
+ if (isVisible && !externalSheetRef) {
+ sheetRef.current?.onOpenBottomSheet();
+ }
+ }, [isVisible, externalSheetRef, sheetRef]);
+
+ const actionOptions: ActionOption[] = [
+ {
+ action: 'add_to_position',
+ label: strings('perps.modify.add_to_position'),
+ description: strings('perps.modify.add_to_position_description', {
+ direction: fromDirection,
+ }),
+ iconName: IconName.Add,
+ },
+ {
+ action: 'reduce_position',
+ label: strings('perps.modify.reduce_position'),
+ description: strings('perps.modify.reduce_position_description', {
+ direction: fromDirection,
+ }),
+ iconName: IconName.Minus,
+ },
+ {
+ action: 'flip_position',
+ label: strings('perps.modify.flip_position'),
+ description: strings('perps.modify.flip_position_description', {
+ fromDirection,
+ toDirection,
+ }),
+ iconName: IconName.SwapHorizontal,
+ },
+ ];
+
+ const handleActionPress = useCallback(
+ (action: ModifyAction) => {
+ onActionSelect(action);
+ onClose();
+ },
+ [onActionSelect, onClose],
+ );
+
+ return (
+
+
+
+ {strings('perps.modify.title')}
+
+
+
+ {actionOptions.map((option, index) => (
+ handleActionPress(option.action)}
+ testID={`${testID}-${option.action}`}
+ >
+
+
+
+
+ {option.label}
+
+ {option.description}
+
+
+
+ ))}
+
+
+ );
+};
+
+export default PerpsModifyActionSheet;
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts
new file mode 100644
index 00000000000..84f08652683
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/PerpsModifyActionSheet.types.ts
@@ -0,0 +1,4 @@
+export type ModifyAction =
+ | 'add_to_position'
+ | 'reduce_position'
+ | 'flip_position';
diff --git a/app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts b/app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts
new file mode 100644
index 00000000000..402c1b69efb
--- /dev/null
+++ b/app/components/UI/Perps/components/PerpsModifyActionSheet/index.ts
@@ -0,0 +1,2 @@
+export { default } from './PerpsModifyActionSheet';
+export * from './PerpsModifyActionSheet.types';
diff --git a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
index 78a5c1d2b72..e1afe76b2ee 100644
--- a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
+++ b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
@@ -20,32 +20,35 @@ import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
import type { OrderType } from '../../controllers/types';
interface PerpsOrderTypeBottomSheetProps {
- isVisible: boolean;
+ isVisible?: boolean;
onClose: () => void;
onSelect: (orderType: OrderType) => void;
- currentOrderType: OrderType;
+ currentOrderType?: OrderType;
asset?: string;
direction?: 'long' | 'short';
+ sheetRef?: React.RefObject;
}
const PerpsOrderTypeBottomSheet: React.FC = ({
- isVisible,
+ isVisible = true,
onClose,
onSelect,
currentOrderType,
asset = 'BTC',
direction = 'long',
+ sheetRef: externalSheetRef,
}) => {
const { colors } = useTheme();
const styles = createStyles(colors);
- const bottomSheetRef = useRef(null);
+ const internalSheetRef = useRef(null);
+ const sheetRef = externalSheetRef || internalSheetRef;
const { track } = usePerpsEventTracking();
useEffect(() => {
- if (isVisible) {
- bottomSheetRef.current?.onOpenBottomSheet();
+ if (isVisible && !externalSheetRef) {
+ sheetRef.current?.onOpenBottomSheet();
}
- }, [isVisible]);
+ }, [isVisible, externalSheetRef, sheetRef]);
const orderTypes = [
{
@@ -86,9 +89,9 @@ const PerpsOrderTypeBottomSheet: React.FC = ({
return (
diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts
index 58496e378f4..984da75000f 100644
--- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts
+++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts
@@ -6,107 +6,153 @@ const styleSheet = (params: { theme: Theme }) => {
const { colors } = theme;
return StyleSheet.create({
- // Legacy container for backward compatibility
container: {
- backgroundColor: colors.background.section,
- borderRadius: 12,
- marginVertical: 6,
- },
- // Container styles for different states
- expandedContainer: {
- backgroundColor: colors.background.section,
+ backgroundColor: colors.background.default,
borderRadius: 12,
- padding: 16,
- marginVertical: 8,
- },
- collapsedContainer: {
- borderRadius: 8,
- paddingVertical: 12,
- marginVertical: 2, // Reduced spacing between cards
},
header: {
flexDirection: 'row',
- justifyContent: 'space-between',
alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 16,
},
- headerExpanded: {
- marginBottom: 16, // Extra spacing for expanded cards before the divider
+ pnlSection: {
+ flexDirection: 'row',
+ gap: 8,
+ marginBottom: 8,
},
- // Icon section styles
- perpIcon: {
- width: 40,
- height: 40,
- borderRadius: 20,
- marginRight: 12,
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
+ pnlCard: {
+ flex: 1,
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
+ gap: 4,
+ },
+ pnlCardLeft: {
+ // Left card styling if different
},
- tpslCountPress: {
- textDecorationLine: 'underline',
- textDecorationStyle: 'dotted',
+ pnlCardRight: {
+ // Right card styling if different
},
- headerLeft: {
+ sizeMarginRow: {
+ flexDirection: 'row',
+ gap: 8,
+ marginBottom: 8,
+ },
+ sizeContainer: {
flex: 1,
- alignItems: 'flex-start',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
},
- headerRight: {
+ sizeLeftContent: {
flex: 1,
- alignItems: 'flex-end',
+ gap: 4,
},
- headerRow: {
+ sizeLabelRow: {
flexDirection: 'row',
alignItems: 'center',
+ gap: 4,
},
- // Right accessory styles
- rightAccessory: {
- marginLeft: 12,
+ marginContainer: {
+ flex: 1,
+ flexDirection: 'row',
alignItems: 'center',
- justifyContent: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
},
- body: {
- borderTopWidth: 1,
- borderTopColor: colors.border.muted,
- paddingVertical: 16,
- marginBottom: 4,
+ marginLeftContent: {
+ flex: 1,
+ gap: 4,
+ },
+ marginLabelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
},
- bodyRow: {
+ autoCloseSection: {
flexDirection: 'row',
+ alignItems: 'center',
justifyContent: 'space-between',
+ backgroundColor: colors.background.section,
+ borderRadius: 8,
+ padding: 12,
marginBottom: 12,
},
- bodyRowLast: {
- marginBottom: 0,
- },
- bodyItem: {
+ autoCloseTextContainer: {
flex: 1,
- alignItems: 'flex-start',
+ gap: 4,
+ },
+ autoCloseButton: {
+ borderRadius: 8,
+ },
+ iconButton: {
+ backgroundColor: colors.background.muted,
+ borderRadius: 8,
+ },
+ iconButtonContainer: {
+ height: '100%',
+ alignItems: 'flex-end',
+ },
+ toggleContainer: {
+ marginLeft: 16,
+ },
+ toggle: {
+ width: 48,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: colors.background.alternative,
+ padding: 2,
+ justifyContent: 'center',
+ },
+ toggleEnabled: {
+ backgroundColor: colors.primary.default,
+ },
+ toggleThumb: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: colors.background.default,
+ shadowColor: colors.shadow.default,
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 2,
+ elevation: 2,
},
- bodyItemLabel: {
- marginBottom: 4,
+ toggleThumbEnabled: {
+ alignSelf: 'flex-end',
},
- footer: {
+ detailsSection: {
+ gap: 1,
+ marginTop: 20,
+ },
+ detailsTitle: {
+ marginBottom: 16,
+ },
+ detailRow: {
+ padding: 12,
flexDirection: 'row',
justifyContent: 'space-between',
- gap: 12,
+ alignItems: 'center',
+ backgroundColor: colors.background.section,
},
- footerButton: {
- flex: 1,
+ detailRowFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
},
- fundingCostLabelRightMargin: {
- marginRight: 4,
+ detailRowLast: {
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
- fundingCostLabelFlex: {
- display: 'flex',
+ liquidationPriceValue: {
flexDirection: 'row',
alignItems: 'center',
},
- shareButton: {
- alignSelf: 'center',
- backgroundColor: colors.background.muted,
- height: 40,
- width: 40,
- },
});
};
diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx
index cfff1a1496a..53b09c50060 100644
--- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx
+++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx
@@ -1,14 +1,11 @@
import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react-native';
-import { useNavigation } from '@react-navigation/native';
-import Routes from '../../../../../constants/navigation/Routes';
+import { render, screen } from '@testing-library/react-native';
import { PerpsPositionCardSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
import PerpsPositionCard from './PerpsPositionCard';
import type { Position } from '../../controllers/types';
jest.mock('@react-navigation/native', () => ({
- useNavigation: jest.fn(),
useFocusEffect: jest.fn(),
}));
@@ -32,10 +29,13 @@ jest.mock('../../../../../../locales/i18n', () => ({
if (key === 'perps.position.card.tpsl_count_single' && params?.count) {
return `${params.count} order`;
}
- if (key === 'perps.market.long_lowercase') {
+ if (key === 'perps.market.long_lowercase' || key === 'perps.market.long') {
return 'long';
}
- if (key === 'perps.market.short_lowercase') {
+ if (
+ key === 'perps.market.short_lowercase' ||
+ key === 'perps.market.short'
+ ) {
return 'short';
}
return key;
@@ -172,10 +172,6 @@ jest.mock('../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip', () => ({
}));
describe('PerpsPositionCard', () => {
- const mockNavigation = {
- navigate: jest.fn(),
- };
-
const mockPosition: Position = {
coin: 'ETH',
size: '2.5',
@@ -211,7 +207,6 @@ describe('PerpsPositionCard', () => {
beforeEach(() => {
jest.clearAllMocks();
- (useNavigation as jest.Mock).mockReturnValue(mockNavigation);
mockUseTheme.mockReturnValue(mockTheme);
// Reset the PnL calculation mock to default value
const { calculatePnLPercentageFromUnrealized } = jest.requireMock(
@@ -264,44 +259,62 @@ describe('PerpsPositionCard', () => {
describe('Component Rendering', () => {
it('renders position card with all sections', () => {
- // Act - Render expanded to show all sections
- render();
+ // Arrange
+ const currentPrice = 2000;
+
+ // Act
+ render(
+ ,
+ );
// Assert - Header section
- expect(screen.getByText(/10x\s+long/)).toBeOnTheScreen();
- expect(screen.getByText('2.5 ETH')).toBeOnTheScreen(); // Trailing zero removed
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$5,000')).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.HEADER),
+ ).toBeOnTheScreen();
- // Assert - Body section - using string keys since strings() mock returns keys
+ // Assert - P&L section
expect(
- screen.getByText('perps.position.card.entry_price'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.PNL_CARD),
).toBeOnTheScreen();
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$2,000')).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.funding_cost'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.PNL_VALUE),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.RETURN_CARD),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.RETURN_VALUE),
+ ).toBeOnTheScreen();
+
+ // Assert - Size/Margin row
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.SIZE_CONTAINER),
).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.liquidation_price'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.SIZE_VALUE),
).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.take_profit'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.MARGIN_CONTAINER),
).toBeOnTheScreen();
expect(
- screen.getByText('perps.position.card.stop_loss'),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.MARGIN_VALUE),
).toBeOnTheScreen();
- expect(screen.getByText('perps.position.card.margin')).toBeOnTheScreen();
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, max 2 decimals for $100-$1k, trailing zeros removed
- expect(screen.getByText('$500')).toBeOnTheScreen();
- // Assert - Footer section
+ // Assert - Auto-close section
expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.AUTO_CLOSE_TOGGLE),
).toBeOnTheScreen();
+
+ // Assert - Details section
expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.DETAILS_SECTION),
).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsPositionCardSelectorsIDs.DIRECTION_VALUE),
+ ).toHaveTextContent(/long\s+10x/);
});
it('renders SHORT position correctly', () => {
@@ -315,16 +328,29 @@ describe('PerpsPositionCard', () => {
render();
// Assert
- expect(screen.getByText('short')).toBeOnTheScreen();
- expect(screen.getByText(/2\.5.*ETH/)).toBeOnTheScreen(); // Should show absolute value (trailing zero removed)
+ const directionValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.DIRECTION_VALUE,
+ );
+ expect(directionValue).toHaveTextContent(/short\s+10x/);
+ const sizeValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.SIZE_VALUE,
+ );
+ expect(sizeValue).toHaveTextContent(/2\.5.*ETH/); // Should show absolute value
});
it('renders with PnL data', () => {
// Act
render();
- // Assert - ROE is 12.5 * 100 = 1250%
- expect(screen.getByText(/\+\$250\.00.*\+1250\.0%/)).toBeOnTheScreen();
+ // Assert
+ const pnlValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.PNL_VALUE,
+ );
+ expect(pnlValue).toHaveTextContent(/\+\$250\.00/);
+ const returnValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.RETURN_VALUE,
+ );
+ expect(returnValue).toHaveTextContent(/\+1250\.00%/); // ROE is 12.5 * 100 = 1250%
});
it('handles missing PnL percentage data', () => {
@@ -338,7 +364,10 @@ describe('PerpsPositionCard', () => {
render();
// Assert - Should show 0% when ROE is missing
- expect(screen.getByText(/\+\$250\.00.*\+0\.0%/)).toBeOnTheScreen();
+ const returnValue = screen.getByTestId(
+ PerpsPositionCardSelectorsIDs.RETURN_VALUE,
+ );
+ expect(returnValue).toHaveTextContent(/\+0\.00%/);
});
it('handles missing liquidation price', () => {
@@ -356,134 +385,9 @@ describe('PerpsPositionCard', () => {
screen.getByText(PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY),
).toBeOnTheScreen();
});
-
- it('renders with icon when showIcon is true and not expanded', () => {
- // Act - Render collapsed with showIcon
- render(
- ,
- );
-
- // Assert - PerpsTokenLogo should be rendered
- expect(screen.getByTestId('perps-token-logo')).toBeOnTheScreen();
- });
-
- it('does not render icon when showIcon is false', () => {
- // Act - Render collapsed without showIcon
- render(
- ,
- );
-
- // Assert - PerpsTokenLogo should not be rendered
- expect(screen.queryByTestId('perps-token-logo')).not.toBeOnTheScreen();
- });
-
- it('does not render icon when expanded even if showIcon is true', () => {
- // Act - Render expanded with showIcon (should not show icon)
- render();
-
- // Assert - PerpsTokenLogo should not be rendered in expanded mode
- expect(screen.queryByTestId('perps-token-logo')).not.toBeOnTheScreen();
- });
- });
-
- describe('User Interactions', () => {
- it('navigates to market details when card is pressed', () => {
- // Act - render with expanded=false to make card clickable
- render();
- fireEvent.press(screen.getByTestId('PerpsPositionCard'));
-
- // Assert
- expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
- screen: Routes.PERPS.MARKET_DETAILS,
- params: {
- market: expect.any(Object),
- initialTab: 'position',
- },
- });
- });
-
- it('opens close position bottom sheet when close button is pressed and user is eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return true;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Verify bottom sheet is not visible initially
- expect(screen.queryByText('perps.close_position.title')).toBeNull();
-
- // Press close button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- );
-
- // Assert - The bottom sheet should be rendered
- // Note: The actual bottom sheet content might be mocked, so we check for its presence
- expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- ).toBeDefined();
- });
-
- it('opens TP/SL bottom sheet when edit button is pressed and user is eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return true;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Verify bottom sheet is not visible initially
- expect(screen.queryByText('perps.tpsl.title')).toBeNull();
-
- // Press edit button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Assert - The TP/SL bottom sheet should be opened
- // Note: The actual bottom sheet content might be mocked, so we check for its presence
- expect(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- ).toBeDefined();
- });
});
describe('Data Formatting', () => {
- it('formats leverage correctly', () => {
- // Arrange
- const highLeveragePosition = {
- ...mockPosition,
- leverage: { type: 'cross' as const, value: 100 },
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText(/100x\s+long/)).toBeOnTheScreen();
- });
-
it('formats position size correctly for different coin', () => {
// Arrange
const btcPosition = {
@@ -514,70 +418,7 @@ describe('PerpsPositionCard', () => {
});
});
- describe('Position Direction Logic', () => {
- it('correctly identifies LONG position', () => {
- // Arrange
- const longPosition = {
- ...mockPosition,
- size: '1.5',
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText('long')).toBeOnTheScreen();
- });
-
- it('correctly identifies SHORT position', () => {
- // Arrange
- const shortPosition = {
- ...mockPosition,
- size: '-1.5',
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText('short')).toBeOnTheScreen();
- });
-
- it('handles zero position size as LONG', () => {
- // Arrange
- const zeroPosition = {
- ...mockPosition,
- size: '0',
- };
-
- // Act
- render();
-
- // Assert
- expect(screen.getByText('long')).toBeOnTheScreen(); // Zero is >= 0, so it's long
- });
- });
-
describe('Edge Cases', () => {
- it('handles missing price change data gracefully', () => {
- // Arrange
- const positionWithZeroPnl = {
- ...mockPosition,
- unrealizedPnl: '0.00',
- returnOnEquity: '0',
- };
- const { calculatePnLPercentageFromUnrealized } = jest.requireMock(
- '../../utils/pnlCalculations',
- );
- calculatePnLPercentageFromUnrealized.mockReturnValueOnce(0);
-
- // Act
- render();
-
- // Assert - ROE is shown as 0.0% (not 0.00%)
- expect(screen.getByText(/\$0\.00.*\+0\.0%/)).toBeOnTheScreen();
- });
-
it('handles position with empty liquidation price', () => {
// Arrange
const positionWithEmptyLiquidation = {
@@ -593,221 +434,6 @@ describe('PerpsPositionCard', () => {
screen.getByText(PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY),
).toBeOnTheScreen();
});
-
- it('renders all body items in correct order', () => {
- // Act - Render with expanded=true to show body items
- render();
-
- // Assert - Check that all 6 body items are present
- const bodyLabels = [
- 'perps.position.card.entry_price',
- 'perps.position.card.funding_cost',
- 'perps.position.card.liquidation_price',
- 'perps.position.card.take_profit',
- 'perps.position.card.stop_loss',
- 'perps.position.card.margin',
- ];
-
- bodyLabels.forEach((label) => {
- expect(screen.getByText(label)).toBeOnTheScreen();
- });
- });
- });
-
- describe('Hook Integration', () => {
- // Tests removed - loadPositions no longer exists with WebSocket streaming
- // Positions update automatically via WebSocket subscriptions
-
- it('returns early from handleCardPress when isLoading is true', () => {
- // Arrange
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [
- {
- name: 'ETH',
- symbol: 'ETH',
- priceDecimals: 2,
- sizeDecimals: 4,
- maxLeverage: 50,
- minSize: 0.01,
- sizeIncrement: 0.01,
- },
- ],
- error: null,
- isLoading: true, // Set loading to true
- });
-
- // Act
- render();
- fireEvent.press(screen.getByTestId('PerpsPositionCard'));
-
- // Assert - navigation should not be called
- expect(mockNavigation.navigate).not.toHaveBeenCalled();
- });
-
- it('returns early from handleCardPress when error exists', () => {
- // Arrange
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [
- {
- name: 'ETH',
- symbol: 'ETH',
- priceDecimals: 2,
- sizeDecimals: 4,
- maxLeverage: 50,
- minSize: 0.01,
- sizeIncrement: 0.01,
- },
- ],
- error: 'Failed to fetch markets',
- isLoading: false,
- });
-
- // Act
- render();
- fireEvent.press(screen.getByTestId('PerpsPositionCard'));
-
- // Assert - navigation should not be called
- expect(mockNavigation.navigate).not.toHaveBeenCalled();
- });
- });
-
- describe('Bottom Sheet Interactions', () => {
- it('navigates to TP/SL screen when edit button is pressed', () => {
- // Act
- render();
-
- // Open the TP/SL screen via navigation
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Assert - Should navigate to TP/SL screen
- expect(mockNavigation.navigate).toHaveBeenCalledWith(
- expect.stringContaining('TPSL'),
- expect.objectContaining({
- position: mockPosition,
- }),
- );
- });
-
- it('navigates to close position screen when close button is pressed', () => {
- // Act
- render();
-
- // Press the close button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- );
-
- // Assert - should navigate to close position screen
- expect(mockNavigation.navigate).toHaveBeenCalledWith(
- Routes.PERPS.CLOSE_POSITION,
- { position: mockPosition },
- );
- });
-
- it('does not show close button when card is collapsed', () => {
- // Act
- render(
- ,
- );
-
- // Assert - close button should not be visible in collapsed view
- expect(
- screen.queryByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- ).toBeNull();
- });
-
- it('shows geo block modal when edit TP/SL button is pressed and user is not eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return false;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Press edit button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Assert - Geo block tooltip should be shown
- expect(screen.getByText('Geo Block Tooltip')).toBeOnTheScreen();
- // Assert - Navigation should not be called
- expect(mockNavigation.navigate).not.toHaveBeenCalled();
- });
-
- it('shows geo block modal when close position button is pressed and user is not eligible', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return false;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Press close button
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.CLOSE_BUTTON),
- );
-
- // Assert - Geo block tooltip should be shown
- expect(screen.getByText('Geo Block Tooltip')).toBeOnTheScreen();
- // Assert - Close position bottom sheet should not be shown
- expect(
- screen.queryByTestId('perps-close-position-bottomsheet'),
- ).toBeNull();
- });
-
- it('closes geo block modal when onClose is called', () => {
- // Arrange
- const { useSelector } = jest.requireMock('react-redux');
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
- useSelector.mockImplementation((selector: unknown) => {
- if (selector === mockSelectPerpsEligibility) {
- return false;
- }
- return undefined;
- });
-
- // Act
- render();
-
- // Press edit button to show geo block modal
- fireEvent.press(
- screen.getByTestId(PerpsPositionCardSelectorsIDs.EDIT_BUTTON),
- );
-
- // Verify modal is shown
- expect(screen.getByText('Geo Block Tooltip')).toBeOnTheScreen();
-
- // Press the geo block tooltip to close it
- fireEvent.press(
- screen.getByTestId('perps-position-card-geo-block-tooltip'),
- );
-
- // Assert - Geo block tooltip should be closed
- expect(screen.queryByText('Geo Block Tooltip')).not.toBeOnTheScreen();
- });
});
describe('Cumulative Funding Display', () => {
@@ -942,256 +568,16 @@ describe('PerpsPositionCard', () => {
});
});
- describe('TP/SL Count Functionality', () => {
- it('displays take profit count when multiple TP orders exist', () => {
- const positionWithMultipleTP = {
- ...mockPosition,
- takeProfitCount: 3,
- takeProfitPrice: undefined, // No single price when multiple orders
- stopLossCount: 0,
- };
-
- render();
-
- expect(screen.getByText('3 orders')).toBeOnTheScreen();
- });
-
- it('displays stop loss count when multiple SL orders exist', () => {
- const positionWithMultipleSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 2,
- stopLossPrice: undefined, // No single price when multiple orders
- };
-
- render();
-
- expect(screen.getByText('2 orders')).toBeOnTheScreen();
- });
-
- it('displays single TP price when only one TP order exists with price', () => {
- const positionWithSingleTP = {
- ...mockPosition,
- takeProfitCount: 1,
- takeProfitPrice: '2500.00',
- stopLossCount: 0,
- };
-
- render();
-
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$2,500')).toBeOnTheScreen();
- expect(screen.queryByText(/1.*orders/)).not.toBeOnTheScreen();
- });
-
- it('displays single SL price when only one SL order exists with price', () => {
- const positionWithSingleSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 1,
- stopLossPrice: '1500.00',
- };
-
- render();
-
- // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $1k-$10k, trailing zeros removed
- expect(screen.getByText('$1,500')).toBeOnTheScreen();
- expect(screen.queryByText(/1.*orders/)).not.toBeOnTheScreen();
- });
-
- it('displays single TP count when only one TP order exists without price', () => {
- const positionWithSingleTPNoPrice = {
- ...mockPosition,
- takeProfitCount: 1,
- takeProfitPrice: undefined, // No price available
- stopLossCount: 0,
- stopLossPrice: undefined,
- };
-
- render(
- ,
- );
-
- expect(screen.getByText('1 order')).toBeOnTheScreen();
- // Should still show "Not Set" for stop loss since stopLossCount is 0
- const notSetTexts = screen.getAllByText('perps.position.card.not_set');
- expect(notSetTexts).toHaveLength(1); // Only for SL
- });
-
- it('displays single SL count when only one SL order exists without price', () => {
- const positionWithSingleSLNoPrice = {
- ...mockPosition,
- takeProfitCount: 0,
- takeProfitPrice: undefined,
- stopLossCount: 1,
- stopLossPrice: undefined, // No price available
- };
-
- render(
- ,
- );
-
- expect(screen.getByText('1 order')).toBeOnTheScreen();
- // Note: There should still be one "Not Set" for the TP field
- const notSetTexts = screen.getAllByText('perps.position.card.not_set');
- expect(notSetTexts).toHaveLength(1); // Only for TP
- });
-
- it('displays "Not Set" when no TP/SL orders exist', () => {
- const positionWithoutTPSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 0,
- takeProfitPrice: undefined,
- stopLossPrice: undefined,
- };
-
- render();
-
- // Should show "Not Set" for both TP and SL
- const notSetTexts = screen.getAllByText('perps.position.card.not_set');
- expect(notSetTexts).toHaveLength(2); // One for TP, one for SL
- });
- });
-
- describe('TP/SL Configuration Behavior', () => {
- it('renders count as clickable when position bound TP/SL is enabled and count > 1', () => {
- // Position bound TP/SL is always enabled in production (TP_SL_CONFIG.USE_POSITION_BOUND_TPSL = true)
-
- const positionWithMultipleTP = {
- ...mockPosition,
- takeProfitCount: 3,
- takeProfitPrice: undefined,
- stopLossCount: 0,
- };
-
- render();
-
- const tpCountText = screen.getByText('3 orders');
- // TouchableOpacity should be pressable
- expect(tpCountText).toBeTruthy();
- });
-
- it('handles navigation error gracefully when pressing TP/SL count', () => {
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [],
- error: 'Network error',
- isLoading: false,
- });
-
- const mockOnTpslCountPress = jest.fn();
- const positionWithMultipleTP = {
- ...mockPosition,
- takeProfitCount: 3,
- takeProfitPrice: undefined,
- stopLossCount: 0,
- };
-
- render(
- ,
- );
-
- const tpCountText = screen.getByText('3 orders');
- fireEvent.press(tpCountText);
-
- // Should not call onTpslCountPress due to error
- expect(mockOnTpslCountPress).not.toHaveBeenCalled();
- });
-
- it('handles missing market data gracefully when pressing TP/SL count', () => {
- const { usePerpsMarkets } = jest.requireMock('../../hooks');
- usePerpsMarkets.mockReturnValue({
- markets: [], // Empty markets array
- error: null,
- isLoading: false,
- });
-
- const mockOnTpslCountPress = jest.fn();
- const positionWithMultipleSL = {
- ...mockPosition,
- takeProfitCount: 0,
- stopLossCount: 2,
- stopLossPrice: undefined,
- };
-
- render(
- ,
- );
-
- const slCountText = screen.getByText('2 orders');
- fireEvent.press(slCountText);
-
- // Should not call onTpslCountPress due to missing market data
- expect(mockOnTpslCountPress).not.toHaveBeenCalled();
- });
- });
-
- describe('share button functionality', () => {
- it('renders share button when expanded is true', () => {
- render();
-
- const shareButton = screen.getByTestId(
- PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
- );
-
- expect(shareButton).toBeOnTheScreen();
- });
-
- it('does not render share button when expanded is false', () => {
- render();
+ describe('Share Button Functionality', () => {
+ it('does not render share button when onSharePress not provided', () => {
+ // Arrange & Act
+ render();
+ // Assert
const shareButton = screen.queryByTestId(
PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
);
-
expect(shareButton).toBeNull();
});
-
- it('navigates to PNL_HERO_CARD route when share button pressed', () => {
- const mockNavigate = jest.fn();
- (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
-
- render();
-
- const shareButton = screen.getByTestId(
- PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
- );
- fireEvent.press(shareButton);
-
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PERPS.PNL_HERO_CARD,
- expect.objectContaining({
- position: mockPosition,
- marketPrice: '2100.50',
- }),
- );
- });
-
- it('passes position and marketPrice to route params', () => {
- const mockNavigate = jest.fn();
- (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
-
- render();
-
- const shareButton = screen.getByTestId(
- PerpsPositionCardSelectorsIDs.SHARE_BUTTON,
- );
- fireEvent.press(shareButton);
-
- expect(mockNavigate).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- position: mockPosition,
- marketPrice: '2100.50', // Live price from usePerpsLivePrices, not market data
- }),
- );
- });
});
});
diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
index 33c96bbc932..ad8455d6283 100644
--- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
+++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx
@@ -1,20 +1,14 @@
-import { useNavigation, type NavigationProp } from '@react-navigation/native';
-import React, { useCallback, useMemo, useState } from 'react';
-import { Modal, TouchableOpacity, View } from 'react-native';
-import { useSelector } from 'react-redux';
-import {
- PerpsMarketDetailsViewSelectorsIDs,
- PerpsPositionCardSelectorsIDs,
-} from '../../../../../../e2e/selectors/Perps/Perps.selectors';
+import React, { useState, useMemo } from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import { PerpsPositionCardSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import { strings } from '../../../../../../locales/i18n';
-import Button, {
- ButtonSize,
- ButtonVariants,
- ButtonWidthTypes,
-} from '../../../../../component-library/components/Buttons/Button';
import ButtonIcon, {
ButtonIconSizes,
} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import Button, {
+ ButtonVariants,
+ ButtonSize,
+} from '../../../../../component-library/components/Buttons/Button';
import Icon, {
IconColor,
IconName,
@@ -25,20 +19,8 @@ import Text, {
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
import { useStyles } from '../../../../../component-library/hooks';
-import Routes from '../../../../../constants/navigation/Routes';
-import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger';
-import { PERPS_CONSTANTS, TP_SL_CONFIG } from '../../constants/perpsConfig';
-import type {
- PerpsNavigationParamList,
- Position,
- TPSLTrackingData,
-} from '../../controllers/types';
-import {
- usePerpsLivePrices,
- usePerpsMarkets,
- usePerpsTPSLUpdate,
-} from '../../hooks';
-import { selectPerpsEligibility } from '../../selectors/perpsController';
+import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
+import type { Position, Order } from '../../controllers/types';
import {
formatPerpsFiat,
formatPnl,
@@ -47,93 +29,70 @@ import {
PRICE_RANGES_UNIVERSAL,
} from '../../utils/formatUtils';
import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
-import { PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip';
-import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip';
-import PerpsTokenLogo from '../PerpsTokenLogo';
import styleSheet from './PerpsPositionCard.styles';
+/**
+ * PerpsPositionCard Component
+ *
+ * Displays open position details with interactive controls for position management.
+ *
+ * @component
+ *
+ * @remarks
+ * **Callback Requirements by Context:**
+ * - **View-Only Mode** (no callbacks): Shows position data only, no interactive elements
+ * - **Interactive Mode** (with callbacks): Enables position management actions
+ *
+ * **Interactive Callbacks:**
+ * - `onAutoClosePress`: Required for TP/SL configuration - opens auto-close settings
+ * - `onMarginPress`: Required for margin adjustment - opens add/remove margin flow
+ * - `onSharePress`: Optional - enables sharing position P&L card
+ * - `onFlipPress`: Not currently used (flip handled via modify action sheet)
+ *
+ * @example
+ * // View-only mode
+ *
+ *
+ * @example
+ * // Interactive mode
+ * navigateToTPSL(position)}
+ * onMarginPress={() => setShowAdjustMarginSheet(true)}
+ * onSharePress={() => navigateToPnlShare(position)}
+ * />
+ */
interface PerpsPositionCardProps {
position: Position;
- expanded?: boolean;
+ orders?: Order[];
showIcon?: boolean;
- rightAccessory?: React.ReactNode;
- onPositionUpdate?: () => Promise;
- onTooltipPress?: (contentKey: PerpsTooltipContentKey) => void;
- onTpslCountPress?: (tabId: string) => void;
+ currentPrice?: number;
+ autoCloseEnabled?: boolean;
+ onAutoClosePress?: () => void;
+ onFlipPress?: () => void;
+ onMarginPress?: () => void;
+ onSharePress?: () => void;
}
const PerpsPositionCard: React.FC = ({
position,
- expanded = true, // Default to expanded for backward compatibility
- showIcon = false, // Default to not showing icon
- rightAccessory,
- onPositionUpdate,
- onTooltipPress,
- onTpslCountPress,
+ orders,
+ currentPrice,
+ autoCloseEnabled: _autoCloseEnabled = false,
+ onAutoClosePress,
+ onFlipPress: _onFlipPress,
+ onMarginPress,
+ onSharePress,
}) => {
const { styles } = useStyles(styleSheet, {});
- const navigation = useNavigation>();
-
- const [isEligibilityModalVisible, setIsEligibilityModalVisible] =
- useState(false);
-
- const [isTPSLCountWarningVisible, setIsTPSLCountWarningVisible] =
- useState(false);
-
- const isEligible = useSelector(selectPerpsEligibility);
-
- const { handleUpdateTPSL } = usePerpsTPSLUpdate({
- onSuccess: () => {
- // Positions update automatically via WebSocket
- // Call parent's position update callback if provided
- if (onPositionUpdate) {
- onPositionUpdate();
- }
- },
- });
+ const [showSizeInUSD, setShowSizeInUSD] = useState(false);
// Determine if position is long or short based on size
const isLong = parseFloat(position.size) >= 0;
const direction = isLong ? 'long' : 'short';
const absoluteSize = Math.abs(parseFloat(position.size));
- const { markets, error, isLoading } = usePerpsMarkets();
-
- const livePrices = usePerpsLivePrices({ symbols: [position.coin] });
-
- const marketData = useMemo(
- () => markets.find((market) => market.symbol === position.coin),
- [markets, position.coin],
- );
-
- const handleCardPress = async () => {
- if (isLoading || error) {
- DevLogger.log(
- 'Failed to redirect to market details. Error fetching market data: ',
- error,
- );
- return;
- }
-
- navigation.navigate(Routes.PERPS.ROOT, {
- screen: Routes.PERPS.MARKET_DETAILS,
- params: {
- market: marketData,
- initialTab: 'position',
- },
- });
- };
-
- const handleClosePress = () => {
- if (!isEligible) {
- setIsEligibilityModalVisible(true);
- return;
- }
-
- DevLogger.log('PerpsPositionCard: Navigating to close position screen');
- navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position });
- };
-
const pnlNum = parseFloat(position.unrealizedPnl);
// ROE is always stored as a decimal (e.g., 0.171 for 17.1%)
@@ -141,77 +100,6 @@ const PerpsPositionCard: React.FC = ({
const roeValue = parseFloat(position.returnOnEquity || '0');
const roe = isNaN(roeValue) ? 0 : roeValue * 100;
- const handleEditTPSL = useCallback(() => {
- if (!isEligible) {
- setIsEligibilityModalVisible(true);
- return;
- }
-
- if (!TP_SL_CONFIG.USE_POSITION_BOUND_TPSL) {
- if (
- (position.takeProfitCount > 0 || position.stopLossCount > 0) &&
- (!position.takeProfitPrice || !position.stopLossPrice)
- ) {
- setIsTPSLCountWarningVisible(true);
- return;
- }
- }
-
- DevLogger.log('PerpsPositionCard: Editing TPSL', { position });
-
- navigation.navigate(Routes.PERPS.TPSL, {
- asset: position.coin,
- position,
- initialTakeProfitPrice: position.takeProfitPrice,
- initialStopLossPrice: position.stopLossPrice,
- onConfirm: async (
- takeProfitPrice?: string,
- stopLossPrice?: string,
- trackingData?: TPSLTrackingData,
- ) => {
- await handleUpdateTPSL(
- position,
- takeProfitPrice,
- stopLossPrice,
- trackingData,
- );
- },
- });
- }, [
- isEligible,
- position,
- navigation,
- handleUpdateTPSL,
- setIsEligibilityModalVisible,
- setIsTPSLCountWarningVisible,
- ]);
-
- const handleSharePress = () => {
- navigation.navigate(Routes.PERPS.PNL_HERO_CARD, {
- position,
- marketPrice: livePrices[position.coin]?.price,
- });
- };
-
- const handleTpslCountPress = useCallback(async () => {
- if (isLoading || error) {
- DevLogger.log(
- 'Failed to redirect to orders tab. Error fetching market data: ',
- error,
- );
- return;
- }
-
- if (!marketData) {
- DevLogger.log('No market data available for navigation');
- return;
- }
-
- if (onTpslCountPress) {
- onTpslCountPress('orders');
- }
- }, [isLoading, error, marketData, onTpslCountPress]);
-
// Funding cost (cumulative since open) formatting logic
const fundingSinceOpenRaw = position.cumulativeFunding?.sinceOpen ?? '0';
const fundingSinceOpen = parseFloat(fundingSinceOpenRaw);
@@ -235,358 +123,359 @@ const PerpsPositionCard: React.FC = ({
ranges: PRICE_RANGES_MINIMAL_VIEW,
})}`;
- const positionTakeProfitCount = position.takeProfitCount || 0;
- const positionStopLossCount = position.stopLossCount || 0;
-
- // Shared helper function for rendering TP/SL text
- const renderTPSLText = useCallback(
- (
- _type: 'takeProfit' | 'stopLoss',
- count: number,
- price: string | undefined,
- ) => {
- if (TP_SL_CONFIG.USE_POSITION_BOUND_TPSL) {
- // Multiple orders - show count as clickable
- if (count > 1) {
- return (
-
-
- {strings('perps.position.card.tpsl_count_multiple', {
- count,
- })}
-
-
- );
- }
+ const handleSizeToggle = () => {
+ setShowSizeInUSD(!showSizeInUSD);
+ };
- // Single order with price - show price
- if (count === 1 && price) {
- return (
-
- {formatPerpsFiat(price, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })}
-
- );
- }
-
- // Single order without price - show count as clickable
- if (count === 1 && !price) {
- return (
-
-
- {strings('perps.position.card.tpsl_count_single', {
- count,
- })}
-
-
- );
- }
+ // Calculate liquidation distance percentage
+ const liquidationDistance = useMemo(() => {
+ if (!currentPrice || !position.liquidationPrice) return null;
+ const liqPrice = parseFloat(String(position.liquidationPrice));
+ if (liqPrice <= 0 || currentPrice <= 0) return null;
+ return (Math.abs(currentPrice - liqPrice) / currentPrice) * 100;
+ }, [currentPrice, position.liquidationPrice]);
+
+ // Compute whether TPSL is configured (for button label)
+ const hasTPSLConfigured = useMemo(() => {
+ // First, check position-level TP/SL (from separate trigger orders)
+ let takeProfitPrice = position.takeProfitPrice;
+ let stopLossPrice = position.stopLossPrice;
+
+ // If position-level TP/SL is undefined, check order-level TP/SL (from child orders)
+ if ((!takeProfitPrice || !stopLossPrice) && orders && orders.length > 0) {
+ const parentOrder = orders.find(
+ (order) =>
+ order.symbol === position.coin &&
+ !order.isTrigger &&
+ (order.takeProfitPrice || order.stopLossPrice),
+ );
- // No orders - show "Not Set"
- return (
-
- {strings('perps.position.card.not_set')}
-
- );
+ if (parentOrder) {
+ takeProfitPrice = takeProfitPrice || parentOrder.takeProfitPrice;
+ stopLossPrice = stopLossPrice || parentOrder.stopLossPrice;
}
+ }
- // if position bound TP/SL is disabled
- if (count > 0 && !price) {
- return (
-
-
- {count === 1
- ? strings('perps.position.card.tpsl_count_single', {
- count,
- })
- : strings('perps.position.card.tpsl_count_multiple', {
- count,
- })}
-
-
- );
- }
+ const hasTakeProfit = takeProfitPrice && parseFloat(takeProfitPrice) > 0;
+ const hasStopLoss = stopLossPrice && parseFloat(stopLossPrice) > 0;
+ return Boolean(hasTakeProfit || hasStopLoss);
+ }, [position.takeProfitPrice, position.stopLossPrice, position.coin, orders]);
- return (
-
- {price !== undefined && price !== null
- ? formatPerpsFiat(price, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })
- : strings('perps.position.card.not_set')}
-
- );
- },
- [handleTpslCountPress, styles.tpslCountPress],
- );
+ const handleAutoCloseButtonPress = () => {
+ if (onAutoClosePress) {
+ onAutoClosePress();
+ }
+ };
- const renderStopLossText = useMemo(
- () =>
- renderTPSLText('stopLoss', positionStopLossCount, position.stopLossPrice),
- [renderTPSLText, positionStopLossCount, position.stopLossPrice],
- );
+ return (
+
+ {/* Header Section */}
+
+
+ {strings('perps.position.card.position_title')}
+
+ {onSharePress && (
+
+ )}
+
+
+ {/* P&L Section - Two cards side by side */}
+
+
+
+ {strings('perps.position.card.pnl_label')}
+
+ = 0 ? TextColor.Success : TextColor.Error}
+ testID={PerpsPositionCardSelectorsIDs.PNL_VALUE}
+ >
+ {formatPnl(pnlNum)}
+
+
- const renderTakeProfitText = useMemo(
- () =>
- renderTPSLText(
- 'takeProfit',
- positionTakeProfitCount,
- position.takeProfitPrice,
- ),
- [renderTPSLText, positionTakeProfitCount, position.takeProfitPrice],
- );
+
+
+ {strings('perps.position.card.return_label')}
+
+ = 0 ? TextColor.Success : TextColor.Error}
+ testID={PerpsPositionCardSelectorsIDs.RETURN_VALUE}
+ >
+ {roe >= 0 ? '+' : ''}
+ {roe.toFixed(2)}%
+
+
+
+
+ {/* Size/Margin Row */}
+
+
+
+
+ {strings('perps.position.card.size_label')}
+
+
+ {showSizeInUSD && currentPrice
+ ? formatPerpsFiat(absoluteSize * currentPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })
+ : `${formatPositionSize(absoluteSize.toString())} ${getPerpsDisplaySymbol(position.coin)}`}
+
+
+
+
+
+
+
+
+
+
+ {strings('perps.position.card.margin_label')}
+
+
+ {formatPerpsFiat(position.marginUsed, {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ })}
+
+
+ {onMarginPress && (
+
+
+
+ )}
+
+
- return (
- <>
+ {/* Auto Close Section */}
- {/* Header - Always shown */}
-
- {/* Icon Section - Conditionally shown (only in collapsed mode) */}
- {showIcon && !expanded && (
-
-
-
- )}
+
+
+ {strings('perps.auto_close.title')}
+
+ {(() => {
+ // First, check position-level TP/SL (from separate trigger orders)
+ let takeProfitPrice = position.takeProfitPrice;
+ let stopLossPrice = position.stopLossPrice;
+
+ // If position-level TP/SL is undefined, check order-level TP/SL (from child orders)
+ if (
+ (!takeProfitPrice || !stopLossPrice) &&
+ orders &&
+ orders.length > 0
+ ) {
+ // Find the parent order for this position
+ // Parent orders: same symbol, not trigger orders, have TP/SL children
+ const parentOrder = orders.find(
+ (order) =>
+ order.symbol === position.coin &&
+ !order.isTrigger &&
+ (order.takeProfitPrice || order.stopLossPrice),
+ );
+
+ if (parentOrder) {
+ takeProfitPrice =
+ takeProfitPrice || parentOrder.takeProfitPrice;
+ stopLossPrice = stopLossPrice || parentOrder.stopLossPrice;
+ }
+ }
-
-
-
- {getPerpsDisplaySymbol(position.coin)} {position.leverage.value}
- x{' '}
-
- {direction === 'long'
- ? strings('perps.market.long_lowercase')
- : strings('perps.market.short_lowercase')}
-
-
-
-
-
- {formatPositionSize(absoluteSize.toString())}{' '}
- {getPerpsDisplaySymbol(position.coin)}
-
-
-
+ const hasTakeProfit =
+ takeProfitPrice && parseFloat(takeProfitPrice) > 0;
+ const hasStopLoss = stopLossPrice && parseFloat(stopLossPrice) > 0;
-
-
-
- {formatPerpsFiat(position.positionValue, {
- ranges: PRICE_RANGES_MINIMAL_VIEW,
- })}
-
-
-
- = 0 ? TextColor.Success : TextColor.Error}
- >
- {formatPnl(pnlNum)} ({roe >= 0 ? '+' : ''}
- {roe.toFixed(1)}%)
-
-
-
+ if (hasTakeProfit || hasStopLoss) {
+ const parts: string[] = [];
- {/* Right Accessory - Conditionally shown */}
- {rightAccessory && (
- {rightAccessory}
- )}
-
+ if (hasTakeProfit && takeProfitPrice) {
+ const tpPrice = formatPerpsFiat(parseFloat(takeProfitPrice), {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ });
+ parts.push(`${strings('perps.order.tp')} ${tpPrice}`);
+ }
- {/* Body - Only shown when expanded */}
- {expanded && (
-
-
-
-
- {strings('perps.position.card.entry_price')}
-
-
- {formatPerpsFiat(position.entryPrice, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })}
-
-
-
-
- {strings('perps.position.card.liquidation_price')}
-
-
- {position.liquidationPrice !== undefined &&
- position.liquidationPrice !== null
- ? formatPerpsFiat(position.liquidationPrice, {
- ranges: PRICE_RANGES_UNIVERSAL,
- })
- : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY}
-
-
-
-
- {strings('perps.position.card.margin')}
-
-
- {formatPerpsFiat(position.marginUsed, {
- ranges: PRICE_RANGES_MINIMAL_VIEW,
- })}
-
-
-
+ if (hasStopLoss && stopLossPrice) {
+ const slPrice = formatPerpsFiat(parseFloat(stopLossPrice), {
+ ranges: PRICE_RANGES_MINIMAL_VIEW,
+ });
+ parts.push(`${strings('perps.order.sl')} ${slPrice}`);
+ }
-
-
-
- {strings('perps.position.card.take_profit')}
+ return (
+
+ {parts.join(', ')}
- <>{renderTakeProfitText}>
-
-
-
- {strings('perps.position.card.stop_loss')}
-
- <>{renderStopLossText}>
-
-
-
-
- {strings('perps.position.card.funding_cost')}
-
- {onTooltipPress && (
- onTooltipPress('funding_payments')}
- >
-
-
- )}
-
-
- {fundingDisplay}
-
-
-
-
- )}
+ );
+ }
- {/* Footer - Only shown when expanded */}
- {expanded && (
-
+ return (
+
+ {strings('perps.auto_close.description')}
+
+ );
+ })()}
+
+
+ {onAutoClosePress && (
+ )}
+
+
+
+ {/* Details Section - Always expanded */}
+
+
+ {strings('perps.position.card.details_title')}
+
+
+
+
+ {strings('perps.position.card.direction_label')}
+
+
+ {direction === 'long'
+ ? strings('perps.market.long')
+ : strings('perps.market.short')}{' '}
+ {position.leverage.value}x
+
+
+
+
+
+ {strings('perps.position.card.entry_label')}
+
+
+ {formatPerpsFiat(position.entryPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })}
+
+
+
+
+
+ {strings('perps.position.card.liquidation_price_label')}
+
+
+
+ {position.liquidationPrice !== undefined &&
+ position.liquidationPrice !== null
+ ? formatPerpsFiat(position.liquidationPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ })
+ : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY}
+
+ {liquidationDistance !== null && (
+ <>
- {strings('perps.position.card.close_position')}
+ {' '}
+ {Math.round(liquidationDistance)}%
- }
- onPress={handleClosePress}
- style={styles.footerButton}
- testID={PerpsPositionCardSelectorsIDs.CLOSE_BUTTON}
- />
-
-
+
+ >
+ )}
- )}
-
- {isTPSLCountWarningVisible && (
- // Android Compatibility: Wrap the in a plain component to prevent rendering issues and freezing.
-
-
- setIsTPSLCountWarningVisible(false)}
- contentKey={'tpsl_count_warning'}
- buttonConfig={[
- {
- label: strings(
- 'perps.tooltips.tpsl_count_warning.got_it_button',
- ),
- onPress: () => setIsTPSLCountWarningVisible(false),
- variant: ButtonVariants.Secondary,
- size: ButtonSize.Lg,
- testID:
- PerpsPositionCardSelectorsIDs.TPSL_COUNT_WARNING_TOOLTIP_GOT_IT_BUTTON,
- },
- {
- label: strings(
- 'perps.tooltips.tpsl_count_warning.view_orders_button',
- ),
- onPress: () => handleTpslCountPress(),
- variant: ButtonVariants.Primary,
- size: ButtonSize.Lg,
- testID:
- PerpsPositionCardSelectorsIDs.TPSL_COUNT_WARNING_TOOLTIP_VIEW_ORDERS_BUTTON,
- },
- ]}
- />
-
- )}
- {isEligibilityModalVisible && (
- // Android Compatibility: Wrap the in a plain component to prevent rendering issues and freezing.
-
-
- setIsEligibilityModalVisible(false)}
- contentKey={'geo_block'}
- />
-
+
+
+
+ {strings('perps.position.card.funding_payments_label')}
+
+
+ {fundingDisplay}
+
- )}
- >
+
+
);
};
diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts
index 8220d30d9a3..348952634b3 100644
--- a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts
+++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts
@@ -14,14 +14,26 @@ const styleSheet = (params: { theme: Theme }) => {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
- marginBottom: 12,
+ marginBottom: 16,
+ paddingHorizontal: 16,
+ },
+ listContainer: {
+ gap: 1,
paddingHorizontal: 16,
},
activityItem: {
flexDirection: 'row',
alignItems: 'center',
- paddingVertical: 12,
- paddingHorizontal: 16,
+ padding: 12,
+ backgroundColor: colors.background.section,
+ },
+ activityItemFirst: {
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
+ },
+ activityItemLast: {
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
},
leftSection: {
flexDirection: 'row',
diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx
index 5b9852c644d..90d8684156e 100644
--- a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx
+++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx
@@ -67,11 +67,18 @@ const PerpsRecentActivityList: React.FC = ({
}, []);
const renderItem = useCallback(
- (props: { item: PerpsTransaction }) => {
- const { item } = props;
+ (props: { item: PerpsTransaction; index: number }) => {
+ const { item, index } = props;
+ const isFirstItem = index === 0;
+ const isLastItem = index === transactions.length - 1;
+
return (
handleTransactionPress(item)}
activeOpacity={0.7}
>
@@ -105,14 +112,20 @@ const PerpsRecentActivityList: React.FC = ({
);
},
- [styles, handleTransactionPress, iconSize, renderRightContent],
+ [
+ styles,
+ handleTransactionPress,
+ iconSize,
+ renderRightContent,
+ transactions.length,
+ ],
);
if (isLoading) {
return (
-
+
{strings('perps.home.recent_activity')}
@@ -125,7 +138,7 @@ const PerpsRecentActivityList: React.FC = ({
return (
-
+
{strings('perps.home.recent_activity')}
@@ -143,7 +156,7 @@ const PerpsRecentActivityList: React.FC = ({
return (
-
+
{strings('perps.home.recent_activity')}
@@ -153,12 +166,14 @@ const PerpsRecentActivityList: React.FC = ({
- `${item.id || index}`}
- scrollEnabled={false}
- />
+
+ `${item.id || index}`}
+ scrollEnabled={false}
+ />
+
);
};
diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx
index f600abf2af4..9af99bfad9d 100644
--- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx
+++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx
@@ -173,7 +173,7 @@ export const PerpsTabControlBar: React.FC = ({
]}
>
diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts
index 5d193261407..6c9dd36b07f 100644
--- a/app/components/UI/Perps/constants/perpsConfig.ts
+++ b/app/components/UI/Perps/constants/perpsConfig.ts
@@ -268,6 +268,36 @@ export const CLOSE_POSITION_CONFIG = {
FALLBACK_TOKEN_DECIMALS: 18,
} as const;
+/**
+ * Margin adjustment configuration
+ * Controls behavior for adding/removing margin from positions
+ */
+export const MARGIN_ADJUSTMENT_CONFIG = {
+ // Risk thresholds for margin removal warnings
+ // Threshold values represent ratio of (price distance to liquidation) / (liquidation price)
+ // Values < 1.0 mean price is dangerously close to liquidation
+ LIQUIDATION_RISK_THRESHOLD: 1.2, // 20% buffer before liquidation - triggers danger state
+ LIQUIDATION_WARNING_THRESHOLD: 1.5, // 50% buffer before liquidation - triggers warning state
+
+ // Minimum margin adjustment amount (USD)
+ // Prevents dust adjustments and ensures meaningful position changes
+ MIN_ADJUSTMENT_AMOUNT: 1,
+
+ // Precision for margin calculations
+ // Ensures accurate decimal handling in margin/leverage calculations
+ CALCULATION_PRECISION: 6,
+
+ // Safety buffer for margin removal to account for HyperLiquid's transfer margin requirement
+ // HyperLiquid enforces: transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
+ // See: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl
+ MARGIN_REMOVAL_SAFETY_BUFFER: 0.1,
+
+ // Fallback max leverage when market data is unavailable
+ // Conservative value to prevent over-removal of margin
+ // Most HyperLiquid assets support at least 50x leverage
+ FALLBACK_MAX_LEVERAGE: 50,
+} as const;
+
/**
* Data Lake API configuration
* Endpoints for reporting perps trading activity for notifications
diff --git a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx
index f03fb4fa480..8688a24c6aa 100644
--- a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx
+++ b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx
@@ -3,9 +3,11 @@ import {
usePerpsOrderForm,
UsePerpsOrderFormReturn,
} from '../hooks/usePerpsOrderForm';
-import { OrderType } from '../controllers/types';
+import { OrderType, Position } from '../controllers/types';
-interface PerpsOrderContextType extends UsePerpsOrderFormReturn {}
+interface PerpsOrderContextType extends UsePerpsOrderFormReturn {
+ existingPosition?: Position;
+}
const PerpsOrderContext = createContext(null);
@@ -16,6 +18,7 @@ interface PerpsOrderProviderProps {
initialAmount?: string;
initialLeverage?: number;
initialType?: OrderType;
+ existingPosition?: Position;
}
export const PerpsOrderProvider = ({
@@ -25,6 +28,7 @@ export const PerpsOrderProvider = ({
initialAmount,
initialLeverage,
initialType,
+ existingPosition,
}: PerpsOrderProviderProps) => {
const orderFormState = usePerpsOrderForm({
initialAsset,
@@ -35,7 +39,12 @@ export const PerpsOrderProvider = ({
});
return (
-
+
{children}
);
diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts
index 9d80caa108c..d810e51351a 100644
--- a/app/components/UI/Perps/controllers/PerpsController.test.ts
+++ b/app/components/UI/Perps/controllers/PerpsController.test.ts
@@ -170,6 +170,8 @@ jest.mock('./services/TradingService', () => ({
closePosition: jest.fn(),
closePositions: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
},
}));
@@ -1945,6 +1947,138 @@ describe('PerpsController', () => {
},
);
});
+
+ it('updates margin successfully', async () => {
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ const mockUpdateResult = {
+ success: true,
+ };
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'updateMargin')
+ .mockResolvedValue(mockUpdateResult);
+
+ const result = await controller.updateMargin(updateMarginParams);
+
+ expect(result).toEqual(mockUpdateResult);
+ expect(TradingService.updateMargin).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: mockProvider,
+ coin: updateMarginParams.coin,
+ amount: '100',
+ context: expect.any(Object),
+ }),
+ );
+ });
+
+ it('handles updateMargin error', async () => {
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ const errorMessage = 'Insufficient balance';
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'updateMargin')
+ .mockRejectedValue(new Error(errorMessage));
+
+ await expect(controller.updateMargin(updateMarginParams)).rejects.toThrow(
+ errorMessage,
+ );
+ expect(TradingService.updateMargin).toHaveBeenCalled();
+ });
+
+ it('flips position successfully', async () => {
+ const mockPosition = {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const flipPositionParams = {
+ coin: 'BTC',
+ position: mockPosition,
+ };
+
+ const mockFlipResult = {
+ success: true,
+ orderId: 'flip-123',
+ filledSize: '1.0',
+ averagePrice: '50000',
+ };
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'flipPosition')
+ .mockResolvedValue(mockFlipResult);
+
+ const result = await controller.flipPosition(flipPositionParams);
+
+ expect(result).toEqual(mockFlipResult);
+ expect(TradingService.flipPosition).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: mockProvider,
+ position: mockPosition,
+ context: expect.any(Object),
+ }),
+ );
+ });
+
+ it('handles flipPosition error', async () => {
+ const mockPosition = {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const flipPositionParams = {
+ coin: 'BTC',
+ position: mockPosition,
+ };
+
+ const errorMessage = 'Insufficient balance for flip fees';
+
+ markControllerAsInitialized();
+ controller.testSetProviders(new Map([['hyperliquid', mockProvider]]));
+ jest
+ .spyOn(TradingService, 'flipPosition')
+ .mockRejectedValue(new Error(errorMessage));
+
+ await expect(controller.flipPosition(flipPositionParams)).rejects.toThrow(
+ errorMessage,
+ );
+ expect(TradingService.flipPosition).toHaveBeenCalled();
+ });
});
describe('fee calculations', () => {
diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts
index 0bbf5f1c6f0..73047b8fe3b 100644
--- a/app/components/UI/Perps/controllers/PerpsController.ts
+++ b/app/components/UI/Perps/controllers/PerpsController.ts
@@ -57,6 +57,7 @@ import type {
EditOrderParams,
FeeCalculationParams,
FeeCalculationResult,
+ FlipPositionParams,
Funding,
GetAccountStateParams,
GetAvailableDexsParams,
@@ -68,6 +69,7 @@ import type {
LiquidationPriceParams,
LiveDataConfig,
MaintenanceMarginParams,
+ MarginResult,
MarketInfo,
Order,
OrderFill,
@@ -84,6 +86,7 @@ import type {
SubscribePricesParams,
SwitchProviderResult,
ToggleTestnetResult,
+ UpdateMarginParams,
UpdatePositionTPSLParams,
WithdrawParams,
WithdrawResult,
@@ -1234,6 +1237,43 @@ export class PerpsController extends BaseController<
});
}
+ /**
+ * Update margin for an existing position (add or remove)
+ */
+ async updateMargin(params: UpdateMarginParams): Promise {
+ const provider = this.getActiveProvider();
+ const { RewardsController, NetworkController } = Engine.context;
+
+ return TradingService.updateMargin({
+ provider,
+ coin: params.coin,
+ amount: params.amount,
+ context: this.createServiceContext('updateMargin', {
+ rewardsController: RewardsController,
+ networkController: NetworkController,
+ messenger: this.messenger,
+ }),
+ });
+ }
+
+ /**
+ * Flip position (reverse direction while keeping size and leverage)
+ */
+ async flipPosition(params: FlipPositionParams): Promise {
+ const provider = this.getActiveProvider();
+ const { RewardsController, NetworkController } = Engine.context;
+
+ return TradingService.flipPosition({
+ provider,
+ position: params.position,
+ context: this.createServiceContext('flipPosition', {
+ rewardsController: RewardsController,
+ networkController: NetworkController,
+ messenger: this.messenger,
+ }),
+ });
+ }
+
/**
* Simplified deposit method that prepares transaction for confirmation screen
* No complex state tracking - just sets a loading flag
diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
index af0f17adfb7..a57b7f05e9b 100644
--- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
+++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts
@@ -98,6 +98,7 @@ import type {
LiquidationPriceParams,
LiveDataConfig,
MaintenanceMarginParams,
+ MarginResult,
MarketInfo,
Order,
OrderFill,
@@ -2983,6 +2984,94 @@ export class HyperLiquidProvider implements IPerpsProvider {
}
}
+ /**
+ * Update margin for an existing position (add or remove)
+ *
+ * @param params - Margin adjustment parameters
+ * @param params.coin - Asset symbol (e.g., 'BTC', 'ETH')
+ * @param params.amount - Amount to adjust as string (positive = add, negative = remove)
+ * @returns Promise resolving to margin adjustment result
+ *
+ * Note: HyperLiquid uses micro-units (multiply by 1e6) for the ntli parameter.
+ * The SDK's updateIsolatedMargin requires:
+ * - asset: Asset ID (number)
+ * - isBuy: Position direction (true for long, false for short)
+ * - ntli: Amount in micro-units (amount * 1e6)
+ */
+ async updateMargin(params: {
+ coin: string;
+ amount: string;
+ }): Promise {
+ try {
+ DevLogger.log('Updating position margin:', params);
+
+ const { coin, amount } = params;
+
+ // Ensure provider is ready
+ await this.ensureReady();
+
+ // Get current position to determine direction
+ // Force fresh API data since we're about to mutate the position
+ const positions = await this.getPositions({ skipCache: true });
+ const position = positions.find((p) => p.coin === coin);
+
+ if (!position) {
+ throw new Error(`No position found for ${coin}`);
+ }
+
+ // Determine position direction
+ const isBuy = parseFloat(position.size) > 0; // true for long, false for short
+
+ // Get asset ID for the coin
+ const assetId = this.coinToAssetId.get(coin);
+ if (assetId === undefined) {
+ throw new Error(`Asset ID not found for ${coin}`);
+ }
+
+ // Convert amount to micro-units (HyperLiquid SDK requirement)
+ const amountFloat = parseFloat(amount);
+ const ntli = Math.floor(amountFloat * 1e6);
+
+ DevLogger.log('Margin adjustment details', {
+ coin,
+ assetId,
+ isBuy,
+ amount: amountFloat,
+ ntli,
+ });
+
+ // Call SDK to update isolated margin
+ const exchangeClient = this.clientService.getExchangeClient();
+ const result = await exchangeClient.updateIsolatedMargin({
+ asset: assetId,
+ isBuy,
+ ntli,
+ });
+
+ DevLogger.log('Margin update result:', result);
+
+ if (result.status !== 'ok') {
+ throw new Error(`Margin adjustment failed: ${JSON.stringify(result)}`);
+ }
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ Logger.error(
+ ensureError(error),
+ this.getErrorContext('updateMargin', {
+ coin: params.coin,
+ amount: params.amount,
+ }),
+ );
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+
/**
* Get current positions with TP/SL prices
*
diff --git a/app/components/UI/Perps/controllers/services/TradingService.test.ts b/app/components/UI/Perps/controllers/services/TradingService.test.ts
index 0d85e3b7476..84c3d2c7232 100644
--- a/app/components/UI/Perps/controllers/services/TradingService.test.ts
+++ b/app/components/UI/Perps/controllers/services/TradingService.test.ts
@@ -1645,4 +1645,365 @@ describe('TradingService', () => {
);
});
});
+
+ describe('updateMargin', () => {
+ it('updates margin successfully when adding margin', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ const result = await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(result).toEqual(mockResult);
+ expect(mockProvider.updateMargin).toHaveBeenCalledWith({
+ coin: 'BTC',
+ amount: '100',
+ });
+ });
+
+ it('updates margin successfully when removing margin', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ const result = await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '-50',
+ context: mockContext,
+ });
+
+ expect(result).toEqual(mockResult);
+ expect(mockProvider.updateMargin).toHaveBeenCalledWith({
+ coin: 'BTC',
+ amount: '-50',
+ });
+ });
+
+ it('throws error when provider does not support margin adjustment', async () => {
+ mockProvider.updateMargin = undefined as never;
+
+ await expect(
+ TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Provider does not support margin adjustment');
+ });
+
+ it('returns error when margin update fails', async () => {
+ const mockResult = { success: false, error: 'Insufficient balance' };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ const result = await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Insufficient balance');
+ });
+
+ it('tracks analytics on success', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('tracks analytics on failure with error message', async () => {
+ mockProvider.updateMargin = jest
+ .fn()
+ .mockRejectedValue(new Error('Network error'));
+
+ await expect(
+ TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Network error');
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('updates state on success', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(mockContext.stateManager?.update).toHaveBeenCalled();
+ });
+
+ it('creates trace for margin update', async () => {
+ const mockResult = { success: true };
+ mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult);
+
+ await TradingService.updateMargin({
+ provider: mockProvider,
+ coin: 'BTC',
+ amount: '100',
+ context: mockContext,
+ });
+
+ expect(trace).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'PerpsUpdateMargin',
+ id: 'mock-trace-id',
+ }),
+ );
+ expect(endTrace).toHaveBeenCalled();
+ });
+ });
+
+ describe('flipPosition', () => {
+ const mockPosition: Position = {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ liquidationPrice: '45000',
+ leverage: { type: 'cross', value: 10 },
+ marginUsed: '2500',
+ maxLeverage: 20,
+ positionValue: '25000',
+ returnOnEquity: '0.2',
+ unrealizedPnl: '5000',
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockAccountState = {
+ availableBalance: '10000',
+ equity: '15000',
+ marginUsed: '5000',
+ };
+
+ beforeEach(() => {
+ mockProvider.getAccountState = jest
+ .fn()
+ .mockResolvedValue(mockAccountState);
+ });
+
+ it('places order with 2x position size to flip position', async () => {
+ const mockResult: OrderResult = {
+ success: true,
+ orderId: 'flip-123',
+ filledSize: '1.0',
+ averagePrice: '50000',
+ };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ // Verify order placed with 2x position size (0.5 * 2 = 1.0)
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ coin: 'BTC',
+ size: '1',
+ }),
+ );
+ });
+
+ it('flips long position to short (isBuy=false)', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ // Long position (positive size)
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: { ...mockPosition, size: '0.5' },
+ context: mockContext,
+ });
+
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isBuy: false,
+ }),
+ );
+ });
+
+ it('flips short position to long (isBuy=true)', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ // Short position (negative size)
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: { ...mockPosition, size: '-0.5' },
+ context: mockContext,
+ });
+
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isBuy: true,
+ }),
+ );
+ });
+
+ it('returns error when insufficient balance for fees', async () => {
+ // Set very low available balance
+ mockProvider.getAccountState = jest.fn().mockResolvedValue({
+ ...mockAccountState,
+ availableBalance: '1', // $1 balance, insufficient for fees
+ });
+
+ await expect(
+ TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ }),
+ ).rejects.toThrow(/Insufficient balance for flip fees/);
+ });
+
+ it('throws error when account state cannot be retrieved', async () => {
+ mockProvider.getAccountState = jest.fn().mockResolvedValue(null);
+
+ await expect(
+ TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Failed to get account state');
+ });
+
+ it('returns error when order placement fails', async () => {
+ const mockResult: OrderResult = {
+ success: false,
+ error: 'Order rejected',
+ };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ const result = await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Order rejected');
+ });
+
+ it('tracks analytics on success', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('tracks analytics on failure', async () => {
+ mockProvider.placeOrder.mockRejectedValue(new Error('Network error'));
+
+ await expect(
+ TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ }),
+ ).rejects.toThrow('Network error');
+
+ expect(mockContext.analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: expect.any(String),
+ }),
+ );
+ });
+
+ it('updates state on success', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(mockContext.stateManager?.update).toHaveBeenCalled();
+ });
+
+ it('creates trace for flip position', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(trace).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'PerpsFlipPosition',
+ id: 'mock-trace-id',
+ }),
+ );
+ expect(endTrace).toHaveBeenCalled();
+ });
+
+ it('uses correct order params including leverage', async () => {
+ const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
+ mockProvider.placeOrder.mockResolvedValue(mockResult);
+
+ await TradingService.flipPosition({
+ provider: mockProvider,
+ position: mockPosition,
+ context: mockContext,
+ });
+
+ expect(mockProvider.placeOrder).toHaveBeenCalledWith({
+ coin: 'BTC',
+ isBuy: false,
+ size: '1',
+ orderType: 'market',
+ leverage: 10,
+ currentPrice: 50000,
+ });
+ });
+ });
});
diff --git a/app/components/UI/Perps/controllers/services/TradingService.ts b/app/components/UI/Perps/controllers/services/TradingService.ts
index 466ef1057f4..6fb25ca9d63 100644
--- a/app/components/UI/Perps/controllers/services/TradingService.ts
+++ b/app/components/UI/Perps/controllers/services/TradingService.ts
@@ -1548,4 +1548,233 @@ export class TradingService {
});
}
}
+
+ /**
+ * Update margin for an existing position (add or remove)
+ */
+ static async updateMargin(options: {
+ provider: IPerpsProvider;
+ coin: string;
+ amount: string;
+ context: ServiceContext;
+ }): Promise<{ success: boolean; error?: string }> {
+ const { provider, coin, amount, context } = options;
+ const traceId = uuidv4();
+ const startTime = performance.now();
+
+ try {
+ trace({
+ name: 'PerpsUpdateMargin' as TraceName,
+ id: traceId,
+ op: TraceOperation.PerpsPositionManagement,
+ tags: {
+ provider: context.tracingContext.provider,
+ coin,
+ isAdd: parseFloat(amount) > 0,
+ isTestnet: context.tracingContext.isTestnet,
+ },
+ });
+
+ // Call provider method
+ const result = await provider.updateMargin?.({ coin, amount });
+
+ if (!result) {
+ throw new Error('Provider does not support margin adjustment');
+ }
+
+ const completionDuration = performance.now() - startTime;
+
+ if (result.success) {
+ // Update state on success
+ if (context.stateManager) {
+ context.stateManager.update((state) => {
+ state.lastUpdateTimestamp = Date.now();
+ });
+ }
+
+ // Track success analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_RISK_MANAGEMENT,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED,
+ [PerpsEventProperties.ASSET]: coin,
+ [PerpsEventProperties.ACTION]:
+ parseFloat(amount) > 0 ? 'add_margin' : 'remove_margin',
+ [PerpsEventProperties.MARGIN_USED]: Math.abs(parseFloat(amount)),
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+ }
+
+ endTrace({
+ name: 'PerpsUpdateMargin' as TraceName,
+ id: traceId,
+ data: { success: result.success, error: result.error || '' },
+ });
+
+ return result;
+ } catch (error) {
+ const completionDuration = performance.now() - startTime;
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+
+ Logger.error(
+ ensureError(error),
+ this.getErrorContext('updateMargin', { coin, amount }),
+ );
+
+ // Track failure analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_RISK_MANAGEMENT,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED,
+ [PerpsEventProperties.ASSET]: coin,
+ [PerpsEventProperties.ACTION]:
+ parseFloat(amount) > 0 ? 'add_margin' : 'remove_margin',
+ [PerpsEventProperties.MARGIN_USED]: Math.abs(parseFloat(amount)),
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ [PerpsEventProperties.ERROR_MESSAGE]: errorMessage,
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+
+ endTrace({
+ name: 'PerpsUpdateMargin' as TraceName,
+ id: traceId,
+ data: { success: false, error: errorMessage },
+ });
+
+ throw error;
+ }
+ }
+
+ /**
+ * Flip position (reverse direction while keeping size and leverage)
+ */
+ static async flipPosition(options: {
+ provider: IPerpsProvider;
+ position: Position;
+ context: ServiceContext;
+ }): Promise {
+ const { provider, position, context } = options;
+ const traceId = uuidv4();
+ const startTime = performance.now();
+
+ try {
+ trace({
+ name: 'PerpsFlipPosition' as TraceName,
+ id: traceId,
+ op: TraceOperation.PerpsPositionManagement,
+ tags: {
+ provider: context.tracingContext.provider,
+ coin: position.coin,
+ isTestnet: context.tracingContext.isTestnet,
+ },
+ });
+
+ // Calculate flip parameters
+ const positionSize = Math.abs(parseFloat(position.size));
+ const isCurrentlyLong = parseFloat(position.size) > 0;
+ const oppositeDirection = !isCurrentlyLong;
+
+ // Validate available balance for fees
+ const accountState = await provider.getAccountState?.();
+ if (!accountState) {
+ throw new Error('Failed to get account state');
+ }
+
+ const availableBalance = parseFloat(accountState.availableBalance);
+
+ // Estimate fees (close + open, approximately 0.09% of notional)
+ // Flip requires 2x position size (1x to close, 1x to open opposite)
+ const entryPrice = parseFloat(position.entryPrice);
+ const flipSize = positionSize * 2;
+ const notionalValue = flipSize * entryPrice;
+ const estimatedFees = notionalValue * 0.0009;
+
+ if (estimatedFees > availableBalance) {
+ throw new Error(
+ `Insufficient balance for flip fees. Need $${estimatedFees.toFixed(2)}, have $${availableBalance.toFixed(2)}`,
+ );
+ }
+
+ // Create order params for flip
+ // Use 2x position size: 1x to close current position + 1x to open opposite position
+ const orderParams: OrderParams = {
+ coin: position.coin,
+ isBuy: oppositeDirection,
+ size: flipSize.toString(),
+ orderType: 'market',
+ leverage: position.leverage?.value,
+ currentPrice: entryPrice,
+ };
+
+ // Place flip order (HyperLiquid handles margin transfer automatically)
+ const result = await provider.placeOrder(orderParams);
+
+ const completionDuration = performance.now() - startTime;
+
+ if (result.success) {
+ // Update state on success
+ if (context.stateManager) {
+ context.stateManager.update((state) => {
+ state.lastUpdateTimestamp = Date.now();
+ });
+ }
+
+ // Track success analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_TRADE_TRANSACTION,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.EXECUTED,
+ [PerpsEventProperties.ASSET]: position.coin,
+ [PerpsEventProperties.DIRECTION]: oppositeDirection
+ ? PerpsEventValues.DIRECTION.LONG
+ : PerpsEventValues.DIRECTION.SHORT,
+ [PerpsEventProperties.ORDER_TYPE]: 'market',
+ [PerpsEventProperties.LEVERAGE]: position.leverage?.value || 1,
+ [PerpsEventProperties.ORDER_SIZE]: positionSize,
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ [PerpsEventProperties.ACTION]: 'flip_position',
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+ }
+
+ endTrace({
+ name: 'PerpsFlipPosition' as TraceName,
+ id: traceId,
+ data: { success: result.success ?? false, error: result.error || '' },
+ });
+
+ return result;
+ } catch (error) {
+ const completionDuration = performance.now() - startTime;
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+
+ Logger.error(
+ ensureError(error),
+ this.getErrorContext('flipPosition', { coin: position.coin }),
+ );
+
+ // Track failure analytics
+ const eventBuilder = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.PERPS_TRADE_TRANSACTION,
+ ).addProperties({
+ [PerpsEventProperties.STATUS]: PerpsEventValues.STATUS.FAILED,
+ [PerpsEventProperties.ASSET]: position.coin,
+ [PerpsEventProperties.ACTION]: 'flip_position',
+ [PerpsEventProperties.COMPLETION_DURATION]: completionDuration,
+ [PerpsEventProperties.ERROR_MESSAGE]: errorMessage,
+ });
+ context.analytics.trackEvent(eventBuilder.build());
+
+ endTrace({
+ name: 'PerpsFlipPosition' as TraceName,
+ id: traceId,
+ data: { success: false, error: errorMessage },
+ });
+
+ throw error;
+ }
+ }
}
diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts
index 334ac0ab26d..ba69dc43694 100644
--- a/app/components/UI/Perps/controllers/types/index.ts
+++ b/app/components/UI/Perps/controllers/types/index.ts
@@ -216,6 +216,21 @@ export type ClosePositionsResult = {
}[];
};
+export type UpdateMarginParams = {
+ coin: string; // Asset symbol (e.g., 'BTC', 'ETH')
+ amount: string; // Amount to adjust as string (positive = add, negative = remove)
+};
+
+export type MarginResult = {
+ success: boolean;
+ error?: string;
+};
+
+export type FlipPositionParams = {
+ coin: string; // Asset symbol to flip
+ position: Position; // Current position to flip
+};
+
export interface InitializeResult {
success: boolean;
error?: string;
@@ -719,6 +734,7 @@ export interface IPerpsProvider {
closePosition(params: ClosePositionParams): Promise;
closePositions?(params: ClosePositionsParams): Promise; // Optional: batch close for protocols that support it
updatePositionTPSL(params: UpdatePositionTPSLParams): Promise;
+ updateMargin(params: UpdateMarginParams): Promise;
getPositions(params?: GetPositionsParams): Promise;
getAccountState(params?: GetAccountStateParams): Promise;
getMarkets(params?: GetMarketsParams): Promise;
diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts
index 7893243f03e..3fb62cc13cf 100644
--- a/app/components/UI/Perps/hooks/index.ts
+++ b/app/components/UI/Perps/hooks/index.ts
@@ -54,6 +54,7 @@ export { usePerpsRewardAccountOptedIn } from './usePerpsRewardAccountOptedIn';
export { usePerpsCloseAllCalculations } from './usePerpsCloseAllCalculations';
export { usePerpsCancelAllOrders } from './usePerpsCancelAllOrders';
export { usePerpsCloseAllPositions } from './usePerpsCloseAllPositions';
+export { usePositionManagement } from './usePositionManagement';
// Removed from barrel: usePerpsHomeActions imports Engine-dependent hooks
// Import directly: import { usePerpsHomeActions } from './hooks/usePerpsHomeActions';
export { useHasExistingPosition } from './useHasExistingPosition';
diff --git a/app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts b/app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts
new file mode 100644
index 00000000000..3023fc8f9ba
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsFlipPosition.test.ts
@@ -0,0 +1,319 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { usePerpsFlipPosition } from './usePerpsFlipPosition';
+import type { Position } from '../controllers/types';
+
+const mockFlipPosition = jest.fn();
+const mockShowToast = jest.fn();
+const mockCaptureException = jest.fn();
+
+jest.mock('./usePerpsTrading', () => ({
+ usePerpsTrading: () => ({
+ flipPosition: mockFlipPosition,
+ }),
+}));
+
+jest.mock('./usePerpsToasts', () => ({
+ __esModule: true,
+ default: () => ({
+ showToast: mockShowToast,
+ PerpsToastOptions: {
+ orderManagement: {
+ market: {
+ confirmed: jest.fn((direction, amount, symbol) => ({
+ type: 'success',
+ direction,
+ amount,
+ symbol,
+ })),
+ creationFailed: jest.fn((error) => ({
+ type: 'error',
+ error,
+ })),
+ },
+ },
+ },
+ }),
+}));
+
+jest.mock('@sentry/react-native', () => ({
+ captureException: (error: Error, context: unknown) =>
+ mockCaptureException(error, context),
+}));
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => key),
+}));
+
+jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({
+ DevLogger: {
+ log: jest.fn(),
+ },
+}));
+
+jest.mock('../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+describe('usePerpsFlipPosition', () => {
+ const mockLongPosition: Position = {
+ coin: 'ETH',
+ size: '2.5',
+ marginUsed: '500',
+ entryPrice: '2000',
+ liquidationPrice: '1900',
+ unrealizedPnl: '100',
+ returnOnEquity: '0.20',
+ leverage: { value: 10, type: 'isolated' },
+ cumulativeFunding: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockShortPosition: Position = {
+ ...mockLongPosition,
+ size: '-2.5',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns handleFlipPosition function and isFlipping state', () => {
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ expect(result.current.handleFlipPosition).toBeDefined();
+ expect(typeof result.current.handleFlipPosition).toBe('function');
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('sets isFlipping to true while flipping', async () => {
+ let resolveFlip: (value: { success: boolean }) => void;
+ mockFlipPosition.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveFlip = resolve;
+ }),
+ );
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ // Start the flip operation
+ act(() => {
+ result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ // Check that isFlipping is true during the operation
+ expect(result.current.isFlipping).toBe(true);
+
+ // Resolve the promise
+ await act(async () => {
+ resolveFlip({ success: true });
+ });
+
+ // Check that isFlipping is false after completion
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('calls flipPosition with correct parameters for long position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockFlipPosition).toHaveBeenCalledWith({
+ coin: 'ETH',
+ position: mockLongPosition,
+ });
+ });
+
+ it('calls flipPosition with correct parameters for short position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockShortPosition);
+ });
+
+ expect(mockFlipPosition).toHaveBeenCalledWith({
+ coin: 'ETH',
+ position: mockShortPosition,
+ });
+ });
+
+ it('shows success toast and calls onSuccess callback on successful flip', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+ const mockOnSuccess = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onSuccess: mockOnSuccess }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockOnSuccess).toHaveBeenCalled();
+ });
+
+ it('shows error toast and calls onError callback on failed flip', async () => {
+ mockFlipPosition.mockResolvedValue({
+ success: false,
+ error: 'Insufficient margin',
+ });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockOnError).toHaveBeenCalledWith('Insufficient margin');
+ });
+
+ it('shows default error message when no error provided', async () => {
+ mockFlipPosition.mockResolvedValue({ success: false });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+
+ it('handles exceptions and captures to Sentry', async () => {
+ const testError = new Error('Network error');
+ mockFlipPosition.mockRejectedValue(testError);
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ testError,
+ expect.objectContaining({
+ tags: expect.objectContaining({
+ component: 'usePerpsFlipPosition',
+ action: 'flip_position',
+ }),
+ extra: expect.objectContaining({
+ positionContext: expect.objectContaining({
+ coin: 'ETH',
+ size: '2.5',
+ }),
+ }),
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Network error');
+ });
+
+ it('handles non-Error exceptions', async () => {
+ mockFlipPosition.mockRejectedValue('String error');
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsFlipPosition({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.anything(),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+
+ it('resets isFlipping to false after completion', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('resets isFlipping to false after error', async () => {
+ mockFlipPosition.mockRejectedValue(new Error('Test error'));
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(result.current.isFlipping).toBe(false);
+ });
+
+ it('works without options provided', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ expect(mockFlipPosition).toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+
+ it('determines correct opposite direction for long position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockLongPosition);
+ });
+
+ // The toast should be called with 'short' as the opposite direction
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ direction: 'short',
+ }),
+ );
+ });
+
+ it('determines correct opposite direction for short position', async () => {
+ mockFlipPosition.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsFlipPosition());
+
+ await act(async () => {
+ await result.current.handleFlipPosition(mockShortPosition);
+ });
+
+ // The toast should be called with 'long' as the opposite direction
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ direction: 'long',
+ }),
+ );
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePerpsFlipPosition.ts b/app/components/UI/Perps/hooks/usePerpsFlipPosition.ts
new file mode 100644
index 00000000000..46f616a142a
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsFlipPosition.ts
@@ -0,0 +1,129 @@
+import { useCallback, useState } from 'react';
+import { strings } from '../../../../../locales/i18n';
+import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
+import { usePerpsTrading } from './usePerpsTrading';
+import type { Position } from '../controllers/types';
+import { captureException } from '@sentry/react-native';
+import usePerpsToasts from './usePerpsToasts';
+import { getPerpsDisplaySymbol } from '../utils/marketUtils';
+import type { OrderDirection } from '../types/perps-types';
+
+export interface UsePerpsFlipPositionOptions {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+}
+
+/**
+ * Hook for flipping (reversing) position direction
+ * Converts long positions to short and vice versa
+ * Provides consistent error handling, toast notifications, and Sentry tracking
+ * @param options Optional callbacks for success and error cases
+ * @returns handleFlipPosition function and loading state
+ */
+export function usePerpsFlipPosition(options?: UsePerpsFlipPositionOptions) {
+ const { flipPosition } = usePerpsTrading();
+ const [isFlipping, setIsFlipping] = useState(false);
+
+ const { showToast, PerpsToastOptions } = usePerpsToasts();
+
+ const handleFlipPosition = useCallback(
+ async (position: Position) => {
+ setIsFlipping(true);
+ DevLogger.log('usePerpsFlipPosition: Setting isFlipping to true');
+
+ // Determine current and opposite direction
+ const currentDirection: OrderDirection =
+ parseFloat(position.size) > 0 ? 'long' : 'short';
+ const oppositeDirection: OrderDirection =
+ currentDirection === 'long' ? 'short' : 'long';
+ const positionSize = Math.abs(parseFloat(position.size));
+
+ try {
+ const result = await flipPosition({
+ coin: position.coin,
+ position,
+ });
+
+ if (result.success) {
+ DevLogger.log('Position flipped successfully:', result);
+
+ // Show success toast using existing market order confirmation toast
+ // (flip is implemented as a market order in opposite direction)
+ const displaySymbol = getPerpsDisplaySymbol(position.coin);
+ showToast(
+ PerpsToastOptions.orderManagement.market.confirmed(
+ oppositeDirection,
+ positionSize.toString(),
+ displaySymbol,
+ ),
+ );
+
+ // Call success callback if provided
+ options?.onSuccess?.();
+ } else {
+ DevLogger.log('Failed to flip position:', result.error);
+
+ const errorMessage = result.error || strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.orderManagement.market.creationFailed(
+ errorMessage,
+ ),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ }
+ } catch (error) {
+ DevLogger.log('Error flipping position:', error);
+
+ // Capture exception with position context
+ captureException(
+ error instanceof Error ? error : new Error(String(error)),
+ {
+ tags: {
+ component: 'usePerpsFlipPosition',
+ action: 'flip_position',
+ operation: 'position_management',
+ },
+ extra: {
+ positionContext: {
+ coin: position.coin,
+ size: position.size,
+ currentDirection,
+ targetDirection: oppositeDirection,
+ positionSize,
+ entryPrice: position.entryPrice,
+ unrealizedPnl: position.unrealizedPnl,
+ leverage: position.leverage,
+ },
+ },
+ },
+ );
+
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.orderManagement.market.creationFailed(errorMessage),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ } finally {
+ DevLogger.log('usePerpsFlipPosition: Setting isFlipping to false');
+ setIsFlipping(false);
+ }
+ },
+ [
+ flipPosition,
+ showToast,
+ PerpsToastOptions.orderManagement.market,
+ options,
+ ],
+ );
+
+ return { handleFlipPosition, isFlipping };
+}
diff --git a/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts
new file mode 100644
index 00000000000..7c521b68be1
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.test.ts
@@ -0,0 +1,367 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { usePerpsMarginAdjustment } from './usePerpsMarginAdjustment';
+
+const mockUpdateMargin = jest.fn();
+const mockShowToast = jest.fn();
+const mockCaptureException = jest.fn();
+
+jest.mock('./usePerpsTrading', () => ({
+ usePerpsTrading: () => ({
+ updateMargin: mockUpdateMargin,
+ }),
+}));
+
+jest.mock('./usePerpsToasts', () => ({
+ __esModule: true,
+ default: () => ({
+ showToast: mockShowToast,
+ PerpsToastOptions: {
+ positionManagement: {
+ margin: {
+ addSuccess: jest.fn((symbol, amount) => ({
+ type: 'add_success',
+ symbol,
+ amount,
+ })),
+ removeSuccess: jest.fn((symbol, amount) => ({
+ type: 'remove_success',
+ symbol,
+ amount,
+ })),
+ adjustmentFailed: jest.fn((error) => ({
+ type: 'error',
+ error,
+ })),
+ },
+ },
+ },
+ }),
+}));
+
+jest.mock('@sentry/react-native', () => ({
+ captureException: (error: Error, context: unknown) =>
+ mockCaptureException(error, context),
+}));
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key) => key),
+}));
+
+jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({
+ DevLogger: {
+ log: jest.fn(),
+ },
+}));
+
+jest.mock('../utils/marketUtils', () => ({
+ getPerpsDisplaySymbol: jest.fn((symbol) => symbol),
+}));
+
+describe('usePerpsMarginAdjustment', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns handleAddMargin, handleRemoveMargin functions and isAdjusting state', () => {
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ expect(result.current.handleAddMargin).toBeDefined();
+ expect(typeof result.current.handleAddMargin).toBe('function');
+ expect(result.current.handleRemoveMargin).toBeDefined();
+ expect(typeof result.current.handleRemoveMargin).toBe('function');
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ describe('handleAddMargin', () => {
+ it('sets isAdjusting to true while adding margin', async () => {
+ let resolveMargin: (value: { success: boolean }) => void;
+ mockUpdateMargin.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveMargin = resolve;
+ }),
+ );
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ // Start the margin operation
+ act(() => {
+ result.current.handleAddMargin('ETH', 100);
+ });
+
+ // Check that isAdjusting is true during the operation
+ expect(result.current.isAdjusting).toBe(true);
+
+ // Resolve the promise
+ await act(async () => {
+ resolveMargin({ success: true });
+ });
+
+ // Check that isAdjusting is false after completion
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('calls updateMargin with positive amount for add', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockUpdateMargin).toHaveBeenCalledWith({
+ coin: 'ETH',
+ amount: '100',
+ });
+ });
+
+ it('shows success toast on successful add margin', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+ const mockOnSuccess = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onSuccess: mockOnSuccess }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('BTC', 50);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'add_success',
+ }),
+ );
+ expect(mockOnSuccess).toHaveBeenCalled();
+ });
+
+ it('shows error toast on failed add margin', async () => {
+ mockUpdateMargin.mockResolvedValue({
+ success: false,
+ error: 'Insufficient funds',
+ });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 1000);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Insufficient funds');
+ });
+ });
+
+ describe('handleRemoveMargin', () => {
+ it('calls updateMargin with negative amount for remove', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('ETH', 100);
+ });
+
+ expect(mockUpdateMargin).toHaveBeenCalledWith({
+ coin: 'ETH',
+ amount: '-100',
+ });
+ });
+
+ it('shows success toast on successful remove margin', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+ const mockOnSuccess = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onSuccess: mockOnSuccess }),
+ );
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('BTC', 25);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'remove_success',
+ }),
+ );
+ expect(mockOnSuccess).toHaveBeenCalled();
+ });
+
+ it('shows error toast on failed remove margin', async () => {
+ mockUpdateMargin.mockResolvedValue({
+ success: false,
+ error: 'Cannot reduce below minimum',
+ });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('ETH', 500);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Cannot reduce below minimum');
+ });
+ });
+
+ describe('error handling', () => {
+ it('shows default error message when no error provided', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: false });
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+
+ it('handles exceptions and captures to Sentry', async () => {
+ const testError = new Error('Network error');
+ mockUpdateMargin.mockRejectedValue(testError);
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ testError,
+ expect.objectContaining({
+ tags: expect.objectContaining({
+ component: 'usePerpsMarginAdjustment',
+ action: 'margin_add',
+ }),
+ extra: expect.objectContaining({
+ marginContext: expect.objectContaining({
+ coin: 'ETH',
+ amount: 100,
+ action: 'add',
+ }),
+ }),
+ }),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('Network error');
+ });
+
+ it('captures remove action in Sentry context', async () => {
+ const testError = new Error('API error');
+ mockUpdateMargin.mockRejectedValue(testError);
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleRemoveMargin('BTC', 50);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ testError,
+ expect.objectContaining({
+ tags: expect.objectContaining({
+ action: 'margin_remove',
+ }),
+ extra: expect.objectContaining({
+ marginContext: expect.objectContaining({
+ action: 'remove',
+ adjustmentAmount: -50,
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('handles non-Error exceptions', async () => {
+ mockUpdateMargin.mockRejectedValue('String error');
+ const mockOnError = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePerpsMarginAdjustment({ onError: mockOnError }),
+ );
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockCaptureException).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.anything(),
+ );
+ expect(mockOnError).toHaveBeenCalledWith('perps.errors.unknown');
+ });
+ });
+
+ describe('state management', () => {
+ it('resets isAdjusting to false after successful operation', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('resets isAdjusting to false after failed operation', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: false, error: 'Failed' });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('resets isAdjusting to false after exception', async () => {
+ mockUpdateMargin.mockRejectedValue(new Error('Test error'));
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(result.current.isAdjusting).toBe(false);
+ });
+
+ it('works without options provided', async () => {
+ mockUpdateMargin.mockResolvedValue({ success: true });
+
+ const { result } = renderHook(() => usePerpsMarginAdjustment());
+
+ await act(async () => {
+ await result.current.handleAddMargin('ETH', 100);
+ });
+
+ expect(mockUpdateMargin).toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts
new file mode 100644
index 00000000000..5a6902d45b3
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsMarginAdjustment.ts
@@ -0,0 +1,139 @@
+import { useCallback, useState } from 'react';
+import { strings } from '../../../../../locales/i18n';
+import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
+import { usePerpsTrading } from './usePerpsTrading';
+import { captureException } from '@sentry/react-native';
+import usePerpsToasts from './usePerpsToasts';
+import { getPerpsDisplaySymbol } from '../utils/marketUtils';
+
+export interface UsePerpsMarginAdjustmentOptions {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+}
+
+/**
+ * Hook for handling margin adjustment operations (add/remove margin from positions)
+ * Provides consistent error handling, toast notifications, and Sentry tracking
+ * @param options Optional callbacks for success and error cases
+ * @returns handleAddMargin, handleRemoveMargin functions and loading state
+ */
+export function usePerpsMarginAdjustment(
+ options?: UsePerpsMarginAdjustmentOptions,
+) {
+ const { updateMargin } = usePerpsTrading();
+ const [isAdjusting, setIsAdjusting] = useState(false);
+
+ const { showToast, PerpsToastOptions } = usePerpsToasts();
+
+ const handleMarginUpdate = useCallback(
+ async (coin: string, amount: number, action: 'add' | 'remove') => {
+ setIsAdjusting(true);
+ DevLogger.log(
+ `usePerpsMarginAdjustment: Setting isAdjusting to true (action: ${action})`,
+ );
+
+ try {
+ // Convert amount to string with proper sign
+ // Positive for add, negative for remove
+ const adjustmentAmount = action === 'remove' ? -amount : amount;
+
+ const result = await updateMargin({
+ coin,
+ amount: adjustmentAmount.toString(),
+ });
+
+ if (result.success) {
+ DevLogger.log('Margin adjusted successfully:', result);
+
+ // Show success toast
+ const displaySymbol = getPerpsDisplaySymbol(coin);
+ showToast(
+ action === 'add'
+ ? PerpsToastOptions.positionManagement.margin.addSuccess(
+ displaySymbol,
+ amount.toString(),
+ )
+ : PerpsToastOptions.positionManagement.margin.removeSuccess(
+ displaySymbol,
+ amount.toString(),
+ ),
+ );
+
+ // Call success callback if provided
+ options?.onSuccess?.();
+ } else {
+ DevLogger.log('Failed to adjust margin:', result.error);
+
+ const errorMessage = result.error || strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.positionManagement.margin.adjustmentFailed(
+ errorMessage,
+ ),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ }
+ } catch (error) {
+ DevLogger.log('Error adjusting margin:', error);
+
+ // Capture exception with margin context
+ captureException(
+ error instanceof Error ? error : new Error(String(error)),
+ {
+ tags: {
+ component: 'usePerpsMarginAdjustment',
+ action: `margin_${action}`,
+ operation: 'position_management',
+ },
+ extra: {
+ marginContext: {
+ coin,
+ amount,
+ action,
+ adjustmentAmount: action === 'remove' ? -amount : amount,
+ },
+ },
+ },
+ );
+
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : strings('perps.errors.unknown');
+
+ showToast(
+ PerpsToastOptions.positionManagement.margin.adjustmentFailed(
+ errorMessage,
+ ),
+ );
+
+ // Call error callback if provided
+ options?.onError?.(errorMessage);
+ } finally {
+ DevLogger.log('usePerpsMarginAdjustment: Setting isAdjusting to false');
+ setIsAdjusting(false);
+ }
+ },
+ [
+ updateMargin,
+ showToast,
+ PerpsToastOptions.positionManagement.margin,
+ options,
+ ],
+ );
+
+ const handleAddMargin = useCallback(
+ (coin: string, amount: number) => handleMarginUpdate(coin, amount, 'add'),
+ [handleMarginUpdate],
+ );
+
+ const handleRemoveMargin = useCallback(
+ (coin: string, amount: number) =>
+ handleMarginUpdate(coin, amount, 'remove'),
+ [handleMarginUpdate],
+ );
+
+ return { handleAddMargin, handleRemoveMargin, isAdjusting };
+}
diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts
index 8c5d9c9252e..d9f3bf48113 100644
--- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts
+++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import Routes from '../../../../constants/navigation/Routes';
import type { PerpsNavigationParamList } from '../types/navigation';
-import type { PerpsMarketData } from '../controllers/types';
+import type { PerpsMarketData, Position, Order } from '../controllers/types';
/**
* Navigation handler result interface
@@ -25,6 +25,9 @@ export interface PerpsNavigationHandlers {
navigateToTutorial: (
params?: PerpsNavigationParamList['PerpsTutorial'],
) => void;
+ navigateToAdjustMargin: (position: Position, mode: 'add' | 'remove') => void;
+ navigateToClosePosition: (position: Position) => void;
+ navigateToOrderDetails: (order: Order) => void;
// Utility navigation
navigateBack: () => void;
@@ -135,6 +138,27 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => {
[navigation],
);
+ const navigateToAdjustMargin = useCallback(
+ (position: Position, mode: 'add' | 'remove') => {
+ navigation.navigate(Routes.PERPS.ADJUST_MARGIN, { position, mode });
+ },
+ [navigation],
+ );
+
+ const navigateToClosePosition = useCallback(
+ (position: Position) => {
+ navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position });
+ },
+ [navigation],
+ );
+
+ const navigateToOrderDetails = useCallback(
+ (order: Order) => {
+ navigation.navigate(Routes.PERPS.ORDER_DETAILS, { order });
+ },
+ [navigation],
+ );
+
// Utility navigation handlers
const navigateBack = useCallback(() => {
if (navigation.canGoBack()) {
@@ -158,6 +182,9 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => {
navigateToMarketList,
navigateToOrder,
navigateToTutorial,
+ navigateToAdjustMargin,
+ navigateToClosePosition,
+ navigateToOrderDetails,
// Utility navigation
navigateBack,
diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
index 4f2f9c891ee..9819d3d7316 100644
--- a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
@@ -149,6 +149,8 @@ describe('usePerpsOrderFees', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
@@ -781,6 +783,8 @@ describe('usePerpsOrderFees - Maker/Taker Determination', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
@@ -1488,6 +1492,8 @@ describe('usePerpsOrderFees - Enhanced Error Handling', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
diff --git a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts
index 71a7866bb93..21409ce7bb8 100644
--- a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts
@@ -100,6 +100,8 @@ describe('usePerpsPositions', () => {
calculateMaintenanceMargin: jest.fn(),
getMaxLeverage: jest.fn(),
updatePositionTPSL: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
calculateFees: jest.fn(),
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.test.ts b/app/components/UI/Perps/hooks/usePerpsToasts.test.ts
index 62ae416f947..77985dab3fa 100644
--- a/app/components/UI/Perps/hooks/usePerpsToasts.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsToasts.test.ts
@@ -814,6 +814,93 @@ describe('usePerpsToasts', () => {
});
});
+ describe('positionManagement.margin', () => {
+ it('returns add margin success configuration', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.addSuccess(
+ 'ETH',
+ '100',
+ );
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ hapticsType: NotificationFeedbackType.Success,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(1);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ });
+
+ it('returns remove margin success configuration', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.removeSuccess(
+ 'BTC',
+ '50',
+ );
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ hapticsType: NotificationFeedbackType.Success,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(1);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ });
+
+ it('returns adjustment failed configuration with custom error', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const customError = 'Insufficient funds';
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.adjustmentFailed(
+ customError,
+ );
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Warning,
+ hapticsType: NotificationFeedbackType.Error,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(3);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ expect(config.labelOptions?.[2]).toMatchObject({
+ label: customError,
+ isBold: false,
+ });
+ });
+
+ it('returns adjustment failed configuration with default error', () => {
+ const { result } = renderHook(() => usePerpsToasts());
+ const config =
+ result.current.PerpsToastOptions.positionManagement.margin.adjustmentFailed();
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Warning,
+ hapticsType: NotificationFeedbackType.Error,
+ hasNoTimeout: false,
+ });
+ expect(config.labelOptions).toHaveLength(3);
+ expect(config.labelOptions?.[0]).toMatchObject({
+ isBold: true,
+ });
+ // Default error uses perps.errors.unknown key
+ expect(config.labelOptions?.[2]).toMatchObject({
+ isBold: false,
+ });
+ });
+ });
+
describe('positionManagement.tpsl', () => {
it('returns update TPSL success configuration', () => {
const { result } = renderHook(() => usePerpsToasts());
diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.tsx b/app/components/UI/Perps/hooks/usePerpsToasts.tsx
index 52bd79cb8f0..9968b392256 100644
--- a/app/components/UI/Perps/hooks/usePerpsToasts.tsx
+++ b/app/components/UI/Perps/hooks/usePerpsToasts.tsx
@@ -149,6 +149,11 @@ export interface PerpsToastOptionsConfig {
updateTPSLSuccess: PerpsToastOptions;
updateTPSLError: (error?: string) => PerpsToastOptions;
};
+ margin: {
+ addSuccess: (assetSymbol: string, amount: string) => PerpsToastOptions;
+ removeSuccess: (assetSymbol: string, amount: string) => PerpsToastOptions;
+ adjustmentFailed: (error?: string) => PerpsToastOptions;
+ };
};
formValidation: {
orderForm: {
@@ -818,6 +823,37 @@ const usePerpsToasts = (): {
};
},
},
+ margin: {
+ addSuccess: (assetSymbol: string, amount: string) => ({
+ ...perpsBaseToastOptions.success,
+ labelOptions: getPerpsToastLabels(
+ strings('perps.position.margin.add_success', {
+ amount,
+ asset: assetSymbol,
+ }),
+ ),
+ }),
+ removeSuccess: (assetSymbol: string, amount: string) => ({
+ ...perpsBaseToastOptions.success,
+ labelOptions: getPerpsToastLabels(
+ strings('perps.position.margin.remove_success', {
+ amount,
+ asset: assetSymbol,
+ }),
+ ),
+ }),
+ adjustmentFailed: (error?: string) => {
+ const errorMessage = error || strings('perps.errors.unknown');
+
+ return {
+ ...perpsBaseToastOptions.error,
+ labelOptions: getPerpsToastLabels(
+ strings('perps.position.margin.adjustment_failed'),
+ errorMessage,
+ ),
+ };
+ },
+ },
},
formValidation: {
orderForm: {
diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts
index 40c721f48b2..175e01baa39 100644
--- a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts
@@ -44,6 +44,8 @@ jest.mock('../../../../core/Engine', () => ({
validateOrder: jest.fn(),
validateClosePosition: jest.fn(),
validateWithdrawal: jest.fn(),
+ updateMargin: jest.fn(),
+ flipPosition: jest.fn(),
},
},
}));
@@ -767,6 +769,121 @@ describe('usePerpsTrading', () => {
});
});
+ describe('updateMargin', () => {
+ it('should call PerpsController.updateMargin with correct parameters', async () => {
+ const mockResult = { success: true };
+ (
+ Engine.context.PerpsController.updateMargin as jest.Mock
+ ).mockResolvedValue(mockResult);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ const response = await result.current.updateMargin(updateMarginParams);
+
+ expect(Engine.context.PerpsController.updateMargin).toHaveBeenCalledWith(
+ updateMarginParams,
+ );
+ expect(response).toEqual(mockResult);
+ });
+
+ it('should handle updateMargin errors', async () => {
+ const mockError = new Error('Insufficient balance');
+ (
+ Engine.context.PerpsController.updateMargin as jest.Mock
+ ).mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const updateMarginParams = {
+ coin: 'BTC',
+ amount: '100',
+ };
+
+ await expect(
+ result.current.updateMargin(updateMarginParams),
+ ).rejects.toThrow('Insufficient balance');
+ });
+ });
+
+ describe('flipPosition', () => {
+ it('should call PerpsController.flipPosition with correct parameters', async () => {
+ const mockResult: OrderResult = {
+ success: true,
+ orderId: 'flip-123',
+ filledSize: '1.0',
+ averagePrice: '50000',
+ };
+ (
+ Engine.context.PerpsController.flipPosition as jest.Mock
+ ).mockResolvedValue(mockResult);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const flipParams = {
+ coin: 'BTC',
+ position: {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ },
+ };
+
+ const response = await result.current.flipPosition(flipParams);
+
+ expect(Engine.context.PerpsController.flipPosition).toHaveBeenCalledWith(
+ flipParams,
+ );
+ expect(response).toEqual(mockResult);
+ });
+
+ it('should handle flipPosition errors', async () => {
+ const mockError = new Error('Insufficient balance for flip fees');
+ (
+ Engine.context.PerpsController.flipPosition as jest.Mock
+ ).mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => usePerpsTrading());
+
+ const flipParams = {
+ coin: 'BTC',
+ position: {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '50000',
+ positionValue: '25000',
+ unrealizedPnl: '1000',
+ returnOnEquity: '0.04',
+ leverage: { type: 'cross' as const, value: 10 },
+ liquidationPrice: '45000',
+ marginUsed: '2500',
+ maxLeverage: 100,
+ cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ },
+ };
+
+ await expect(result.current.flipPosition(flipParams)).rejects.toThrow(
+ 'Insufficient balance for flip fees',
+ );
+ });
+ });
+
describe('hook stability', () => {
it('should return stable function references', () => {
const { result, rerender } = renderHook(() => usePerpsTrading());
@@ -828,6 +945,8 @@ describe('usePerpsTrading', () => {
expect(initialFunctions.validateWithdrawal).toBe(
updatedFunctions.validateWithdrawal,
);
+ expect(initialFunctions.updateMargin).toBe(updatedFunctions.updateMargin);
+ expect(initialFunctions.flipPosition).toBe(updatedFunctions.flipPosition);
});
});
});
diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.ts b/app/components/UI/Perps/hooks/usePerpsTrading.ts
index 198703072a8..2ca537d704b 100644
--- a/app/components/UI/Perps/hooks/usePerpsTrading.ts
+++ b/app/components/UI/Perps/hooks/usePerpsTrading.ts
@@ -7,6 +7,7 @@ import type {
ClosePositionParams,
FeeCalculationParams,
FeeCalculationResult,
+ FlipPositionParams,
GetAccountStateParams,
GetOrderFillsParams,
GetOrdersParams,
@@ -16,6 +17,7 @@ import type {
Funding,
LiquidationPriceParams,
MaintenanceMarginParams,
+ MarginResult,
MarketInfo,
OrderParams,
OrderResult,
@@ -23,6 +25,7 @@ import type {
SubscribeOrderFillsParams,
SubscribePricesParams,
SubscribePositionsParams,
+ UpdateMarginParams,
UpdatePositionTPSLParams,
WithdrawParams,
WithdrawResult,
@@ -151,6 +154,22 @@ export function usePerpsTrading() {
[],
);
+ const updateMargin = useCallback(
+ async (params: UpdateMarginParams): Promise => {
+ const controller = Engine.context.PerpsController;
+ return controller.updateMargin(params);
+ },
+ [],
+ );
+
+ const flipPosition = useCallback(
+ async (params: FlipPositionParams): Promise => {
+ const controller = Engine.context.PerpsController;
+ return controller.flipPosition(params);
+ },
+ [],
+ );
+
const calculateFees = useCallback(
async (params: FeeCalculationParams): Promise => {
const controller = Engine.context.PerpsController;
@@ -230,6 +249,8 @@ export function usePerpsTrading() {
calculateMaintenanceMargin,
getMaxLeverage,
updatePositionTPSL,
+ updateMargin,
+ flipPosition,
calculateFees,
validateOrder,
validateClosePosition,
diff --git a/app/components/UI/Perps/hooks/usePositionManagement.test.ts b/app/components/UI/Perps/hooks/usePositionManagement.test.ts
new file mode 100644
index 00000000000..041b5068e42
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePositionManagement.test.ts
@@ -0,0 +1,181 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { usePositionManagement } from './usePositionManagement';
+import type { Position } from '../controllers/types';
+
+describe('usePositionManagement', () => {
+ 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: { sinceOpen: '5', allTime: '10', sinceChange: '2' },
+ positionValue: '5000',
+ maxLeverage: 50,
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ it('returns initial state with all sheets hidden', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ expect(result.current.showAdjustMarginActionSheet).toBe(false);
+ expect(result.current.showReversePositionSheet).toBe(false);
+ });
+
+ it('returns refs for all bottom sheets', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ expect(result.current.modifyActionSheetRef).toBeDefined();
+ expect(result.current.modifyActionSheetRef.current).toBeNull();
+ expect(result.current.adjustMarginActionSheetRef).toBeDefined();
+ expect(result.current.adjustMarginActionSheetRef.current).toBeNull();
+ expect(result.current.reversePositionSheetRef).toBeDefined();
+ expect(result.current.reversePositionSheetRef.current).toBeNull();
+ });
+
+ describe('modify action sheet', () => {
+ it('opens modify sheet when openModifySheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openModifySheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(true);
+ });
+
+ it('closes modify sheet when closeModifySheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openModifySheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(true);
+
+ act(() => {
+ result.current.closeModifySheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ });
+ });
+
+ describe('adjust margin action sheet', () => {
+ it('opens adjust margin sheet when openAdjustMarginSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openAdjustMarginSheet();
+ });
+
+ expect(result.current.showAdjustMarginActionSheet).toBe(true);
+ });
+
+ it('closes adjust margin sheet when closeAdjustMarginSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openAdjustMarginSheet();
+ });
+
+ expect(result.current.showAdjustMarginActionSheet).toBe(true);
+
+ act(() => {
+ result.current.closeAdjustMarginSheet();
+ });
+
+ expect(result.current.showAdjustMarginActionSheet).toBe(false);
+ });
+ });
+
+ describe('reverse position sheet', () => {
+ it('opens reverse position sheet when openReversePositionSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openReversePositionSheet();
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(true);
+ });
+
+ it('closes reverse position sheet when closeReversePositionSheet is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.openReversePositionSheet();
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(true);
+
+ act(() => {
+ result.current.closeReversePositionSheet();
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(false);
+ });
+
+ it('opens reverse position sheet when handleReversePosition is called', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ act(() => {
+ result.current.handleReversePosition(mockPosition);
+ });
+
+ expect(result.current.showReversePositionSheet).toBe(true);
+ });
+ });
+
+ describe('multiple sheets interaction', () => {
+ it('can have only one sheet open at a time (conceptual - sheets are independent)', () => {
+ const { result } = renderHook(() => usePositionManagement());
+
+ // Open modify sheet
+ act(() => {
+ result.current.openModifySheet();
+ });
+ expect(result.current.showModifyActionSheet).toBe(true);
+
+ // Open adjust margin sheet (in real usage, you'd close modify first)
+ act(() => {
+ result.current.closeModifySheet();
+ result.current.openAdjustMarginSheet();
+ });
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ expect(result.current.showAdjustMarginActionSheet).toBe(true);
+ });
+ });
+
+ describe('with options', () => {
+ it('accepts options without errors', () => {
+ const mockOnNavigateToTPSL = jest.fn();
+ const mockOnNavigateToAdjustMargin = jest.fn();
+ const mockOnNavigateToClosePosition = jest.fn();
+
+ const { result } = renderHook(() =>
+ usePositionManagement({
+ position: mockPosition,
+ onNavigateToTPSL: mockOnNavigateToTPSL,
+ onNavigateToAdjustMargin: mockOnNavigateToAdjustMargin,
+ onNavigateToClosePosition: mockOnNavigateToClosePosition,
+ }),
+ );
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ });
+
+ it('works with empty options', () => {
+ const { result } = renderHook(() => usePositionManagement({}));
+
+ expect(result.current.showModifyActionSheet).toBe(false);
+ expect(result.current.openModifySheet).toBeDefined();
+ });
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePositionManagement.ts b/app/components/UI/Perps/hooks/usePositionManagement.ts
new file mode 100644
index 00000000000..2cb497c3387
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePositionManagement.ts
@@ -0,0 +1,141 @@
+import { useCallback, useRef, useState } from 'react';
+import type { BottomSheetRef } from '../../../../component-library/components/BottomSheets/BottomSheet';
+import type { Position } from '../controllers/types';
+
+/**
+ * Options for position management hook
+ */
+export interface UsePositionManagementOptions {
+ position?: Position;
+ onNavigateToTPSL?: (position: Position) => void;
+ onNavigateToAdjustMargin?: (
+ position: Position,
+ mode: 'add' | 'remove',
+ ) => void;
+ onNavigateToClosePosition?: (position: Position) => void;
+}
+
+/**
+ * Return type for position management hook
+ */
+export interface UsePositionManagementReturn {
+ // Bottom sheet state
+ showModifyActionSheet: boolean;
+ showAdjustMarginActionSheet: boolean;
+ showReversePositionSheet: boolean;
+
+ // Bottom sheet refs
+ modifyActionSheetRef: React.RefObject;
+ adjustMarginActionSheetRef: React.RefObject;
+ reversePositionSheetRef: React.RefObject;
+
+ // Action handlers
+ openModifySheet: () => void;
+ closeModifySheet: () => void;
+ openAdjustMarginSheet: () => void;
+ closeAdjustMarginSheet: () => void;
+ openReversePositionSheet: () => void;
+ closeReversePositionSheet: () => void;
+ handleReversePosition: (position: Position) => void;
+}
+
+/**
+ * usePositionManagement Hook
+ *
+ * Centralizes position management UI state and handlers for bottom sheets.
+ * Extracted from PerpsMarketDetailsView to reduce component complexity.
+ *
+ * This hook manages the state and refs for three bottom sheets:
+ * 1. Modify Action Sheet - Shows position modification options (increase, reduce, flip, TP/SL, margin)
+ * 2. Adjust Margin Action Sheet - Specific sheet for margin adjustment mode selection
+ * 3. Reverse Position Sheet - Confirmation sheet for flipping position direction
+ *
+ * @param options - Configuration for callbacks (currently unused but available for future extension)
+ * @returns State, refs, and handlers for position management bottom sheets
+ *
+ * @example
+ * ```tsx
+ * const {
+ * showModifyActionSheet,
+ * modifyActionSheetRef,
+ * openModifySheet,
+ * closeModifySheet,
+ * // ... other returns
+ * } = usePositionManagement();
+ *
+ * // Use in JSX
+ *
+ *
+ * ```
+ */
+export const usePositionManagement = (
+ _options: UsePositionManagementOptions = {},
+): UsePositionManagementReturn => {
+ // Bottom sheet state
+ const [showModifyActionSheet, setShowModifyActionSheet] = useState(false);
+ const [showAdjustMarginActionSheet, setShowAdjustMarginActionSheet] =
+ useState(false);
+ const [showReversePositionSheet, setShowReversePositionSheet] =
+ useState(false);
+
+ // Bottom sheet refs
+ const modifyActionSheetRef = useRef(null);
+ const adjustMarginActionSheetRef = useRef(null);
+ const reversePositionSheetRef = useRef(null);
+
+ // Modify action sheet handlers
+ const openModifySheet = useCallback(() => {
+ setShowModifyActionSheet(true);
+ }, []);
+
+ const closeModifySheet = useCallback(() => {
+ setShowModifyActionSheet(false);
+ }, []);
+
+ // Adjust margin action sheet handlers
+ const openAdjustMarginSheet = useCallback(() => {
+ setShowAdjustMarginActionSheet(true);
+ }, []);
+
+ const closeAdjustMarginSheet = useCallback(() => {
+ setShowAdjustMarginActionSheet(false);
+ }, []);
+
+ // Reverse position sheet handlers
+ const openReversePositionSheet = useCallback(() => {
+ setShowReversePositionSheet(true);
+ }, []);
+
+ const closeReversePositionSheet = useCallback(() => {
+ setShowReversePositionSheet(false);
+ }, []);
+
+ const handleReversePosition = useCallback((_position: Position) => {
+ setShowReversePositionSheet(true);
+ }, []);
+
+ return {
+ // State
+ showModifyActionSheet,
+ showAdjustMarginActionSheet,
+ showReversePositionSheet,
+
+ // Refs
+ modifyActionSheetRef,
+ adjustMarginActionSheetRef,
+ reversePositionSheetRef,
+
+ // Handlers
+ openModifySheet,
+ closeModifySheet,
+ openAdjustMarginSheet,
+ closeAdjustMarginSheet,
+ openReversePositionSheet,
+ closeReversePositionSheet,
+ handleReversePosition,
+ };
+};
diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx
index fdd3ca97734..31293bffd1b 100644
--- a/app/components/UI/Perps/routes/index.tsx
+++ b/app/components/UI/Perps/routes/index.tsx
@@ -19,6 +19,11 @@ import { Confirm } from '../../../Views/confirmations/components/confirm';
import PerpsGTMModal from '../components/PerpsGTMModal';
import PerpsTooltipView from '../Views/PerpsTooltipView/PerpsTooltipView';
import PerpsTPSLView from '../Views/PerpsTPSLView/PerpsTPSLView';
+import PerpsAdjustMarginView from '../Views/PerpsAdjustMarginView/PerpsAdjustMarginView';
+import PerpsSelectModifyActionView from '../Views/PerpsSelectModifyActionView';
+import PerpsSelectAdjustMarginActionView from '../Views/PerpsSelectAdjustMarginActionView';
+import PerpsSelectOrderTypeView from '../Views/PerpsSelectOrderTypeView';
+import PerpsOrderDetailsView from '../Views/PerpsOrderDetailsView';
import PerpsHeroCardView from '../Views/PerpsHeroCardView';
import ActivityView from '../../../Views/ActivityView';
import PerpsStreamBridge from '../components/PerpsStreamBridge';
@@ -74,6 +79,28 @@ const PerpsModalStack = () => (
title: strings('perps.crossMargin.title'),
}}
/>
+ {/* Action Selection Modals */}
+
+
+
@@ -212,6 +239,26 @@ const PerpsScreenStack = () => (
}}
/>
+ {/* Adjust Margin View */}
+
+
+ {/* Order Details View */}
+
+
{
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('calculateMaxRemovableMargin', () => {
+ it('uses 10% minimum when it exceeds leverage-based minimum (high leverage)', () => {
+ // For 50x leverage: initial margin = 2%, but 10% is higher
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 1000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ // notionalValue = 10 * 2000 = 20000
+ // initialMarginRequired = 20000 / 50 = 400 (2%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // baseMinimumRequired = max(400, 2000) = 2000
+ // minimumMarginRequired = 2000 * 3 = 6000 (with 3x safety buffer)
+ // maxRemovable = 1000 - 6000 = -5000 -> 0 (capped)
+ expect(result).toBe(0);
+ });
+
+ it('uses leverage-based minimum when it exceeds 10% (low leverage)', () => {
+ // For 5x leverage: initial margin = 20%, which is > 10%
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 15000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 5,
+ });
+
+ // notionalValue = 10 * 2000 = 20000
+ // initialMarginRequired = 20000 / 5 = 4000 (20%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // baseMinimumRequired = max(4000, 2000) = 4000
+ // minimumMarginRequired = 4000 * 3 = 12000 (with 3x safety buffer)
+ // maxRemovable = 15000 - 12000 = 3000
+ expect(result).toBe(3000);
+ });
+
+ it('uses higher of entry and current price for conservative calculation', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 20000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2500, // Higher than entry
+ maxLeverage: 5,
+ });
+
+ // Uses currentPrice (2500) since it's higher
+ // notionalValue = 10 * 2500 = 25000
+ // initialMarginRequired = 25000 / 5 = 5000 (20%)
+ // tenPercentMargin = 25000 * 0.1 = 2500 (10%)
+ // baseMinimumRequired = max(5000, 2500) = 5000
+ // minimumMarginRequired = 5000 * 3 = 15000 (with 3x safety buffer)
+ // maxRemovable = 20000 - 15000 = 5000
+ expect(result).toBe(5000);
+ });
+
+ it('allows margin removal when current margin exceeds minimum with safety buffer', () => {
+ // User has 8000 margin for a position requiring 6000 minimum (with buffer)
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 8000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ // notionalValue = 10 * 2000 = 20000
+ // initialMarginRequired = 20000 / 50 = 400 (2%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // baseMinimumRequired = max(400, 2000) = 2000
+ // minimumMarginRequired = 2000 * 3 = 6000 (with 3x safety buffer)
+ // maxRemovable = 8000 - 6000 = 2000
+ expect(result).toBe(2000);
+ });
+
+ it('correctly limits small positions (real-world scenario)', () => {
+ // Real scenario: ~$10.39 notional, $3.50 margin, 3x leverage
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 3.5,
+ positionSize: 0.1,
+ entryPrice: 103.9,
+ currentPrice: 103.9,
+ maxLeverage: 3,
+ });
+
+ // notionalValue = 0.1 * 103.9 = 10.39
+ // initialMarginRequired = 10.39 / 3 = 3.46 (33%)
+ // tenPercentMargin = 10.39 * 0.1 = 1.04 (10%)
+ // baseMinimumRequired = max(3.46, 1.04) = 3.46
+ // minimumMarginRequired = 3.46 * 3 = 10.38 (with 3x safety buffer)
+ // maxRemovable = 3.5 - 10.38 = -6.88 -> 0 (capped)
+ // With only 3.5 margin at 3x leverage, no margin can be removed!
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 for negative margin values', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: -100,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when position size is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 0,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when max leverage is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ maxLeverage: 0,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when entry price is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 10,
+ entryPrice: 0,
+ currentPrice: 2000,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+
+ it('returns 0 when current price is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 500,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 0,
+ maxLeverage: 50,
+ });
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('calculateNewLiquidationPrice', () => {
+ it('calculates liquidation price below entry for long position', () => {
+ const newMargin = 200;
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+ const expectedMarginPerUnit = newMargin / positionSize; // 20
+ const expectedLiquidation = entryPrice - expectedMarginPerUnit; // 1980
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(expectedLiquidation);
+ });
+
+ it('calculates liquidation price above entry for short position', () => {
+ const newMargin = 200;
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = false;
+ const currentLiquidationPrice = 2100;
+ const expectedMarginPerUnit = newMargin / positionSize; // 20
+ const expectedLiquidation = entryPrice + expectedMarginPerUnit; // 2020
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(expectedLiquidation);
+ });
+
+ it('returns current liquidation price when new margin is 0', () => {
+ const newMargin = 0;
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(currentLiquidationPrice);
+ });
+
+ it('returns current liquidation price when position size is 0', () => {
+ const newMargin = 200;
+ const positionSize = 0;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(currentLiquidationPrice);
+ });
+
+ it('returns 0 for long position when liquidation would be negative', () => {
+ const newMargin = 25000; // Very high margin
+ const positionSize = 10;
+ const entryPrice = 2000;
+ const isLong = true;
+ const currentLiquidationPrice = 1900;
+
+ const result = calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('assessMarginRemovalRisk', () => {
+ it('returns danger risk level when buffer is below 20%', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 1900;
+ const isLong = true;
+ const priceDiff = currentPrice - newLiquidationPrice; // 100
+ const riskRatio = priceDiff / newLiquidationPrice; // 0.0526 (5.26%)
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('danger');
+ expect(result.priceDiff).toBe(priceDiff);
+ expect(result.riskRatio).toBeCloseTo(riskRatio, 4);
+ });
+
+ it('returns warning risk level when buffer is between 20% and 50%', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 1600;
+ const isLong = true;
+ const priceDiff = currentPrice - newLiquidationPrice; // 400
+ const riskRatio = priceDiff / newLiquidationPrice; // 0.25 (25%)
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('warning');
+ expect(result.priceDiff).toBe(priceDiff);
+ expect(result.riskRatio).toBe(riskRatio);
+ });
+
+ it('returns safe risk level when buffer is above 50%', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 1200;
+ const isLong = true;
+ const priceDiff = currentPrice - newLiquidationPrice; // 800
+ const riskRatio = priceDiff / newLiquidationPrice; // 0.6667 (66.67%)
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('safe');
+ expect(result.priceDiff).toBe(priceDiff);
+ expect(result.riskRatio).toBeCloseTo(riskRatio, 4);
+ });
+
+ it('returns danger when liquidation price equals current price', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 2000;
+ const isLong = true;
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('danger');
+ expect(result.priceDiff).toBe(0);
+ expect(result.riskRatio).toBe(0);
+ });
+
+ it('calculates correct price difference for short position', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = 2600;
+ const isLong = false;
+ const expectedPriceDiff = newLiquidationPrice - currentPrice; // 600
+ const expectedRiskRatio = expectedPriceDiff / newLiquidationPrice; // 0.2308
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.priceDiff).toBe(expectedPriceDiff);
+ expect(result.riskRatio).toBeCloseTo(expectedRiskRatio, 4);
+ });
+
+ it('returns safe risk assessment when liquidation price is NaN', () => {
+ const currentPrice = 2000;
+ const newLiquidationPrice = NaN;
+ const isLong = true;
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('safe');
+ expect(result.priceDiff).toBe(0);
+ expect(result.riskRatio).toBe(0);
+ });
+
+ it('returns safe risk assessment when current price is 0', () => {
+ const currentPrice = 0;
+ const newLiquidationPrice = 1900;
+ const isLong = true;
+
+ const result = assessMarginRemovalRisk({
+ newLiquidationPrice,
+ currentPrice,
+ isLong,
+ });
+
+ expect(result.riskLevel).toBe('safe');
+ expect(result.priceDiff).toBe(0);
+ expect(result.riskRatio).toBe(0);
+ });
+ });
+});
diff --git a/app/components/UI/Perps/utils/marginUtils.ts b/app/components/UI/Perps/utils/marginUtils.ts
new file mode 100644
index 00000000000..d468b1e0a76
--- /dev/null
+++ b/app/components/UI/Perps/utils/marginUtils.ts
@@ -0,0 +1,191 @@
+/**
+ * Margin adjustment calculation utilities
+ * Provides risk assessment and margin calculation functions for position management
+ */
+import { MARGIN_ADJUSTMENT_CONFIG } from '../constants/perpsConfig';
+
+export type RiskLevel = 'safe' | 'warning' | 'danger';
+
+export interface MarginRiskAssessment {
+ riskLevel: RiskLevel;
+ priceDiff: number;
+ riskRatio: number;
+}
+
+export interface AssessMarginRemovalRiskParams {
+ newLiquidationPrice: number;
+ currentPrice: number;
+ isLong: boolean;
+}
+
+export interface CalculateMaxRemovableMarginParams {
+ currentMargin: number;
+ positionSize: number;
+ entryPrice: number;
+ currentPrice: number;
+ maxLeverage: number;
+}
+
+export interface CalculateNewLiquidationPriceParams {
+ newMargin: number;
+ positionSize: number;
+ entryPrice: number;
+ isLong: boolean;
+ currentLiquidationPrice: number;
+}
+
+/**
+ * Assess liquidation risk after margin removal
+ * Compares new liquidation price against current market price to determine risk level
+ * @param params - New liquidation price, current market price, and position direction
+ * @returns Risk assessment with level (safe/warning/danger), price difference, and risk ratio
+ */
+export function assessMarginRemovalRisk(
+ params: AssessMarginRemovalRiskParams,
+): MarginRiskAssessment {
+ const { newLiquidationPrice, currentPrice, isLong } = params;
+
+ if (
+ !newLiquidationPrice ||
+ !currentPrice ||
+ isNaN(newLiquidationPrice) ||
+ isNaN(currentPrice)
+ ) {
+ return { riskLevel: 'safe', priceDiff: 0, riskRatio: 0 };
+ }
+
+ // Calculate price difference based on position direction
+ // For long: current price should be above liquidation price
+ // For short: liquidation price should be above current price
+ const priceDiff = isLong
+ ? currentPrice - newLiquidationPrice
+ : newLiquidationPrice - currentPrice;
+
+ // Risk ratio: how far away is price from liquidation, relative to liquidation price
+ // Higher ratio = safer (price is far from liquidation)
+ // Lower ratio = riskier (price is close to liquidation)
+ const riskRatio = priceDiff / newLiquidationPrice;
+
+ let riskLevel: RiskLevel;
+ if (riskRatio < MARGIN_ADJUSTMENT_CONFIG.LIQUIDATION_RISK_THRESHOLD - 1) {
+ riskLevel = 'danger'; // <20% buffer - critical risk
+ } else if (
+ riskRatio <
+ MARGIN_ADJUSTMENT_CONFIG.LIQUIDATION_WARNING_THRESHOLD - 1
+ ) {
+ riskLevel = 'warning'; // <50% buffer - moderate risk
+ } else {
+ riskLevel = 'safe'; // >=50% buffer - safe
+ }
+
+ return { riskLevel, priceDiff, riskRatio };
+}
+
+/**
+ * Calculate maximum margin that can be safely removed from a position
+ *
+ * HyperLiquid enforces: transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
+ * See: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl
+ *
+ * For high leverage assets (e.g., 50x where initial margin = 2%),
+ * the 10% requirement is the binding constraint.
+ *
+ * IMPORTANT: We apply an additional 50% safety buffer on top of the minimum required
+ * because HyperLiquid's actual margin requirements can vary based on market conditions,
+ * unrealized PnL, and other factors not captured in this simplified calculation.
+ *
+ * @param params - Current margin, position size, entry price, current price, and max leverage limit
+ * @returns Maximum removable margin amount in USD
+ */
+export function calculateMaxRemovableMargin(
+ params: CalculateMaxRemovableMarginParams,
+): number {
+ const { currentMargin, positionSize, entryPrice, currentPrice, maxLeverage } =
+ params;
+
+ // Validate inputs
+ if (
+ isNaN(currentMargin) ||
+ isNaN(positionSize) ||
+ isNaN(entryPrice) ||
+ isNaN(currentPrice) ||
+ isNaN(maxLeverage) ||
+ currentMargin <= 0 ||
+ positionSize <= 0 ||
+ entryPrice <= 0 ||
+ currentPrice <= 0 ||
+ maxLeverage <= 0
+ ) {
+ return 0;
+ }
+
+ // Use the higher price to be conservative (HyperLiquid uses current mark price)
+ const price = Math.max(entryPrice, currentPrice);
+
+ // Calculate notional value
+ const notionalValue = positionSize * price;
+
+ // HyperLiquid's transfer margin requirement:
+ // transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
+ const initialMarginRequired = notionalValue / maxLeverage;
+ const tenPercentMargin =
+ notionalValue * MARGIN_ADJUSTMENT_CONFIG.MARGIN_REMOVAL_SAFETY_BUFFER;
+
+ // Minimum margin is the MAX of these two constraints
+ const baseMinimumRequired = Math.max(initialMarginRequired, tenPercentMargin);
+
+ // Apply 3x safety buffer because HyperLiquid's actual requirements are significantly higher
+ // than the documented formula due to:
+ // - Maintenance margin requirements
+ // - Unrealized PnL impact
+ // - Market volatility adjustments
+ // - Funding rate considerations
+ // Testing showed 1.5x was insufficient - $2 removal rejected when calc showed $2.95 available
+ const minimumMarginRequired = baseMinimumRequired * 3;
+
+ // Maximum removable = current - minimum (must be non-negative)
+ return Math.max(0, currentMargin - minimumMarginRequired);
+}
+
+/**
+ * Calculate new liquidation price after margin adjustment
+ * Estimates where the liquidation price will move based on margin change
+ * Note: This is a simplified calculation; actual liquidation price may vary based on protocol
+ * @param params - New margin amount, position size, entry price, direction, and current liquidation price
+ * @returns Estimated new liquidation price
+ */
+export function calculateNewLiquidationPrice(
+ params: CalculateNewLiquidationPriceParams,
+): number {
+ const {
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ } = params;
+
+ // Validate inputs
+ if (
+ isNaN(newMargin) ||
+ isNaN(positionSize) ||
+ isNaN(entryPrice) ||
+ newMargin <= 0 ||
+ positionSize <= 0 ||
+ entryPrice <= 0
+ ) {
+ return currentLiquidationPrice; // Return current if invalid inputs
+ }
+
+ // Calculate margin per unit of position
+ const marginPerUnit = newMargin / positionSize;
+
+ // For long positions: liquidation price is below entry price
+ // liquidationPrice = entryPrice - marginPerUnit
+ // For short positions: liquidation price is above entry price
+ // liquidationPrice = entryPrice + marginPerUnit
+ if (isLong) {
+ return Math.max(0, entryPrice - marginPerUnit);
+ }
+ return entryPrice + marginPerUnit;
+}
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
index 162b6fa42b4..a0ceee7db5b 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
+++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
@@ -692,5 +692,47 @@ describe('PredictDetailsChart', () => {
expect(timeLabels.length).toBeGreaterThan(0);
});
});
+
+ describe('Axis Label Deduplication', () => {
+ it('removes consecutive duplicate axis labels', () => {
+ const axisData = [
+ { timestamp: 1740000000000, value: 0.2 },
+ { timestamp: 1740003600000, value: 0.3 },
+ { timestamp: 1740007200000, value: 0.4 },
+ { timestamp: 1740010800000, value: 0.5 },
+ ];
+
+ const labelByTimestamp = new Map([
+ [axisData[0].timestamp, 'AXIS_LABEL_ONE'],
+ [axisData[1].timestamp, 'AXIS_LABEL_ONE'],
+ [axisData[2].timestamp, 'AXIS_LABEL_TWO'],
+ [axisData[3].timestamp, 'AXIS_LABEL_TWO'],
+ ]);
+
+ const chartUtils =
+ jest.requireActual('./utils');
+ const formatSpy = jest
+ .spyOn(chartUtils, 'formatPriceHistoryLabel')
+ .mockImplementation(
+ (timestamp: number) =>
+ labelByTimestamp.get(Number(timestamp)) ?? 'AXIS_FALLBACK',
+ );
+
+ const { getAllByText } = setupTest({
+ data: [
+ {
+ label: 'Dedup Series',
+ color: '#123456',
+ data: axisData,
+ },
+ ],
+ });
+
+ expect(getAllByText('AXIS_LABEL_ONE')).toHaveLength(1);
+ expect(getAllByText('AXIS_LABEL_TWO')).toHaveLength(1);
+
+ formatSpy.mockRestore();
+ });
+ });
});
});
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
index 0aad87d7789..994c342406b 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
+++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
@@ -30,6 +30,7 @@ import {
CHART_CONTENT_INSET,
MAX_SERIES,
formatPriceHistoryLabel,
+ getTimestampInMs,
} from './utils';
export interface ChartSeries {
@@ -279,16 +280,30 @@ const PredictDetailsChart: React.FC = ({
const isMultipleSeries = seriesToRender.length > 1;
// Process data with labels
+ const chartTimeRangeMs = React.useMemo(() => {
+ const timestamps = seriesToRender
+ .flatMap((series) => series.data)
+ .map((point) => getTimestampInMs(point.timestamp));
+
+ if (!timestamps.length) {
+ return 0;
+ }
+
+ return Math.max(...timestamps) - Math.min(...timestamps);
+ }, [seriesToRender]);
+
const seriesWithLabels = React.useMemo(
() =>
seriesToRender.map((series) => ({
...series,
data: series.data.map((point) => ({
...point,
- label: formatPriceHistoryLabel(point.timestamp, selectedTimeframe),
+ label: formatPriceHistoryLabel(point.timestamp, selectedTimeframe, {
+ timeRangeMs: chartTimeRangeMs,
+ }),
})),
})),
- [seriesToRender, selectedTimeframe],
+ [seriesToRender, selectedTimeframe, chartTimeRangeMs],
);
// Filter out empty series
@@ -459,10 +474,26 @@ const PredictDetailsChart: React.FC = ({
// Calculate axis labels
const axisLabelStep = Math.max(1, Math.floor(primaryData.length / 4) || 1);
- const axisLabels = primaryData.filter(
- (_, index) =>
- index % axisLabelStep === 0 || index === primaryData.length - 1,
- );
+ const axisLabelEntries = primaryData
+ .map((point, index) => ({
+ point,
+ label: point.label ?? '',
+ key: `${point.timestamp}-${index}`,
+ index,
+ }))
+ .filter(
+ (entry) =>
+ entry.index % axisLabelStep === 0 ||
+ entry.index === primaryData.length - 1,
+ );
+
+ const dedupedAxisLabels = axisLabelEntries.filter((entry, idx, arr) => {
+ if (!entry.label) {
+ return true;
+ }
+ const previous = arr[idx - 1];
+ return !previous || previous.label !== entry.label;
+ });
return (
@@ -540,13 +571,13 @@ const PredictDetailsChart: React.FC = ({
justifyContent={BoxJustifyContent.Between}
twClassName="px-4"
>
- {axisLabels.map((point, index) => (
+ {dedupedAxisLabels.map(({ label, key }) => (
- {point.label}
+ {label}
))}
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts
index ae8fa618349..de7e611feda 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts
+++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts
@@ -7,6 +7,7 @@ import {
MAX_SERIES,
formatPriceHistoryLabel,
formatTickValue,
+ DAY_IN_MS,
} from './utils';
describe('PredictDetailsChart utils', () => {
@@ -143,6 +144,30 @@ describe('PredictDetailsChart utils', () => {
expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/);
});
+ it('formats MAX interval with month and day when time range is under 30 days', () => {
+ const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z');
+
+ const result = formatPriceHistoryLabel(
+ timestamp,
+ PredictPriceHistoryInterval.MAX,
+ { timeRangeMs: 15 * DAY_IN_MS },
+ );
+
+ expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/);
+ });
+
+ it('formats MAX interval with time when time range is under 1 day', () => {
+ const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z');
+
+ const result = formatPriceHistoryLabel(
+ timestamp,
+ PredictPriceHistoryInterval.MAX,
+ { timeRangeMs: 0.5 * DAY_IN_MS },
+ );
+
+ expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/);
+ });
+
it('formats unknown interval as month and 2-digit year', () => {
const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z');
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts
index 37f80c73b03..4a3dd5a75ea 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts
+++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts
@@ -11,13 +11,45 @@ export const CHART_CONTENT_INSET = {
right: 48,
};
export const MAX_SERIES = 3;
+export const MS_IN_SECOND = 1000;
+export const DAY_IN_MS = 24 * 60 * 60 * 1000;
+
+const MAX_INTERVAL_SHORT_RANGE_THRESHOLD_IN_MS = 30 * DAY_IN_MS;
+
+export const getTimestampInMs = (timestamp: number): number =>
+ timestamp > 1_000_000_000_000 ? timestamp : timestamp * MS_IN_SECOND;
+
+export interface FormatPriceHistoryLabelOptions {
+ timeRangeMs?: number;
+}
export const formatPriceHistoryLabel = (
timestamp: number,
interval: PredictPriceHistoryInterval | string,
+ options?: FormatPriceHistoryLabelOptions,
) => {
- const isMilliseconds = timestamp > 1_000_000_000_000;
- const date = new Date(isMilliseconds ? timestamp : timestamp * 1000);
+ const date = new Date(getTimestampInMs(timestamp));
+ const timeRangeMs =
+ typeof options?.timeRangeMs === 'number' ? options.timeRangeMs : null;
+
+ const shouldUseShortMaxFormat =
+ interval === PredictPriceHistoryInterval.MAX &&
+ typeof timeRangeMs === 'number' &&
+ timeRangeMs > 0 &&
+ timeRangeMs < MAX_INTERVAL_SHORT_RANGE_THRESHOLD_IN_MS;
+
+ if (shouldUseShortMaxFormat) {
+ if (timeRangeMs !== null && timeRangeMs < DAY_IN_MS) {
+ return new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(date);
+ }
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+ }
switch (interval) {
case PredictPriceHistoryInterval.ONE_HOUR:
diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
index b552bd41f9a..c05c0db60bb 100644
--- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
@@ -119,27 +119,27 @@ describe('PredictMarketMultiple', () => {
// Press the "Yes" button
fireEvent.press(buttons[0]);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockMarket.outcomes[0],
outcomeToken: mockMarket.outcomes[0].tokens[0],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
// Press the "No" button
fireEvent.press(buttons[1]);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockMarket.outcomes[0],
outcomeToken: mockMarket.outcomes[0].tokens[1],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
});
it('handle missing or invalid market data gracefully', () => {
@@ -296,11 +296,14 @@ describe('PredictMarketMultiple', () => {
);
fireEvent.press(marketTitle);
- expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_DETAILS, {
- marketId: mockMarket.id,
- entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
- title: mockMarket.title,
- image: mockMarket.image,
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: mockMarket.id,
+ entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ title: mockMarket.title,
+ image: mockMarket.image,
+ },
});
});
diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
index 5374c0910ed..68bb4241a3f 100644
--- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
+++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
@@ -125,11 +125,14 @@ const PredictMarketMultiple: React.FC = ({
) => {
executeGuardedAction(
() => {
- navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, {
- market,
- outcome,
- outcomeToken,
- entryPoint,
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
+ market,
+ outcome,
+ outcomeToken,
+ entryPoint,
+ },
});
},
{
@@ -148,11 +151,14 @@ const PredictMarketMultiple: React.FC = ({
{
- navigation.navigate(Routes.PREDICT.MARKET_DETAILS, {
- marketId: market.id,
- entryPoint,
- title: market.title,
- image: market.image,
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: market.id,
+ entryPoint,
+ title: market.title,
+ image: market.image,
+ },
});
}}
>
diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
index 2a657c6fa82..956b7f2bf23 100644
--- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
@@ -140,26 +140,26 @@ describe('PredictMarketSingle', () => {
const noButton = getByText('No');
fireEvent.press(yesButton);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockOutcome,
outcomeToken: mockOutcome.tokens[0],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
fireEvent.press(noButton);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.PREDICT.MODALS.BUY_PREVIEW,
- {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
market: mockMarket,
outcome: mockOutcome,
outcomeToken: mockOutcome.tokens[1],
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
},
- );
+ });
});
it('handle missing or invalid market data gracefully', () => {
@@ -311,11 +311,14 @@ describe('PredictMarketSingle', () => {
);
fireEvent.press(marketTitle);
- expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_DETAILS, {
- marketId: mockMarket.id,
- entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
- title: mockMarket.title,
- image: mockMarket.image,
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: mockMarket.id,
+ entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ title: mockMarket.title,
+ image: mockMarket.image,
+ },
});
});
diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
index 101529f8d63..0ab28167535 100644
--- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
@@ -175,11 +175,14 @@ const PredictMarketSingle: React.FC = ({
const handleBuy = (token: PredictOutcomeToken) => {
executeGuardedAction(
() => {
- navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, {
- market,
- outcome,
- outcomeToken: token,
- entryPoint,
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: {
+ market,
+ outcome,
+ outcomeToken: token,
+ entryPoint,
+ },
});
},
{
@@ -193,11 +196,14 @@ const PredictMarketSingle: React.FC = ({
{
- navigation.navigate(Routes.PREDICT.MARKET_DETAILS, {
- marketId: market.id,
- entryPoint,
- title: market.title,
- image: getImageUrl(),
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: market.id,
+ entryPoint,
+ title: market.title,
+ image: getImageUrl(),
+ },
});
}}
>
diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
index 4fe6b0195ea..1563e512eeb 100644
--- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
+++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
@@ -9,6 +9,8 @@ import {
useRoute,
} from '@react-navigation/native';
import PredictMarketDetails from './PredictMarketDetails';
+import { PredictPriceHistoryInterval } from '../../types';
+import type { UsePredictPriceHistoryOptions } from '../../hooks/usePredictPriceHistory';
import { strings } from '../../../../../../locales/i18n';
import Routes from '../../../../../constants/navigation/Routes';
import { PredictEventValues } from '../../constants/eventNames';
@@ -926,7 +928,7 @@ describe('PredictMarketDetails', () => {
expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen();
});
- it('does not render chart when market has no open outcomes', () => {
+ it('removes chart when closed market lacks open outcomes', () => {
const emptyOutcomesMarket = createMockMarket({
status: 'closed',
outcomes: [
@@ -2097,6 +2099,76 @@ describe('PredictMarketDetails', () => {
expect(screen.getByTestId('predict-details-chart')).toBeOnTheScreen();
});
+ describe('Price history fidelity adjustments', () => {
+ const BASE_TIMESTAMP = 1_700_000_000_000;
+ const SHORT_RANGE_FIDELITY = 240;
+ const MAX_DEFAULT_FIDELITY = 1440;
+
+ const buildPriceHistory = (deltaMs: number) => [
+ [
+ { timestamp: BASE_TIMESTAMP, price: 0.5 },
+ { timestamp: BASE_TIMESTAMP + deltaMs, price: 0.6 },
+ ],
+ [],
+ ];
+
+ it('requests higher fidelity when MAX history span is shorter than a month', async () => {
+ const shortRangeHistory = buildPriceHistory(12 * 60 * 60 * 1000); // 12 hours
+
+ setupPredictMarketDetailsTest(
+ { status: 'closed' },
+ {},
+ { priceHistory: { priceHistories: shortRangeHistory } },
+ );
+
+ const { usePredictPriceHistory } = jest.requireMock(
+ '../../hooks/usePredictPriceHistory',
+ );
+
+ await waitFor(() => {
+ const maxCalls = usePredictPriceHistory.mock.calls.filter(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.interval === PredictPriceHistoryInterval.MAX,
+ );
+ expect(maxCalls.length).toBeGreaterThan(0);
+ expect(
+ maxCalls.some(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.fidelity === SHORT_RANGE_FIDELITY,
+ ),
+ ).toBe(true);
+ });
+ });
+
+ it('keeps default MAX fidelity when history span exceeds the threshold', async () => {
+ const longRangeHistory = buildPriceHistory(45 * 24 * 60 * 60 * 1000); // 45 days
+
+ setupPredictMarketDetailsTest(
+ { status: 'closed' },
+ {},
+ { priceHistory: { priceHistories: longRangeHistory } },
+ );
+
+ const { usePredictPriceHistory } = jest.requireMock(
+ '../../hooks/usePredictPriceHistory',
+ );
+
+ await waitFor(() => {
+ const maxCalls = usePredictPriceHistory.mock.calls.filter(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.interval === PredictPriceHistoryInterval.MAX,
+ );
+ expect(maxCalls.length).toBeGreaterThan(0);
+ expect(
+ maxCalls.every(
+ (call: [UsePredictPriceHistoryOptions]) =>
+ call[0]?.fidelity === MAX_DEFAULT_FIDELITY,
+ ),
+ ).toBe(true);
+ });
+ });
+ });
+
it('handles no balance scenario for Yes button', () => {
const { usePredictBalance } = jest.requireMock(
'../../hooks/usePredictBalance',
diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx
index 80136750fbb..b1f6a10ed46 100644
--- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx
+++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx
@@ -50,6 +50,10 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset';
import PredictDetailsChart, {
ChartSeries,
} from '../../components/PredictDetailsChart/PredictDetailsChart';
+import {
+ DAY_IN_MS,
+ getTimestampInMs,
+} from '../../components/PredictDetailsChart/utils';
import PredictPositionDetail from '../../components/PredictPositionDetail';
import { usePredictMarket } from '../../hooks/usePredictMarket';
import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory';
@@ -91,6 +95,12 @@ const DEFAULT_FIDELITY_BY_INTERVAL: Partial<
[PredictPriceHistoryInterval.MAX]: 1440, // 24-hour resolution for max window
};
+const MAX_INTERVAL_SHORT_RANGE_THRESHOLD_DAYS = 30;
+const MAX_INTERVAL_SHORT_RANGE_MS =
+ MAX_INTERVAL_SHORT_RANGE_THRESHOLD_DAYS * DAY_IN_MS;
+const MAX_INTERVAL_SHORT_RANGE_FIDELITY =
+ DEFAULT_FIDELITY_BY_INTERVAL[PredictPriceHistoryInterval.ONE_WEEK] ?? 240;
+
// Use theme tokens instead of hex values for multi-series charts
interface PredictMarketDetailsProps {}
@@ -105,6 +115,8 @@ const PredictMarketDetails: React.FC = () => {
const tw = useTailwind();
const [selectedTimeframe, setSelectedTimeframe] =
useState(PredictPriceHistoryInterval.ONE_DAY);
+ const [maxIntervalAdaptiveFidelity, setMaxIntervalAdaptiveFidelity] =
+ useState(null);
const [activeTab, setActiveTab] = useState(null);
const [userSelectedTab, setUserSelectedTab] = useState(false);
const insets = useSafeAreaInsets();
@@ -315,7 +327,16 @@ const PredictMarketDetails: React.FC = () => {
[chartOpenOutcomes],
);
- const selectedFidelity = DEFAULT_FIDELITY_BY_INTERVAL[selectedTimeframe];
+ const selectedFidelity = useMemo(() => {
+ if (
+ selectedTimeframe === PredictPriceHistoryInterval.MAX &&
+ maxIntervalAdaptiveFidelity
+ ) {
+ return maxIntervalAdaptiveFidelity;
+ }
+
+ return DEFAULT_FIDELITY_BY_INTERVAL[selectedTimeframe];
+ }, [selectedTimeframe, maxIntervalAdaptiveFidelity]);
const {
priceHistories,
isFetching: isPriceHistoryFetching,
@@ -329,6 +350,50 @@ const PredictMarketDetails: React.FC = () => {
enabled: chartOutcomeTokenIds.length > 0,
});
+ const maxIntervalRangeMs = useMemo(() => {
+ if (selectedTimeframe !== PredictPriceHistoryInterval.MAX) {
+ return null;
+ }
+
+ const timestamps = priceHistories.flatMap((history) =>
+ history.map((point) => getTimestampInMs(point.timestamp)),
+ );
+
+ if (!timestamps.length) {
+ return null;
+ }
+
+ return Math.max(...timestamps) - Math.min(...timestamps);
+ }, [priceHistories, selectedTimeframe]);
+
+ useEffect(() => {
+ if (selectedTimeframe !== PredictPriceHistoryInterval.MAX) {
+ if (maxIntervalAdaptiveFidelity !== null) {
+ setMaxIntervalAdaptiveFidelity(null);
+ }
+ return;
+ }
+
+ if (
+ typeof maxIntervalRangeMs === 'number' &&
+ maxIntervalRangeMs > 0 &&
+ maxIntervalRangeMs < MAX_INTERVAL_SHORT_RANGE_MS
+ ) {
+ if (maxIntervalAdaptiveFidelity !== MAX_INTERVAL_SHORT_RANGE_FIDELITY) {
+ setMaxIntervalAdaptiveFidelity(MAX_INTERVAL_SHORT_RANGE_FIDELITY);
+ }
+ return;
+ }
+
+ if (
+ maxIntervalAdaptiveFidelity !== null &&
+ (maxIntervalRangeMs === null ||
+ maxIntervalRangeMs >= MAX_INTERVAL_SHORT_RANGE_MS)
+ ) {
+ setMaxIntervalAdaptiveFidelity(null);
+ }
+ }, [maxIntervalRangeMs, maxIntervalAdaptiveFidelity, selectedTimeframe]);
+
const chartData: ChartSeries[] = useMemo(() => {
const palette = [
colors.primary.default,
diff --git a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx
index 60739fda480..c02658fcafb 100644
--- a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.test.tsx
@@ -9,6 +9,7 @@ import { createSsnInfoModalNavigationDetails } from '../Modals/SsnInfoModal';
import { BuyQuote } from '@consensys/native-ramps-sdk';
import { endTrace } from '../../../../../../util/trace';
import Logger from '../../../../../../util/Logger';
+import { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import {
MOCK_REGIONS,
MOCK_US_REGION,
@@ -392,14 +393,27 @@ describe('BasicInfo Component', () => {
});
it('displays logout button when error has errorCode 2020', async () => {
- // Mock Transak API error structure: { error: { errorCode: 2020, message: "..." } }
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.',
+ // Mock Transak API error structure: { response: { data: { error: { errorCode: 2020, message: "..." } } } }
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
@@ -435,13 +449,26 @@ describe('BasicInfo Component', () => {
it('displays formatted error message for errorCode 2020', async () => {
// Mock Transak API error structure with email in message
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.',
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with k****@pedalsup.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
@@ -505,13 +532,26 @@ describe('BasicInfo Component', () => {
});
it('calls logoutFromProvider and navigates to EnterEmail on logout click', async () => {
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with test@gmail.com. Login with this email to continue.',
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with test@gmail.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
@@ -553,13 +593,26 @@ describe('BasicInfo Component', () => {
const logoutError = new Error('Logout failed');
mockLogoutFromProvider.mockRejectedValueOnce(logoutError);
- const error2020 = Object.assign(new Error('API Error'), {
- error: {
- errorCode: 2020,
- message:
- 'This phone number is already registered. It has been used by an account created with d***@example.com. Login with this email to continue.',
+ const errorMessage =
+ 'This phone number is already registered. It has been used by an account created with d***@example.com. Login with this email to continue.';
+ const error2020 = new AxiosError(
+ errorMessage,
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ data: {
+ error: {
+ errorCode: 2020,
+ message: errorMessage,
+ },
+ },
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {} as InternalAxiosRequestConfig,
},
- });
+ );
mockPostKycForm.mockRejectedValueOnce(error2020);
render(BasicInfo);
diff --git a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx
index 54363ce480a..531aabb9268 100644
--- a/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx
@@ -49,6 +49,7 @@ import Logger from '../../../../../../util/Logger';
import BannerAlert from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert';
import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types';
import { useRegions } from '../../hooks/useRegions';
+import type { AxiosError } from 'axios';
export interface BasicInfoParams {
quote: BuyQuote;
@@ -230,31 +231,34 @@ const BasicInfo = (): JSX.Element => {
}),
);
} catch (submissionError) {
- // Check for Transak error code 2020 (phone already registered)
- // API returns: { error: { errorCode: 2020, message: "..." } }
- const errorWithCode = submissionError as unknown as {
- error?: { errorCode?: number; message?: string };
- };
- const isPhoneError = errorWithCode?.error?.errorCode === 2020;
+ const axiosError = submissionError as AxiosError;
+ const apiError = (
+ axiosError?.response?.data as {
+ error?: { errorCode?: number; message?: string };
+ }
+ )?.error;
+ const isPhoneError = apiError?.errorCode === 2020;
setIsPhoneRegisteredError(isPhoneError);
- // For error code 2020, extract email from message and format it
- let errorMessage = '';
- if (isPhoneError && errorWithCode?.error?.message) {
- // Extract email from message like "...created with k****@pedalsup.com..."
- const emailMatch = errorWithCode.error.message.match(
- /[\w*]+@[\w*]+(?:\.[\w*]+)*/,
- );
+ const errorMessageText =
+ submissionError instanceof Error && submissionError.message
+ ? submissionError.message
+ : strings('deposit.basic_info.unexpected_error');
+
+ let errorMessage = errorMessageText;
+ if (isPhoneError && errorMessageText) {
+ // Extract email from message for error code 2020 (phone already registered)
+ const emailMatch = errorMessageText.match(/[\w*]+@[\w*]+(?:\.[\w*]+)*/);
const email = emailMatch ? emailMatch[0] : '';
- errorMessage = email
- ? strings('deposit.basic_info.phone_already_registered', { email })
- : errorWithCode.error.message;
- } else {
- errorMessage =
- submissionError instanceof Error && submissionError.message
- ? submissionError.message
- : strings('deposit.basic_info.unexpected_error');
+ if (email) {
+ errorMessage = strings(
+ 'deposit.basic_info.phone_already_registered',
+ {
+ email,
+ },
+ );
+ }
}
setError(errorMessage);
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx
index cdb4b6ab175..3dce7d3a3f5 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx
@@ -1,14 +1,21 @@
import React from 'react';
-import { render } from '@testing-library/react-native';
+import { render, fireEvent } from '@testing-library/react-native';
import { useSelector } from 'react-redux';
import { ActivityEventRow } from './ActivityEventRow';
-import { PointsEventDto } from '../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ CardEventPayload,
+ PerpsEventPayload,
+ PointsEventDto,
+ SeasonActivityTypeDto,
+ SwapEventPayload,
+} from '../../../../../../core/Engine/controllers/rewards-controller/types';
import { formatRewardsDate } from '../../../utils/formatUtils';
import { getEventDetails } from '../../../utils/eventDetailsUtils';
import { IconName } from '@metamask/design-system-react-native';
import TEST_ADDRESS from '../../../../../../constants/address';
import { useActivityDetailsConfirmAction } from '../../../hooks/useActivityDetailsConfirmAction';
import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants';
+import { selectSeasonActivityTypes } from '../../../../../../reducers/rewards/selectors';
// Mock the utility functions
jest.mock('../../../utils/formatUtils', () => ({
@@ -76,6 +83,53 @@ const mockUseActivityDetailsConfirmAction =
useActivityDetailsConfirmAction as jest.MockedFunction<
typeof useActivityDetailsConfirmAction
>;
+jest.mock('../../../../../../util/networks', () => ({
+ getNetworkImageSource: jest.fn(),
+}));
+
+jest.mock('@metamask/utils', () => ({
+ parseCaipAssetType: jest.fn(),
+}));
+
+jest.mock('./EventDetails/ActivityDetailsSheet', () => ({
+ openActivityDetailsSheet: jest.fn(),
+}));
+
+jest.mock('../../../../../../util/Logger', () => ({
+ __esModule: true,
+ default: {
+ error: jest.fn(),
+ },
+}));
+import { getNetworkImageSource } from '../../../../../../util/networks';
+import { parseCaipAssetType } from '@metamask/utils';
+import { openActivityDetailsSheet } from './EventDetails/ActivityDetailsSheet';
+import { ModalAction } from '../../RewardsBottomSheetModal';
+jest.mock(
+ '../../../../../../component-library/components/Badges/Badge',
+ () => ({
+ __esModule: true,
+ default: ({ children }: { children?: React.ReactNode }) => children ?? null,
+ BadgeVariant: { Network: 'Network' },
+ }),
+);
+
+jest.mock(
+ '../../../../../../component-library/components/Badges/BadgeWrapper',
+ () => ({
+ __esModule: true,
+ default: ({ children }: { children?: React.ReactNode }) => children ?? null,
+ BadgePosition: { BottomRight: 'BottomRight' },
+ }),
+);
+
+jest.mock(
+ '../../../../../../component-library/components/Avatars/Avatar',
+ () => ({
+ __esModule: true,
+ AvatarSize: { Sm: 'Sm' },
+ }),
+);
describe('ActivityEventRow', () => {
// Helper to create a valid PointsEventDto for all event types
@@ -253,11 +307,31 @@ describe('ActivityEventRow', () => {
icon: IconName.Star,
};
+ const mockActivityTypes: SeasonActivityTypeDto[] = [
+ {
+ type: 'SWAP',
+ title: 'Swap',
+ description: 'Swap desc',
+ icon: 'SwapVertical',
+ },
+ {
+ type: 'CARD',
+ title: 'Card spend',
+ description: 'Spend',
+ icon: 'Card',
+ },
+ ];
+
beforeEach(() => {
jest.clearAllMocks();
mockGetEventDetails.mockReturnValue(defaultEventDetails);
mockFormatRewardsDate.mockReturnValue('Sep 9, 2025');
- mockUseSelector.mockReturnValue({});
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectSeasonActivityTypes) {
+ return mockActivityTypes;
+ }
+ return {} as unknown;
+ });
mockUseActivityDetailsConfirmAction.mockReturnValue(undefined);
});
@@ -459,7 +533,11 @@ describe('ActivityEventRow', () => {
expect(getByText('Opened position')).toBeOnTheScreen();
expect(getByText('Opened SHORT BIO position')).toBeOnTheScreen();
expect(getByText('+1')).toBeOnTheScreen();
- expect(mockGetEventDetails).toHaveBeenCalledWith(event, TEST_ADDRESS);
+ expect(mockGetEventDetails).toHaveBeenCalledWith(
+ event,
+ mockActivityTypes,
+ TEST_ADDRESS,
+ );
});
it('should render SIGN_UP_BONUS event correctly', () => {
@@ -562,7 +640,11 @@ describe('ActivityEventRow', () => {
expect(getByText('43.25 USDC')).toBeOnTheScreen();
expect(getByText('+15')).toBeOnTheScreen();
expect(getByText('+50%')).toBeOnTheScreen();
- expect(mockGetEventDetails).toHaveBeenCalledWith(event, TEST_ADDRESS);
+ expect(mockGetEventDetails).toHaveBeenCalledWith(
+ event,
+ mockActivityTypes,
+ TEST_ADDRESS,
+ );
});
it('renders PREDICT event without description', () => {
@@ -734,7 +816,11 @@ describe('ActivityEventRow', () => {
// Assert
expect(getByText('Test Event')).toBeOnTheScreen();
- expect(mockGetEventDetails).toHaveBeenCalledWith(event, TEST_ADDRESS);
+ expect(mockGetEventDetails).toHaveBeenCalledWith(
+ event,
+ mockActivityTypes,
+ TEST_ADDRESS,
+ );
});
it('should handle formatRewardsDate returning different date formats', () => {
@@ -762,6 +848,8 @@ describe('ActivityEventRow', () => {
details: 'Swapped USDC for ETH',
icon: IconName.SwapHorizontal,
});
+ (parseCaipAssetType as jest.Mock).mockReturnValue({ chainId: '59144' });
+ (getNetworkImageSource as jest.Mock).mockReturnValue({ uri: 'net.png' });
// Act
const { getByText } = render(
@@ -771,6 +859,10 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Swap')).toBeOnTheScreen();
expect(getByText('Swapped USDC for ETH')).toBeOnTheScreen();
+ expect(parseCaipAssetType).toHaveBeenCalledWith(
+ (event.payload as unknown as SwapEventPayload).srcAsset.type,
+ );
+ expect(getNetworkImageSource).toHaveBeenCalledWith({ chainId: '59144' });
});
it('should extract chainId from PERPS event asset type', () => {
@@ -781,6 +873,8 @@ describe('ActivityEventRow', () => {
details: 'Opened SHORT BIO position',
icon: IconName.Candlestick,
});
+ (parseCaipAssetType as jest.Mock).mockReturnValue({ chainId: '999' });
+ (getNetworkImageSource as jest.Mock).mockReturnValue({ uri: 'p.png' });
// Act
const { getByText } = render(
@@ -790,6 +884,10 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Opened position')).toBeOnTheScreen();
expect(getByText('Opened SHORT BIO position')).toBeOnTheScreen();
+ expect(parseCaipAssetType).toHaveBeenCalledWith(
+ (event.payload as unknown as PerpsEventPayload).asset.type,
+ );
+ expect(getNetworkImageSource).toHaveBeenCalledWith({ chainId: '999' });
});
it('should extract chainId from CARD event asset type', () => {
@@ -800,6 +898,8 @@ describe('ActivityEventRow', () => {
details: '43.25 USDC',
icon: IconName.Card,
});
+ (parseCaipAssetType as jest.Mock).mockReturnValue({ chainId: '1' });
+ (getNetworkImageSource as jest.Mock).mockReturnValue({ uri: 'c.png' });
// Act
const { getByText } = render(
@@ -809,6 +909,10 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Card spend')).toBeOnTheScreen();
expect(getByText('43.25 USDC')).toBeOnTheScreen();
+ expect(parseCaipAssetType).toHaveBeenCalledWith(
+ (event.payload as unknown as CardEventPayload).asset.type,
+ );
+ expect(getNetworkImageSource).toHaveBeenCalledWith({ chainId: '1' });
});
it('should handle CARD event without asset type gracefully', () => {
@@ -840,6 +944,8 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Card spend')).toBeOnTheScreen();
expect(getByText('50 USDC')).toBeOnTheScreen();
+ expect(parseCaipAssetType).not.toHaveBeenCalled();
+ expect(getNetworkImageSource).not.toHaveBeenCalled();
});
it('should handle events without payload gracefully', () => {
@@ -859,6 +965,8 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Sign up bonus')).toBeOnTheScreen();
expect(getByText('Welcome bonus')).toBeOnTheScreen();
+ expect(parseCaipAssetType).not.toHaveBeenCalled();
+ expect(getNetworkImageSource).not.toHaveBeenCalled();
});
it('should handle CARD event with missing payload fields', () => {
@@ -880,6 +988,54 @@ describe('ActivityEventRow', () => {
// Assert - Component should render without error
expect(getByText('Card spend')).toBeOnTheScreen();
+ expect(parseCaipAssetType).not.toHaveBeenCalled();
+ expect(getNetworkImageSource).not.toHaveBeenCalled();
+ });
+
+ it('handles error when asset parsing throws without crashing', () => {
+ // Arrange
+ const event = createMockEvent({ type: 'SWAP' });
+ (parseCaipAssetType as jest.Mock).mockImplementation(() => {
+ throw new Error('bad parse');
+ });
+
+ // Act
+ const { getByText } = render(
+ ,
+ );
+
+ // Assert
+ expect(getByText('Swap Event')).toBeOnTheScreen();
+ });
+ });
+
+ describe('openActivityDetailsSheet', () => {
+ it('opens details sheet with activityTypes and confirmAction on press', () => {
+ // Arrange
+ const event = createMockEvent({ type: 'CARD' });
+ const confirmAction = jest.fn();
+ mockUseActivityDetailsConfirmAction.mockReturnValue(
+ confirmAction as unknown as ModalAction,
+ );
+
+ // Act
+ const { getByTestId } = render(
+ ,
+ );
+ const row = getByTestId('row-1');
+ fireEvent.press(row);
+
+ // Assert
+ expect(openActivityDetailsSheet).toHaveBeenCalledWith(expect.anything(), {
+ event,
+ accountName: TEST_ADDRESS,
+ activityTypes: mockActivityTypes,
+ confirmAction,
+ });
});
});
});
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx
index 2274138595c..1bc9a521cc5 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.tsx
@@ -12,7 +12,12 @@ import {
} from '@metamask/design-system-react-native';
import { useNavigation } from '@react-navigation/native';
import { CaipAssetType, parseCaipAssetType } from '@metamask/utils';
-import { PointsEventDto } from '../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ CardEventPayload,
+ PerpsEventPayload,
+ PointsEventDto,
+ SwapEventPayload,
+} from '../../../../../../core/Engine/controllers/rewards-controller/types';
import { formatRewardsDate, formatNumber } from '../../../utils/formatUtils';
import { getEventDetails } from '../../../utils/eventDetailsUtils';
import { getNetworkImageSource } from '../../../../../../util/networks';
@@ -28,6 +33,8 @@ import { openActivityDetailsSheet } from './EventDetails/ActivityDetailsSheet';
import { TouchableOpacity } from 'react-native';
import { useActivityDetailsConfirmAction } from '../../../hooks/useActivityDetailsConfirmAction';
import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants';
+import { useSelector } from 'react-redux';
+import { selectSeasonActivityTypes } from '../../../../../../reducers/rewards/selectors';
export const ActivityEventRow: React.FC<{
event: PointsEventDto;
@@ -35,9 +42,12 @@ export const ActivityEventRow: React.FC<{
testID?: string;
}> = ({ event, accountName, testID }) => {
const navigation = useNavigation();
+ const activityTypes = useSelector(selectSeasonActivityTypes);
+
const eventDetails = React.useMemo(
- () => (event ? getEventDetails(event, accountName) : undefined),
- [event, accountName],
+ () =>
+ event ? getEventDetails(event, activityTypes, accountName) : undefined,
+ [event, accountName, activityTypes],
);
const confirmAction = useActivityDetailsConfirmAction(event);
@@ -50,14 +60,26 @@ export const ActivityEventRow: React.FC<{
let assetType: CaipAssetType | undefined;
let chainId: string | undefined;
- if (event.type === 'SWAP' && event.payload.srcAsset?.type) {
- assetType = event.payload.srcAsset.type as CaipAssetType;
+ if (
+ event.type === 'SWAP' &&
+ (event.payload as SwapEventPayload).srcAsset?.type
+ ) {
+ assetType = (event.payload as SwapEventPayload).srcAsset
+ .type as CaipAssetType;
chainId = parseCaipAssetType(assetType).chainId;
- } else if (event.type === 'PERPS' && event.payload.asset?.type) {
- assetType = event.payload.asset.type as CaipAssetType;
+ } else if (
+ event.type === 'PERPS' &&
+ (event.payload as PerpsEventPayload).asset?.type
+ ) {
+ assetType = (event.payload as PerpsEventPayload).asset
+ .type as CaipAssetType;
chainId = parseCaipAssetType(assetType).chainId;
- } else if (event.type === 'CARD' && event.payload.asset?.type) {
- assetType = event.payload.asset.type as CaipAssetType;
+ } else if (
+ event.type === 'CARD' &&
+ (event.payload as CardEventPayload).asset?.type
+ ) {
+ assetType = (event.payload as CardEventPayload).asset
+ .type as CaipAssetType;
chainId = parseCaipAssetType(assetType).chainId;
} else {
return;
@@ -81,6 +103,7 @@ export const ActivityEventRow: React.FC<{
openActivityDetailsSheet(navigation, {
event,
accountName,
+ activityTypes,
confirmAction,
});
};
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx
index b69873edc06..9e3cf6ee4e7 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.test.tsx
@@ -253,6 +253,7 @@ describe('ActivityTab', () => {
startDate: Date.now(),
endDate: Date.now() + 1000,
tiers: [],
+ activityTypes: [],
},
balance: {
total: 0,
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx
index c1c2f3b28dc..075751b9c63 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx
@@ -10,7 +10,10 @@ import { ButtonVariant } from '@metamask/design-system-react-native';
import Routes from '../../../../../../../constants/navigation/Routes';
import { ModalType } from '../../../RewardsBottomSheetModal';
import TEST_ADDRESS from '../../../../../../../constants/address';
-import { PointsEventDto } from '../../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ PointsEventDto,
+ SeasonActivityTypeDto,
+} from '../../../../../../../core/Engine/controllers/rewards-controller/types';
import { AvatarAccountType } from '../../../../../../../component-library/components/Avatars/Avatar';
// Mock navigation
@@ -37,6 +40,7 @@ jest.mock('../../../../../../../../locales/i18n', () => ({
'rewards.events.points_base': 'Base',
'rewards.events.points_boost': 'Boost',
'rewards.events.points_total': 'Total',
+ 'rewards.events.description': 'Description',
'rewards.events.for_deposit_period': 'For deposit period',
};
return t[key] || key;
@@ -66,6 +70,12 @@ jest.mock('../../../../utils/formatUtils', () => ({
}).format(date);
},
),
+ resolveTemplate: jest.fn((template: string, values: Record) =>
+ template.replace(/\$\{(\w+)\}/g, (match, placeholder) => {
+ const value = values[placeholder as keyof typeof values];
+ return value !== undefined ? String(value) : match;
+ }),
+ ),
}));
// Mock eventDetailsUtils
@@ -94,6 +104,27 @@ describe('ActivityDetailsSheet', () => {
mockUseSelector.mockReturnValue(AvatarAccountType.JazzIcon);
});
+ const mockActivityTypes: SeasonActivityTypeDto[] = [
+ {
+ type: 'SWAP',
+ title: 'Swap',
+ description: 'Swap desc',
+ icon: 'SwapVertical',
+ },
+ {
+ type: 'CARD',
+ title: 'Card spend',
+ description: 'Spend',
+ icon: 'Card',
+ },
+ {
+ type: 'BRIDGE',
+ title: 'Bridge',
+ description: 'Bridge details',
+ icon: 'ArrowRight',
+ },
+ ];
+
const baseEvent: PointsEventDto = {
id: 'test-id',
timestamp: new Date('2025-09-09T09:09:33.000Z'),
@@ -126,7 +157,13 @@ describe('ActivityDetailsSheet', () => {
},
};
- render();
+ render(
+ ,
+ );
// Verify GenericEventDetails content is rendered (base component)
expect(screen.getByText('Details')).toBeTruthy();
@@ -151,7 +188,13 @@ describe('ActivityDetailsSheet', () => {
},
};
- render();
+ render(
+ ,
+ );
// Verify GenericEventDetails content is rendered (base component)
expect(screen.getByText('Details')).toBeTruthy();
@@ -173,7 +216,11 @@ describe('ActivityDetailsSheet', () => {
};
render(
- ,
+ ,
);
// Verify GenericEventDetails content is rendered (base component)
@@ -183,21 +230,70 @@ describe('ActivityDetailsSheet', () => {
expect(screen.getByText('Nov 11, 2025')).toBeTruthy();
});
- it('renders GenericEventDetails for other event types', () => {
+ it('renders GenericEventDetails with extra description for unspecified type when payload exists', () => {
const genericEvent: PointsEventDto = {
+ ...baseEvent,
+ type: 'BRIDGE' as never,
+ payload: { txHash: '0xabc123' } as unknown as PointsEventDto['payload'],
+ };
+
+ const activityTypesWithTemplate: SeasonActivityTypeDto[] = [
+ {
+ type: 'BRIDGE',
+ title: 'Bridge',
+ description: 'Tx: ${txHash}',
+ icon: 'ArrowRight',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ // Verify GenericEventDetails content is rendered
+ expect(screen.getByText('Details')).toBeTruthy();
+ expect(screen.getByText('Points')).toBeTruthy();
+ expect(screen.getByText('Date')).toBeTruthy();
+ // Description row label and resolved template
+ expect(screen.getByText('Description')).toBeTruthy();
+ expect(screen.getByText('Tx: 0xabc123')).toBeTruthy();
+ });
+
+ it('renders GenericEventDetails without extra description when payload is null', () => {
+ const genericEventNoPayload: PointsEventDto = {
...baseEvent,
type: 'BRIDGE' as never,
payload: null,
};
+ const activityTypesTemplate: SeasonActivityTypeDto[] = [
+ {
+ type: 'BRIDGE',
+ title: 'Bridge',
+ description: 'Tx: ${txHash}',
+ icon: 'ArrowRight',
+ },
+ ];
+
render(
- ,
+ ,
);
// Verify GenericEventDetails content is rendered
expect(screen.getByText('Details')).toBeTruthy();
expect(screen.getByText('Points')).toBeTruthy();
expect(screen.getByText('Date')).toBeTruthy();
+ // No Description row since payload is null
+ expect(screen.queryByText('Description')).toBeNull();
+ expect(screen.queryByText('Tx: 0xabc123')).toBeNull();
});
});
@@ -225,6 +321,7 @@ describe('ActivityDetailsSheet', () => {
openActivityDetailsSheet(mockNavigation, {
event: testEvent,
+ activityTypes: mockActivityTypes,
accountName: 'Test Account',
});
@@ -270,6 +367,7 @@ describe('ActivityDetailsSheet', () => {
event: testEvent,
accountName: 'Test Account',
confirmAction: customAction,
+ activityTypes: mockActivityTypes,
});
// Verify custom action is used
@@ -305,6 +403,7 @@ describe('ActivityDetailsSheet', () => {
openActivityDetailsSheet(mockNavigation, {
event: testEvent,
accountName: 'My Custom Account',
+ activityTypes: mockActivityTypes,
});
// Get the description prop which is the ActivityDetailsSheet component
@@ -342,6 +441,7 @@ describe('ActivityDetailsSheet', () => {
expect(() => {
openActivityDetailsSheet(mockNavigation, {
event: testEvent,
+ activityTypes: mockActivityTypes,
});
}).not.toThrow();
diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx
index 0cc13a2e70b..d326b88efdb 100644
--- a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx
+++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx
@@ -1,18 +1,28 @@
import React from 'react';
-import { ButtonVariant } from '@metamask/design-system-react-native';
+import {
+ Text,
+ TextVariant,
+ TextColor,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
import { useNavigation } from '@react-navigation/native';
import Routes from '../../../../../../../constants/navigation/Routes';
import { ModalAction, ModalType } from '../../../RewardsBottomSheetModal';
import { strings } from '../../../../../../../../locales/i18n';
import { getEventDetails } from '../../../../utils/eventDetailsUtils';
-import { GenericEventDetails } from './GenericEventDetails';
+import { DetailsRow, GenericEventDetails } from './GenericEventDetails';
import { SwapEventDetails } from './SwapEventDetails';
import { CardEventDetails } from './CardEventDetails';
import { MusdDepositEventDetails } from './MusdDepositEventDetails';
-import { PointsEventDto } from '../../../../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ PointsEventDto,
+ SeasonActivityTypeDto,
+} from '../../../../../../../core/Engine/controllers/rewards-controller/types';
+import { resolveTemplate } from '../../../../utils/formatUtils';
interface ActivityDetailsSheetProps {
event: PointsEventDto;
+ activityTypes: SeasonActivityTypeDto[];
accountName?: string;
confirmAction?: ModalAction;
}
@@ -21,18 +31,54 @@ interface ActivityDetailsSheetProps {
export const ActivityDetailsSheet: React.FC = ({
event,
accountName,
+ activityTypes,
}) => {
+ const matchingActivityType = activityTypes.find(
+ (activity) => activity.type === event.type,
+ );
+
+ const extraDetails =
+ matchingActivityType && event.payload ? (
+
+
+ {resolveTemplate(
+ matchingActivityType.description,
+ (event.payload ?? {}) as Record,
+ )}
+
+
+ ) : null;
+
switch (event.type) {
case 'SWAP':
- return ;
+ return (
+ }
+ accountName={accountName}
+ />
+ );
case 'CARD':
- return ;
+ return (
+ }
+ accountName={accountName}
+ />
+ );
case 'MUSD_DEPOSIT':
return (
-
+ }
+ accountName={accountName}
+ />
);
default:
- return ;
+ return (
+
+ );
}
};
@@ -43,6 +89,7 @@ export const openActivityDetailsSheet = (
) => {
const {
event,
+ activityTypes,
accountName,
confirmAction = {
label: strings('navigation.close'),
@@ -50,12 +97,16 @@ export const openActivityDetailsSheet = (
variant: ButtonVariant.Secondary,
},
} = props;
- const eventDetails = getEventDetails(event, accountName);
+ const eventDetails = getEventDetails(event, activityTypes, accountName);
navigation.navigate(Routes.MODAL.REWARDS_BOTTOM_SHEET_MODAL, {
title: eventDetails.title,
description: (
-
+
),
type: ModalType.Confirmation,
showIcon: false,
diff --git a/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts b/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts
index 405bd58264c..65e29736203 100644
--- a/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts
+++ b/app/components/UI/Rewards/hooks/useActivityDetailsConfirmAction.ts
@@ -2,7 +2,10 @@ import { useMemo } from 'react';
import { Linking } from 'react-native';
import { ButtonVariant } from '@metamask/design-system-react-native';
import { CaipAssetType } from '@metamask/utils';
-import { PointsEventDto } from '../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ PointsEventDto,
+ SwapEventPayload,
+} from '../../../../core/Engine/controllers/rewards-controller/types';
import { useTransactionExplorer } from './useTransactionExplorer';
import { strings } from '../../../../../locales/i18n';
import { ModalAction } from '../components/RewardsBottomSheetModal';
@@ -19,9 +22,11 @@ export const useActivityDetailsConfirmAction = (
const explorerInfo = useTransactionExplorer(
isSwap
- ? (event.payload?.srcAsset?.type as CaipAssetType | undefined)
+ ? ((event.payload as SwapEventPayload)?.srcAsset?.type as
+ | CaipAssetType
+ | undefined)
: undefined,
- isSwap ? event.payload?.txHash : undefined,
+ isSwap ? (event.payload as SwapEventPayload)?.txHash : undefined,
);
return useMemo(() => {
diff --git a/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts b/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts
index 4e07ec3eb91..1200513d33b 100644
--- a/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts
+++ b/app/components/UI/Rewards/hooks/useSeasonStatus.test.ts
@@ -185,6 +185,7 @@ describe('useSeasonStatus', () => {
startDate: 1640995200000,
endDate: 1672531200000,
tiers: [],
+ activityTypes: [],
};
const mockStatusData = {
@@ -516,6 +517,7 @@ describe('useSeasonStatus', () => {
startDate: 1640995200000,
endDate: 1672531200000,
tiers: [],
+ activityTypes: [],
};
const mockStatusData = {
@@ -623,6 +625,7 @@ describe('useSeasonStatus', () => {
startDate: 1640995200000,
endDate: 1672531200000,
tiers: [],
+ activityTypes: [],
};
const mockStatusData = {
diff --git a/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts b/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts
index 3b76d5f29b3..8c3539e36dd 100644
--- a/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts
+++ b/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts
@@ -4,6 +4,7 @@ import {
PointsEventDto,
PointsEventEarnType,
SwapEventPayload,
+ SeasonActivityTypeDto,
} from '../../../../core/Engine/controllers/rewards-controller/types';
import { PerpsEventType } from './eventConstants';
import {
@@ -48,29 +49,58 @@ jest.mock('../../../../../locales/i18n', () => ({
}));
// Mock formatUtils
-jest.mock('./formatUtils', () => ({
- formatNumber: jest.fn((value: number) => value.toString()),
- formatRewardsMusdDepositPayloadDate: jest.fn(
- (isoDate: string | undefined) => {
- // Mock implementation that matches the real implementation behavior
- if (
- !isoDate ||
- typeof isoDate !== 'string' ||
- !/^\d{4}-\d{2}-\d{2}$/.test(isoDate)
- ) {
- return null;
- }
- // Mock implementation that formats the date
- const date = new Date(`${isoDate}T00:00:00Z`);
- return new Intl.DateTimeFormat('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- timeZone: 'UTC',
- }).format(date);
- },
- ),
-}));
+jest.mock('./formatUtils', () => {
+ const { IconName: IconEnum } = jest.requireActual(
+ '@metamask/design-system-react-native',
+ );
+ return {
+ formatNumber: jest.fn((value: number) => value.toString()),
+ formatRewardsMusdDepositPayloadDate: jest.fn(
+ (isoDate: string | undefined) => {
+ if (
+ !isoDate ||
+ typeof isoDate !== 'string' ||
+ !/^\d{4}-\d{2}-\d{2}$/.test(isoDate)
+ ) {
+ return null;
+ }
+ const date = new Date(`${isoDate}T00:00:00Z`);
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+ }).format(date);
+ },
+ ),
+ resolveTemplate: jest.fn(
+ (template: string, values: Record) =>
+ template.replace(/\$\{(\w+)\}/g, (match, placeholder) => {
+ const value = values[placeholder as keyof typeof values];
+ return value !== undefined ? String(value) : match;
+ }),
+ ),
+ getIconName: jest.fn((name: string) => {
+ const map: Record = {
+ Star: IconEnum.Star,
+ ArrowDown: IconEnum.ArrowDown,
+ ArrowUp: IconEnum.ArrowUp,
+ ArrowRight: IconEnum.ArrowRight,
+ Lock: IconEnum.Lock,
+ Gift: IconEnum.Gift,
+ Edit: IconEnum.Edit,
+ ThumbUp: IconEnum.ThumbUp,
+ Speedometer: IconEnum.Speedometer,
+ Coin: IconEnum.Coin,
+ Card: IconEnum.Card,
+ Candlestick: IconEnum.Candlestick,
+ SwapVertical: IconEnum.SwapVertical,
+ UserCircleAdd: IconEnum.UserCircleAdd,
+ };
+ return map[name] ?? IconEnum.Star;
+ }),
+ };
+});
describe('eventDetailsUtils', () => {
beforeEach(() => {
@@ -548,7 +578,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return swap details
expect(result).toEqual({
@@ -574,7 +604,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -598,7 +628,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -622,7 +652,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -646,7 +676,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -670,7 +700,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -694,7 +724,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return perps details
expect(result).toEqual({
@@ -717,7 +747,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return undefined details
expect(result).toEqual({
@@ -739,7 +769,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -764,7 +794,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return card spend details
expect(result).toEqual({
@@ -788,7 +818,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return card spend details with decimals
expect(result).toEqual({
@@ -803,7 +833,7 @@ describe('eventDetailsUtils', () => {
const event = createMockEvent('CARD', null);
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return card spend title with undefined details
expect(result).toEqual({
@@ -818,7 +848,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for REFERRAL event', () => {
const event = createMockEvent('REFERRAL');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Referral action',
@@ -832,7 +862,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for SIGN_UP_BONUS event', () => {
const event = createMockEvent('SIGN_UP_BONUS');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Sign up bonus',
@@ -844,7 +874,7 @@ describe('eventDetailsUtils', () => {
it('returns empty details when account name is not provided', () => {
const event = createMockEvent('SIGN_UP_BONUS');
- const result = getEventDetails(event, undefined);
+ const result = getEventDetails(event, [], undefined);
expect(result).toEqual({
title: 'Sign up bonus',
@@ -858,7 +888,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for LOYALTY_BONUS event', () => {
const event = createMockEvent('LOYALTY_BONUS');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Loyalty bonus',
@@ -872,7 +902,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for ONE_TIME_BONUS event', () => {
const event = createMockEvent('ONE_TIME_BONUS');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'One-time bonus',
@@ -886,7 +916,7 @@ describe('eventDetailsUtils', () => {
it('returns correct details for PREDICT event', () => {
const event = createMockEvent('PREDICT');
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Prediction',
@@ -904,7 +934,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit details with formatted date
expect(result).toEqual({
@@ -921,7 +951,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit details with formatted date
expect(result).toEqual({
@@ -936,7 +966,7 @@ describe('eventDetailsUtils', () => {
const event = createMockEvent('MUSD_DEPOSIT', null);
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -954,7 +984,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -972,7 +1002,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -989,7 +1019,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1006,7 +1036,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1023,7 +1053,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1040,7 +1070,7 @@ describe('eventDetailsUtils', () => {
});
// When getting event details
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
// Then it should return mUSD deposit title with undefined details
expect(result).toEqual({
@@ -1055,7 +1085,7 @@ describe('eventDetailsUtils', () => {
it('returns uncategorized event details for unknown type', () => {
const event = createMockEvent('UNKNOWN_TYPE' as PointsEventDto['type']);
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Uncategorized event',
@@ -1078,7 +1108,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1099,7 +1129,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1120,7 +1150,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1141,7 +1171,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1162,7 +1192,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Opened position',
@@ -1187,7 +1217,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Swap',
@@ -1212,7 +1242,7 @@ describe('eventDetailsUtils', () => {
},
});
- const result = getEventDetails(event, TEST_ADDRESS);
+ const result = getEventDetails(event, [], TEST_ADDRESS);
expect(result).toEqual({
title: 'Swap',
@@ -1222,4 +1252,131 @@ describe('eventDetailsUtils', () => {
});
});
});
+
+ describe('Custom activity types', () => {
+ const CUSTOM_TYPE = 'CUSTOM_ACTION' as PointsEventDto['type'];
+
+ const makeCustomActivity = (
+ icon: string,
+ description: string = 'Custom description',
+ ): SeasonActivityTypeDto => ({
+ type: CUSTOM_TYPE as unknown as PointsEventEarnType,
+ title: 'Custom Title',
+ description,
+ icon,
+ });
+
+ const makeEvent = (): PointsEventDto => ({
+ id: 'custom-id',
+ timestamp: new Date('2024-02-01T00:00:00Z'),
+ value: 5,
+ bonus: null,
+ accountAddress: TEST_ADDRESS,
+ updatedAt: new Date('2024-02-01T00:00:00Z'),
+ type: CUSTOM_TYPE as PointsEventEarnType,
+ payload: null,
+ });
+
+ it('uses custom title, description, and icon when activityTypes provides a match', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('Lock'),
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Custom description',
+ icon: IconName.Lock,
+ });
+ });
+
+ it('falls back to Star icon when provided invalid icon name', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('NotARealIcon'),
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Custom description',
+ icon: IconName.Star,
+ });
+ });
+
+ it('returns uncategorized event when no matching activity type is found', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ // Different type that should not match
+ {
+ type: 'OTHER_ACTION' as unknown as PointsEventEarnType,
+ title: 'Other',
+ description: 'Other desc',
+ icon: 'Gift',
+ },
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Uncategorized event',
+ details: undefined,
+ icon: IconName.Star,
+ });
+ });
+
+ it('preserves empty description value when provided by activityTypes', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('ArrowDown', ''),
+ ];
+ const event = makeEvent();
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: '',
+ icon: IconName.ArrowDown,
+ });
+ });
+
+ it('resolves ${...} tokens in description using payload values', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('Lock', 'Tx: ${txHash}'),
+ ];
+ const event: PointsEventDto = {
+ ...makeEvent(),
+ payload: { txHash: '0xabc123' } as unknown as Record,
+ };
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Tx: 0xabc123',
+ icon: IconName.Lock,
+ });
+ });
+
+ it('leaves ${...} tokens intact when payload is null', () => {
+ const activityTypes: SeasonActivityTypeDto[] = [
+ makeCustomActivity('ArrowRight', 'Tx: ${txHash}'),
+ ];
+ const event: PointsEventDto = {
+ ...makeEvent(),
+ payload: null,
+ };
+
+ const result = getEventDetails(event, activityTypes, TEST_ADDRESS);
+
+ expect(result).toEqual({
+ title: 'Custom Title',
+ details: 'Tx: ${txHash}',
+ icon: IconName.ArrowRight,
+ });
+ });
+ });
});
diff --git a/app/components/UI/Rewards/utils/eventDetailsUtils.ts b/app/components/UI/Rewards/utils/eventDetailsUtils.ts
index 8e9e5c27b49..ca21b3a81bf 100644
--- a/app/components/UI/Rewards/utils/eventDetailsUtils.ts
+++ b/app/components/UI/Rewards/utils/eventDetailsUtils.ts
@@ -6,12 +6,17 @@ import {
PerpsEventPayload,
CardEventPayload,
EventAssetDto,
+ SeasonActivityTypeDto,
} from '../../../../core/Engine/controllers/rewards-controller/types';
import { isNullOrUndefined } from '@metamask/utils';
import { formatUnits } from 'viem';
import { formatWithThreshold } from '../../../../util/assets';
import { PerpsEventType } from './eventConstants';
-import { formatRewardsMusdDepositPayloadDate } from './formatUtils';
+import {
+ formatRewardsMusdDepositPayloadDate,
+ getIconName,
+ resolveTemplate,
+} from './formatUtils';
/**
* Formats an asset amount with proper decimals
@@ -159,17 +164,23 @@ export const getCardEventDetails = (
/**
* Formats an event details
* @param event - The event
+ * @param activityTypes - The activity types
* @param accountName - Optional account name to display for bonus events
* @returns The event details
*/
export const getEventDetails = (
event: PointsEventDto,
+ activityTypes: SeasonActivityTypeDto[],
accountName: string | undefined,
): {
title: string;
details: string | undefined;
icon: IconName;
} => {
+ const matchingActivityType = activityTypes.find(
+ (activity) => activity.type === event.type,
+ );
+
switch (event.type) {
case 'SWAP':
return {
@@ -233,11 +244,23 @@ export const getEventDetails = (
icon: IconName.Coin,
};
}
- default:
+ default: {
+ if (matchingActivityType) {
+ return {
+ title: matchingActivityType.title,
+ details: resolveTemplate(
+ matchingActivityType.description,
+ (event.payload ?? {}) as Record,
+ ),
+ icon: getIconName(matchingActivityType.icon),
+ };
+ }
+
return {
title: strings('rewards.events.type.uncategorized_event'),
details: undefined,
icon: IconName.Star,
};
+ }
}
};
diff --git a/app/components/UI/Rewards/utils/formatUtils.test.ts b/app/components/UI/Rewards/utils/formatUtils.test.ts
index 69249bb33a9..b7c3d090a59 100644
--- a/app/components/UI/Rewards/utils/formatUtils.test.ts
+++ b/app/components/UI/Rewards/utils/formatUtils.test.ts
@@ -10,6 +10,7 @@ import {
formatUrl,
formatUTCDate,
formatRewardsMusdDepositPayloadDate,
+ resolveTemplate,
} from './formatUtils';
import { IconName } from '@metamask/design-system-react-native';
import { getTimeDifferenceFromNow } from '../../../../util/date';
@@ -1128,4 +1129,55 @@ describe('formatUtils', () => {
expect(jaResult).toMatch(/11/);
});
});
+
+ describe('resolveTemplate', () => {
+ it('replaces single placeholder with provided value', () => {
+ const template = 'Hello, ${name}!';
+ const values = { name: 'Alice' };
+ expect(resolveTemplate(template, values)).toBe('Hello, Alice!');
+ });
+
+ it('replaces multiple placeholders with provided values', () => {
+ const template = 'User: ${name}, Tier: ${tier}';
+ const values = { name: 'Bob', tier: 'Gold' };
+ expect(resolveTemplate(template, values)).toBe('User: Bob, Tier: Gold');
+ });
+
+ it('leaves placeholders intact when value is missing', () => {
+ const template = 'Hello, ${name}! Tier: ${tier}';
+ const values = { name: 'Charlie' };
+ expect(resolveTemplate(template, values)).toBe(
+ 'Hello, Charlie! Tier: ${tier}',
+ );
+ });
+
+ it('replaces repeated occurrences of the same placeholder', () => {
+ const template = '${name} is ${name}';
+ const values = { name: 'Dana' };
+ expect(resolveTemplate(template, values)).toBe('Dana is Dana');
+ });
+
+ it('does not replace when value is an empty string (fallback to original token)', () => {
+ const template = 'Optional: ${field}';
+ const values = { field: '' };
+ expect(resolveTemplate(template, values)).toBe('Optional: ${field}');
+ });
+
+ it('does not match non-word placeholders (e.g., dot paths)', () => {
+ const template = 'Tx: ${payload.txHash}';
+ const values = { 'payload.txHash': '0xabc' } as unknown as Record<
+ string,
+ string
+ >;
+ expect(resolveTemplate(template, values)).toBe('Tx: ${payload.txHash}');
+ });
+
+ it('returns the original string when no placeholders exist', () => {
+ const template = 'Static string with no tokens';
+ const values = { anything: 'value' };
+ expect(resolveTemplate(template, values)).toBe(
+ 'Static string with no tokens',
+ );
+ });
+ });
});
diff --git a/app/components/UI/Rewards/utils/formatUtils.ts b/app/components/UI/Rewards/utils/formatUtils.ts
index 67483af465a..1ec95eb263d 100644
--- a/app/components/UI/Rewards/utils/formatUtils.ts
+++ b/app/components/UI/Rewards/utils/formatUtils.ts
@@ -163,3 +163,19 @@ export const formatUrl = (url: string): string => {
return cleanedUrl;
}
};
+
+/**
+ * Resolves templated string in the format of ${placeholder}
+ * @param template - The templated string
+ * @param values - The values to replace the placeholders with
+ * @returns The resolved string
+ */
+
+export const resolveTemplate = (
+ template: string,
+ values: Record,
+): string =>
+ template.replace(
+ /\${(\w+)}/g,
+ (match, placeholder) => values[placeholder] || match,
+ );
diff --git a/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx b/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx
index 84a8b5dea79..09e90359951 100644
--- a/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx
+++ b/app/components/UI/SimulationDetails/BalanceChangeList/BalanceChangeList.tsx
@@ -8,6 +8,7 @@ import BalanceChangeRow from '../BalanceChangeRow/BalanceChangeRow';
import { BalanceChange } from '../types';
import { TotalFiatDisplay } from '../FiatDisplay/FiatDisplay';
import styleSheet from './BalanceChangeList.styles';
+
interface BalanceChangeListProperties extends ViewProps {
heading: string;
balanceChanges: BalanceChange[];
@@ -28,6 +29,12 @@ const BalanceChangeList: React.FC = ({
[sortedBalanceChanges],
);
+ const hasIncomingTokens = useMemo(
+ () =>
+ balanceChanges.some((balanceChange) => balanceChange.amount.isPositive()),
+ [balanceChanges],
+ );
+
if (sortedBalanceChanges.length === 0) {
return null;
}
@@ -45,6 +52,7 @@ const BalanceChangeList: React.FC = ({
label={index === 0 ? heading : undefined}
balanceChange={balanceChange}
showFiat={!showFiatTotal}
+ hasIncomingTokens={hasIncomingTokens}
/>
))}
{showFiatTotal && (
diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx
index 5ec3376df6c..ff254ed18d0 100644
--- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx
+++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx
@@ -80,4 +80,41 @@ describe('BalanceChangeList', () => {
expect(getByTestId('edit-spending-cap-button')).toBeTruthy();
});
+
+ it('renders an alert row if there are incoming tokens and a label is provided', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+ expect(getByTestId('info-row')).toBeTruthy();
+ expect(queryByTestId('balance-change-row-label')).toBeNull();
+ });
+
+ it('does not render an alert row if there are no incoming tokens', () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+ expect(getByTestId('balance-change-row-label')).toBeTruthy();
+ expect(queryByTestId('info-row')).toBeNull();
+ });
+
+ it('does not render an alert row if no label is provided', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+ expect(queryByTestId('info-row')).toBeNull();
+ });
});
diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx
index 232f012d7cd..c8d5b74bf9d 100644
--- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx
+++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.tsx
@@ -14,6 +14,9 @@ import AmountPill from '../AmountPill/AmountPill';
import AssetPill from '../AssetPill/AssetPill';
import { IndividualFiatDisplay } from '../FiatDisplay/FiatDisplay';
import styleSheet from './BalanceChangeRow.styles';
+import AlertRow from '../../../Views/confirmations/components/UI/info-row/alert-row';
+import { RowAlertKey } from '../../../Views/confirmations/components/UI/info-row/alert-row/constants';
+import alertRowStyleSheet from '../../../Views/confirmations/components/UI/info-row/alert-row/alert-row.styles';
interface BalanceChangeRowProperties extends ViewProps {
approveMethod?: ApproveMethod;
@@ -24,6 +27,7 @@ interface BalanceChangeRowProperties extends ViewProps {
newSpendingCap: string,
) => Promise;
showFiat?: boolean;
+ hasIncomingTokens?: boolean;
}
const BalanceChangeRow: React.FC = ({
@@ -32,23 +36,42 @@ const BalanceChangeRow: React.FC = ({
label,
onApprovalAmountUpdate,
showFiat,
+ hasIncomingTokens,
}) => {
const { styles } = useStyles(styleSheet, {});
+ const { styles: alertRowStyles } = useStyles(alertRowStyleSheet, {});
const { asset, amount, fiatAmount, isAllApproval, isUnlimitedApproval } =
balanceChange;
const isERC20 = balanceChange.asset.type === AssetType.ERC20;
const shouldShowEditSpendingCapButton = isERC20 && onApprovalAmountUpdate;
+
+ const renderLabel = () => {
+ if (!label) {
+ return null;
+ }
+ if (hasIncomingTokens) {
+ return (
+
+ );
+ }
+ return (
+
+ {label}
+
+ );
+ };
+
return (
- {label && (
-
- {label}
-
- )}
+ {renderLabel()}
{shouldShowEditSpendingCapButton ? (
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx
similarity index 76%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx
rename to app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx
index 37af81dc712..d5e205d5308 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.test.tsx
+++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx
@@ -66,44 +66,6 @@ describe('SiteRowItem', () => {
});
});
- describe('padding behavior', () => {
- it('renders with isViewAll prop set to true', () => {
- const site = createSite();
-
- const { getByTestId } = render(
- ,
- );
-
- const pressable = getByTestId('site-row-item');
- expect(pressable).toBeOnTheScreen();
- // Component renders successfully with isViewAll={true}
- });
-
- it('renders with isViewAll prop set to false', () => {
- const site = createSite();
-
- const { getByTestId } = render(
- ,
- );
-
- const pressable = getByTestId('site-row-item');
- expect(pressable).toBeOnTheScreen();
- // Component renders successfully with isViewAll={false}
- });
-
- it('renders with isViewAll prop not provided', () => {
- const site = createSite();
-
- const { getByTestId } = render(
- ,
- );
-
- const pressable = getByTestId('site-row-item');
- expect(pressable).toBeOnTheScreen();
- // Component renders successfully with default isViewAll
- });
- });
-
describe('interaction', () => {
it('calls onPress when pressed', () => {
const site = createSite();
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx
similarity index 91%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx
rename to app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx
index 72e0d5b414b..4f49b2a1d50 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/SiteRowItem.tsx
+++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx
@@ -22,14 +22,9 @@ export interface SiteData {
interface SiteRowItemProps {
site: SiteData;
onPress: () => void;
- isViewAll?: boolean;
}
-const SiteRowItem = ({
- site,
- onPress,
- isViewAll = false,
-}: SiteRowItemProps) => {
+const SiteRowItem = ({ site, onPress }: SiteRowItemProps) => {
const tw = useTailwind();
const [imageError, setImageError] = useState(false);
@@ -42,7 +37,7 @@ const SiteRowItem = ({
{/* Logo */}
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx
similarity index 78%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx
rename to app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx
index df79947456c..90762de9a58 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.test.tsx
+++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx
@@ -1,27 +1,26 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import SiteRowItemWrapper from './SiteRowItemWrapper';
-import { updateLastTrendingScreen } from '../../../Nav/Main/MainNavigator';
+import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator';
import type { NavigationProp, ParamListBase } from '@react-navigation/native';
-import type { SiteData } from './SiteRowItem/SiteRowItem';
+import type { SiteData } from '../SiteRowItem/SiteRowItem';
// Mock the dependencies
-jest.mock('../../../Nav/Main/MainNavigator', () => ({
+jest.mock('../../../../Nav/Main/MainNavigator', () => ({
updateLastTrendingScreen: jest.fn(),
}));
-jest.mock('./SiteRowItem/SiteRowItem', () => {
+jest.mock('../SiteRowItem/SiteRowItem', () => {
const { TouchableOpacity, Text } = jest.requireActual('react-native');
return {
__esModule: true,
- default: jest.fn(({ onPress, site, isViewAll }) => (
+ default: jest.fn(({ onPress, site }) => (
{site.id}
{site.name}
{site.url}
{site.displayUrl}
- {String(isViewAll)}
{site.logoUrl && {site.logoUrl}}
{site.featured && Featured}
@@ -87,26 +86,6 @@ describe('SiteRowItemWrapper', () => {
);
});
- it('should pass isViewAll as false by default', () => {
- const { getByTestId } = render(
- ,
- );
-
- expect(getByTestId('is-view-all').props.children).toBe('false');
- });
-
- it('should pass isViewAll as true when provided', () => {
- const { getByTestId } = render(
- ,
- );
-
- expect(getByTestId('is-view-all').props.children).toBe('true');
- });
-
it('should render site with logoUrl', () => {
const { getByTestId } = render(
,
@@ -271,40 +250,6 @@ describe('SiteRowItemWrapper', () => {
expect(mockNavigation.navigate).toHaveBeenCalledTimes(3);
});
- it('should use current timestamp on each press', () => {
- dateNowSpy.mockRestore();
- const timestamps = [1000000000, 2000000000, 3000000000];
- let callCount = 0;
-
- dateNowSpy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => timestamps[callCount++]);
-
- const { getByTestId } = render(
- ,
- );
-
- const siteRowItem = getByTestId('site-row-item');
-
- fireEvent.press(siteRowItem);
- expect(mockNavigation.navigate).toHaveBeenLastCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({ timestamp: 1000000000 }),
- );
-
- fireEvent.press(siteRowItem);
- expect(mockNavigation.navigate).toHaveBeenLastCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({ timestamp: 2000000000 }),
- );
-
- fireEvent.press(siteRowItem);
- expect(mockNavigation.navigate).toHaveBeenLastCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({ timestamp: 3000000000 }),
- );
- });
-
it('should always pass fromTrending as true', () => {
const { getByTestId } = render(
,
@@ -343,17 +288,5 @@ describe('SiteRowItemWrapper', () => {
fromTrending: true,
});
});
-
- it('should work with isViewAll explicitly set to false', () => {
- const { getByTestId } = render(
- ,
- );
-
- expect(getByTestId('is-view-all').props.children).toBe('false');
- });
});
});
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx
similarity index 70%
rename from app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx
rename to app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx
index 8221682c7c1..38ce335a057 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItemWrapper.tsx
+++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx
@@ -1,18 +1,16 @@
import React from 'react';
import type { NavigationProp, ParamListBase } from '@react-navigation/native';
-import SiteRowItem, { type SiteData } from './SiteRowItem/SiteRowItem';
-import { updateLastTrendingScreen } from '../../../Nav/Main/MainNavigator';
+import SiteRowItem, { type SiteData } from '../SiteRowItem/SiteRowItem';
+import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator';
interface SiteRowItemWrapperProps {
site: SiteData;
navigation: NavigationProp;
- isViewAll?: boolean;
}
const SiteRowItemWrapper: React.FC = ({
site,
navigation,
- isViewAll = false,
}) => {
const handlePress = () => {
// Update last trending screen state
@@ -26,9 +24,7 @@ const SiteRowItemWrapper: React.FC = ({
});
};
- return (
-
- );
+ return ;
};
export default SiteRowItemWrapper;
diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx
similarity index 62%
rename from app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx
rename to app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx
index b249be13ed7..7a418ceb535 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.test.tsx
+++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.test.tsx
@@ -74,38 +74,4 @@ describe('SiteSkeleton', () => {
});
});
});
-
- describe('padding behavior', () => {
- it('does not apply horizontal padding when isViewAll is false', () => {
- const { getAllByTestId } = render();
-
- const skeletons = getAllByTestId('skeleton');
- const container = skeletons[0].parent;
- const styles = Array.isArray(container?.props.style)
- ? container?.props.style
- : [container?.props.style];
- const hasPaddingHorizontal = styles.some(
- (style: { paddingHorizontal?: number }) =>
- style?.paddingHorizontal === 8,
- );
-
- expect(hasPaddingHorizontal).toBe(false);
- });
-
- it('does not apply horizontal padding when isViewAll is not provided', () => {
- const { getAllByTestId } = render();
-
- const skeletons = getAllByTestId('skeleton');
- const container = skeletons[0].parent;
- const styles = Array.isArray(container?.props.style)
- ? container?.props.style
- : [container?.props.style];
- const hasPaddingHorizontal = styles.some(
- (style: { paddingHorizontal?: number }) =>
- style?.paddingHorizontal === 8,
- );
-
- expect(hasPaddingHorizontal).toBe(false);
- });
- });
});
diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx
similarity index 77%
rename from app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx
rename to app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx
index f2165549503..130d3a81722 100644
--- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/SiteSkeleton.tsx
+++ b/app/components/UI/Sites/components/SiteSkeleton/SiteSkeleton.tsx
@@ -8,9 +8,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingVertical: 16,
},
- containerViewAll: {
- paddingHorizontal: 8,
- },
iconSkeleton: {
borderRadius: 20,
marginBottom: 0,
@@ -27,12 +24,8 @@ const styles = StyleSheet.create({
},
});
-interface SiteSkeletonProps {
- isViewAll?: boolean;
-}
-
-const SiteSkeleton = ({ isViewAll = false }: SiteSkeletonProps) => (
-
+const SiteSkeleton = () => (
+
{/* Logo skeleton */}
diff --git a/app/components/UI/Sites/components/SitesList/SitesList.test.tsx b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx
new file mode 100644
index 00000000000..d6607f79cbd
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx
@@ -0,0 +1,197 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import SitesList from './SitesList';
+import { useNavigation } from '@react-navigation/native';
+import type { SiteData } from '../SiteRowItem/SiteRowItem';
+
+// Mock FlashList to render items in tests
+jest.mock('@shopify/flash-list', () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ FlashList: ({
+ data,
+ renderItem,
+ keyExtractor,
+ testID,
+ refreshControl,
+ ListFooterComponent,
+ }: {
+ data: SiteData[];
+ renderItem: ({ item }: { item: SiteData }) => React.ReactElement;
+ keyExtractor: (item: SiteData) => string;
+ testID: string;
+ refreshControl?: React.ReactElement;
+ ListFooterComponent?: React.ReactElement | null;
+ showsVerticalScrollIndicator?: boolean;
+ }) => (
+
+ {data.map((item: SiteData) => {
+ const key = keyExtractor(item);
+ return (
+
+ {renderItem({ item })}
+
+ );
+ })}
+ {refreshControl}
+ {ListFooterComponent}
+
+ ),
+ };
+});
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn(),
+}));
+
+jest.mock('../SiteRowItemWrapper/SiteRowItemWrapper', () => {
+ const { View, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ site,
+ navigation,
+ }: {
+ site: SiteData;
+ navigation: unknown;
+ }) => (
+
+ {site.id}
+ {site.name}
+ {String(!!navigation)}
+
+ ),
+ };
+});
+
+describe('SitesList', () => {
+ const mockNavigation = {
+ navigate: jest.fn(),
+ };
+
+ const createSite = (
+ id: string,
+ overrides: Partial = {},
+ ): SiteData => ({
+ id,
+ name: `Site ${id}`,
+ url: `https://site${id}.com`,
+ displayUrl: `site${id}.com`,
+ ...overrides,
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useNavigation as jest.Mock).mockReturnValue(mockNavigation);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders with empty sites array', () => {
+ const { getByTestId, queryByTestId } = render();
+
+ expect(getByTestId('sites-list')).toBeOnTheScreen();
+ expect(queryByTestId('site-wrapper-1')).toBeNull();
+ });
+
+ it('renders with single site', () => {
+ const sites = [createSite('1')];
+
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('site-wrapper-1')).toBeOnTheScreen();
+ expect(queryByTestId('site-wrapper-2')).toBeNull();
+ });
+
+ it('renders multiple sites', () => {
+ const sites = [
+ createSite('1'),
+ createSite('2'),
+ createSite('3'),
+ createSite('4'),
+ createSite('5'),
+ ];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-wrapper-1')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-2')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-3')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-4')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-5')).toBeOnTheScreen();
+ });
+ });
+
+ describe('props passthrough', () => {
+ it('passes navigation to SiteRowItemWrapper', () => {
+ const sites = [createSite('1')];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('has-navigation-1').props.children).toBe('true');
+ });
+ });
+
+ describe('site data rendering', () => {
+ it('renders sites with correct data', () => {
+ const sites = [
+ createSite('1', { name: 'MetaMask' }),
+ createSite('unique-id-2', { name: 'Uniswap' }),
+ createSite('3', { name: 'OpenSea' }),
+ ];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-name-1').props.children).toBe('MetaMask');
+ expect(getByTestId('site-name-unique-id-2').props.children).toBe(
+ 'Uniswap',
+ );
+ expect(getByTestId('site-id-unique-id-2').props.children).toBe(
+ 'unique-id-2',
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('renders with sites containing special characters in IDs', () => {
+ const sites = [
+ createSite('site-with-dash'),
+ createSite('site_with_underscore'),
+ createSite('site.with.dot'),
+ ];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-wrapper-site-with-dash')).toBeOnTheScreen();
+ expect(
+ getByTestId('site-wrapper-site_with_underscore'),
+ ).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-site.with.dot')).toBeOnTheScreen();
+ });
+
+ it('renders with large number of sites', () => {
+ const sites = Array.from({ length: 50 }, (_, i) => createSite(`${i}`));
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-wrapper-0')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-25')).toBeOnTheScreen();
+ expect(getByTestId('site-wrapper-49')).toBeOnTheScreen();
+ });
+
+ it('renders when navigation is not provided', () => {
+ (useNavigation as jest.Mock).mockReturnValue(undefined);
+ const sites = [createSite('1')];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('has-navigation-1').props.children).toBe('false');
+ });
+ });
+});
diff --git a/app/components/UI/Sites/components/SitesList/SitesList.tsx b/app/components/UI/Sites/components/SitesList/SitesList.tsx
new file mode 100644
index 00000000000..a6e78345898
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesList/SitesList.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { FlashList } from '@shopify/flash-list';
+import { useNavigation } from '@react-navigation/native';
+// eslint-disable-next-line no-duplicate-imports
+import type { NavigationProp, ParamListBase } from '@react-navigation/native';
+import SiteRowItemWrapper from '../SiteRowItemWrapper/SiteRowItemWrapper';
+import type { SiteData } from '../SiteRowItem/SiteRowItem';
+
+export interface SitesListProps {
+ sites: SiteData[];
+ refreshControl?: React.ReactElement;
+ ListFooterComponent?: React.ReactElement | null;
+}
+
+const SitesList: React.FC = ({
+ sites,
+ refreshControl,
+ ListFooterComponent,
+}) => {
+ const navigation = useNavigation>();
+
+ const renderSiteItem = ({ item }: { item: SiteData }) => (
+
+ );
+
+ return (
+ item.id}
+ showsVerticalScrollIndicator={false}
+ refreshControl={refreshControl}
+ ListFooterComponent={ListFooterComponent}
+ />
+ );
+};
+
+SitesList.displayName = 'SitesList';
+
+export default SitesList;
diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx
new file mode 100644
index 00000000000..434ffbc62ad
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx
@@ -0,0 +1,246 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import { useNavigation } from '@react-navigation/native';
+// eslint-disable-next-line no-duplicate-imports
+import type { NavigationProp, ParamListBase } from '@react-navigation/native';
+import SitesSearchFooter from './SitesSearchFooter';
+
+// Mock dependencies
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn(),
+}));
+
+describe('SitesSearchFooter', () => {
+ let mockNavigation: jest.Mocked>;
+ let dateNowSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockNavigation = {
+ navigate: jest.fn(),
+ } as unknown as jest.Mocked>;
+
+ (useNavigation as jest.Mock).mockReturnValue(mockNavigation);
+ dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890);
+ });
+
+ afterEach(() => {
+ dateNowSpy.mockRestore();
+ jest.resetAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('returns null when searchQuery is empty', () => {
+ const { queryByTestId } = render();
+
+ expect(queryByTestId('trending-search-footer-google-link')).toBeNull();
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+
+ it('renders Google search link when query is provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders URL link when query looks like a URL', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('does not render URL link when query is plain text', () => {
+ const { queryByTestId, getByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('URL detection', () => {
+ it('detects URLs with http protocol', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects URLs with https protocol', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects URLs with path', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects URLs with query parameters', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('detects subdomains as URLs', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('does not detect plain text as URL', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+
+ it('does not detect single word as URL', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+ });
+
+ describe('navigation', () => {
+ it('navigates to URL when URL link is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('trending-search-footer-url-link'));
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'metamask.io',
+ timestamp: 1234567890,
+ fromTrending: true,
+ });
+ expect(mockNavigation.navigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('navigates to Google search when Google link is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('trending-search-footer-google-link'));
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'https://www.google.com/search?q=ethereum',
+ timestamp: 1234567890,
+ fromTrending: true,
+ });
+ expect(mockNavigation.navigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('encodes special characters in Google search query', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('trending-search-footer-google-link'));
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'https://www.google.com/search?q=ethereum%20%26%20bitcoin',
+ timestamp: 1234567890,
+ fromTrending: true,
+ });
+ });
+ });
+
+ describe('text display', () => {
+ it('displays search query in Google search link', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('ethereum')).toBeOnTheScreen();
+ expect(getByText(/on Google/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles URL with uppercase characters', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('handles query with leading/trailing spaces', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ // Component trims or handles spaces, but doesn't return null
+ expect(
+ queryByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('handles URL with multiple subdomains', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeOnTheScreen();
+ });
+
+ it('handles URL with port number', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('trending-search-footer-url-link')).toBeNull();
+ });
+
+ it('handles special characters in query', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ });
+
+ it('handles query with emojis', () => {
+ const { getByText, getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId('trending-search-footer-google-link'),
+ ).toBeOnTheScreen();
+ expect(getByText('ethereum 🚀')).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx
new file mode 100644
index 00000000000..d773c31e4c4
--- /dev/null
+++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx
@@ -0,0 +1,123 @@
+import React, { useCallback } from 'react';
+import { TouchableOpacity } from 'react-native';
+import {
+ Box,
+ Text,
+ TextVariant,
+ Icon,
+ IconName,
+ IconSize,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ NavigationProp,
+ ParamListBase,
+ useNavigation,
+} from '@react-navigation/native';
+
+export interface SitesSearchFooterProps {
+ searchQuery: string;
+}
+
+/**
+ * Checks if a string looks like a URL
+ */
+function looksLikeUrl(str: string): boolean {
+ return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str);
+}
+
+const SitesSearchFooter: React.FC = ({
+ searchQuery,
+}) => {
+ const tw = useTailwind();
+ const navigation = useNavigation>();
+
+ const onPressLink = useCallback(
+ (url: string) => {
+ navigation.navigate('TrendingBrowser', {
+ newTabUrl: url,
+ timestamp: Date.now(),
+ fromTrending: true,
+ });
+ },
+ [navigation],
+ );
+
+ if (!searchQuery || searchQuery.length === 0) {
+ return null;
+ }
+
+ const isUrl = looksLikeUrl(searchQuery.toLowerCase());
+
+ return (
+
+ {isUrl && (
+ onPressLink(searchQuery)}
+ testID="trending-search-footer-url-link"
+ >
+
+
+ {searchQuery}
+
+
+
+
+
+
+ )}
+
+
+ onPressLink(
+ `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`,
+ )
+ }
+ testID="trending-search-footer-google-link"
+ >
+
+
+ Search for {'"'}
+
+
+ {searchQuery}
+
+
+ {'"'} on Google
+
+
+
+
+
+
+
+ );
+};
+
+SitesSearchFooter.displayName = 'SitesSearchFooter';
+
+export default SitesSearchFooter;
diff --git a/app/components/Views/TrendingView/SectionSites/hooks/useSiteData.test.ts b/app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts
similarity index 100%
rename from app/components/Views/TrendingView/SectionSites/hooks/useSiteData.test.ts
rename to app/components/UI/Sites/hooks/useSiteData/useSiteData.test.ts
diff --git a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts
similarity index 97%
rename from app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts
rename to app/components/UI/Sites/hooks/useSiteData/useSitesData.ts
index 21b042710f5..5b2372f4946 100644
--- a/app/components/Views/TrendingView/SectionSites/hooks/useSitesData.ts
+++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import Logger from '../../../../../util/Logger';
-import type { SiteData } from '../SiteRowItem/SiteRowItem';
+import type { SiteData } from '../../components/SiteRowItem/SiteRowItem';
interface ApiDappResponse {
id: string;
diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js
index 7c3501a7726..8be464b4648 100644
--- a/app/components/UI/Swaps/QuotesView.js
+++ b/app/components/UI/Swaps/QuotesView.js
@@ -39,7 +39,6 @@ import {
isMainnetByChainId,
isMultiLayerFeeNetwork,
getDecimalChainId,
- isRemoveGlobalNetworkSelectorEnabled,
} from '../../../util/networks';
import { fetchEstimatedMultiLayerL1Fee } from '../../../util/networks/engineNetworkUtils';
import {
@@ -1169,9 +1168,7 @@ function SwapsQuotesView({
let approvalTransactionMetaId;
// Enable the network if it's not enabled for the Network Manager
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- tryEnableEvmNetwork(chainId);
- }
+ tryEnableEvmNetwork(chainId);
if (shouldUseSmartTransaction) {
try {
diff --git a/app/components/UI/Swaps/QuotesView.test.ts b/app/components/UI/Swaps/QuotesView.test.ts
index 236fa6a19a9..5e805c61d83 100644
--- a/app/components/UI/Swaps/QuotesView.test.ts
+++ b/app/components/UI/Swaps/QuotesView.test.ts
@@ -23,7 +23,6 @@ import { useSwapsSmartTransaction } from './utils/useSwapsSmartTransaction';
import { query } from '@metamask/controller-utils';
import { TransactionStatus } from '@metamask/transaction-controller';
import { useNetworkEnablement } from '../../hooks/useNetworkEnablement/useNetworkEnablement';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks';
import { isHardwareAccount } from '../../../util/address';
jest.mock('../../../util/networks/global-network', () => ({
@@ -64,7 +63,6 @@ jest.mock('../../hooks/useNetworkEnablement/useNetworkEnablement', () => ({
jest.mock('../../../util/networks', () => ({
...jest.requireActual('../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: jest.fn(),
}));
jest.mock('../../../util/address', () => ({
@@ -342,7 +340,6 @@ describe('QuotesView', () => {
(useNetworkEnablement as jest.Mock).mockReturnValue({
tryEnableEvmNetwork: mockTryEnableEvmNetwork,
});
- (isRemoveGlobalNetworkSelectorEnabled as jest.Mock).mockReturnValue(true);
});
it('should render quote screen', async () => {
@@ -625,49 +622,5 @@ describe('QuotesView', () => {
expect(mockTryEnableEvmNetwork).toHaveBeenCalledWith('0x1');
});
});
-
- it('should not call tryEnableEvmNetwork when feature flag is disabled', async () => {
- (isRemoveGlobalNetworkSelectorEnabled as jest.Mock).mockReturnValue(
- false,
- );
-
- const state = merge({}, mockInitialState);
- jest.mocked(query).mockResolvedValueOnce(123).mockResolvedValueOnce({
- timestamp: 1234,
- });
- jest
- .spyOn(Engine.context.TransactionController, 'addTransaction')
- .mockResolvedValue({
- result: Promise.resolve('mock-tx-hash'),
- transactionMeta: {
- id: 'mock-id',
- networkClientId: 'mock-network-id',
- time: Date.now(),
- chainId: '0x1',
- status: 'submitted' as TransactionStatus,
- txParams: {
- from: '0x0',
- to: '0x1',
- value: '0x0',
- gas: '0x0',
- gasPrice: '0x0',
- },
- },
- });
-
- const wrapper = render(QuotesView, state);
-
- const swapButton = await wrapper.findByTestId(
- SwapsViewSelectorsIDs.SWAP_BUTTON,
- );
-
- act(() => {
- fireEvent.press(swapButton);
- });
-
- await waitFor(() => {
- expect(mockTryEnableEvmNetwork).not.toHaveBeenCalled();
- });
- });
});
});
diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js
index b79d63a09ea..0cdcf84ab72 100644
--- a/app/components/UI/Swaps/index.js
+++ b/app/components/UI/Swaps/index.js
@@ -81,10 +81,7 @@ import { selectContractBalances } from '../../../selectors/tokenBalancesControll
import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController';
import AccountSelector from '../Ramp/Aggregator/components/AccountSelector';
import { QuoteViewSelectorIDs } from '../../../../e2e/selectors/swaps/QuoteView.selectors';
-import {
- getDecimalChainId,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../util/networks';
+import { getDecimalChainId } from '../../../util/networks';
import { useMetrics } from '../../../components/hooks/useMetrics';
import { getSwapsLiveness } from '../../../reducers/swaps/utils';
import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController';
@@ -723,13 +720,11 @@ function SwapsAmountView({
contentContainerStyle={styles.screen}
keyboardShouldPersistTaps="handled"
>
- {isRemoveGlobalNetworkSelectorEnabled() ? (
-
- ) : null}
+
diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js
index 145a93d362d..8c094b290c2 100644
--- a/app/components/UI/TransactionElement/index.js
+++ b/app/components/UI/TransactionElement/index.js
@@ -323,10 +323,7 @@ class TransactionElement extends PureComponent {
let incoming = false;
let selfSent = false;
- if (
- this.props.isMultichainAccountsState2Enabled &&
- process.env.MM_REMOVE_GLOBAL_NETWORK_SELECTOR === 'true'
- ) {
+ if (this.props.isMultichainAccountsState2Enabled) {
const selectedAddresses = selectSelectedAccountGroupInternalAccounts.map(
(account) => account.address,
);
diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx
index da16539bf73..a38b2ba43eb 100644
--- a/app/components/Views/AccountSelector/AccountSelector.tsx
+++ b/app/components/Views/AccountSelector/AccountSelector.tsx
@@ -90,7 +90,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
const dispatch = useDispatch();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
- const { height: screenHeight } = useWindowDimensions();
+ const { width: screenWidth } = useWindowDimensions();
const { trackEvent, createEventBuilder } = useMetrics();
const routeParams = useMemo(() => route?.params, [route?.params]);
@@ -162,11 +162,11 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
useState(false);
// Animation using react-native-reanimated - only for full-page version
- const translateY = useSharedValue(screenHeight);
+ const translateX = useSharedValue(screenWidth);
- // Backdrop opacity animation - fades in as screen slides up
+ // Backdrop opacity animation - fades in as screen slides in from right
const backdropOpacity = useDerivedValue(() =>
- interpolate(translateY.value, [screenHeight, 0], [0, 0.5]),
+ interpolate(translateX.value, [screenWidth, 0], [0, 0.5]),
);
useEffect(() => {
@@ -185,7 +185,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
});
};
- translateY.value = withSpring(
+ translateX.value = withSpring(
0,
{
damping: 20,
@@ -204,8 +204,8 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
navigation.goBack();
};
- translateY.value = withTiming(
- screenHeight,
+ translateX.value = withTiming(
+ screenWidth,
{ duration: AnimationDuration.Fast },
() => runOnJS(onCloseComplete)(),
);
@@ -213,7 +213,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
// BottomSheet version: close the sheet
sheetRef.current?.onCloseBottomSheet();
}
- }, [isFullPageAccountList, translateY, navigation, screenHeight]);
+ }, [isFullPageAccountList, translateX, navigation, screenWidth]);
const _onSelectAccount = useCallback(
(address: string) => {
@@ -412,7 +412,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => {
]);
const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ translateY: translateY.value }],
+ transform: [{ translateX: translateX.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
diff --git a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap
index a92d3c1dbd5..4003ff63656 100644
--- a/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/BrowserTab/__snapshots__/index.test.tsx.snap
@@ -413,24 +413,15 @@ exports[`BrowserTab render Browser 1`] = `
({
...jest.requireActual('../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: jest.fn(),
getNetworkImageSource: jest.fn(),
mainnet: {
name: 'Ethereum Main Network',
@@ -31,11 +28,6 @@ jest.mock('../../../../util/networks', () => ({
}));
const { PreferencesController, NetworkController } = Engine.context;
-const mockIsRemoveGlobalNetworkSelectorEnabled =
- isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction<
- typeof isRemoveGlobalNetworkSelectorEnabled
- >;
-
const MOCK_STORE_STATE = {
engine: {
backgroundState: {
@@ -75,7 +67,7 @@ const MOCK_STORE_STATE = {
decimals: 18,
},
},
- '0xtest': {
+ '0x999': {
rpcEndpoints: [
{
url: 'https://test.infura.io/v3/{infuraProjectId}',
@@ -84,10 +76,10 @@ const MOCK_STORE_STATE = {
],
defaultRpcEndpointIndex: 0,
blockExplorerUrls: ['https://lineascan.io'],
- chainId: '0xtest',
+ chainId: '0x999',
name: 'Test Mainnet',
nativeCurrency: {
- name: 'Linea Ether',
+ name: 'Test Ether',
symbol: 'ETH',
decimals: 18,
},
@@ -261,9 +253,6 @@ describe('RpcSelectionModal', () => {
}
return null;
});
-
- // Reset feature flag mock
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
});
afterEach(() => {
jest.clearAllMocks();
@@ -375,7 +364,7 @@ describe('RpcSelectionModal', () => {
{...defaultProps}
showMultiRpcSelectModal={{
isVisible: true,
- chainId: '0xtest',
+ chainId: '0x999',
networkName: 'Test Mainnet',
}}
/>,
@@ -387,75 +376,30 @@ describe('RpcSelectionModal', () => {
);
});
- describe('Feature Flag: isRemoveGlobalNetworkSelectorEnabled', () => {
- // Common test configurations
- const renderAndPressRpc = () => {
+ describe('Network Manager Integration', () => {
+ it('calls updateNetwork when RPC is selected', () => {
const { getByText } = renderWithProvider(
,
);
const rpcUrlElement = getByText('mainnet.infura.io/v3');
- fireEvent.press(rpcUrlElement);
- return { getByText };
- };
-
- const verifyControllersAvailable = () => {
- expect(NetworkController.updateNetwork).toBeDefined();
- expect(PreferencesController.setTokenNetworkFilter).toBeDefined();
- };
-
- describe('when feature flag is enabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
- it('should call selectNetwork', () => {
- renderAndPressRpc();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(NetworkController.updateNetwork).toHaveBeenCalled();
- expect(defaultProps.closeRpcModal).toHaveBeenCalled();
- });
+ fireEvent.press(rpcUrlElement);
- it('should have proper hook setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- verifyControllersAvailable();
- });
+ expect(NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(defaultProps.closeRpcModal).toHaveBeenCalled();
});
- describe('when feature flag is disabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- });
-
- it('should not call selectNetwork', () => {
- renderAndPressRpc();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(NetworkController.updateNetwork).toHaveBeenCalled();
- expect(defaultProps.closeRpcModal).toHaveBeenCalled();
- });
-
- it('should have proper hook setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- verifyControllersAvailable();
- });
+ it('initializes with Engine controllers available', () => {
+ expect(NetworkController.updateNetwork).toBeDefined();
+ expect(PreferencesController.setTokenNetworkFilter).toBeDefined();
});
- });
- describe('Hook Configuration', () => {
- it('should properly initialize hooks with default values', () => {
+ it('renders with default props', () => {
const { getByText } = renderWithProvider(
,
);
- // Verify that the component renders correctly
expect(getByText('Mainnet')).toBeTruthy();
});
-
- it('should have all necessary Engine controllers available', () => {
- // Verify that all necessary controllers are available
- expect(NetworkController.updateNetwork).toBeDefined();
- expect(PreferencesController.setTokenNetworkFilter).toBeDefined();
- });
});
});
diff --git a/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx b/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx
index f8752f8af80..ee63cdf34b3 100644
--- a/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx
+++ b/app/components/Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal.tsx
@@ -16,10 +16,7 @@ import {
AvatarVariant,
} from '../../../../component-library/components/Avatars/Avatar';
import { TextVariant } from '../../../../component-library/components/Texts/Text';
-import Networks, {
- getNetworkImageSource,
- isRemoveGlobalNetworkSelectorEnabled,
-} from '../../../../util/networks';
+import Networks, { getNetworkImageSource } from '../../../../util/networks';
import { strings } from '../../../../../locales/i18n';
import { CHAIN_IDS } from '@metamask/transaction-controller';
import images from 'images/image-icons';
@@ -121,10 +118,8 @@ const RpcSelectionModal: FC = ({
[chainId]: true,
});
}
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- const caipChainId = formatChainIdToCaip(chainId);
- selectNetwork(caipChainId);
- }
+ const caipChainId = formatChainIdToCaip(chainId);
+ selectNetwork(caipChainId);
},
[isAllNetwork, selectNetwork],
);
@@ -145,9 +140,7 @@ const RpcSelectionModal: FC = ({
(networkClientId: string, chainIdArg: `0x${string}`) => {
onRpcSelect(networkClientId, chainIdArg);
setTokenNetworkFilter(chainIdArg);
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- selectNetwork(chainIdArg);
- }
+ selectNetwork(chainIdArg);
closeRpcModal();
},
[onRpcSelect, setTokenNetworkFilter, closeRpcModal, selectNetwork],
diff --git a/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap
index 270208acf8f..e419f3b4947 100644
--- a/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/Settings/Contacts/ContactForm/__snapshots__/index.test.tsx.snap
@@ -265,6 +265,120 @@ exports[`ContactForm renders correctly 1`] = `
/>
+
+ Network
+
+
+
+
+
+
+
+
+
+
+
+
- {((editable &&
- !isRemoveGlobalNetworkSelectorFeatureFlagEnabled) ||
- isAddMode) && (
+ {isAddMode && (
- {isRemoveGlobalNetworkSelectorFeatureFlagEnabled && (
- <>
-
- {strings('address_book.network')}
-
- {
- if (this.state.editable) {
- this.setOpenNetworkSelector(true);
- }
- }}
- onLongPress={() => {
- if (this.state.editable) {
- this.setOpenNetworkSelector(true);
- }
- }}
- testID={AddContactViewSelectorsIDs.NETWORK_INPUT}
- >
-
-
-
- {networkName}
-
-
- {!!editable && (
- {
- if (this.state.editable) {
- this.setOpenNetworkSelector(true);
- }
- }}
- accessibilityRole="button"
- style={styles.buttonIcon}
- />
- )}
-
- >
- )}
+ <>
+
+ {strings('address_book.network')}
+
+ {
+ if (this.state.editable) {
+ this.setOpenNetworkSelector(true);
+ }
+ }}
+ onLongPress={() => {
+ if (this.state.editable) {
+ this.setOpenNetworkSelector(true);
+ }
+ }}
+ testID={AddContactViewSelectorsIDs.NETWORK_INPUT}
+ >
+
+
+
+ {networkName}
+
+
+ {!!editable && (
+ {
+ if (this.state.editable) {
+ this.setOpenNetworkSelector(true);
+ }
+ }}
+ accessibilityRole="button"
+ style={styles.buttonIcon}
+ />
+ )}
+
+ >
{addressError && (
diff --git a/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx b/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx
index abae7975c19..a2ab9202c33 100644
--- a/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx
+++ b/app/components/Views/Settings/Contacts/ContactForm/index.test.tsx
@@ -11,10 +11,6 @@ const MOCK_ADDRESS = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272';
const MOCK_ADDRESS_2 = '0xf55C0d639d99699bFd7EC54d9FAFee40E4d272C4';
const ENS_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12';
-const mockIsRemoveGlobalNetworkSelectorEnabled = jest
- .fn()
- .mockReturnValue(false);
-
jest.mock('../../../../../util/address', () => ({
...jest.requireActual('../../../../../util/address'),
renderShortAddress: jest.fn((address) => `0x123...${address.slice(-4)}`),
@@ -33,8 +29,6 @@ jest.mock('../../../../../util/address', () => ({
jest.mock('../../../../../util/networks', () => ({
...jest.requireActual('../../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled: () =>
- mockIsRemoveGlobalNetworkSelectorEnabled(),
getNetworkImageSource: jest.fn(() => ({ uri: 'mock-image-uri' })),
}));
@@ -135,7 +129,6 @@ const renderContactForm = (
describe('ContactForm', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
});
it('renders correctly', () => {
@@ -261,9 +254,7 @@ describe('ContactForm', () => {
});
});
- it('handles network selection when isRemoveGlobalNetworkSelectorEnabled is true', async () => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
-
+ it('handles network selection', async () => {
const { findByTestId } = renderContactForm();
await waitFor(() => {
@@ -353,7 +344,7 @@ describe('ContactForm', () => {
await waitFor(() => {
expect(addressInput.props.value).toBe(MOCK_ADDRESS);
- expect(addressInput.props.editable).toBeTruthy();
+ expect(addressInput.props.editable).toBeFalsy(); // Address is immutable in edit mode
expect(nameInput.props.editable).toBeTruthy();
expect(memoInput.props.editable).toBeTruthy();
});
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
index 5ba833a4d42..6d193578295 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
@@ -32,6 +32,7 @@ import sanitizeUrl, {
compareSanitizedUrl,
} from '../../../../../util/sanitizeUrl';
import onlyKeepHost from '../../../../../util/onlyKeepHost';
+import { isPublicEndpointUrl } from '../../../../../core/Engine/controllers/network-controller/utils';
import { themeAppearanceLight } from '../../../../../constants/storage';
import CustomNetwork from './CustomNetworkView/CustomNetwork';
import Button, {
@@ -48,6 +49,7 @@ import { selectIsRpcFailoverEnabled } from '../../../../../selectors/featureFlag
import { regex } from '../../../../../../app/util/regex';
import { NetworksViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/NetworksView.selectors';
import { isSafeChainId, toHex } from '@metamask/controller-utils';
+import { hexToNumber } from '@metamask/utils';
import { CustomDefaultNetworkIDs } from '../../../../../../e2e/selectors/Onboarding/CustomDefaultNetwork.selectors';
import { updateIncomingTransactions } from '../../../../../util/transaction-controller';
import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics';
@@ -83,12 +85,11 @@ import Tag from '../../../../../component-library/components/Tags/Tag/Tag';
import { CellComponentSelectorsIDs } from '../../../../../../e2e/selectors/wallet/CellComponent.selectors';
import stripProtocol from '../../../../../util/stripProtocol';
import stripKeyFromInfuraUrl from '../../../../../util/stripKeyFromInfuraUrl';
-import { MetaMetrics } from '../../../../../core/Analytics';
+import { MetaMetrics, MetaMetricsEvents } from '../../../../../core/Analytics';
import {
addItemToChainIdList,
removeItemFromChainIdList,
} from '../../../../../util/metrics/MultichainAPI/networkMetricUtils';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../util/networks';
import { NETWORK_TO_NAME_MAP } from '../../../../../core/Engine/constants';
import { createStyles } from './index.styles';
@@ -532,6 +533,7 @@ export class NetworkSettings extends PureComponent {
isCustomMainnet,
shouldNetworkSwitchPopToWallet,
navigation,
+ trackRpcUpdateFromBanner,
}) => {
const { NetworkController } = Engine.context;
@@ -569,6 +571,38 @@ export class NetworkSettings extends PureComponent {
}
: undefined,
);
+
+ // Track RPC update from network connection banner
+ if (trackRpcUpdateFromBanner) {
+ const newRpcEndpoint =
+ networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex];
+ const oldRpcEndpoint =
+ existingNetwork.rpcEndpoints?.[
+ existingNetwork.defaultRpcEndpointIndex ?? 0
+ ];
+
+ const chainIdAsDecimal = hexToNumber(chainId);
+
+ const sanitizeRpcUrl = (url) =>
+ isPublicEndpointUrl(url, infuraProjectId)
+ ? onlyKeepHost(url)
+ : 'custom';
+
+ this.props.metrics.trackEvent(
+ this.props.metrics
+ .createEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: `eip155:${chainIdAsDecimal}`,
+ from_rpc_domain: oldRpcEndpoint?.url
+ ? sanitizeRpcUrl(oldRpcEndpoint.url)
+ : 'unknown',
+ to_rpc_domain: sanitizeRpcUrl(newRpcEndpoint.url),
+ })
+ .build(),
+ );
+ }
} else {
await NetworkController.addNetwork({
...networkConfig,
@@ -614,6 +648,9 @@ export class NetworkSettings extends PureComponent {
const shouldNetworkSwitchPopToWallet =
route.params?.shouldNetworkSwitchPopToWallet ?? true;
+
+ const trackRpcUpdateFromBanner =
+ route.params?.trackRpcUpdateFromBanner ?? false;
// Check if CTA is disabled
const isCtaDisabled =
!enableAction || this.disabledByChainId() || this.disabledBySymbol();
@@ -663,10 +700,8 @@ export class NetworkSettings extends PureComponent {
});
}
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- const { NetworkEnablementController } = Engine.context;
- NetworkEnablementController.enableNetwork(chainId);
- }
+ const { NetworkEnablementController } = Engine.context;
+ NetworkEnablementController.enableNetwork(chainId);
await this.handleNetworkUpdate({
rpcUrl,
@@ -684,6 +719,7 @@ export class NetworkSettings extends PureComponent {
networkType,
networkUrl,
showNetworkOnboarding,
+ trackRpcUpdateFromBanner,
});
};
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
index 21847e0f60c..be2de00f4dd 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
@@ -23,7 +23,7 @@ import { mockNetworkState } from '../../../../../util/test/network';
import * as jsonRequest from '../../../../../util/jsonRpcRequest';
import Logger from '../../../../../util/Logger';
import Engine from '../../../../../core/Engine';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../util/networks';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
const { PreferencesController } = Engine.context;
jest.mock(
@@ -38,14 +38,28 @@ jest.mock(
}),
);
+const mockTrackEvent = jest.fn();
+
+const mockCreateEventBuilder = jest.fn((eventName) => {
+ let properties = {};
+ return {
+ addProperties(props: Record) {
+ properties = { ...properties, ...props };
+ return this;
+ },
+ build() {
+ return {
+ name: eventName,
+ properties,
+ };
+ },
+ };
+});
+
jest.mock('../../../../../components/hooks/useMetrics', () => ({
useMetrics: () => ({
- trackEvent: jest.fn(),
- createEventBuilder: jest.fn(() => ({
- addProperties: jest.fn(() => ({
- build: jest.fn(),
- })),
- })),
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
}),
withMetricsAwareness: (Component: unknown) => Component,
}));
@@ -53,12 +67,9 @@ jest.mock('../../../../../components/hooks/useMetrics', () => ({
// Mock the feature flag
jest.mock('../../../../../util/networks', () => {
const mockGetAllNetworks = jest.fn(() => ['mainnet', 'sepolia']);
- const mockIsRemoveGlobalNetworkSelectorEnabled = jest.fn();
return {
...jest.requireActual('../../../../../util/networks'),
- isRemoveGlobalNetworkSelectorEnabled:
- mockIsRemoveGlobalNetworkSelectorEnabled,
getAllNetworks: mockGetAllNetworks,
mainnet: {
name: 'Ethereum Main Network',
@@ -1410,6 +1421,274 @@ describe('NetworkSettings', () => {
{ replacementSelectedRpcEndpointIndex: 0 },
);
});
+
+ it('tracks RPC update event when trackRpcUpdateFromBanner is true', async () => {
+ const PROPS_WITH_METRICS = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: 0,
+ chainId: '0x64',
+ rpcEndpoints: [
+ {
+ networkClientId: 'custom',
+ type: 'custom',
+ url: 'https://mainnet.infura.io/v3/',
+ },
+ ],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper5 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper5.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://monad-mainnet.infura.io/v3/',
+ rpcUrls: [
+ {
+ url: 'https://monad-mainnet.infura.io/v3/',
+ type: 'custom',
+ name: 'Monad RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: true,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ mockCreateEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: 'eip155:100',
+ from_rpc_domain: 'mainnet.infura.io',
+ to_rpc_domain: 'monad-mainnet.infura.io',
+ })
+ .build(),
+ );
+ });
+
+ it('does not track RPC update event when trackRpcUpdateFromBanner is false', async () => {
+ const PROPS_WITHOUT_TRACKING = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: 0,
+ chainId: '0x64',
+ rpcEndpoints: [
+ {
+ networkClientId: 'custom',
+ type: 'custom',
+ url: 'https://mainnet.infura.io/v3/',
+ },
+ ],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper6 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper6.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://monad-mainnet.infura.io/v3/',
+ rpcUrls: [
+ {
+ url: 'https://monad-mainnet.infura.io/v3/',
+ type: 'custom',
+ name: 'Monad RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: false,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+ });
+
+ it('sanitizes custom RPC URLs as "custom" in tracking event', async () => {
+ const PROPS_WITH_CUSTOM_RPC = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: 0,
+ chainId: '0x64',
+ rpcEndpoints: [
+ {
+ networkClientId: 'custom',
+ type: 'custom',
+ url: 'https://my-private-rpc.com',
+ },
+ ],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper7 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper7.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://another-private-rpc.com',
+ rpcUrls: [
+ {
+ url: 'https://another-private-rpc.com',
+ type: 'custom',
+ name: 'Another Custom RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: true,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ mockCreateEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: 'eip155:100',
+ from_rpc_domain: 'custom',
+ to_rpc_domain: 'custom',
+ })
+ .build(),
+ );
+ });
+
+ it('tracks unknown for missing old RPC endpoint', async () => {
+ const PROPS_WITHOUT_OLD_ENDPOINT = {
+ ...SAMPLE_PROPS,
+ metrics: {
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ },
+ networkConfigurations: {
+ '0x64': {
+ blockExplorerUrls: ['https://etherscan.io'],
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: undefined,
+ chainId: '0x64',
+ rpcEndpoints: [],
+ name: 'Custom Network',
+ nativeCurrency: 'ETH',
+ },
+ },
+ };
+
+ const wrapper8 = shallow(
+
+
+ ,
+ )
+ .find(NetworkSettings)
+ .dive();
+
+ const instance = wrapper8.instance() as NetworkSettings;
+
+ await instance.handleNetworkUpdate({
+ rpcUrl: 'https://new-rpc.infura.io/v3/',
+ rpcUrls: [
+ {
+ url: 'https://new-rpc.infura.io/v3/',
+ type: 'custom',
+ name: 'New RPC',
+ },
+ ],
+ blockExplorerUrls: ['https://etherscan.io'],
+ blockExplorerUrl: 'https://etherscan.io',
+ nickname: 'Custom Network',
+ ticker: 'ETH',
+ isNetworkExists: [],
+ chainId: '0x64',
+ navigation: mockNavigation,
+ isCustomMainnet: false,
+ shouldNetworkSwitchPopToWallet: true,
+ trackRpcUpdateFromBanner: true,
+ });
+
+ expect(Engine.context.NetworkController.updateNetwork).toHaveBeenCalled();
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ mockCreateEventBuilder(
+ MetaMetricsEvents.NetworkConnectionBannerRpcUpdated,
+ )
+ .addProperties({
+ chain_id_caip: 'eip155:100',
+ from_rpc_domain: 'unknown',
+ to_rpc_domain: 'new-rpc.infura.io',
+ })
+ .build(),
+ );
+ });
});
describe('checkIfRpcUrlExists', () => {
@@ -1850,33 +2129,23 @@ describe('NetworkSettings', () => {
});
});
- describe('Feature Flag: isRemoveGlobalNetworkSelectorEnabled', () => {
- const mockIsRemoveGlobalNetworkSelectorEnabled =
- isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction<
- typeof isRemoveGlobalNetworkSelectorEnabled
- >;
-
- beforeEach(() => {
- // After feature flag removal, always returns true
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
-
- it('should call NetworkEnablementController.enableNetwork when adding a network', async () => {
+ describe('Network Manager Integration', () => {
+ it('calls NetworkEnablementController.enableNetwork when adding a network', async () => {
const { NetworkEnablementController } = Engine.context;
const enableNetworkSpy = jest.spyOn(
NetworkEnablementController,
'enableNetwork',
);
- // Mock validateChainIdOnSubmit to return true so it doesn't return early
- jest
- .spyOn(wrapper.instance(), 'validateChainIdOnSubmit')
- .mockResolvedValue(true);
+ const instance = wrapper.instance();
- // Mock handleNetworkUpdate to prevent actual network addition
+ jest.spyOn(instance, 'disabledByChainId').mockReturnValue(false);
+ jest.spyOn(instance, 'disabledBySymbol').mockReturnValue(false);
jest
- .spyOn(wrapper.instance(), 'handleNetworkUpdate')
- .mockResolvedValue({});
+ .spyOn(instance, 'checkIfNetworkNotExistsByChainId')
+ .mockResolvedValue([]);
+ jest.spyOn(instance, 'validateChainIdOnSubmit').mockResolvedValue(true);
+ jest.spyOn(instance, 'handleNetworkUpdate').mockResolvedValue({});
wrapper.setState({
rpcUrl: 'http://localhost:8545',
@@ -1890,19 +2159,13 @@ describe('NetworkSettings', () => {
blockExplorerUrls: [],
});
- await wrapper.instance().addRpcUrl();
-
- // Verify that the feature flag is enabled
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
+ await instance.addRpcUrl();
// Verify that enableNetwork was called with the correct chainId
expect(enableNetworkSpy).toHaveBeenCalledWith('0x1');
});
it('should have proper Engine controller setup', () => {
- // Verify that the feature flag is enabled
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
-
// Verify that the necessary controllers are available
expect(
Engine.context.NetworkEnablementController.enableNetwork,
@@ -1910,38 +2173,6 @@ describe('NetworkSettings', () => {
expect(Engine.context.NetworkController.addNetwork).toBeDefined();
expect(Engine.context.NetworkController.updateNetwork).toBeDefined();
});
-
- it('should not call NetworkEnablementController.enableNetwork when feature flag is disabled (legacy test)', async () => {
- // Temporarily mock the feature flag as disabled for this legacy test
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
-
- const { NetworkEnablementController } = Engine.context;
- const setEnabledNetworkSpy = jest.spyOn(
- NetworkEnablementController,
- 'enableNetwork',
- );
-
- wrapper.setState({
- rpcUrl: 'http://localhost:8545',
- chainId: '0x1',
- ticker: 'ETH',
- nickname: 'Localhost',
- enableAction: true,
- addMode: true,
- editable: false,
- });
-
- await wrapper.instance().addRpcUrl();
-
- // Verify that the feature flag is disabled
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
-
- // Verify that setEnabledNetwork was not called
- expect(setEnabledNetworkSpy).not.toHaveBeenCalled();
-
- // Reset for other tests
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
});
});
diff --git a/app/components/Views/SitesFullView/SitesFullView.test.tsx b/app/components/Views/SitesFullView/SitesFullView.test.tsx
new file mode 100644
index 00000000000..e94244ce848
--- /dev/null
+++ b/app/components/Views/SitesFullView/SitesFullView.test.tsx
@@ -0,0 +1,465 @@
+import React from 'react';
+import { render, fireEvent, act, waitFor } from '@testing-library/react-native';
+import SitesFullView from './SitesFullView';
+import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData';
+import type { SiteData } from '../../UI/Sites/components/SiteRowItem/SiteRowItem';
+
+// Mock dependencies
+jest.mock('../../UI/Sites/hooks/useSiteData/useSitesData');
+
+jest.mock('react-native-safe-area-context', () => ({
+ SafeAreaView: jest.requireActual('react-native').View,
+ useSafeAreaInsets: () => ({ top: 50, bottom: 34, left: 0, right: 0 }),
+}));
+
+const mockGoBack = jest.fn();
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ goBack: mockGoBack,
+ }),
+}));
+
+jest.mock('../../../util/theme', () => ({
+ useAppThemeFromContext: () => ({
+ colors: {
+ background: { default: '#FFFFFF' },
+ primary: { default: '#037DD6' },
+ icon: { default: '#24272A' },
+ },
+ }),
+}));
+
+jest.mock('../../UI/shared/ListHeaderWithSearch/ListHeaderWithSearch', () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(
+ ({
+ defaultTitle,
+ isSearchVisible,
+ searchQuery,
+ onSearchQueryChange,
+ onBack,
+ onSearchToggle,
+ testID,
+ }) => (
+
+ {!isSearchVisible ? (
+ <>
+
+ Back
+
+
+ {defaultTitle}
+
+
+ Search
+
+ >
+ ) : (
+ <>
+
+
+ Cancel
+
+ >
+ )}
+
+ ),
+ );
+});
+
+jest.mock('../../UI/Sites/components/SitesList/SitesList', () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(({ sites, refreshControl, ListFooterComponent }) => (
+
+ {sites.map((site: SiteData) => (
+
+ {site.name}
+
+ ))}
+ {refreshControl && (
+
+ {refreshControl}
+
+ )}
+ {ListFooterComponent}
+
+ ));
+});
+
+jest.mock('../../UI/Sites/components/SiteSkeleton/SiteSkeleton', () =>
+ jest.fn(() => {
+ const ReactNative = jest.requireActual('react-native');
+ return (
+
+ Loading...
+
+ );
+ }),
+);
+
+jest.mock(
+ '../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter',
+ () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(({ searchQuery }) =>
+ searchQuery ? (
+
+ {searchQuery}
+
+ ) : null,
+ );
+ },
+);
+
+const mockUseSitesData = useSitesData as jest.Mock;
+const mockRefetch = jest.fn();
+
+describe('SitesFullView', () => {
+ const mockSites: SiteData[] = [
+ {
+ id: '1',
+ name: 'MetaMask',
+ url: 'https://metamask.io',
+ displayUrl: 'metamask.io',
+ logoUrl: 'https://example.com/metamask.png',
+ featured: true,
+ },
+ {
+ id: '2',
+ name: 'OpenSea',
+ url: 'https://opensea.io',
+ displayUrl: 'opensea.io',
+ logoUrl: 'https://example.com/opensea.png',
+ featured: false,
+ },
+ {
+ id: '3',
+ name: 'Uniswap',
+ url: 'https://uniswap.org',
+ displayUrl: 'uniswap.org',
+ logoUrl: 'https://example.com/uniswap.png',
+ featured: true,
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRefetch.mockClear();
+ });
+
+ describe('Rendering', () => {
+ it('renders header with back button and title', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('sites-full-view-header')).toBeOnTheScreen();
+ expect(
+ getByTestId('sites-full-view-header-back-button'),
+ ).toBeOnTheScreen();
+ expect(getByTestId('sites-full-view-header-title')).toBeOnTheScreen();
+ expect(
+ getByTestId('sites-full-view-header-search-toggle'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders SitesList component with all site items', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('sites-list')).toBeOnTheScreen();
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(getByTestId('site-item-2')).toBeOnTheScreen();
+ expect(getByTestId('site-item-3')).toBeOnTheScreen();
+ });
+
+ it('renders skeletons when loading', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: [],
+ isLoading: true,
+ refetch: mockRefetch,
+ });
+
+ const { getAllByTestId } = render();
+
+ const skeletons = getAllByTestId('site-skeleton');
+ expect(skeletons.length).toBe(10);
+ });
+
+ it('renders RefreshControl', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('refresh-control')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Navigation', () => {
+ it('navigates back when back button is pressed', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+ const backButton = getByTestId('sites-full-view-header-back-button');
+
+ fireEvent.press(backButton);
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Search Functionality', () => {
+ it('filters sites by name, URL, and display URL', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Search by name
+ fireEvent.changeText(searchInput, 'Meta');
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(queryByTestId('site-item-2')).toBeNull();
+
+ // Search by URL
+ fireEvent.changeText(searchInput, 'opensea');
+ expect(queryByTestId('site-item-1')).toBeNull();
+ expect(getByTestId('site-item-2')).toBeOnTheScreen();
+
+ // Search by display URL
+ fireEvent.changeText(searchInput, 'uniswap.org');
+ expect(queryByTestId('site-item-2')).toBeNull();
+ expect(getByTestId('site-item-3')).toBeOnTheScreen();
+ });
+
+ it('shows all sites when search query is empty', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Empty search
+ fireEvent.changeText(searchInput, '');
+
+ // All sites should be visible
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(getByTestId('site-item-2')).toBeOnTheScreen();
+ expect(getByTestId('site-item-3')).toBeOnTheScreen();
+ });
+
+ it('clears search query when search is closed', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Type search query
+ fireEvent.changeText(searchInput, 'test');
+
+ // Close search
+ fireEvent.press(getByTestId('sites-full-view-header-search-close'));
+
+ // Reopen search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+
+ // Search input should be empty
+ const newSearchInput = getByTestId('sites-full-view-header-search-bar');
+ expect(newSearchInput.props.value).toBe('');
+ });
+
+ it('displays SitesSearchFooter when search is active', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Initially no footer
+ expect(queryByTestId('sites-search-footer')).toBeNull();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Type search query
+ fireEvent.changeText(searchInput, 'test');
+
+ // Footer should appear
+ expect(getByTestId('sites-search-footer')).toBeOnTheScreen();
+ });
+
+ it('hides SitesSearchFooter when search query is empty or search is inactive', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Footer should not appear when search is inactive
+ expect(queryByTestId('sites-search-footer')).toBeNull();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+
+ // Footer should not appear with empty query
+ expect(queryByTestId('sites-search-footer')).toBeNull();
+ });
+ });
+
+ describe('Data Fetching', () => {
+ it('fetches sites with limit of 100', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ render();
+
+ expect(mockUseSitesData).toHaveBeenCalledWith({ limit: 100 });
+ });
+
+ it('calls refetch when refresh is triggered', async () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ render();
+
+ const SitesListMock = jest.requireMock(
+ '../../UI/Sites/components/SitesList/SitesList',
+ );
+
+ // Get the refreshControl prop passed to SitesList
+ const sitesListProps = SitesListMock.mock.calls[0][0];
+ const refreshControl = sitesListProps.refreshControl;
+
+ expect(refreshControl).toBeDefined();
+
+ // Simulate refresh
+ await act(async () => {
+ await refreshControl.props.onRefresh();
+ });
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles sites with missing optional fields', () => {
+ const minimalSites: SiteData[] = [
+ {
+ id: '1',
+ name: 'Test',
+ url: 'https://test.com',
+ displayUrl: 'test.com',
+ },
+ ];
+
+ mockUseSitesData.mockReturnValue({
+ sites: minimalSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ });
+
+ it('handles empty sites array', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: [],
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ expect(getByTestId('sites-list')).toBeOnTheScreen();
+ expect(queryByTestId('site-item-1')).toBeNull();
+ });
+
+ it('performs case-insensitive search', () => {
+ mockUseSitesData.mockReturnValue({
+ sites: mockSites,
+ isLoading: false,
+ refetch: mockRefetch,
+ });
+
+ const { getByTestId, queryByTestId } = render();
+
+ // Activate search
+ fireEvent.press(getByTestId('sites-full-view-header-search-toggle'));
+ const searchInput = getByTestId('sites-full-view-header-search-bar');
+
+ // Search with different case
+ fireEvent.changeText(searchInput, 'METAMASK');
+
+ // MetaMask should still be found
+ expect(getByTestId('site-item-1')).toBeOnTheScreen();
+ expect(queryByTestId('site-item-2')).toBeNull();
+ });
+ });
+});
diff --git a/app/components/Views/SitesFullView/SitesFullView.tsx b/app/components/Views/SitesFullView/SitesFullView.tsx
new file mode 100644
index 00000000000..abc6783059f
--- /dev/null
+++ b/app/components/Views/SitesFullView/SitesFullView.tsx
@@ -0,0 +1,154 @@
+import React, { useCallback, useState, useMemo } from 'react';
+import { StyleSheet, View, RefreshControl } from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+// eslint-disable-next-line no-duplicate-imports
+import type { NavigationProp, ParamListBase } from '@react-navigation/native';
+import {
+ SafeAreaView,
+ useSafeAreaInsets,
+} from 'react-native-safe-area-context';
+import { useAppThemeFromContext } from '../../../util/theme';
+import { Theme } from '../../../util/theme/models';
+import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData';
+import SitesList from '../../UI/Sites/components/SitesList/SitesList';
+import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton';
+import SitesSearchFooter from '../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter';
+import { strings } from '../../../../locales/i18n';
+import ListHeaderWithSearch from '../../UI/shared/ListHeaderWithSearch/ListHeaderWithSearch';
+
+const createStyles = (theme: Theme) =>
+ StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: theme.colors.background.default,
+ paddingBottom: 16,
+ },
+ headerContainer: {
+ backgroundColor: theme.colors.background.default,
+ },
+ listContainer: {
+ flex: 1,
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
+ });
+
+const SitesFullView: React.FC = () => {
+ const theme = useAppThemeFromContext();
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const insets = useSafeAreaInsets();
+ const navigation = useNavigation>();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isSearchActive, setIsSearchActive] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+
+ // Fetch all sites (no limit)
+ const {
+ sites,
+ isLoading,
+ refetch: refetchSites,
+ } = useSitesData({ limit: 100 });
+
+ // Filter sites based on search query
+ const filteredSites = useMemo(() => {
+ if (!searchQuery.trim()) {
+ return sites;
+ }
+
+ const query = searchQuery.toLowerCase();
+ return sites.filter(
+ (site) =>
+ site.name.toLowerCase().includes(query) ||
+ site.displayUrl.toLowerCase().includes(query) ||
+ site.url.toLowerCase().includes(query),
+ );
+ }, [sites, searchQuery]);
+
+ const handleBackPress = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleSearchToggle = useCallback(() => {
+ setIsSearchActive((prev) => {
+ if (prev) {
+ // Closing search, clear the query
+ setSearchQuery('');
+ }
+ return !prev;
+ });
+ }, []);
+
+ // Handle pull-to-refresh
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ refetchSites?.();
+ } catch (error) {
+ console.warn('Failed to refresh sites:', error);
+ } finally {
+ setRefreshing(false);
+ }
+ }, [refetchSites]);
+
+ const renderSkeleton = () => (
+ <>
+ {[...Array(10)].map((_, index) => (
+
+ ))}
+ >
+ );
+
+ const renderFooter = useMemo(() => {
+ if (!isSearchActive) return null;
+
+ return ;
+ }, [isSearchActive, searchQuery]);
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+ {renderSkeleton()}
+ ) : (
+
+
+ }
+ ListFooterComponent={renderFooter}
+ />
+
+ )}
+
+ );
+};
+
+SitesFullView.displayName = 'SitesFullView';
+
+export default SitesFullView;
diff --git a/app/components/Views/TransactionsView/index.js b/app/components/Views/TransactionsView/index.js
index 5b37954eb4c..9843e6b78fe 100644
--- a/app/components/Views/TransactionsView/index.js
+++ b/app/components/Views/TransactionsView/index.js
@@ -20,12 +20,7 @@ import {
} from '../../../util/activity';
import { areAddressesEqual } from '../../../util/address';
import { addAccountTimeFlagFilter } from '../../../util/transactions';
-import {
- selectChainId,
- selectIsPopularNetwork,
- selectProviderType,
- selectSelectedNetworkClientId,
-} from '../../../selectors/networkController';
+import { selectProviderType } from '../../../selectors/networkController';
import {
selectConversionRate,
selectCurrentCurrency,
@@ -42,10 +37,6 @@ import { selectNonEvmTransactions } from '../../../selectors/multichain';
import { isEvmAccountType } from '@metamask/keyring-api';
///: END:ONLY_INCLUDE_IF
import { toChecksumHexAddress } from '@metamask/controller-utils';
-import { selectTokenNetworkFilter } from '../../../selectors/preferencesController';
-import { CHAIN_IDS } from '@metamask/transaction-controller';
-import { PopularList } from '../../../util/networks/customNetworks';
-import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks';
import useCurrencyRatePolling from '../../hooks/AssetPolling/useCurrencyRatePolling';
import useTokenRatesPolling from '../../hooks/AssetPolling/useTokenRatesPolling';
import { selectBridgeHistoryForAccount } from '../../../selectors/bridgeStatusController';
@@ -63,7 +54,6 @@ const TransactionsView = ({
networkType,
currentCurrency,
transactions,
- chainId,
tokens,
tokenNetworkFilter,
}) => {
@@ -71,7 +61,6 @@ const TransactionsView = ({
const [submittedTxs, setSubmittedTxs] = useState([]);
const [confirmedTxs, setConfirmedTxs] = useState([]);
const [loading, setLoading] = useState();
- const selectedNetworkClientId = useSelector(selectSelectedNetworkClientId);
const bridgeHistory = useSelector(selectBridgeHistoryForAccount);
const enabledNetworksByNamespace = useSelector(
@@ -85,162 +74,127 @@ const TransactionsView = ({
selectedInternalAccount?.address,
);
- const isPopularNetwork = useSelector(selectIsPopularNetwork);
+ const filterTransactions = useCallback(() => {
+ let accountAddedTimeInsertPointFound = false;
+ const addedAccountTime = selectedInternalAccount?.metadata.importTime;
- const filterTransactions = useCallback(
- (networkId) => {
- let accountAddedTimeInsertPointFound = false;
- const addedAccountTime = selectedInternalAccount?.metadata.importTime;
+ const submittedTxs = [];
+ const confirmedTxs = [];
+ const submittedNonces = [];
- const submittedTxs = [];
- const confirmedTxs = [];
- const submittedNonces = [];
+ const allTransactionsSorted = sortTransactions(transactions).filter(
+ (tx, index, self) => self.findIndex((_tx) => _tx.id === tx.id) === index,
+ );
- const allTransactionsSorted = sortTransactions(transactions).filter(
- (tx, index, self) =>
- self.findIndex((_tx) => _tx.id === tx.id) === index,
+ const allTransactions = allTransactionsSorted.filter((tx) => {
+ const filter = filterByAddressAndNetwork(
+ tx,
+ tokens,
+ selectedAddress,
+ tokenNetworkFilter,
+ allTransactionsSorted,
+ bridgeHistory,
);
- const allTransactions = allTransactionsSorted.filter((tx) => {
- const filter = filterByAddressAndNetwork(
- tx,
- tokens,
- selectedAddress,
- tokenNetworkFilter,
- allTransactionsSorted,
- bridgeHistory,
- );
+ if (!filter) return false;
- if (!filter) return false;
+ const insertImportTime = addAccountTimeFlagFilter(
+ tx,
+ addedAccountTime,
+ accountAddedTimeInsertPointFound,
+ );
- const insertImportTime = addAccountTimeFlagFilter(
- tx,
- addedAccountTime,
- accountAddedTimeInsertPointFound,
- );
+ // Create a new transaction object with the insertImportTime property
+ const updatedTx = {
+ ...tx,
+ insertImportTime,
+ };
+
+ if (updatedTx.insertImportTime) accountAddedTimeInsertPointFound = true;
+
+ switch (tx.status) {
+ case TX_SUBMITTED:
+ case TX_SIGNED:
+ case TX_UNAPPROVED:
+ case TX_PENDING:
+ submittedTxs.push(updatedTx);
+ return false;
+ case TX_CONFIRMED:
+ confirmedTxs.push(updatedTx);
+ break;
+ }
- // Create a new transaction object with the insertImportTime property
- const updatedTx = {
- ...tx,
- insertImportTime,
- };
-
- if (updatedTx.insertImportTime) accountAddedTimeInsertPointFound = true;
-
- switch (tx.status) {
- case TX_SUBMITTED:
- case TX_SIGNED:
- case TX_UNAPPROVED:
- case TX_PENDING:
- submittedTxs.push(updatedTx);
- return false;
- case TX_CONFIRMED:
- confirmedTxs.push(updatedTx);
- break;
+ return filter;
+ });
+
+ // TODO: Make sure to come back and check on how Solana transactions are handled
+ const allTransactionsFiltered = allTransactions.filter((tx) => {
+ const enabledChainIds = Object.entries(
+ enabledNetworksByNamespace?.[KnownCaipNamespace.Eip155] ?? {},
+ )
+ .filter(([, enabled]) => enabled)
+ .map(([chainId]) => chainId);
+
+ return isTransactionOnChains(tx, enabledChainIds, allTransactionsSorted);
+ });
+
+ const submittedTxsFiltered = submittedTxs.filter(
+ ({ chainId, txParams }) => {
+ const { from, nonce } = txParams;
+
+ if (!areAddressesEqual(from, selectedAddress)) {
+ return false;
}
- return filter;
- });
-
- let allTransactionsFiltered;
- if (isRemoveGlobalNetworkSelectorEnabled()) {
- // TODO: Make sure to come back and check on how Solana transactions are handled
- allTransactionsFiltered = allTransactions.filter((tx) => {
- const enabledChainIds = Object.entries(
- enabledNetworksByNamespace?.[KnownCaipNamespace.Eip155] ?? {},
- )
- .filter(([, enabled]) => enabled)
- .map(([chainId]) => chainId);
-
- return isTransactionOnChains(
- tx,
- enabledChainIds,
- allTransactionsSorted,
- );
- });
- } else {
- allTransactionsFiltered = isPopularNetwork
- ? allTransactions.filter((tx) => {
- const popularChainIds = [
- CHAIN_IDS.MAINNET,
- CHAIN_IDS.LINEA_MAINNET,
- ...PopularList.map((n) => n.chainId),
- ];
- return isTransactionOnChains(
- tx,
- popularChainIds,
- allTransactions,
- );
- })
- : allTransactions.filter((tx) =>
- isTransactionOnChains(tx, [chainId], allTransactionsSorted),
- );
- }
+ const nonceKey = `${chainId}-${nonce}`;
+ const alreadySubmitted = submittedNonces.includes(nonceKey);
+ const alreadyConfirmed = confirmedTxs.find(
+ (tx) =>
+ areAddressesEqual(tx.txParams.from, selectedAddress) &&
+ tx.chainId === chainId &&
+ tx.txParams.nonce === nonce,
+ );
- const submittedTxsFiltered = submittedTxs.filter(
- ({ chainId, txParams }) => {
- const { from, nonce } = txParams;
-
- if (!areAddressesEqual(from, selectedAddress)) {
- return false;
- }
-
- const nonceKey = `${chainId}-${nonce}`;
- const alreadySubmitted = submittedNonces.includes(nonceKey);
- const alreadyConfirmed = confirmedTxs.find(
- (tx) =>
- areAddressesEqual(tx.txParams.from, selectedAddress) &&
- tx.chainId === chainId &&
- tx.txParams.nonce === nonce,
- );
-
- if (alreadyConfirmed) {
- return false;
- }
-
- submittedNonces.push(nonceKey);
- return !alreadySubmitted;
- },
- );
+ if (alreadyConfirmed) {
+ return false;
+ }
- // If the account added insert point is not found, add it to the last transaction
- if (
- !accountAddedTimeInsertPointFound &&
- allTransactionsFiltered &&
- allTransactionsFiltered.length
- ) {
- const lastIndex = allTransactionsFiltered.length - 1;
- allTransactionsFiltered[lastIndex] = {
- ...allTransactionsFiltered[lastIndex],
- insertImportTime: true,
- };
- }
+ submittedNonces.push(nonceKey);
+ return !alreadySubmitted;
+ },
+ );
- setAllTransactions(allTransactionsFiltered);
- setSubmittedTxs(submittedTxsFiltered);
- setConfirmedTxs(confirmedTxs);
- setLoading(false);
- },
- [
- transactions,
- selectedInternalAccount,
- selectedAddress,
- tokens,
- chainId,
- tokenNetworkFilter,
- isPopularNetwork,
- enabledNetworksByNamespace,
- bridgeHistory,
- ],
- );
+ // If the account added insert point is not found, add it to the last transaction
+ if (
+ !accountAddedTimeInsertPointFound &&
+ allTransactionsFiltered &&
+ allTransactionsFiltered.length
+ ) {
+ const lastIndex = allTransactionsFiltered.length - 1;
+ allTransactionsFiltered[lastIndex] = {
+ ...allTransactionsFiltered[lastIndex],
+ insertImportTime: true,
+ };
+ }
+
+ setAllTransactions(allTransactionsFiltered);
+ setSubmittedTxs(submittedTxsFiltered);
+ setConfirmedTxs(confirmedTxs);
+ setLoading(false);
+ }, [
+ transactions,
+ selectedInternalAccount,
+ selectedAddress,
+ tokens,
+ tokenNetworkFilter,
+ enabledNetworksByNamespace,
+ bridgeHistory,
+ ]);
useEffect(() => {
setLoading(true);
-
- if (selectedNetworkClientId) {
- filterTransactions(selectedNetworkClientId);
- }
- }, [filterTransactions, selectedNetworkClientId]);
+ filterTransactions();
+ }, [filterTransactions]);
return (
@@ -288,10 +242,6 @@ TransactionsView.propTypes = {
* Array of ERC20 assets
*/
tokens: PropTypes.array,
- /**
- * Current chainId
- */
- chainId: PropTypes.string,
/**
* Array of network tokens filter
*/
@@ -299,7 +249,6 @@ TransactionsView.propTypes = {
};
const mapStateToProps = (state) => {
- const chainId = selectChainId(state);
const selectedInternalAccount = selectSelectedInternalAccount(state);
const evmTransactions = selectSortedTransactions(state);
@@ -325,13 +274,10 @@ const mapStateToProps = (state) => {
selectedInternalAccount,
transactions: allTransactions,
networkType: selectProviderType(state),
- chainId,
- tokenNetworkFilter: isRemoveGlobalNetworkSelectorEnabled()
- ? selectEVMEnabledNetworks(state).reduce(
- (acc, network) => ({ ...acc, [network]: true }),
- {},
- )
- : selectTokenNetworkFilter(state),
+ tokenNetworkFilter: selectEVMEnabledNetworks(state).reduce(
+ (acc, network) => ({ ...acc, [network]: true }),
+ {},
+ ),
};
};
diff --git a/app/components/Views/TransactionsView/index.test.tsx b/app/components/Views/TransactionsView/index.test.tsx
index 36d1f5a7a48..265d555d03a 100644
--- a/app/components/Views/TransactionsView/index.test.tsx
+++ b/app/components/Views/TransactionsView/index.test.tsx
@@ -85,11 +85,7 @@ jest.mock('../../../selectors/multichain', () => ({
})),
}));
-const mockSelectIsPopularNetwork = jest.fn(() => false);
-
jest.mock('../../../selectors/networkController', () => ({
- selectChainId: jest.fn(() => '0x1'),
- selectIsPopularNetwork: jest.fn(() => false),
selectProviderType: jest.fn(() => 'mainnet'),
selectSelectedNetworkClientId: jest.fn(() => 'selectedNetworkClientId'),
}));
@@ -156,19 +152,6 @@ jest.mock('@metamask/keyring-api', () => ({
isEvmAccountType: jest.fn(() => true),
}));
-jest.mock('../../../util/networks/customNetworks', () => ({
- PopularList: [
- { chainId: '0x89' }, // Polygon
- { chainId: '0xa4b1' }, // Arbitrum
- ],
-}));
-
-const mockIsRemoveGlobalNetworkSelectorEnabled = jest.fn(() => false);
-
-jest.mock('../../../util/networks', () => ({
- isRemoveGlobalNetworkSelectorEnabled: jest.fn(() => false),
-}));
-
jest.mock('../../UI/Transactions', () => jest.fn());
jest.mock('../../../core/Engine', () => ({
@@ -499,34 +482,6 @@ describe('TransactionsView', () => {
expect(filterByAddressAndNetwork).toHaveBeenCalledTimes(2);
});
- it('filters transactions by popular networks when enabled', async () => {
- const mockTransactions = [
- createMockTransaction({ id: 'tx-1', chainId: '0x1' }), // Mainnet
- createMockTransaction({ id: 'tx-2', chainId: '0xe708' }), // Linea
- createMockTransaction({ id: 'tx-3', chainId: '0x89' }), // Polygon
- createMockTransaction({ id: 'tx-4', chainId: '0x999' }), // Unknown chain
- ];
-
- (
- selectSortedTransactions as jest.MockedFunction<
- typeof selectSortedTransactions
- >
- ).mockReturnValue(mockTransactions);
- (
- selectSelectedInternalAccount as jest.MockedFunction<
- typeof selectSelectedInternalAccount
- >
- ).mockReturnValue(createMockAccount());
-
- renderTransactionsView();
-
- act(() => {
- jest.runAllTimers();
- });
-
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
- });
-
it('handles submitted transactions filtering', async () => {
const mockTransactions = [
createMockTransaction({
@@ -829,7 +784,7 @@ describe('TransactionsView', () => {
});
});
- describe('Feature Flag: isRemoveGlobalNetworkSelectorEnabled', () => {
+ describe('Network Filtering', () => {
// Common test configurations
const createMockTransactions = (
transactions: { id: string; chainId: string }[],
@@ -858,103 +813,45 @@ describe('TransactionsView', () => {
});
};
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- });
-
- describe('when feature flag is enabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true);
- });
-
- it('should filter transactions based on enabledNetworksByNamespace', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' }, // Enabled network
- { id: 'tx-2', chainId: '0x89' }, // Disabled network
- { id: 'tx-3', chainId: '0xa4b1' }, // Disabled network
- ]);
-
- setupSelectors(mockTransactions);
- mockSelectEnabledNetworksByNamespace.mockReturnValue({
- eip155: {
- '0x1': true, // Only mainnet is enabled
- },
- });
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
+ it('should filter transactions based on enabledNetworksByNamespace', async () => {
+ const mockTransactions = createMockTransactions([
+ { id: 'tx-1', chainId: '0x1' }, // Enabled network
+ { id: 'tx-2', chainId: '0x89' }, // Disabled network
+ { id: 'tx-3', chainId: '0xa4b1' }, // Disabled network
+ ]);
+
+ setupSelectors(mockTransactions);
+ mockSelectEnabledNetworksByNamespace.mockReturnValue({
+ eip155: {
+ '0x1': true, // Only mainnet is enabled
+ },
});
- it('should handle empty enabledNetworksByNamespace gracefully', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' },
- { id: 'tx-2', chainId: '0x89' },
- ]);
-
- setupSelectors(mockTransactions);
- mockSelectEnabledNetworksByNamespace.mockReturnValue({
- eip155: {
- '0x1': false, // No enabled networks
- },
- });
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
- });
+ runTestWithTimers();
- it('should have proper selector setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(true);
- expect(mockSelectEnabledNetworksByNamespace).toBeDefined();
- expect(mockSelectIsPopularNetwork).toBeDefined();
- });
+ expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
});
- describe('when feature flag is disabled', () => {
- beforeEach(() => {
- mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false);
- });
-
- it('should use popular network filtering when isPopularNetwork is true', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' }, // Mainnet
- { id: 'tx-2', chainId: '0xe708' }, // Linea
- { id: 'tx-3', chainId: '0x89' }, // Polygon (in PopularList)
- { id: 'tx-4', chainId: '0x999' }, // Unknown chain
- ]);
+ it('should handle empty enabledNetworksByNamespace gracefully', async () => {
+ const mockTransactions = createMockTransactions([
+ { id: 'tx-1', chainId: '0x1' },
+ { id: 'tx-2', chainId: '0x89' },
+ ]);
- setupSelectors(mockTransactions);
- mockSelectIsPopularNetwork.mockReturnValue(true);
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
+ setupSelectors(mockTransactions);
+ mockSelectEnabledNetworksByNamespace.mockReturnValue({
+ eip155: {
+ '0x1': false, // No enabled networks
+ },
});
- it('should use chainId filtering when isPopularNetwork is false', async () => {
- const mockTransactions = createMockTransactions([
- { id: 'tx-1', chainId: '0x1' }, // Current chainId
- { id: 'tx-2', chainId: '0x89' }, // Different chainId
- ]);
+ runTestWithTimers();
- setupSelectors(mockTransactions);
- mockSelectIsPopularNetwork.mockReturnValue(false);
-
- runTestWithTimers();
-
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
- });
+ expect(sortTransactions).toHaveBeenCalledWith(mockTransactions);
+ });
- it('should still have proper selector setup', () => {
- expect(mockIsRemoveGlobalNetworkSelectorEnabled()).toBe(false);
- expect(mockSelectEnabledNetworksByNamespace).toBeDefined();
- expect(mockSelectIsPopularNetwork).toBeDefined();
- });
+ it('should have proper selector setup', () => {
+ expect(mockSelectEnabledNetworksByNamespace).toBeDefined();
});
});
});
diff --git a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
index 26856254d1c..eac3a5eb2a4 100644
--- a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
@@ -1,8 +1,28 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import ExploreSearchBar from './ExploreSearchBar';
+import { useSelector } from 'react-redux';
+import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+const mockUseSelector = useSelector as jest.MockedFunction;
describe('ExploreSearchBar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Mock selectBasicFunctionalityEnabled to return true by default
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return true;
+ }
+ return undefined;
+ });
+ });
describe('Button Mode', () => {
it('renders button with placeholder text', () => {
const mockOnPress = jest.fn();
@@ -203,4 +223,23 @@ describe('ExploreSearchBar', () => {
expect(input.props.autoFocus).toBe(true);
});
});
+
+ describe('basic functionality toggle', () => {
+ it('displays sites-only placeholder when basic functionality is disabled', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return false;
+ }
+ return undefined;
+ });
+
+ const mockOnPress = jest.fn();
+
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('Search sites')).toBeDefined();
+ });
+ });
});
diff --git a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx
index 56360565a42..009fbafcff6 100644
--- a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.tsx
@@ -15,6 +15,8 @@ import {
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { useTheme } from '../../../../util/theme';
import { strings } from '../../../../../locales/i18n';
+import { useSelector } from 'react-redux';
+import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings';
interface ExploreSearchBarButtonProps {
type: 'button';
@@ -38,10 +40,15 @@ const ExploreSearchBar: React.FC = (props) => {
const tw = useTailwind();
const { colors } = useTheme();
+ const isBasicFunctionalityEnabled = useSelector(
+ selectBasicFunctionalityEnabled,
+ );
const isInteractiveMode = props.type === 'interactive';
const isButtonMode = props.type === 'button';
const placeholder =
- props.placeholder || strings('trending.search_placeholder');
+ props.placeholder || isBasicFunctionalityEnabled
+ ? strings('trending.search_placeholder')
+ : strings('trending.search_sites');
const handleCancel = () => {
if (isInteractiveMode) {
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
index 7827383f36a..547b1b3ccb8 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.test.tsx
@@ -1,21 +1,27 @@
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import { render } from '@testing-library/react-native';
import ExploreSearchResults from './ExploreSearchResults';
import { useExploreSearch } from './config/useExploreSearch';
-
-const mockNavigate = jest.fn();
+import { useSelector } from 'react-redux';
+import { selectBasicFunctionalityEnabled } from '../../../../../../selectors/settings';
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
- navigate: mockNavigate,
+ navigate: jest.fn(),
}),
}));
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
jest.mock('./config/useExploreSearch');
const mockUseExploreSearch = useExploreSearch as jest.MockedFunction<
typeof useExploreSearch
>;
+const mockUseSelector = useSelector as jest.MockedFunction;
// Mock child components that render individual items
jest.mock(
@@ -33,82 +39,34 @@ jest.mock(
() => () => null,
);
+jest.mock(
+ '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter',
+ () => {
+ const ReactNative = jest.requireActual('react-native');
+ return jest.fn(({ searchQuery }) =>
+ searchQuery ? (
+
+ {searchQuery}
+
+ ) : null,
+ );
+ },
+);
+
describe('ExploreSearchResults', () => {
beforeEach(() => {
jest.clearAllMocks();
- });
- it('renders list when data is available', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [
- { assetId: '1', symbol: 'BTC', name: 'Bitcoin' },
- { assetId: '2', symbol: 'ETH', name: 'Ethereum' },
- ],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
+ // Mock selectBasicFunctionalityEnabled to return true by default
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return true;
+ }
+ return undefined;
});
-
- const { getByTestId } = render();
-
- expect(getByTestId('trending-search-results-list')).toBeDefined();
});
- it('renders section headers when sections have data', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }],
- perps: [{ symbol: 'ETH-USD', name: 'Ethereum' }],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByText } = render();
-
- expect(getByText('Tokens')).toBeDefined();
- expect(getByText('Perps')).toBeDefined();
- });
-
- it('displays skeleton loaders when loading', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: true,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId, getByText } = render(
- ,
- );
-
- expect(getByTestId('trending-search-results-list')).toBeDefined();
- expect(getByText('Tokens')).toBeDefined();
- });
-
- it('renders multiple sections with data simultaneously', () => {
+ it('renders section headers for sections with data or loading', () => {
mockUseExploreSearch.mockReturnValue({
data: {
tokens: [
@@ -127,8 +85,11 @@ describe('ExploreSearchResults', () => {
},
});
- const { getByText } = render();
+ const { getByText, getByTestId } = render(
+ ,
+ );
+ expect(getByTestId('trending-search-results-list')).toBeDefined();
expect(getByText('Tokens')).toBeDefined();
expect(getByText('Perps')).toBeDefined();
expect(getByText('Predictions')).toBeDefined();
@@ -180,33 +141,8 @@ describe('ExploreSearchResults', () => {
expect(mockUseExploreSearch).toHaveBeenCalledWith('ethereum');
});
- it('handles empty query by displaying top results', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [
- { assetId: '1', symbol: 'BTC', name: 'Bitcoin' },
- { assetId: '2', symbol: 'ETH', name: 'Ethereum' },
- { assetId: '3', symbol: 'SOL', name: 'Solana' },
- ],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('trending-search-results-list')).toBeDefined();
- });
-
describe('Footer', () => {
- it('displays Google search option when search query is provided and loading is finished', () => {
+ it('displays SitesSearchFooter when search query is provided', () => {
mockUseExploreSearch.mockReturnValue({
data: {
tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }],
@@ -222,38 +158,11 @@ describe('ExploreSearchResults', () => {
},
});
- const { getByTestId, getByText } = render(
+ const { getByTestId } = render(
,
);
- expect(getByTestId('trending-search-footer-google-link')).toBeDefined();
- expect(getByText('bitcoin')).toBeDefined();
- expect(getByText(/on Google/)).toBeDefined();
- });
-
- it('displays direct URL link when search query looks like a URL', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId, getAllByText } = render(
- ,
- );
-
- expect(getByTestId('trending-search-footer-url-link')).toBeDefined();
- expect(getByTestId('trending-search-footer-google-link')).toBeDefined();
- expect(getAllByText('example.com').length).toBeGreaterThan(0);
+ expect(getByTestId('sites-search-footer')).toBeDefined();
});
it('does not display footer when search query is empty', () => {
@@ -272,98 +181,9 @@ describe('ExploreSearchResults', () => {
},
});
- const { queryByText } = render();
-
- expect(queryByText('Search for')).toBeNull();
- expect(queryByText('on Google')).toBeNull();
- });
-
- it('does not display footer when still loading', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: true,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { queryByText } = render(
- ,
- );
-
- expect(queryByText('Search for')).toBeNull();
- expect(queryByText('on Google')).toBeNull();
- });
-
- it('navigates to Google search when Google search option is pressed', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId } = render(
- ,
- );
+ const { queryByTestId } = render();
- const googleSearchButton = getByTestId(
- 'trending-search-footer-google-link',
- );
-
- fireEvent.press(googleSearchButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: 'https://www.google.com/search?q=ethereum',
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- });
-
- it('navigates to URL when direct URL link is pressed', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- sites: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- sites: false,
- },
- });
-
- const { getByTestId } = render(
- ,
- );
-
- const urlButton = getByTestId('trending-search-footer-url-link');
-
- fireEvent.press(urlButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: 'example.com',
- timestamp: expect.any(Number),
- fromTrending: true,
- });
+ expect(queryByTestId('sites-search-footer')).toBeNull();
});
});
});
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
index 2c3ff842f2a..a9bce9bcdda 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
@@ -1,15 +1,7 @@
import React, { useMemo, useCallback, useRef, useEffect } from 'react';
-import { TouchableOpacity } from 'react-native';
import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
import { useNavigation } from '@react-navigation/native';
-import {
- Box,
- Text,
- TextVariant,
- Icon,
- IconName,
- IconSize,
-} from '@metamask/design-system-react-native';
+import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
SECTIONS_CONFIG,
@@ -17,10 +9,10 @@ import {
type SectionId,
} from '../../../config/sections.config';
import { useExploreSearch } from './config/useExploreSearch';
+import { selectBasicFunctionalityEnabled } from '../../../../../../selectors/settings';
+import SitesSearchFooter from '../../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter';
+import { useSelector } from 'react-redux';
-function looksLikeUrl(str: string): boolean {
- return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str);
-}
interface ExploreSearchResultsProps {
searchQuery: string;
}
@@ -51,16 +43,8 @@ const ExploreSearchResults: React.FC = ({
const tw = useTailwind();
const { data, isLoading } = useExploreSearch(searchQuery);
const flashListRef = useRef>(null);
-
- const handlePressFooterLink = useCallback(
- (url: string) => {
- navigation.navigate('TrendingBrowser', {
- newTabUrl: url,
- timestamp: Date.now(),
- fromTrending: true,
- });
- },
- [navigation],
+ const isBasicFunctionalityEnabled = useSelector(
+ selectBasicFunctionalityEnabled,
);
const renderSectionHeader = useCallback(
@@ -78,7 +62,10 @@ const ExploreSearchResults: React.FC = ({
const flatData = useMemo(() => {
const result: FlatListItem[] = [];
- SECTIONS_ARRAY.forEach((section) => {
+ // Filter sections based on basic functionality toggle
+ const sectionsToShow = isBasicFunctionalityEnabled ? SECTIONS_ARRAY : [];
+
+ sectionsToShow.forEach((section) => {
const items = data[section.id];
const sectionIsLoading = isLoading[section.id];
@@ -113,7 +100,7 @@ const ExploreSearchResults: React.FC = ({
});
return result;
- }, [data, isLoading]);
+ }, [data, isLoading, isBasicFunctionalityEnabled]);
// Scroll to top when search query changes
useEffect(() => {
@@ -125,84 +112,11 @@ const ExploreSearchResults: React.FC = ({
}
}, [searchQuery, flatData.length]);
- const finishedLoading = useMemo(
- () => Object.values(isLoading).every((value) => !value),
- [isLoading],
- );
-
const renderFooter = useMemo(() => {
- if (!finishedLoading || searchQuery.length === 0) return null;
-
- const isUrl = looksLikeUrl(searchQuery.toLowerCase());
-
- return (
-
- {isUrl && (
- handlePressFooterLink(searchQuery)}
- testID="trending-search-footer-url-link"
- >
-
-
- {searchQuery}
-
-
-
-
-
-
- )}
-
-
- handlePressFooterLink(
- `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`,
- )
- }
- testID="trending-search-footer-google-link"
- >
-
-
- Search for {'"'}
-
-
- {searchQuery}
-
-
- {'"'} on Google
-
-
-
-
-
-
-
- );
- }, [finishedLoading, searchQuery, handlePressFooterLink, tw]);
+ if (searchQuery.length === 0) return null;
+
+ return ;
+ }, [searchQuery]);
const renderFlatItem: ListRenderItem = useCallback(
({ item }) => {
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
index 7afd60bad5b..cc4eca8f44c 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
@@ -66,7 +66,7 @@ jest.mock('../../../../../../UI/Predict/hooks/usePredictMarketData', () => ({
usePredictMarketData: () => mockUsePredictMarketData(),
}));
-jest.mock('../../../../SectionSites/hooks/useSitesData', () => ({
+jest.mock('../../../../../../UI/Sites/hooks/useSiteData/useSitesData', () => ({
useSitesData: () => mockUseSitesData(),
}));
diff --git a/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts b/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts
deleted file mode 100644
index 7e23bce8fbe..00000000000
--- a/app/components/Views/TrendingView/SectionSites/SiteRowItem/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default } from './SiteRowItem';
-export type { SiteData } from './SiteRowItem';
diff --git a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts b/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts
deleted file mode 100644
index 4ac14e2d69f..00000000000
--- a/app/components/Views/TrendingView/SectionSites/SiteSkeleton/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './SiteSkeleton';
diff --git a/app/components/Views/TrendingView/SectionSites/hooks/index.ts b/app/components/Views/TrendingView/SectionSites/hooks/index.ts
deleted file mode 100644
index 9d5449d252a..00000000000
--- a/app/components/Views/TrendingView/SectionSites/hooks/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useSitesData } from './useSitesData';
diff --git a/app/components/Views/TrendingView/SectionSites/index.ts b/app/components/Views/TrendingView/SectionSites/index.ts
deleted file mode 100644
index bbeccb8d542..00000000000
--- a/app/components/Views/TrendingView/SectionSites/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { default as SiteRowItem } from './SiteRowItem';
-export { default as SiteRowItemWrapper } from './SiteRowItemWrapper';
-export { default as SiteSkeleton } from './SiteSkeleton';
-export { useSitesData } from './hooks/useSitesData';
-export type { SiteData } from './SiteRowItem';
diff --git a/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx b/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx
deleted file mode 100644
index 4101ce7dff5..00000000000
--- a/app/components/Views/TrendingView/SitesListView/SitesListView.test.tsx
+++ /dev/null
@@ -1,656 +0,0 @@
-import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
-import SitesListView from './SitesListView';
-import { useSitesData } from '../SectionSites/hooks/useSitesData';
-import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem';
-
-// Mock dependencies
-jest.mock('../SectionSites/hooks/useSitesData');
-
-jest.mock('react-native-safe-area-context', () => ({
- useSafeAreaInsets: () => ({ top: 50, bottom: 34, left: 0, right: 0 }),
-}));
-
-const mockGoBack = jest.fn();
-const mockNavigate = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- navigate: mockNavigate,
- goBack: mockGoBack,
- }),
-}));
-
-const mockTwStyle = jest.fn((...args: unknown[]) => {
- const flatArgs = args.flat().filter(Boolean);
- return flatArgs.reduce((acc: Record, arg) => {
- if (typeof arg === 'string') {
- return { ...acc, [arg]: true };
- }
- if (typeof arg === 'object') {
- return { ...acc, ...arg };
- }
- return acc;
- }, {});
-});
-
-// Make mockTw callable as both function and object with style method
-const mockTw = Object.assign(mockTwStyle, { style: mockTwStyle });
-
-jest.mock('@metamask/design-system-twrnc-preset', () => ({
- useTailwind: () => mockTw,
-}));
-
-jest.mock('../SectionSites/SiteRowItemWrapper', () => {
- const ReactNative = jest.requireActual('react-native');
- return jest.fn(({ site }) => (
-
- {site.name}
-
- ));
-});
-
-jest.mock('../SectionSites/SiteSkeleton/SiteSkeleton', () =>
- jest.fn(() => {
- const ReactNative = jest.requireActual('react-native');
- return (
-
- Loading...
-
- );
- }),
-);
-
-jest.mock('../../../../component-library/components/HeaderBase', () => ({
- __esModule: true,
- default: jest.fn(({ children, startAccessory, endAccessory }) => {
- const ReactNative = jest.requireActual('react-native');
- return (
-
- {startAccessory}
- {children}
- {endAccessory}
-
- );
- }),
- HeaderBaseVariant: {
- Display: 'Display',
- },
-}));
-
-jest.mock(
- '../../../../component-library/components/Buttons/ButtonIcon',
- () => ({
- __esModule: true,
- default: jest.fn(({ onPress, iconName, testID }) => {
- const ReactNative = jest.requireActual('react-native');
- return (
-
- {iconName}
-
- );
- }),
- ButtonIconSizes: {
- Lg: 'Lg',
- },
- }),
-);
-
-jest.mock('../ExploreSearchBar/ExploreSearchBar', () => {
- const ReactNative = jest.requireActual('react-native');
- return jest.fn(({ searchQuery, onSearchChange, onCancel, placeholder }) => (
-
-
-
- Cancel
-
-
- ));
-});
-
-const mockUseSitesData = useSitesData as jest.Mock;
-
-describe('SitesListView', () => {
- const mockSites: SiteData[] = [
- {
- id: '1',
- name: 'MetaMask',
- url: 'https://metamask.io',
- displayUrl: 'metamask.io',
- logoUrl: 'https://example.com/metamask.png',
- featured: true,
- },
- {
- id: '2',
- name: 'OpenSea',
- url: 'https://opensea.io',
- displayUrl: 'opensea.io',
- logoUrl: 'https://example.com/opensea.png',
- featured: false,
- },
- {
- id: '3',
- name: 'Uniswap',
- url: 'https://uniswap.org',
- displayUrl: 'uniswap.org',
- logoUrl: 'https://example.com/uniswap.png',
- featured: true,
- },
- ];
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- describe('Rendering', () => {
- it('renders header with back and search buttons', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('header-base')).toBeOnTheScreen();
- expect(getByTestId('back-button')).toBeOnTheScreen();
- expect(getByTestId('search-button')).toBeOnTheScreen();
- });
-
- it('renders all site items', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- expect(getByTestId('site-item-2')).toBeOnTheScreen();
- expect(getByTestId('site-item-3')).toBeOnTheScreen();
- });
-
- it('renders skeletons when loading', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: true,
- error: null,
- });
-
- const { getAllByTestId } = render();
-
- const skeletons = getAllByTestId('site-skeleton');
- expect(skeletons.length).toBe(10);
- });
- });
-
- describe('Navigation', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('navigates back when back button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
- const backButton = getByTestId('back-button');
-
- fireEvent.press(backButton);
-
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- });
-
- it('activates search mode when search button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
- const searchButton = getByTestId('search-button');
-
- expect(queryByTestId('explore-search-bar')).toBeNull();
-
- fireEvent.press(searchButton);
-
- expect(getByTestId('explore-search-bar')).toBeOnTheScreen();
- });
-
- it('closes search mode when cancel button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- expect(getByTestId('explore-search-bar')).toBeOnTheScreen();
-
- // Press cancel
- fireEvent.press(getByTestId('explore-search-cancel-button'));
-
- // Search should be closed
- expect(queryByTestId('explore-search-bar')).toBeNull();
- expect(mockGoBack).not.toHaveBeenCalled();
- });
- });
-
- describe('Search Functionality', () => {
- it('filters sites by name', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
-
- // Search for "Meta"
- fireEvent.changeText(searchInput, 'Meta');
-
- // Only MetaMask should be visible
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- expect(queryByTestId('site-item-2')).toBeNull();
- expect(queryByTestId('site-item-3')).toBeNull();
- });
-
- it('filters sites by URL', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
-
- // Search for "opensea"
- fireEvent.changeText(searchInput, 'opensea');
-
- // Only OpenSea should be visible
- expect(queryByTestId('site-item-1')).toBeNull();
- expect(getByTestId('site-item-2')).toBeOnTheScreen();
- expect(queryByTestId('site-item-3')).toBeNull();
- });
-
- it('shows all sites when search query is empty', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
-
- // Empty search
- fireEvent.changeText(searchInput, '');
-
- // All sites should be visible
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- expect(getByTestId('site-item-2')).toBeOnTheScreen();
- expect(getByTestId('site-item-3')).toBeOnTheScreen();
- });
-
- it('shows cancel button when search is active', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Initially no cancel button
- expect(queryByTestId('explore-search-cancel-button')).toBeNull();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Cancel button should appear
- expect(getByTestId('explore-search-cancel-button')).toBeOnTheScreen();
- });
-
- it('clears search and closes search mode when cancel button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search and type
- fireEvent.press(getByTestId('search-button'));
- const searchInput = getByTestId('explore-view-search-input');
- fireEvent.changeText(searchInput, 'test');
-
- // Cancel
- fireEvent.press(getByTestId('explore-search-cancel-button'));
-
- // Search should be closed
- expect(queryByTestId('explore-search-bar')).toBeNull();
- });
-
- it('shows search on Google option when there is a search query', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Initially no Google search option
- expect(queryByTestId('search-on-google-button')).toBeNull();
-
- // Type any search query
- fireEvent.changeText(getByTestId('explore-view-search-input'), 'test');
-
- // Google search option should appear
- expect(getByTestId('search-on-google-button')).toBeOnTheScreen();
- });
-
- it('navigates to Google search when search on Google button is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search and type
- fireEvent.press(getByTestId('search-button'));
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'test query',
- );
-
- // Press Google search button
- fireEvent.press(getByTestId('search-on-google-button'));
-
- // Should navigate to TrendingBrowser with Google search URL
- expect(mockNavigate).toHaveBeenCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({
- newTabUrl: 'https://www.google.com/search?q=test%20query',
- fromTrending: true,
- }),
- );
- });
-
- it('displays URL item when search query is a valid URL', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a valid URL
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'example.com',
- );
-
- // Should show the URL item
- expect(getByTestId('url-item')).toBeOnTheScreen();
- });
-
- it('displays URL item with https protocol', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a valid URL with protocol
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'https://example.com',
- );
-
- // Should show the URL item
- expect(getByTestId('url-item')).toBeOnTheScreen();
- });
-
- it('shows URL item separately from matching sites', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a URL that also matches existing sites
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'metamask.io',
- );
-
- // URL item should appear
- expect(getByTestId('url-item')).toBeOnTheScreen();
- // Original matching sites should still appear
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
-
- it('shows both URL item and Google search option for valid URLs', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a valid URL
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'example.com',
- );
-
- // Should show both URL item
- expect(getByTestId('url-item')).toBeOnTheScreen();
-
- // AND Google search option
- expect(getByTestId('search-on-google-button')).toBeOnTheScreen();
- });
-
- it('navigates to URL when URL item is pressed', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- // Activate search and type URL
- fireEvent.press(getByTestId('search-button'));
- fireEvent.changeText(
- getByTestId('explore-view-search-input'),
- 'example.com',
- );
-
- // Press URL item
- fireEvent.press(getByTestId('url-item'));
-
- // Should navigate to the URL
- expect(mockNavigate).toHaveBeenCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({
- newTabUrl: 'https://example.com',
- fromTrending: true,
- }),
- );
- });
-
- it('does not show URL item for non-URL search queries', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Type a non-URL query
- fireEvent.changeText(getByTestId('explore-view-search-input'), 'meta');
-
- // URL item should not appear
- expect(queryByTestId('url-item')).toBeNull();
-
- // But matching sites should appear
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
-
- it('hides Google search option when search query is empty', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId, queryByTestId } = render();
-
- // Activate search
- fireEvent.press(getByTestId('search-button'));
-
- // Google search should not appear with empty query
- expect(queryByTestId('search-on-google-button')).toBeNull();
- });
- });
-
- describe('Data Fetching', () => {
- it('fetches sites with limit of 100', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- render();
-
- expect(mockUseSitesData).toHaveBeenCalledWith({ limit: 100 });
- });
-
- it('passes isViewAll prop to child components', () => {
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- const SiteRowItemWrapper = jest.requireMock(
- '../SectionSites/SiteRowItemWrapper',
- );
-
- render();
-
- expect(SiteRowItemWrapper).toHaveBeenCalledWith(
- expect.objectContaining({
- isViewAll: true,
- }),
- expect.anything(),
- );
- });
- });
-
- describe('Edge Cases', () => {
- it('handles transition from loading to loaded', () => {
- mockUseSitesData.mockReturnValue({
- sites: [],
- isLoading: true,
- error: null,
- });
-
- const { rerender, getAllByTestId, queryByTestId, getByTestId } = render(
- ,
- );
-
- expect(getAllByTestId('site-skeleton').length).toBe(10);
-
- mockUseSitesData.mockReturnValue({
- sites: mockSites,
- isLoading: false,
- error: null,
- });
-
- rerender();
-
- expect(queryByTestId('site-skeleton')).toBeNull();
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
-
- it('handles sites with missing optional fields', () => {
- const minimalSites: SiteData[] = [
- {
- id: '1',
- name: 'Test',
- url: 'https://test.com',
- displayUrl: 'test.com',
- },
- ];
-
- mockUseSitesData.mockReturnValue({
- sites: minimalSites,
- isLoading: false,
- error: null,
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('site-item-1')).toBeOnTheScreen();
- });
- });
-});
diff --git a/app/components/Views/TrendingView/SitesListView/SitesListView.tsx b/app/components/Views/TrendingView/SitesListView/SitesListView.tsx
deleted file mode 100644
index d4c7e83bee9..00000000000
--- a/app/components/Views/TrendingView/SitesListView/SitesListView.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import React, { useCallback, useState, useMemo } from 'react';
-import { FlatList, TouchableOpacity } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-// eslint-disable-next-line no-duplicate-imports
-import type { NavigationProp, ParamListBase } from '@react-navigation/native';
-import {
- Box,
- Icon,
- IconName,
- IconSize,
-} from '@metamask/design-system-react-native';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { useSitesData } from '../SectionSites/hooks/useSitesData';
-import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper';
-import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton';
-import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem';
-import HeaderBase, {
- HeaderBaseVariant,
-} from '../../../../component-library/components/HeaderBase';
-import ButtonIcon, {
- ButtonIconSizes,
-} from '../../../../component-library/components/Buttons/ButtonIcon';
-import { IconName as IconNameType } from '../../../../component-library/components/Icons/Icon';
-import Text, {
- TextColor,
- TextVariant,
-} from '../../../../component-library/components/Texts/Text';
-import { strings } from '../../../../../locales/i18n';
-import ExploreSearchBar from '../ExploreSearchBar/ExploreSearchBar';
-
-function looksLikeUrl(str: string): boolean {
- return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str);
-}
-
-const SitesListView: React.FC = () => {
- const tw = useTailwind();
- const insets = useSafeAreaInsets();
- const navigation = useNavigation>();
- const [searchQuery, setSearchQuery] = useState('');
- const [isSearchActive, setIsSearchActive] = useState(false);
-
- // Fetch all sites (no limit)
- const { sites, isLoading } = useSitesData({ limit: 100 });
-
- // Filter sites based on search query
- const filteredSites = useMemo(() => {
- if (!searchQuery.trim()) {
- return sites;
- }
-
- const query = searchQuery.toLowerCase();
- return sites.filter(
- (site) =>
- site.name.toLowerCase().includes(query) ||
- site.displayUrl.toLowerCase().includes(query) ||
- site.url.toLowerCase().includes(query),
- );
- }, [sites, searchQuery]);
-
- const handleBackPress = useCallback(() => {
- if (isSearchActive) {
- setIsSearchActive(false);
- setSearchQuery('');
- } else {
- navigation.goBack();
- }
- }, [navigation, isSearchActive]);
-
- const handleSearchPress = useCallback(() => {
- setIsSearchActive(true);
- }, []);
-
- const handleCancelSearch = useCallback(() => {
- setIsSearchActive(false);
- setSearchQuery('');
- }, []);
-
- const handlePressFooterLink = useCallback(
- (url: string) => {
- navigation.navigate('TrendingBrowser', {
- newTabUrl: url,
- timestamp: Date.now(),
- fromTrending: true,
- });
- },
- [navigation],
- );
-
- const renderSiteItem = ({ item }: { item: SiteData }) => (
-
- );
-
- const renderSkeleton = () => (
- <>
- {[...Array(10)].map((_, index) => (
-
- ))}
- >
- );
-
- const renderFooter = useMemo(() => {
- if (!isSearchActive || searchQuery.length === 0) return null;
-
- const isUrl = looksLikeUrl(searchQuery.toLowerCase());
- const urlWithProtocol =
- isUrl && !searchQuery.startsWith('http')
- ? `https://${searchQuery}`
- : searchQuery;
-
- return (
-
- {isUrl && (
- handlePressFooterLink(urlWithProtocol)}
- testID="url-item"
- >
-
-
- {searchQuery}
-
-
-
-
-
-
- )}
-
-
- handlePressFooterLink(
- `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`,
- )
- }
- testID="search-on-google-button"
- >
-
-
- Search for {'"'}
-
-
- {searchQuery}
-
-
- {'"'} on Google
-
-
-
-
-
-
-
- );
- }, [isSearchActive, searchQuery, handlePressFooterLink, tw]);
-
- return (
-
- {/* Header */}
-
- {isSearchActive ? (
-
- ) : (
-
- }
- endAccessory={
-
- }
- style={tw.style('flex-row items-center gap-1')}
- >
-
- {strings('trending.popular_sites')}
-
-
- )}
-
-
- {/* Sites List */}
-
- {isLoading ? (
- renderSkeleton()
- ) : (
- item.id}
- contentContainerStyle={tw.style('pb-4')}
- showsVerticalScrollIndicator={false}
- ListFooterComponent={renderFooter}
- />
- )}
-
-
- );
-};
-
-export default SitesListView;
diff --git a/app/components/Views/TrendingView/SitesListView/index.ts b/app/components/Views/TrendingView/SitesListView/index.ts
deleted file mode 100644
index f705e80c2ab..00000000000
--- a/app/components/Views/TrendingView/SitesListView/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './SitesListView';
diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx
index 327b39c0904..61e2865b41e 100644
--- a/app/components/Views/TrendingView/TrendingView.test.tsx
+++ b/app/components/Views/TrendingView/TrendingView.test.tsx
@@ -27,7 +27,6 @@ jest.mock('react-redux', () => ({
}));
import TrendingView from './TrendingView';
-import { updateLastTrendingScreen } from '../../Nav/Main/MainNavigator';
import {
selectChainId,
selectPopularNetworkConfigurationsByCaipChainId,
@@ -37,6 +36,7 @@ import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetwork
import { selectEnabledNetworksByNamespace } from '../../../selectors/networkEnablementController';
import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts';
import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts';
+import { selectBasicFunctionalityEnabled } from '../../../selectors/settings';
import { useSelector } from 'react-redux';
jest.mock('../../../components/hooks/useMetrics', () => ({
@@ -52,11 +52,6 @@ jest.mock('../../../util/browser', () => ({
})),
}));
-jest.mock('../Browser', () => ({
- __esModule: true,
- default: jest.fn(() => null),
-}));
-
// Mock the network hooks used by useTrendingRequest
jest.mock(
'../../../components/hooks/useNetworksByNamespace/useNetworksByNamespace',
@@ -143,6 +138,10 @@ describe('TrendingView', () => {
// Return false to use default networks behavior
return false;
}
+ if (selector === selectBasicFunctionalityEnabled) {
+ // Return true by default (enabled)
+ return true;
+ }
// Handle selectSelectedInternalAccountByScope which is a selector factory
// It returns a function that takes a scope and returns an account
if (selector === selectSelectedInternalAccountByScope) {
@@ -387,52 +386,7 @@ describe('TrendingView', () => {
expect(getByText('99')).toBeDefined();
});
- it('navigates to TrendingBrowser when button is pressed with no tabs', () => {
- mockUseSelector.mockImplementation((selector) => {
- // Handle browser tabs count selector
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (selectorStr.includes('browser') && selectorStr.includes('tabs')) {
- return 0;
- }
- if (selectorStr.includes('dataCollectionForMarketing')) {
- return false;
- }
- }
- // Return default mock values for other selectors
- if (selector === selectChainId) {
- return '0x1';
- }
- if (selector === selectIsEvmNetworkSelected) {
- return true;
- }
- if (selector === selectEnabledNetworksByNamespace) {
- return { eip155: { '0x1': true } };
- }
- if (selector === selectPopularNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectCustomNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectMultichainAccountsState2Enabled) {
- return false;
- }
- if (selector === selectSelectedInternalAccountByScope) {
- return (_scope: string) => null;
- }
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (
- selectorStr.includes('selectSelectedInternalAccountByScope') ||
- selectorStr.includes('SelectedInternalAccountByScope')
- ) {
- return (_scope: string) => null;
- }
- }
- return undefined;
- });
-
+ it('navigates to TrendingBrowser when button is pressed', () => {
const { getByTestId } = render(
@@ -442,75 +396,13 @@ describe('TrendingView', () => {
const browserButton = getByTestId('trending-view-browser-button');
fireEvent.press(browserButton);
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser');
- });
-
- it('navigates to TrendingBrowser when button is pressed with existing tabs', () => {
- mockUseSelector.mockImplementation((selector) => {
- // Handle browser tabs count selector
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (selectorStr.includes('browser') && selectorStr.includes('tabs')) {
- return 3;
- }
- if (selectorStr.includes('dataCollectionForMarketing')) {
- return false;
- }
- }
- // Return default mock values for other selectors
- if (selector === selectChainId) {
- return '0x1';
- }
- if (selector === selectIsEvmNetworkSelected) {
- return true;
- }
- if (selector === selectEnabledNetworksByNamespace) {
- return { eip155: { '0x1': true } };
- }
- if (selector === selectPopularNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectCustomNetworkConfigurationsByCaipChainId) {
- return [];
- }
- if (selector === selectMultichainAccountsState2Enabled) {
- return false;
- }
- if (selector === selectSelectedInternalAccountByScope) {
- return (_scope: string) => null;
- }
- if (typeof selector === 'function') {
- const selectorStr = selector.toString();
- if (
- selectorStr.includes('selectSelectedInternalAccountByScope') ||
- selectorStr.includes('SelectedInternalAccountByScope')
- ) {
- return (_scope: string) => null;
- }
- }
- return undefined;
- });
-
- const { getByTestId } = render(
-
-
- ,
+ expect(mockNavigate).toHaveBeenCalledWith(
+ 'TrendingBrowser',
+ expect.objectContaining({
+ newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
+ fromTrending: true,
+ }),
);
-
- const browserButton = getByTestId('trending-view-browser-button');
- fireEvent.press(browserButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser');
});
});
@@ -524,45 +416,6 @@ describe('TrendingView', () => {
expect(getByText('Explore')).toBeDefined();
});
- it('navigates to TrendingBrowser route when browser button is pressed', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const browserButton = getByTestId('trending-view-browser-button');
-
- fireEvent.press(browserButton);
-
- expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
- newTabUrl: expect.stringContaining('?metamaskEntry=mobile'),
- timestamp: expect.any(Number),
- fromTrending: true,
- });
- expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser');
- });
-
- it('includes portfolio URL with correct parameters when browser button is pressed', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const browserButton = getByTestId('trending-view-browser-button');
-
- fireEvent.press(browserButton);
-
- expect(mockNavigate).toHaveBeenCalledWith(
- 'TrendingBrowser',
- expect.objectContaining({
- newTabUrl: expect.stringContaining('metamaskEntry=mobile'),
- fromTrending: true,
- }),
- );
- });
-
it('renders search bar button', () => {
const { getByTestId } = render(
@@ -588,4 +441,23 @@ describe('TrendingView', () => {
expect(mockNavigate).toHaveBeenCalledWith('ExploreSearch');
});
+
+ describe('basic functionality toggle', () => {
+ it('displays empty state when basic functionality is disabled', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectBasicFunctionalityEnabled) {
+ return false;
+ }
+ return undefined;
+ });
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('basic-functionality-empty-state')).toBeDefined();
+ });
+ });
});
diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx
index 5593c6cd644..7c6b5a6ec35 100644
--- a/app/components/Views/TrendingView/TrendingView.tsx
+++ b/app/components/Views/TrendingView/TrendingView.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
@@ -18,7 +18,6 @@ import AppConstants from '../../../core/AppConstants';
import { appendURLParams } from '../../../util/browser';
import { useMetrics } from '../../hooks/useMetrics';
import { useTheme } from '../../../util/theme';
-import Browser from '../Browser';
import Routes from '../../../constants/navigation/Routes';
import {
lastTrendingScreenRef,
@@ -26,42 +25,14 @@ import {
} from '../../Nav/Main/MainNavigator';
import ExploreSearchScreen from './ExploreSearchScreen/ExploreSearchScreen';
import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar';
-import {
- PredictModalStack,
- PredictMarketDetails,
- PredictSellPreview,
-} from '../../UI/Predict';
-import PredictBuyPreview from '../../UI/Predict/views/PredictBuyPreview/PredictBuyPreview';
import QuickActions from './components/QuickActions/QuickActions';
import SectionHeader from './components/SectionHeader/SectionHeader';
import { HOME_SECTIONS_ARRAY } from './config/sections.config';
+import { selectBasicFunctionalityEnabled } from '../../../selectors/settings';
+import BasicFunctionalityEmptyState from './components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState';
const Stack = createStackNavigator();
-// Wrapper component to intercept navigation
-const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => {
- const navigation = useNavigation();
-
- // 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 ;
-};
-
const TrendingFeed: React.FC = () => {
const tw = useTailwind();
const insets = useSafeAreaInsets();
@@ -88,6 +59,10 @@ const TrendingFeed: React.FC = () => {
const browserTabsCount = useSelector(
(state: { browser: { tabs: unknown[] } }) => state.browser.tabs.length,
);
+ // check if basic functionality toggle is on
+ const isBasicFunctionalityEnabled = useSelector(
+ selectBasicFunctionalityEnabled,
+ );
const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, {
metamaskEntry: 'mobile',
@@ -162,27 +137,31 @@ const TrendingFeed: React.FC = () => {
-
- }
- >
-
-
- {HOME_SECTIONS_ARRAY.map((section) => (
-
-
-
-
- ))}
-
+ {isBasicFunctionalityEnabled ? (
+
+ }
+ >
+
+
+ {HOME_SECTIONS_ARRAY.map((section) => (
+
+
+
+
+ ))}
+
+ ) : (
+
+ )}
);
};
@@ -198,43 +177,10 @@ const TrendingView: React.FC = () => {
}}
>
-
-
-
-
-
);
};
diff --git a/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx
new file mode 100644
index 00000000000..beabeff54da
--- /dev/null
+++ b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import BasicFunctionalityEmptyState from './BasicFunctionalityEmptyState';
+import Routes from '../../../../../constants/navigation/Routes';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+}));
+
+describe('BasicFunctionalityEmptyState', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders empty state', () => {
+ const { getByText } = render();
+
+ expect(getByText('Explore is not available')).toBeDefined();
+ expect(
+ getByText(
+ "We can't fetch the required metadata when basic functionality is disabled.",
+ ),
+ ).toBeDefined();
+ expect(getByText('Enable basic functionality')).toBeDefined();
+ });
+
+ it('navigates to basic functionality settings when button is pressed', () => {
+ const { getByText } = render();
+
+ const enableButton = getByText('Enable basic functionality');
+
+ fireEvent.press(enableButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, {
+ screen: Routes.SHEET.BASIC_FUNCTIONALITY,
+ });
+ });
+});
diff --git a/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx
new file mode 100644
index 00000000000..d4217104fdd
--- /dev/null
+++ b/app/components/Views/TrendingView/components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState.tsx
@@ -0,0 +1,52 @@
+import React, { useCallback } from 'react';
+import {
+ Box,
+ Text,
+ TextVariant,
+ Button,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../locales/i18n';
+import { useNavigation } from '@react-navigation/native';
+import Routes from '../../../../../constants/navigation/Routes';
+
+const BasicFunctionalityEmptyState = () => {
+ const navigation = useNavigation();
+
+ const handleEnableBasicFunctionality = useCallback(() => {
+ navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
+ screen: Routes.SHEET.BASIC_FUNCTIONALITY,
+ });
+ }, [navigation]);
+
+ return (
+
+
+
+ {strings('trending.basic_functionality_disabled_title')}
+
+
+ {strings('trending.basic_functionality_disabled_description')}
+
+
+ {strings('trending.enable_basic_functionality')}
+
+
+
+ );
+};
+
+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"