diff --git a/app/components/UI/Bridge/hooks/useTokenSelection.test.ts b/app/components/UI/Bridge/hooks/useTokenSelection.test.ts index ce807f01bc4..c536f2d7996 100644 --- a/app/components/UI/Bridge/hooks/useTokenSelection.test.ts +++ b/app/components/UI/Bridge/hooks/useTokenSelection.test.ts @@ -30,6 +30,13 @@ jest.mock('./useIsNetworkEnabled', () => ({ useIsNetworkEnabled: jest.fn(() => true), })); +const mockAutoUpdateDestToken = jest.fn(); +jest.mock('./useAutoUpdateDestToken', () => ({ + useAutoUpdateDestToken: () => ({ + autoUpdateDestToken: mockAutoUpdateDestToken, + }), +})); + import { useSelector } from 'react-redux'; import { useIsNetworkEnabled } from './useIsNetworkEnabled'; const mockUseSelector = useSelector as jest.Mock; @@ -60,7 +67,7 @@ describe('useTokenSelection', () => { .mockReturnValueOnce(mockDestAmount); // selectDestAmount }); - it('dispatches setSourceToken when selecting new source token', async () => { + it('dispatches setSourceToken and calls autoUpdateDestToken when selecting new source token', async () => { const { result } = renderHook(() => useTokenSelection(TokenSelectorType.Source), ); @@ -74,6 +81,7 @@ describe('useTokenSelection', () => { }); expect(mockDispatch).toHaveBeenCalledWith(setSourceToken(newToken)); + expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(newToken); expect(mockGoBack).toHaveBeenCalled(); }); @@ -88,6 +96,7 @@ describe('useTokenSelection', () => { expect(mockHandleSwitchTokens).toHaveBeenCalledWith(mockDestAmount); expect(mockHandleSwitchTokensInner).toHaveBeenCalled(); + expect(mockAutoUpdateDestToken).not.toHaveBeenCalled(); expect(mockGoBack).toHaveBeenCalled(); }); @@ -193,6 +202,7 @@ describe('useTokenSelection', () => { }); expect(mockDispatch).toHaveBeenCalledWith(setSourceToken(newToken)); + expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(newToken); expect(mockGoBack).toHaveBeenCalled(); }); @@ -237,6 +247,7 @@ describe('useTokenSelection', () => { setSourceToken(sameAddressToken), ); expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(sameAddressToken); expect(mockHandleSwitchTokens).not.toHaveBeenCalled(); }); @@ -260,6 +271,7 @@ describe('useTokenSelection', () => { expect(mockDispatch).toHaveBeenCalledWith(setSourceToken(sameChainToken)); expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockAutoUpdateDestToken).toHaveBeenCalledWith(sameChainToken); expect(mockHandleSwitchTokens).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Bridge/hooks/useTokenSelection.ts b/app/components/UI/Bridge/hooks/useTokenSelection.ts index 6419badb76e..6745f771628 100644 --- a/app/components/UI/Bridge/hooks/useTokenSelection.ts +++ b/app/components/UI/Bridge/hooks/useTokenSelection.ts @@ -12,6 +12,7 @@ import { import { BridgeToken, TokenSelectorType } from '../types'; import { useSwitchTokens } from './useSwitchTokens'; import { useIsNetworkEnabled } from './useIsNetworkEnabled'; +import { useAutoUpdateDestToken } from './useAutoUpdateDestToken'; /** * Hook to manage token selection logic for Bridge token selector @@ -26,6 +27,7 @@ export const useTokenSelection = (type: TokenSelectorType) => { const destAmount = useSelector(selectDestAmount); const { handleSwitchTokens } = useSwitchTokens(); const isDestNetworkEnabled = useIsNetworkEnabled(destToken?.chainId); + const { autoUpdateDestToken } = useAutoUpdateDestToken(); const handleTokenPress = useCallback( async (token: BridgeToken) => { @@ -59,6 +61,9 @@ export const useTokenSelection = (type: TokenSelectorType) => { dispatch(isSourcePicker ? setSourceToken(token) : setDestToken(token)); if (!isSourcePicker) { dispatch(setIsDestTokenManuallySet(true)); + } else { + // Auto-update dest token when source token changes + autoUpdateDestToken(token); } } @@ -73,6 +78,7 @@ export const useTokenSelection = (type: TokenSelectorType) => { navigation, handleSwitchTokens, isDestNetworkEnabled, + autoUpdateDestToken, ], ); diff --git a/app/components/UI/Earn/hooks/useMusdConversion.test.ts b/app/components/UI/Earn/hooks/useMusdConversion.test.ts index bd41b2be542..5108f4276dc 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.test.ts @@ -12,6 +12,8 @@ import { useSelector } from 'react-redux'; import { TransactionType } from '@metamask/transaction-controller'; import { selectMusdConversionEducationSeen } from '../../../../reducers/user'; import { trace, TraceName, TraceOperation } from '../../../../util/trace'; +import { RootState } from '../../../../reducers'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; const mockTrace = trace as jest.MockedFunction; @@ -83,21 +85,44 @@ describe('useMusdConversion', () => { const setupUseSelectorMock = ({ selectedAccount = mockSelectedAccount, hasSeenConversionEducationScreen = true, + pendingApprovals = {}, + transactions = [], }: { selectedAccount?: typeof mockSelectedAccount | null; hasSeenConversionEducationScreen?: boolean; + pendingApprovals?: Record; + transactions?: { + id: string; + type?: TransactionType; + chainId?: Hex; + txParams?: { from?: string }; + }[]; } = {}) => { - const mockAccountSelector = jest.fn(() => selectedAccount); mockUseSelector.mockReset(); + const mockState = { + engine: { + backgroundState: { + ApprovalController: { + pendingApprovals, + }, + TransactionController: { + transactions, + }, + }, + }, + } as unknown as RootState; + mockUseSelector.mockImplementation((selector) => { if (selector === selectMusdConversionEducationSeen) { return hasSeenConversionEducationScreen; } - return mockAccountSelector; - }); + if (selector === selectSelectedInternalAccountByScope) { + return () => selectedAccount; + } - return { mockAccountSelector }; + return selector(mockState); + }); }; beforeEach(() => { @@ -141,7 +166,9 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - await result.current.initiateConversion(mockConfig); + await act(async () => { + await result.current.initiateConversion(mockConfig); + }); expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.EARN.ROOT, { screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, @@ -155,6 +182,109 @@ describe('useMusdConversion', () => { }); }); + it('returns same transaction ID for concurrent initiations before approval exists', async () => { + setupUseSelectorMock(); + + mockNetworkController.findNetworkClientIdByChainId.mockReturnValue( + 'mainnet', + ); + + let resolveAddTransaction!: (value: { + transactionMeta: { id: string }; + }) => void; + const addTransactionPromise = new Promise<{ + transactionMeta: { id: string }; + }>((resolve) => { + resolveAddTransaction = resolve; + }); + mockTransactionController.addTransaction.mockReturnValue( + addTransactionPromise, + ); + + const { result } = renderHook(() => useMusdConversion()); + + let transactionIds!: [string | void, string | void]; + await act(async () => { + const firstCall = result.current.initiateConversion(mockConfig); + const secondCall = result.current.initiateConversion(mockConfig); + + resolveAddTransaction({ transactionMeta: { id: 'tx-123' } }); + + transactionIds = await Promise.all([firstCall, secondCall]); + }); + + expect(transactionIds).toEqual(['tx-123', 'tx-123']); + expect( + mockNetworkController.findNetworkClientIdByChainId, + ).toHaveBeenCalledTimes(1); + expect(mockTransactionController.addTransaction).toHaveBeenCalledTimes(1); + expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); + }); + + it('returns existing pending musdConversion transaction ID for same account and chain', async () => { + setupUseSelectorMock({ + pendingApprovals: { + 'tx-existing': { id: 'tx-existing' }, + }, + transactions: [ + { + id: 'tx-existing', + type: TransactionType.musdConversion, + chainId: '0x1', + txParams: { from: mockSelectedAccount.address }, + }, + ], + }); + + const { result } = renderHook(() => useMusdConversion()); + + let transactionId!: string | void; + await act(async () => { + transactionId = await result.current.initiateConversion(mockConfig); + }); + + expect(transactionId).toBe('tx-existing'); + expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); + expect( + mockNetworkController.findNetworkClientIdByChainId, + ).not.toHaveBeenCalled(); + expect(mockTransactionController.addTransaction).not.toHaveBeenCalled(); + }); + + it('returns existing pending musdConversion transaction ID with mixed-case chainId and from address', async () => { + setupUseSelectorMock({ + pendingApprovals: { + 'tx-existing': { id: 'tx-existing' }, + }, + transactions: [ + { + id: 'tx-existing', + type: TransactionType.musdConversion, + chainId: '0x1', + txParams: { + from: mockSelectedAccount.address.toUpperCase() as unknown as string, + }, + }, + ], + }); + + const { result } = renderHook(() => useMusdConversion()); + + let transactionId!: string | void; + await act(async () => { + transactionId = await result.current.initiateConversion({ + preferredPaymentToken: { + ...mockConfig.preferredPaymentToken, + chainId: '0x1' as Hex, + }, + }); + }); + + expect(transactionId).toBe('tx-existing'); + expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); + expect(mockTransactionController.addTransaction).not.toHaveBeenCalled(); + }); + it('creates transaction with correct data structure', async () => { setupUseSelectorMock(); @@ -167,7 +297,9 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - await result.current.initiateConversion(mockConfig); + await act(async () => { + await result.current.initiateConversion(mockConfig); + }); expect(mockTransactionController.addTransaction).toHaveBeenCalledWith( { @@ -242,7 +374,10 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - const transactionId = await result.current.initiateConversion(mockConfig); + let transactionId!: string | void; + await act(async () => { + transactionId = await result.current.initiateConversion(mockConfig); + }); expect(transactionId).toBeUndefined(); expect(mockTransactionController.addTransaction).not.toHaveBeenCalled(); @@ -275,9 +410,12 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - const transactionId = await result.current.initiateConversion({ - ...mockConfig, - skipEducationCheck: true, + let transactionId!: string | void; + await act(async () => { + transactionId = await result.current.initiateConversion({ + ...mockConfig, + skipEducationCheck: true, + }); }); expect(transactionId).toBe('tx-123'); @@ -334,7 +472,9 @@ describe('useMusdConversion', () => { navigationStack: 'CustomStack', }; - await result.current.initiateConversion(configWithCustomStack); + await act(async () => { + await result.current.initiateConversion(configWithCustomStack); + }); expect(mockNavigation.navigate).toHaveBeenCalledWith('CustomStack', { screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, @@ -360,7 +500,10 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - const transactionId = await result.current.initiateConversion(mockConfig); + let transactionId!: string | void; + await act(async () => { + transactionId = await result.current.initiateConversion(mockConfig); + }); expect(transactionId).toBe('tx-123'); }); @@ -377,7 +520,9 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - await result.current.initiateConversion(mockConfig); + await act(async () => { + await result.current.initiateConversion(mockConfig); + }); expect(mockTrace).toHaveBeenCalledWith({ name: TraceName.MusdConversionNavigation, @@ -409,7 +554,9 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - await result.current.initiateConversion(mockConfig); + await act(async () => { + await result.current.initiateConversion(mockConfig); + }); expect(callOrder).toEqual(['trace', 'navigate']); }); diff --git a/app/components/UI/Earn/hooks/useMusdConversion.ts b/app/components/UI/Earn/hooks/useMusdConversion.ts index 3fafa97755c..2dbdbdce0c6 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.ts @@ -1,6 +1,11 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; +import { isEqual } from 'lodash'; import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; import { useNavigation } from '@react-navigation/native'; @@ -11,6 +16,60 @@ import { selectSelectedInternalAccountByScope } from '../../../../selectors/mult import { selectMusdConversionEducationSeen } from '../../../../reducers/user'; import { trace, TraceName, TraceOperation } from '../../../../util/trace'; import { createMusdConversionTransaction } from '../utils/musdConversionTransaction'; +import { selectPendingApprovals } from '../../../../selectors/approvalController'; +import { RootState } from '../../../../reducers'; +import { selectTransactionsByIds } from '../../../../selectors/transactionController'; + +/** + * Why do we have BOTH `existingPendingMusdConversion` AND `inFlightInitiationPromises`? + * + * These protect against two *different* duplication mechanisms: + * + * 1) `existingPendingMusdConversion` (post-approval creation / observable state): + * Once a `musdConversion` transaction is added, it becomes a pending approval in Redux. + * Subsequent CTA presses should **re-enter that existing flow** rather than creating a new tx. + * + * 2) `inFlightInitiationPromises` (pre-approval creation race window): + * There is a short window after the CTA press where we have started the async initiation + * but the pending approval is not yet observable in Redux. Rapid spam during that window + * can otherwise create multiple transactions before (1) can detect an existing pending tx. + */ +const inFlightInitiationPromises = new Map>(); + +function getInitiationKey(params: { selectedAddress: string; chainId: Hex }) { + const { selectedAddress, chainId } = params; + return `${selectedAddress.toLowerCase()}_${chainId.toLowerCase()}`; +} + +function findExistingPendingMusdConversion(params: { + pendingTransactionMetas: TransactionMeta[]; + selectedAddress: string; + preferredPaymentTokenChainId: Hex; +}) { + const { + pendingTransactionMetas, + selectedAddress, + preferredPaymentTokenChainId, + } = params; + + return pendingTransactionMetas.find((transactionMeta) => { + if (transactionMeta?.type !== TransactionType.musdConversion) { + return false; + } + + if ( + transactionMeta?.chainId.toLowerCase() !== + preferredPaymentTokenChainId.toLowerCase() + ) { + return false; + } + + return ( + transactionMeta?.txParams?.from?.toLowerCase() === + selectedAddress.toLowerCase() + ); + }); +} /** * Configuration for mUSD conversion @@ -57,6 +116,17 @@ export const useMusdConversion = () => { const [error, setError] = useState(null); const navigation = useNavigation(); + const pendingApprovals = useSelector(selectPendingApprovals, isEqual); + + const pendingApprovalIds = useMemo( + () => Object.keys(pendingApprovals ?? {}), + [pendingApprovals], + ); + + const pendingTransactionMetas = useSelector((state: RootState) => + selectTransactionsByIds(state, pendingApprovalIds), + ); + const selectedAccount = useSelector(selectSelectedInternalAccountByScope)( EVM_SCOPE, ); @@ -142,42 +212,80 @@ export const useMusdConversion = () => { throw new Error('No account selected'); } - const { NetworkController } = Engine.context; - const networkClientId = NetworkController.findNetworkClientIdByChainId( - preferredPaymentToken.chainId, + const existingPendingMusdConversion = findExistingPendingMusdConversion( + { + pendingTransactionMetas, + selectedAddress, + preferredPaymentTokenChainId: preferredPaymentToken.chainId, + }, ); - if (!networkClientId) { - throw new Error( - `Network client not found for chain ID: ${preferredPaymentToken.chainId}`, - ); - } - /** - * Navigate to the confirmation screen immediately for better UX, - * since there can be a delay between the user's button press and - * transaction creation in the background. + * Prevents the user from creating multiple transactions. + * Typically caused by the user quickly clicking the CTA multiple times in quick succession. */ - navigateToConversionScreen(config); + if (existingPendingMusdConversion?.id) { + navigateToConversionScreen(config); + return existingPendingMusdConversion.id; + } + + const initiationKey = getInitiationKey({ + selectedAddress, + chainId: preferredPaymentToken.chainId, + }); + + const inFlightInitiation = + inFlightInitiationPromises.get(initiationKey); + + if (inFlightInitiation) { + return await inFlightInitiation; + } + + const initiationPromise = (async () => { + const { NetworkController } = Engine.context; + const networkClientId = + NetworkController.findNetworkClientIdByChainId( + preferredPaymentToken.chainId, + ); + + if (!networkClientId) { + throw new Error( + `Network client not found for chain ID: ${preferredPaymentToken.chainId}`, + ); + } + + /** + * Navigate to the confirmation screen immediately for better UX, + * since there can be a delay between the user's button press and + * transaction creation in the background. + */ + navigateToConversionScreen(config); + + try { + const ZERO_HEX_VALUE = '0x0'; + const selectedAddressHex = selectedAddress as Hex; + + const { transactionId } = await createMusdConversionTransaction({ + chainId: preferredPaymentToken.chainId, + fromAddress: selectedAddressHex, + recipientAddress: selectedAddressHex, + amountHex: ZERO_HEX_VALUE, + networkClientId, + }); + + return transactionId; + } catch (err) { + // Prevent the user from being stuck on the confirmation screen without a transaction. + navigation.goBack(); + throw err; + } + })(); + inFlightInitiationPromises.set(initiationKey, initiationPromise); try { - const ZERO_HEX_VALUE = '0x0'; - - const selectedAddressHex = selectedAddress as Hex; - - const { transactionId } = await createMusdConversionTransaction({ - chainId: preferredPaymentToken.chainId, - fromAddress: selectedAddressHex, - recipientAddress: selectedAddressHex, - amountHex: ZERO_HEX_VALUE, - networkClientId, - }); - - return transactionId; - } catch (err) { - // Prevent the user from being stuck on the confirmation screen without a transaction. - navigation.goBack(); - throw err; + return await initiationPromise; + } finally { + inFlightInitiationPromises.delete(initiationKey); } } catch (err) { const errorMessage = @@ -199,6 +307,7 @@ export const useMusdConversion = () => { handleEducationRedirectIfNeeded, navigateToConversionScreen, navigation, + pendingTransactionMetas, selectedAddress, ], ); diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 235caac57d8..705d33626b0 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -127,6 +127,7 @@ const PerpsHomeView = () => { orders, watchlistMarkets, perpsMarkets, // Crypto markets (renamed from trendingMarkets) + commoditiesMarkets, // Commodity markets stocksMarkets, // Equity markets only forexMarkets, recentActivity, @@ -503,6 +504,15 @@ const PerpsHomeView = () => { /> + {/* Commodities Markets List */} + + {/* Stocks Markets List */} = () => { }); // Get current price for the symbol + // Use mark price (oracle price) for stop loss calculations to reduce manipulation risk + // Falls back to mid price if mark price unavailable const currentPrice = useMemo(() => { if (!market?.symbol) return 0; const priceData = livePrices[market.symbol]; + if (priceData?.markPrice) { + return parseFloat(priceData.markPrice); + } if (priceData?.price) { return parseFloat(priceData.price); } diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index b07e8c788f0..730d4753891 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -291,8 +291,11 @@ const PerpsTabView = () => { const handleSeeAllPerps = useCallback(() => { navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.PERPS_HOME, - params: { source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB }, + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: 'all', + source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + }, }); }, [navigation]); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx index 2daf03338cd..290684d2321 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx @@ -113,7 +113,7 @@ describe('PerpsOrderTransactionView', () => { }); }); - it('should render order transaction details correctly', () => { + it('renders order transaction details correctly', () => { const { getByText, getByTestId } = render(); expect( @@ -131,18 +131,18 @@ describe('PerpsOrderTransactionView', () => { expect(getByText('100%')).toBeTruthy(); }); - it('should render fee breakdown correctly', () => { + it('renders fee breakdown correctly', () => { const { getByText } = render(); expect(getByText('MetaMask fee')).toBeTruthy(); expect(getByText('Hyperliquid fee')).toBeTruthy(); expect(getByText('Total fee')).toBeTruthy(); expect(getByText('$3')).toBeTruthy(); - expect(getByText('$7.50')).toBeTruthy(); - expect(getByText('$10.50')).toBeTruthy(); + expect(getByText('$7.5')).toBeTruthy(); // Trailing zero stripped + expect(getByText('$10.5')).toBeTruthy(); // Trailing zero stripped }); - it('should show zero fees when order is not filled', () => { + it('shows zero fees when order is not filled', () => { const unfilledTransaction = { ...mockTransaction, order: { @@ -162,7 +162,7 @@ describe('PerpsOrderTransactionView', () => { expect(zeroFees).toHaveLength(3); // All three fees should be $0 }); - it('should show "< $0.01" for fees less than 0.01', () => { + it('shows exact fee values for fees less than 0.01', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.005, protocolFee: 0.003, @@ -173,14 +173,16 @@ describe('PerpsOrderTransactionView', () => { error: null, }); - const { getAllByText } = render(); + const { getByText, queryByText } = render(); - // All three fees should show "< $0.01" since they're all less than 0.01 - const smallFeeLabels = getAllByText('< $0.01'); - expect(smallFeeLabels).toHaveLength(3); + // All three fees should show exact values, not "< $0.01" + expect(queryByText('< $0.01')).toBeNull(); + expect(getByText('$0.005')).toBeTruthy(); // Total fee + expect(getByText('$0.003')).toBeTruthy(); // Protocol fee + expect(getByText('$0.002')).toBeTruthy(); // MetaMask fee }); - it('should format fees normally when they are exactly 0.01', () => { + it('formats fees normally when they are exactly 0.01', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.03, protocolFee: 0.01, @@ -203,7 +205,7 @@ describe('PerpsOrderTransactionView', () => { expect(getByText('$0.03')).toBeTruthy(); // Total fee }); - it('should format fees normally when they are greater than 0.01', () => { + it('formats fees with exact values regardless of size', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.015, protocolFee: 0.012, @@ -214,16 +216,16 @@ describe('PerpsOrderTransactionView', () => { error: null, }); - const { getByText, getAllByText } = render(); + const { getByText, queryByText } = render(); - // Metamask fee is less than 0.01, should show "< $0.01" - expect(getAllByText('< $0.01')).toHaveLength(1); - // Protocol and total fees are >= 0.01, should be formatted normally - expect(getByText('$0.01')).toBeTruthy(); // Protocol fee formatted - expect(getByText('$0.02')).toBeTruthy(); // Total fee formatted (rounded) + // All fees should show exact values, not "< $0.01" + expect(queryByText('< $0.01')).toBeNull(); + expect(getByText('$0.003')).toBeTruthy(); // MetaMask fee (exact) + expect(getByText('$0.012')).toBeTruthy(); // Protocol fee (exact) + expect(getByText('$0.015')).toBeTruthy(); // Total fee (exact) }); - it('should handle mixed small and large fees correctly', () => { + it('handles mixed small and large fees correctly with exact values', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.025, protocolFee: 0.02, @@ -234,17 +236,16 @@ describe('PerpsOrderTransactionView', () => { error: null, }); - const { getByText, getAllByText } = render(); + const { getByText, queryByText } = render(); - // Metamask fee is less than 0.01 - const smallFeeLabels = getAllByText('< $0.01'); - expect(smallFeeLabels).toHaveLength(1); - // Protocol and total fees are >= 0.01, should be formatted + // All fees should show exact values + expect(queryByText('< $0.01')).toBeNull(); + expect(getByText('$0.005')).toBeTruthy(); // MetaMask fee (exact) expect(getByText('$0.02')).toBeTruthy(); // Protocol fee - expect(getByText('$0.03')).toBeTruthy(); // Total fee (rounded) + expect(getByText('$0.025')).toBeTruthy(); // Total fee (exact) }); - it('should handle edge case: fee just below 0.01 threshold', () => { + it('handles edge case: fee just below 0.01 threshold with exact values', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.029, protocolFee: 0.0099, @@ -255,15 +256,20 @@ describe('PerpsOrderTransactionView', () => { error: null, }); - const { getAllByText } = render(); + const { getByText, getAllByText, queryByText } = render( + , + ); - // Both metamask and protocol fees are just below 0.01 - const smallFeeLabels = getAllByText('< $0.01'); - expect(smallFeeLabels).toHaveLength(2); - // Total fee is >= 0.01, should be formatted + // All fees should show exact values, not "< $0.01" + expect(queryByText('< $0.01')).toBeNull(); + // Both metamask and protocol fees show exact value + const fee0099Labels = getAllByText('$0.0099'); + expect(fee0099Labels).toHaveLength(2); + // Total fee shows exact value + expect(getByText('$0.029')).toBeTruthy(); }); - it('should handle edge case: fee just above 0.01 threshold', () => { + it('handles edge case: fee just above 0.01 threshold with exact values', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.0201, protocolFee: 0.0101, @@ -274,19 +280,16 @@ describe('PerpsOrderTransactionView', () => { error: null, }); - const { queryByText, getAllByText, getByText } = render( - , - ); + const { queryByText, getByText } = render(); - // All fees are >= 0.01, should be formatted normally + // All fees should show exact values, not "< $0.01" expect(queryByText('< $0.01')).toBeNull(); - // Metamask fee and protocol fee (rounded) both show $0.01 - const fee01Labels = getAllByText('$0.01'); - expect(fee01Labels.length).toBeGreaterThanOrEqual(2); - expect(getByText('$0.02')).toBeTruthy(); // Total fee (rounded) + expect(getByText('$0.01')).toBeTruthy(); // MetaMask fee (exact) + expect(getByText('$0.0101')).toBeTruthy(); // Protocol fee (exact) + expect(getByText('$0.0201')).toBeTruthy(); // Total fee (exact) }); - it('should show "< $0.01" for all fees when all are below threshold', () => { + it('shows exact values for all fees when all are below 0.01', () => { mockUsePerpsOrderFees.mockReturnValue({ totalFee: 0.008, protocolFee: 0.005, @@ -297,14 +300,16 @@ describe('PerpsOrderTransactionView', () => { error: null, }); - const { getAllByText } = render(); + const { getByText, queryByText } = render(); - // All three fees are below 0.01 - const smallFeeLabels = getAllByText('< $0.01'); - expect(smallFeeLabels).toHaveLength(3); + // All three fees should show exact values, not "< $0.01" + expect(queryByText('< $0.01')).toBeNull(); + expect(getByText('$0.008')).toBeTruthy(); // Total fee + expect(getByText('$0.005')).toBeTruthy(); // Protocol fee + expect(getByText('$0.003')).toBeTruthy(); // MetaMask fee }); - it('should navigate to block explorer in browser tab when button is pressed', () => { + it('navigates to block explorer in browser tab when button is pressed', () => { const mockNavigate = jest.fn(); mockUseNavigation.mockReturnValue({ navigate: mockNavigate, @@ -326,7 +331,7 @@ describe('PerpsOrderTransactionView', () => { }); }); - it('should use testnet URL when network is testnet', () => { + it('uses testnet URL when network is testnet', () => { const mockNavigate = jest.fn(); mockUseNavigation.mockReturnValue({ navigate: mockNavigate, @@ -350,7 +355,7 @@ describe('PerpsOrderTransactionView', () => { }); }); - it('should not navigate to block explorer when no selected account', () => { + it('does not navigate to block explorer when no selected account', () => { const mockNavigate = jest.fn(); mockUseNavigation.mockReturnValue({ navigate: mockNavigate, @@ -373,7 +378,7 @@ describe('PerpsOrderTransactionView', () => { ); }); - it('should render error message when transaction is not found', () => { + it('renders error message when transaction is not found', () => { mockUseRoute.mockReturnValue({ params: { transaction: null }, }); @@ -383,14 +388,14 @@ describe('PerpsOrderTransactionView', () => { expect(getByText('Transaction not found')).toBeTruthy(); }); - it('should format date correctly', () => { + it('formats date correctly', () => { const { getByText } = render(); expect(getByText('Date')).toBeTruthy(); // The actual date format would depend on the formatTransactionDate utility }); - it('should handle different order types', () => { + it('handles different order types', () => { const marketOrderTransaction = { ...mockTransaction, order: { @@ -412,7 +417,7 @@ describe('PerpsOrderTransactionView', () => { }); }); - it('should handle missing order data gracefully', () => { + it('handles missing order data gracefully', () => { const transactionWithoutOrder = { ...mockTransaction, order: undefined, @@ -430,7 +435,34 @@ describe('PerpsOrderTransactionView', () => { }); }); - it('should call usePerpsOrderFees with correct parameters', () => { + it('displays exact price for low-priced assets like PUMP instead of "< $0.01"', () => { + // Arrange: Create a transaction with a very low limit price (typical for meme coins) + const lowPriceTransaction = { + ...mockTransaction, + asset: 'PUMP', + title: 'Long PUMP limit', + order: { + ...mockTransaction.order, + limitPrice: 0.00234, // Price below $0.01 + }, + }; + + mockUseRoute.mockReturnValue({ + params: { transaction: lowPriceTransaction }, + }); + + // Act + const { getByText, queryByText } = render(); + + // Assert: Should show the actual formatted price, not "< $0.01" + expect(getByText('Limit price')).toBeTruthy(); + // The price should be formatted with PRICE_RANGES_UNIVERSAL showing actual value + expect(queryByText('< $0.01')).toBeNull(); + // Should display the actual price with appropriate decimals (e.g., "$0.00234") + expect(getByText('$0.00234')).toBeTruthy(); + }); + + it('calls usePerpsOrderFees with correct parameters', () => { render(); expect(mockUsePerpsOrderFees).toHaveBeenCalledWith({ @@ -439,7 +471,7 @@ describe('PerpsOrderTransactionView', () => { }); }); - it('should set correct navigation options', () => { + it('sets correct navigation options', () => { const mockSetOptions = jest.fn(); mockUseNavigation.mockReturnValue({ setOptions: mockSetOptions, diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx index b33d1938f55..83a7f513f49 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx @@ -28,8 +28,8 @@ import { PerpsNavigationParamList } from '../../types/navigation'; import { PerpsOrderTransactionRouteProp } from '../../types/transactionHistory'; import { formatPerpsFiat, - formatPositiveFiat, formatTransactionDate, + PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { styleSheet } from './PerpsOrderTransactionView.styles'; @@ -96,7 +96,9 @@ const PerpsOrderTransactionView: React.FC = () => { }, { label: strings('perps.transactions.order.limit_price'), - value: formatPositiveFiat(transaction.order?.limitPrice ?? 0), + value: formatPerpsFiat(transaction.order?.limitPrice ?? 0, { + ranges: PRICE_RANGES_UNIVERSAL, + }), }, { label: strings('perps.transactions.order.filled'), @@ -106,20 +108,22 @@ const PerpsOrderTransactionView: React.FC = () => { const isFilled = transaction.order?.text === 'Filled'; - // Fee breakdown + // Fee breakdown - use PRICE_RANGES_UNIVERSAL to show exact values instead of "< $0.01" + const formatFee = (fee: number) => + formatPerpsFiat(fee, { ranges: PRICE_RANGES_UNIVERSAL }); const feeRows = [ { label: strings('perps.transactions.order.metamask_fee'), - value: formatPositiveFiat(isFilled ? metamaskFee : 0), + value: formatFee(isFilled ? metamaskFee : 0), }, { label: strings('perps.transactions.order.hyperliquid_fee'), - value: formatPositiveFiat(isFilled ? protocolFee : 0), + value: formatFee(isFilled ? protocolFee : 0), }, { label: strings('perps.transactions.order.total_fee'), - value: formatPositiveFiat(isFilled ? totalFee : 0), + value: formatFee(isFilled ? totalFee : 0), }, ]; diff --git a/app/components/UI/Perps/animations/perps-onboarding-carousel-dark.riv b/app/components/UI/Perps/animations/perps-onboarding-carousel-dark.riv index 8265ba19aa5..948bbcc2e73 100644 Binary files a/app/components/UI/Perps/animations/perps-onboarding-carousel-dark.riv and b/app/components/UI/Perps/animations/perps-onboarding-carousel-dark.riv differ diff --git a/app/components/UI/Perps/animations/perps-onboarding-carousel-light.riv b/app/components/UI/Perps/animations/perps-onboarding-carousel-light.riv index c1a5f5d1bbc..44ddf494009 100644 Binary files a/app/components/UI/Perps/animations/perps-onboarding-carousel-light.riv and b/app/components/UI/Perps/animations/perps-onboarding-carousel-light.riv differ diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx index 44a4e177c30..f40a27404f6 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx @@ -36,7 +36,7 @@ const PerpsMarketRowItem = ({ onPress, iconSize = HOME_SCREEN_CONFIG.DefaultIconSize, displayMetric = 'volume', - showBadge = true, + showBadge = false, // We can re-enable this if/when we decide to render the badges for stocks and commodities }: PerpsMarketRowItemProps) => { const { styles } = useStyles(styleSheet, {}); diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 97f94350721..bde6a6931b5 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -558,7 +558,7 @@ export const STOP_LOSS_PROMPT_CONFIG = { // Minimum position age before showing any banner (milliseconds) // Prevents banner from appearing immediately after opening a position - PositionMinAgeMs: 60_000, // 60 seconds + PositionMinAgeMs: 120_000, // 2 minutes // Suggested stop loss ROE percentage // When suggesting a stop loss, calculate price at this ROE from entry diff --git a/app/components/UI/Perps/hooks/usePerpsTabExploreData.test.ts b/app/components/UI/Perps/hooks/usePerpsTabExploreData.test.ts index d85f17b637c..2e5c6c6b580 100644 --- a/app/components/UI/Perps/hooks/usePerpsTabExploreData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTabExploreData.test.ts @@ -189,7 +189,7 @@ describe('usePerpsTabExploreData', () => { expect(result.current.exploreMarkets[7].symbol).toBe('TOKEN7'); }); - it('filters out non-crypto/equity market types', () => { + it('includes all market types without filtering', () => { // Arrange const mixedMarkets: PerpsMarketDataWithVolumeNumber[] = [ { ...mockMarkets[0], marketType: undefined }, // crypto (no type) @@ -197,7 +197,7 @@ describe('usePerpsTabExploreData', () => { { ...mockMarkets[2], marketType: 'forex' as PerpsMarketData['marketType'], - }, // forex - should be filtered out + }, // forex ]; mockUsePerpsMarkets.mockReturnValue({ markets: mixedMarkets, @@ -212,13 +212,13 @@ describe('usePerpsTabExploreData', () => { usePerpsTabExploreData({ enabled: true }), ); - // Assert - forex should be filtered out - expect(result.current.exploreMarkets).toHaveLength(2); - expect( - result.current.exploreMarkets.every( - (m) => !m.marketType || m.marketType === 'equity', - ), - ).toBe(true); + // Assert - all market types are included (no filtering) + expect(result.current.exploreMarkets).toHaveLength(3); + expect(result.current.exploreMarkets.map((m) => m.symbol)).toEqual([ + 'BTC', + 'ETH', + 'SOL', + ]); }); it('returns empty watchlist when no symbols match', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsTabExploreData.ts b/app/components/UI/Perps/hooks/usePerpsTabExploreData.ts index b6c631ae0ea..6cdd7657db8 100644 --- a/app/components/UI/Perps/hooks/usePerpsTabExploreData.ts +++ b/app/components/UI/Perps/hooks/usePerpsTabExploreData.ts @@ -42,18 +42,11 @@ export const usePerpsTabExploreData = ({ // Get watchlist symbols from Redux const watchlistSymbols = useSelector(selectPerpsWatchlistMarkets); - // Filter explore markets: crypto + equity, top 8 by volume + // Filter explore markets: all market types, top 8 by volume // Markets are already sorted by volume from usePerpsMarkets const exploreMarkets = useMemo(() => { if (!enabled) return []; - return markets - .filter( - (m) => - !m.marketType || - m.marketType === 'crypto' || - m.marketType === 'equity', - ) - .slice(0, EXPLORE_MARKETS_LIMIT); + return markets.slice(0, EXPLORE_MARKETS_LIMIT); }, [markets, enabled]); // Filter watchlist markets diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts index 23f96b04d10..de025ad912a 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts @@ -248,9 +248,14 @@ describe('usePerpsTransactionHistory', () => { endTime: undefined, }); - expect(mockTransformFillsToTransactions).toHaveBeenCalledWith(mockFills); + // Fills are enriched with detailedOrderType from matching orders + expect(mockTransformFillsToTransactions).toHaveBeenCalledWith([ + { ...mockFills[0], detailedOrderType: 'Market' }, + ]); + // Orders are passed with a fillSizeByOrderId Map for accurate filled percentage calculation expect(mockTransformOrdersToTransactions).toHaveBeenCalledWith( mockOrders, + expect.any(Map), ); expect(mockTransformFundingToTransactions).toHaveBeenCalledWith( mockFunding, diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts index c399cda2661..01189c69591 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { BigNumber } from 'bignumber.js'; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import type { CaipAccountId } from '@metamask/utils'; @@ -102,9 +103,23 @@ export const usePerpsTransactionHistory = ({ detailedOrderType: orderMap.get(fill.orderId)?.detailedOrderType, })); + // Build fill size map: orderId -> total filled size + // This allows accurate filled percentage calculation for historical orders, + // since HyperLiquid's historical orders API returns sz=0 for all completed orders + const fillSizeByOrderId = new Map(); + for (const fill of fills) { + if (fill.orderId) { + const current = fillSizeByOrderId.get(fill.orderId) || BigNumber(0); + fillSizeByOrderId.set(fill.orderId, current.plus(fill.size || '0')); + } + } + // Transform each data type to PerpsTransaction format const fillTransactions = transformFillsToTransactions(enrichedFills); - const orderTransactions = transformOrdersToTransactions(orders); + const orderTransactions = transformOrdersToTransactions( + orders, + fillSizeByOrderId, + ); const fundingTransactions = transformFundingToTransactions(funding); const userHistoryTransactions = transformUserHistoryToTransactions( userHistoryRef.current, diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts index 24ff5ad4225..b8f76377aac 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts @@ -312,14 +312,21 @@ describe('useStopLossPrompt', () => { // Should NOT show immediately (position too new) expect(result.current.shouldShowBanner).toBe(false); - // Should still require full debounce period + // Should still require full debounce period AND position age + // Need to wait for max of both: RoeDebounceMs (60s) and PositionMinAgeMs (120s) + const requiredTime = + Math.max( + STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, + ) + 100; + act(() => { - jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs - 100); + jest.advanceTimersByTime(requiredTime - 200); }); expect(result.current.shouldShowBanner).toBe(false); - // After full debounce, should show + // After full time passes, should show act(() => { jest.advanceTimersByTime(200); }); @@ -443,9 +450,16 @@ describe('useStopLossPrompt', () => { // Should NOT show immediately (no timestamp provided) expect(result.current.shouldShowBanner).toBe(false); - // Should require full debounce period + // Should require full debounce period AND position age + // Need to wait for max of both: RoeDebounceMs (60s) and PositionMinAgeMs (120s) + const requiredTime = + Math.max( + STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, + ) + 100; + act(() => { - jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs + 100); + jest.advanceTimersByTime(requiredTime); }); expect(result.current.shouldShowBanner).toBe(true); @@ -530,11 +544,12 @@ describe('useStopLossPrompt', () => { }); describe('suggested stop loss calculations', () => { - it('calculates suggested stop loss price for long position', () => { + it('calculates suggested stop loss price as midpoint between current price and liquidation for long position', () => { const position = createMockPosition({ entryPrice: '50000', size: '1', // Long position leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '45000', }); const { result } = renderHook(() => @@ -544,17 +559,17 @@ describe('useStopLossPrompt', () => { }), ); - // With -50% target ROE and 10x leverage: - // priceChange = (-0.50 * 50000) / 10 / 1 = -2500 - // slPrice = 50000 + (-2500) = 47500 - expect(result.current.suggestedStopLossPrice).toBe('47500'); + // Midpoint between current (48000) and liquidation (45000): + // midpoint = (48000 + 45000) / 2 = 46500 + expect(result.current.suggestedStopLossPrice).toBe('46500'); }); - it('calculates suggested stop loss price for short position', () => { + it('calculates suggested stop loss price as midpoint between current price and liquidation for short position', () => { const position = createMockPosition({ entryPrice: '50000', size: '-1', // Short position leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '55000', }); const { result } = renderHook(() => @@ -564,17 +579,17 @@ describe('useStopLossPrompt', () => { }), ); - // With -50% target ROE and 10x leverage for short: - // priceChange = (-0.50 * 50000) / 10 / -1 = 2500 - // slPrice = 50000 + 2500 = 52500 - expect(result.current.suggestedStopLossPrice).toBe('52500'); + // Midpoint between current (52000) and liquidation (55000): + // midpoint = (52000 + 55000) / 2 = 53500 + expect(result.current.suggestedStopLossPrice).toBe('53500'); }); - it('returns target ROE as suggested stop loss percent', () => { + it('calculates ROE at the midpoint stop loss price for long position', () => { const position = createMockPosition({ entryPrice: '50000', size: '1', leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '45000', }); const { result } = renderHook(() => @@ -584,10 +599,31 @@ describe('useStopLossPrompt', () => { }), ); - // Should return the configured target ROE (-50%), not the price change (-5%) - expect(result.current.suggestedStopLossPercent).toBe( - STOP_LOSS_PROMPT_CONFIG.SuggestedStopLossRoe, + // Midpoint SL price = 46500 + // ROE = (priceChange / entryPrice) * leverage * direction + // ROE = ((46500 - 50000) / 50000) * 10 * 1 * 100 = -70% + expect(result.current.suggestedStopLossPercent).toBe(-70); + }); + + it('calculates ROE at the midpoint stop loss price for short position', () => { + const position = createMockPosition({ + entryPrice: '50000', + size: '-1', + leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '55000', + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 52000, + }), ); + + // Midpoint SL price = 53500 + // ROE = (priceChange / entryPrice) * leverage * direction + // ROE = ((53500 - 50000) / 50000) * 10 * -1 * 100 = -70% + expect(result.current.suggestedStopLossPercent).toBe(-70); }); it('returns null for suggested price when no position', () => { @@ -601,6 +637,44 @@ describe('useStopLossPrompt', () => { expect(result.current.suggestedStopLossPrice).toBeNull(); expect(result.current.suggestedStopLossPercent).toBeNull(); }); + + it('returns null for suggested price when no liquidation price', () => { + const position = createMockPosition({ + entryPrice: '50000', + size: '1', + leverage: { type: 'isolated', value: 10 }, + liquidationPrice: undefined, + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 48000, + }), + ); + + expect(result.current.suggestedStopLossPrice).toBeNull(); + expect(result.current.suggestedStopLossPercent).toBeNull(); + }); + + it('returns null for suggested price when current price is zero', () => { + const position = createMockPosition({ + entryPrice: '50000', + size: '1', + leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '45000', + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 0, + }), + ); + + expect(result.current.suggestedStopLossPrice).toBeNull(); + expect(result.current.suggestedStopLossPercent).toBeNull(); + }); }); describe('minimum loss threshold', () => { @@ -957,11 +1031,14 @@ describe('useStopLossPrompt', () => { ); expect(result.current.liquidationDistance).toBeNull(); + expect(result.current.suggestedStopLossPrice).toBeNull(); + expect(result.current.suggestedStopLossPercent).toBeNull(); }); - it('handles missing entry price', () => { + it('handles missing entry price - still calculates SL price but not percent', () => { const position = createMockPosition({ entryPrice: undefined as unknown as string, + liquidationPrice: '45000', }); const { result } = renderHook(() => @@ -971,7 +1048,11 @@ describe('useStopLossPrompt', () => { }), ); - expect(result.current.suggestedStopLossPrice).toBeNull(); + // SL price uses currentPrice and liquidationPrice (doesn't need entryPrice) + // Midpoint = (48000 + 45000) / 2 = 46500 + expect(result.current.suggestedStopLossPrice).toBe('46500'); + // But percent needs entryPrice to calculate ROE + expect(result.current.suggestedStopLossPercent).toBeNull(); }); it('prioritizes add_margin over stop_loss when both conditions met', () => { @@ -998,4 +1079,154 @@ describe('useStopLossPrompt', () => { expect(result.current.variant).toBe('add_margin'); }); }); + + describe('safety guard: suggested SL too close to current price', () => { + it('shows add_margin when suggested SL is within 3% of current price', () => { + // To test the safety guard, we need: + // 1. Liquidation distance >= 3% (to pass Priority 1) + // 2. Midpoint distance < 3% (to trigger safety guard) + // + // Current: 50000, Liquidation: 47500 → liquidationDistance = 5% + // Midpoint = (50000 + 47500) / 2 = 48750 → midpointDistance = 2.5% + const position = createMockPosition({ + entryPrice: '50000', + size: '1', + returnOnEquity: '-0.15', // Below threshold + liquidationPrice: '47500', // 5% from current (passes Priority 1) + leverage: { type: 'isolated', value: 10 }, + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + }), + ); + + // Fast-forward past both position age and debounce requirements + const requiredTime = + Math.max( + STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, + ) + 100; + + act(() => { + jest.advanceTimersByTime(requiredTime); + }); + + // Midpoint = (50000 + 47500) / 2 = 48750 + // Distance from current = |50000 - 48750| / 50000 * 100 = 2.5% + // Since < 3%, safety guard triggers and shows add_margin instead of stop_loss + expect(result.current.suggestedStopLossPrice).toBe('48750'); + expect(result.current.variant).toBe('add_margin'); + }); + + it('shows stop_loss when suggested SL is more than 3% from current price', () => { + // Position where midpoint is safely away from current price + // Current: 50000, Liquidation: 40000 → Midpoint: 45000 → Distance: 10% from current + const position = createMockPosition({ + entryPrice: '50000', + size: '1', + returnOnEquity: '-0.15', // Below threshold + liquidationPrice: '40000', // Far from current + leverage: { type: 'isolated', value: 10 }, + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + }), + ); + + // Fast-forward past both position age and debounce requirements + const requiredTime = + Math.max( + STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, + ) + 100; + + act(() => { + jest.advanceTimersByTime(requiredTime); + }); + + // Midpoint = (50000 + 40000) / 2 = 45000 + // Distance from current = |50000 - 45000| / 50000 * 100 = 10% + // Since > 3%, should show stop_loss + expect(result.current.suggestedStopLossPrice).toBe('45000'); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('shows stop_loss when suggested SL is exactly at 3% threshold', () => { + // Position where midpoint is exactly at 3% from current + // Current: 50000, need midpoint at 48500 (3% away) + // midpoint = (current + liq) / 2, so liq = 2*midpoint - current = 2*48500 - 50000 = 47000 + const position = createMockPosition({ + entryPrice: '50000', + size: '1', + returnOnEquity: '-0.15', // Below threshold + liquidationPrice: '47000', + leverage: { type: 'isolated', value: 10 }, + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + }), + ); + + // Fast-forward past both position age and debounce requirements + const requiredTime = + Math.max( + STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, + ) + 100; + + act(() => { + jest.advanceTimersByTime(requiredTime); + }); + + // Midpoint = (50000 + 47000) / 2 = 48500 + // Distance from current = |50000 - 48500| / 50000 * 100 = 3% + // Since safety guard uses < 3% (not <=), exactly 3% is NOT within threshold, so stop_loss + expect(result.current.suggestedStopLossPrice).toBe('48500'); + expect(result.current.variant).toBe('stop_loss'); + }); + + it('shows add_margin when suggestedStopLossPrice is null', () => { + // When liquidationPrice is missing, suggestedStopLossPrice will be null + // The hook should fall back to add_margin to avoid showing garbled banner text + const position = createMockPosition({ + entryPrice: '50000', + size: '1', + returnOnEquity: '-0.15', // Below threshold + liquidationPrice: undefined, // Missing - causes null suggestedStopLossPrice + leverage: { type: 'isolated', value: 10 }, + }); + + const { result } = renderHook(() => + useStopLossPrompt({ + position, + currentPrice: 50000, + }), + ); + + // Fast-forward past both position age and debounce requirements + const requiredTime = + Math.max( + STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, + ) + 100; + + act(() => { + jest.advanceTimersByTime(requiredTime); + }); + + // Without a valid liquidation price, we can't calculate a stop loss price + expect(result.current.suggestedStopLossPrice).toBeNull(); + // Should show add_margin instead of stop_loss with null price + expect(result.current.variant).toBe('add_margin'); + }); + }); }); diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.ts index 560c4f73719..80f8e6e276d 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.ts @@ -133,7 +133,7 @@ export const useStopLossPrompt = ({ // Reset hasBeenShownRef when position changes (from main) useEffect(() => { hasBeenShownRef.current = false; - }, [position?.symbol]); + }, [position?.symbol, position?.liquidationPrice, position?.entryPrice]); // Server timestamp bypass effect (from main) // If positionOpenedTimestamp shows position is >2 minutes old, bypass debounce AND position age check @@ -239,46 +239,36 @@ export const useStopLossPrompt = ({ return undefined; }, [enabled, roePercent, position, positionOpenedTimestamp, finishDebounce]); - // Calculate suggested stop loss price based on entry price and target ROE - // Formula: For a position, SL price at -50% ROE = entryPrice * (1 + targetROE/100/leverage) + // Calculate suggested stop loss price as midpoint between current price and liquidation price + // This provides a balanced protection point that limits losses while avoiding premature triggers const suggestedStopLossPrice = useMemo(() => { // Dev override: provide mock price for stop_loss variant without position if (__DEV__ && FORCE_BANNER_VARIANT === 'stop_loss' && !position) { return '45000'; // Mock price for display } - if (!position?.entryPrice) { + if (!position?.liquidationPrice || !currentPrice || currentPrice <= 0) { return null; } - const entryPrice = parseFloat(position.entryPrice); - const leverage = position.leverage?.value ?? 1; - const positionSize = parseFloat(position.size); + const liquidationPrice = parseFloat(position.liquidationPrice); - if (isNaN(entryPrice) || entryPrice <= 0 || leverage <= 0) { + if (isNaN(liquidationPrice) || liquidationPrice <= 0) { return null; } - // Target ROE is configurable (default -50%) - const targetRoeDecimal = STOP_LOSS_PROMPT_CONFIG.SuggestedStopLossRoe / 100; - - // Calculate price at target ROE - // ROE = (priceChange / entryPrice) * leverage * direction - // priceChange = ROE * entryPrice / leverage / direction - const isLong = positionSize >= 0; - const direction = isLong ? 1 : -1; - const priceChange = (targetRoeDecimal * entryPrice) / leverage / direction; - const slPrice = entryPrice + priceChange; + // Calculate midpoint between current price and liquidation price + const midpointPrice = (currentPrice + liquidationPrice) / 2; // Ensure SL price is positive and reasonable - if (slPrice <= 0) { + if (midpointPrice <= 0) { return null; } - return slPrice.toString(); - }, [position]); + return midpointPrice.toString(); + }, [position, currentPrice]); - // Return the target ROE percentage used to calculate the stop loss + // Calculate the ROE percentage at the suggested stop loss price // This represents the ROE the user will experience if the stop loss triggers const suggestedStopLossPercent = useMemo(() => { // Dev override: provide mock percentage for stop_loss variant without position @@ -286,15 +276,48 @@ export const useStopLossPrompt = ({ return STOP_LOSS_PROMPT_CONFIG.SuggestedStopLossRoe; } - // Return the configured target ROE if we have a valid stop loss price - if (!suggestedStopLossPrice) { + if (!suggestedStopLossPrice || !position?.entryPrice) { + return null; + } + + const entryPrice = parseFloat(position.entryPrice); + const slPrice = parseFloat(suggestedStopLossPrice); + const leverage = position.leverage?.value ?? 1; + const positionSize = parseFloat(position.size); + + if ( + isNaN(entryPrice) || + entryPrice <= 0 || + isNaN(slPrice) || + leverage <= 0 + ) { return null; } - // The stop loss price was calculated to achieve this specific ROE - return STOP_LOSS_PROMPT_CONFIG.SuggestedStopLossRoe; + // Calculate ROE at the suggested stop loss price + // ROE = (priceChange / entryPrice) * leverage * direction + const isLong = positionSize >= 0; + const direction = isLong ? 1 : -1; + const priceChange = slPrice - entryPrice; + const roe = (priceChange / entryPrice) * leverage * direction * 100; + + return roe; }, [suggestedStopLossPrice, position]); + // Safety guard: Check if suggested SL price is too close to current price + // If within 3% of mark price, we should show "add_margin" instead to avoid accidental immediate fills + const isSuggestedSlTooClose = useMemo(() => { + if (!suggestedStopLossPrice || !currentPrice || currentPrice <= 0) { + return false; + } + const slPrice = parseFloat(suggestedStopLossPrice); + if (isNaN(slPrice) || slPrice <= 0) { + return false; + } + const distance = (Math.abs(currentPrice - slPrice) / currentPrice) * 100; + return distance < STOP_LOSS_PROMPT_CONFIG.LiquidationDistanceThreshold; // Within 3% of current price + }, [suggestedStopLossPrice, currentPrice]); + // Determine if banner should show and which variant const { shouldShowBanner, variant } = useMemo((): { shouldShowBanner: boolean; @@ -358,7 +381,17 @@ export const useStopLossPrompt = ({ } // Priority 2: ROE below threshold with debounce → Stop loss variant + // But if suggested SL is too close to current price (within 3%), show add_margin instead if (roeDebounceComplete) { + // Guard: Don't show stop_loss variant if we can't calculate a valid suggested price + // This prevents displaying garbled banner text like "Set a stop loss at ( ROE)" + if (!suggestedStopLossPrice) { + return { shouldShowBanner: true, variant: 'add_margin' }; + } + if (isSuggestedSlTooClose) { + // Safety guard: SL price too close to current price, suggest adding margin instead + return { shouldShowBanner: true, variant: 'add_margin' }; + } return { shouldShowBanner: true, variant: 'stop_loss' }; } @@ -368,6 +401,8 @@ export const useStopLossPrompt = ({ position, liquidationDistance, roeDebounceComplete, + isSuggestedSlTooClose, + suggestedStopLossPrice, positionAgeCheckPassed, roePercent, ]); diff --git a/app/components/UI/Perps/utils/transactionTransforms.test.ts b/app/components/UI/Perps/utils/transactionTransforms.test.ts index 5a9c1bdaa79..4f454a4e705 100644 --- a/app/components/UI/Perps/utils/transactionTransforms.test.ts +++ b/app/components/UI/Perps/utils/transactionTransforms.test.ts @@ -1,3 +1,4 @@ +import { BigNumber } from 'bignumber.js'; import { transformFillsToTransactions, transformOrdersToTransactions, @@ -942,7 +943,7 @@ describe('transactionTransforms', () => { timestamp: 1640995200000, }; - it('transforms filled order correctly', () => { + it('transforms filled order correctly (defaults to 100% without fill data)', () => { const result = transformOrdersToTransactions([mockOrder]); expect(result).toHaveLength(1); @@ -960,7 +961,7 @@ describe('transactionTransforms', () => { type: 'limit', size: '50000', limitPrice: '50000', - filled: '50%', + filled: '100%', // Filled status without fill data defaults to 100% }, }); }); @@ -1115,14 +1116,15 @@ describe('transactionTransforms', () => { expect(result[0].order.type).toBe('market'); }); - it('calculates filled percentage correctly', () => { - const partiallyFilledOrder = { + it('calculates filled percentage from size fields for open orders', () => { + const partiallyFilledOpenOrder = { ...mockOrder, + status: 'open' as const, // Open orders use size-based calculation size: '0.2', originalSize: '1', }; - const result = transformOrdersToTransactions([partiallyFilledOrder]); + const result = transformOrdersToTransactions([partiallyFilledOpenOrder]); if (!result[0]?.order) { return; @@ -1182,6 +1184,220 @@ describe('transactionTransforms', () => { expect(result[0].subtitle).toBe('0.5 BTC'); }); + + describe('fill-based percentage calculation', () => { + it('calculates accurate percentage from fill data map', () => { + // Arrange: Order with originalSize 1, actual fills totaling 0.3 (30%) + const order = { + ...mockOrder, + orderId: 'order-with-fills', + originalSize: '1', + size: '0', // HyperLiquid returns 0 for historical orders + status: 'canceled' as const, + }; + + const fillSizeByOrderId = new Map(); + fillSizeByOrderId.set('order-with-fills', BigNumber('0.3')); + + // Act + const result = transformOrdersToTransactions( + [order], + fillSizeByOrderId, + ); + + // Assert: Should show 30% based on actual fills, not 0% or 100% + expect(result[0].order?.filled).toBe('30%'); + }); + + it('shows 0% for canceled order without any fills', () => { + // Arrange: Canceled order with no fills in the map + const canceledOrder = { + ...mockOrder, + orderId: 'canceled-no-fills', + originalSize: '1', + size: '0', + status: 'canceled' as const, + }; + + // No entry in fill map for this order + const fillSizeByOrderId = new Map(); + + // Act + const result = transformOrdersToTransactions( + [canceledOrder], + fillSizeByOrderId, + ); + + // Assert: Should show 0% since canceled without fills + expect(result[0].order?.filled).toBe('0%'); + }); + + it('shows 100% for fully filled order from fill data', () => { + // Arrange: Order with originalSize 2, fills totaling 2 (100%) + const filledOrder = { + ...mockOrder, + orderId: 'fully-filled', + originalSize: '2', + size: '0', + status: 'filled' as const, + }; + + const fillSizeByOrderId = new Map(); + fillSizeByOrderId.set('fully-filled', BigNumber('2')); + + // Act + const result = transformOrdersToTransactions( + [filledOrder], + fillSizeByOrderId, + ); + + // Assert: Should show 100% + expect(result[0].order?.filled).toBe('100%'); + }); + + it('handles partial fill then cancel scenario correctly', () => { + // Arrange: Order for 10 units, 7 filled then canceled (70%) + const partialThenCanceled = { + ...mockOrder, + orderId: 'partial-canceled', + originalSize: '10', + size: '0', // HyperLiquid sets to 0 for all historical orders + status: 'canceled' as const, + }; + + const fillSizeByOrderId = new Map(); + fillSizeByOrderId.set('partial-canceled', BigNumber('7')); + + // Act + const result = transformOrdersToTransactions( + [partialThenCanceled], + fillSizeByOrderId, + ); + + // Assert: Should show 70% based on actual fills + expect(result[0].order?.filled).toBe('70%'); + }); + + it('rounds percentage to whole number', () => { + // Arrange: Order that would result in 33.333...% + const order = { + ...mockOrder, + orderId: 'fractional', + originalSize: '3', + size: '0', + status: 'canceled' as const, + }; + + const fillSizeByOrderId = new Map(); + fillSizeByOrderId.set('fractional', BigNumber('1')); + + // Act + const result = transformOrdersToTransactions( + [order], + fillSizeByOrderId, + ); + + // Assert: Should round to 33% + expect(result[0].order?.filled).toBe('33%'); + }); + + it('handles multiple fills for same order', () => { + // Arrange: The fill map should contain the SUM of all fills + // (this is done in usePerpsTransactionHistory, we just test that the function uses the sum) + const order = { + ...mockOrder, + orderId: 'multi-fill', + originalSize: '1', + size: '0', + status: 'filled' as const, + }; + + // Simulating sum of fills: 0.3 + 0.4 + 0.3 = 1.0 + const fillSizeByOrderId = new Map(); + fillSizeByOrderId.set('multi-fill', BigNumber('1')); + + // Act + const result = transformOrdersToTransactions( + [order], + fillSizeByOrderId, + ); + + // Assert: Should show 100% + expect(result[0].order?.filled).toBe('100%'); + }); + + it('falls back to status-based logic when fill map not provided', () => { + // Arrange: Filled order without fill map + const filledOrder = { + ...mockOrder, + status: 'filled' as const, + }; + + // Act: No fill map provided + const result = transformOrdersToTransactions([filledOrder]); + + // Assert: Should default to 100% for filled status + expect(result[0].order?.filled).toBe('100%'); + }); + + it('falls back to 0% for rejected order without fill data', () => { + // Arrange + const rejectedOrder = { + ...mockOrder, + status: 'rejected' as const, + }; + + const fillSizeByOrderId = new Map(); + + // Act + const result = transformOrdersToTransactions( + [rejectedOrder], + fillSizeByOrderId, + ); + + // Assert + expect(result[0].order?.filled).toBe('0%'); + }); + + it('returns 0% for position-bound TP/SL orders with zero size (not filled yet)', () => { + // Arrange: Position-bound TP/SL orders have size=0 and originalSize=0 + // because they're tied to the position size, not a fixed amount + const positionTpslOrder = { + ...mockOrder, + orderId: 'position-tpsl-order', + size: '0', // No fixed size - tied to position + originalSize: '0', // No fixed original size + status: 'open' as const, + isTrigger: true, + reduceOnly: true, + detailedOrderType: 'Take Profit Limit', + }; + + // Act: No fill map - test the fallback logic + const result = transformOrdersToTransactions([positionTpslOrder]); + + // Assert: Should show 0% (not triggered yet), NOT 100% + expect(result[0].order?.filled).toBe('0%'); + }); + + it('returns 100% for regular order with zero remaining size (fully filled)', () => { + // Arrange: Regular order that was fully filled + // originalSize > 0 but size = 0 means it was filled + const fullyFilledOrder = { + ...mockOrder, + orderId: 'fully-filled-regular', + size: '0', // All filled + originalSize: '1', // Started with 1 + status: 'open' as const, // Testing the size-based fallback + }; + + // Act: No fill map - test the fallback logic + const result = transformOrdersToTransactions([fullyFilledOrder]); + + // Assert: Should show 100% (nothing remaining) + expect(result[0].order?.filled).toBe('100%'); + }); + }); }); describe('transformFundingToTransactions', () => { diff --git a/app/components/UI/Perps/utils/transactionTransforms.ts b/app/components/UI/Perps/utils/transactionTransforms.ts index 13bcaaec36b..9dc7e82208a 100644 --- a/app/components/UI/Perps/utils/transactionTransforms.ts +++ b/app/components/UI/Perps/utils/transactionTransforms.ts @@ -376,10 +376,15 @@ export function transformFillsToTransactions( /** * Transform abstract Order objects to PerpsTransaction format * @param orders - Array of abstract Order objects + * @param fillSizeByOrderId - Optional map of orderId to total filled size (from actual fills). + * When provided, uses actual fill data to calculate accurate filled percentages. + * This is important because HyperLiquid's historical orders API returns sz=0 for all + * completed orders, making it impossible to calculate partial fill percentages without fill data. * @returns Array of PerpsTransaction objects */ export function transformOrdersToTransactions( orders: Order[], + fillSizeByOrderId?: Map, ): PerpsTransaction[] { return orders.map((order) => { const { @@ -428,15 +433,50 @@ export function transformOrdersToTransactions( : PerpsOrderTransactionStatus.Queued; } - // Calculate filled percentage from abstract types - const filledPercent = BigNumber(size).isEqualTo(0) - ? '100' - : BigNumber(originalSize) + // Calculate filled percentage - prefer actual fill data when available + let filledPercent: string; + const actualFilledSize = fillSizeByOrderId?.get(orderId); + + if (actualFilledSize !== undefined) { + // Use actual fill data for accurate percentage + const origSize = BigNumber(originalSize); + + if (origSize.isZero()) { + filledPercent = '0'; + } else { + filledPercent = actualFilledSize + .dividedBy(origSize) + .multipliedBy(100) + .toFixed(0); // Round to whole number + } + } else if (isCompleted || isTriggered) { + // Filled/triggered orders are 100% filled + filledPercent = '100'; + } else if (isCancelled || isRejected) { + // Canceled/rejected orders without fills = 0% filled + filledPercent = '0'; + } else { + // Open/pending orders - use the order's size fields + const sizeIsZero = BigNumber(size).isEqualTo(0); + const originalSizeIsZero = BigNumber(originalSize).isZero(); + + if (sizeIsZero && originalSizeIsZero) { + // Position-bound TP/SL orders have no fixed size (both are 0) + // They're not filled yet - they're just tied to the position size + filledPercent = '0'; + } else if (sizeIsZero) { + // Regular order with 0 remaining size = fully filled + filledPercent = '100'; + } else { + // Partially filled order + filledPercent = BigNumber(originalSize) .minus(size) .dividedBy(originalSize) .absoluteValue() .multipliedBy(100) .toString(); + } + } return { id: `${orderId}-${timestamp}`, diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx index 3694a5511dc..8a82fcd1f51 100644 --- a/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx @@ -19,6 +19,7 @@ import { useTokensWithBalance } from '../../../../UI/Bridge/hooks/useTokensWithB import { useConfirmActions } from '../../hooks/useConfirmActions'; import { useParams } from '../../../../../util/navigation/navUtils'; import useConfirmationAlerts from '../../hooks/alerts/useConfirmationAlerts'; +import { useFullScreenConfirmation } from '../../hooks/ui/useFullScreenConfirmation'; jest.mock('../../hooks/useConfirmActions'); @@ -42,6 +43,7 @@ jest.mock('../../../../hooks/AssetPolling/AssetPollingProvider', () => ({ jest.mock('../../hooks/gas/useGasFeeToken'); jest.mock('../../hooks/tokens/useTokenWithBalance'); jest.mock('../../hooks/alerts/useConfirmationAlerts'); +jest.mock('../../hooks/ui/useFullScreenConfirmation'); jest.mock('../../../../hooks/useRefreshSmartTransactionsLiveness', () => ({ useRefreshSmartTransactionsLiveness: jest.fn(), })); @@ -171,6 +173,9 @@ describe('Confirm', () => { }); jest.mocked(useConfirmationAlerts).mockReturnValue([]); + jest.mocked(useFullScreenConfirmation).mockReturnValue({ + isFullScreenConfirmation: false, + }); }); afterEach(() => { @@ -188,6 +193,10 @@ describe('Confirm', () => { }); it('renders a flat confirmation for specified type(s): staking deposit', () => { + jest.mocked(useFullScreenConfirmation).mockReturnValue({ + isFullScreenConfirmation: true, + }); + const { getByTestId } = renderWithProvider(, { state: stakingDepositConfirmationState, }); @@ -195,6 +204,10 @@ describe('Confirm', () => { }); it('renders a flat confirmation for specified type(s): staking withdrawal', () => { + jest.mocked(useFullScreenConfirmation).mockReturnValue({ + isFullScreenConfirmation: true, + }); + const { getByTestId } = renderWithProvider(, { state: stakingWithdrawalConfirmationState, }); @@ -497,20 +510,28 @@ describe('Confirm', () => { expect(getByTestId('confirm-loader-default')).toBeDefined(); }); - it('sets navigation options with header hidden for modal confirmations', () => { + it('sets navigation options with header shown for full screen confirmations', () => { + jest.mocked(useFullScreenConfirmation).mockReturnValue({ + isFullScreenConfirmation: true, + }); + renderWithProvider(, { - state: typedSignV1ConfirmationState, + state: stakingDepositConfirmationState, }); expect(mockSetOptions).toHaveBeenCalledWith({ - headerShown: false, + headerShown: true, gestureEnabled: true, }); }); - it('sets navigation options with header hidden for full screen confirmations', () => { + it('sets navigation options with header hidden for non-full screen confirmations', () => { + jest.mocked(useFullScreenConfirmation).mockReturnValue({ + isFullScreenConfirmation: false, + }); + renderWithProvider(, { - state: stakingDepositConfirmationState, + state: typedSignV1ConfirmationState, }); expect(mockSetOptions).toHaveBeenCalledWith({ diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 4a36c698367..e11f5650014 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -12,8 +12,6 @@ import { useNavigation } from '@react-navigation/native'; import { ConfirmationUIType } from '../../ConfirmationView.testIds'; import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet'; import { useStyles } from '../../../../../component-library/hooks'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; -import { strings } from '../../../../../../locales/i18n'; import { UnstakeConfirmationViewProps } from '../../../../UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.types'; import useConfirmationAlerts from '../../hooks/alerts/useConfirmationAlerts'; import useApprovalRequest from '../../hooks/useApprovalRequest'; @@ -126,14 +124,19 @@ export const Confirm = ({ useEffect(() => { if (approvalRequest) { - navigation.setOptions({ - // HeaderCenter is used for full screen confirmations, so we don't need React Navigation's header + const options = { headerShown: false, // If there is an approvalRequest, we need to allow the user to swipe to reject the confirmation gestureEnabled: true, - }); + }; + + if (isFullScreenConfirmation) { + // If the confirmation is full screen, we need to show the header + options.headerShown = true; + } + navigation.setOptions(options); } - }, [approvalRequest, navigation]); + }, [approvalRequest, isFullScreenConfirmation, navigation]); useEffect(() => { if (!approvalRequest) { @@ -162,11 +165,6 @@ export const Confirm = ({ style={[styles.flatContainer, fullscreenStyle]} testID={ConfirmationUIType.FLAT} > - ); diff --git a/bitrise.yml b/bitrise.yml index 83f6afa4c84..50c212289e4 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -87,7 +87,6 @@ pipelines: stages: - build_e2e_ios_android_stage: {} - run_release_e2e_ios_android_stage: {} - - report_results_stage: {} - notify: {} #PR_e2e_verfication (build ios & android), run iOS (smoke), emulator Android pr_smoke_e2e_pipeline: @@ -464,9 +463,6 @@ stages: workflows: - ios_run_regression_network_abstraction_tests_gns_disabled: {} - report_results_stage: - workflows: - - run_testrail_update_automated_test_results: {} # TODO: Remove this stage since it's not used anymore run_e2e_ios_android_stage: workflows: @@ -1080,18 +1076,6 @@ workflows: machine_type_id: elite-xl after_run: - android_e2e_build - ### Report automated test results to TestRail - run_testrail_update_automated_test_results: - before_run: - - code_setup - steps: - - script@1: - title: 'Add Automated Test Results to TestRail' - inputs: - - content: |- - #!/usr/bin/env bash - echo 'REPORT AUTOMATED TEST RESULTS TO TESTRAIL' - node ./scripts/testrail/testrail.api.js ### Separating workflows so they run concurrently during smoke runs run_tag_smoke_multichain_api_ios: diff --git a/e2e/pages/swaps/QuoteView.ts b/e2e/pages/swaps/QuoteView.ts index 9818f9f701d..5d49c08b9d5 100644 --- a/e2e/pages/swaps/QuoteView.ts +++ b/e2e/pages/swaps/QuoteView.ts @@ -1,6 +1,7 @@ import { waitFor } from 'detox'; import Matchers from '../../../tests/framework/Matchers'; import Gestures from '../../../tests/framework/Gestures'; +import Assertions from '../../../tests/framework/Assertions'; import { QuoteViewSelectorIDs, QuoteViewSelectorText, @@ -167,6 +168,25 @@ class QuoteView { elemDescription: 'Tap Max link to use maximum balance', }); } + + /** + * Gets the slippage display text element (e.g., "2.5%") + * @param value - The slippage value to match (e.g., "2.5" for "2.5%") + */ + slippageDisplayText(value: string): DetoxElement { + return Matchers.getElementByText(`${value}%`); + } + + /** + * Verifies that the slippage value is displayed correctly in the quote view + * @param value - The expected slippage value (e.g., "2.5" for 2.5%) + */ + async verifySlippageDisplayed(value: string): Promise { + await Assertions.expectElementToBeVisible(this.slippageDisplayText(value), { + timeout: 10000, + description: `Slippage should display ${value}%`, + }); + } } export default new QuoteView(); diff --git a/e2e/pages/swaps/SlippageModal.ts b/e2e/pages/swaps/SlippageModal.ts new file mode 100644 index 00000000000..28ef58487ca --- /dev/null +++ b/e2e/pages/swaps/SlippageModal.ts @@ -0,0 +1,107 @@ +import Matchers from '../../../tests/framework/Matchers'; +import Gestures from '../../../tests/framework/Gestures'; +import Assertions from '../../../tests/framework/Assertions'; +import { + SlippageModalSelectorIDs, + SlippageModalSelectorText, +} from '../../selectors/Bridge/SlippageModal.selectors'; + +class SlippageModal { + get editSlippageButton(): DetoxElement { + return Matchers.getElementByID( + SlippageModalSelectorIDs.EDIT_SLIPPAGE_BUTTON, + ); + } + + get customButton(): DetoxElement { + return Matchers.getElementByText(SlippageModalSelectorText.CUSTOM); + } + + get confirmButton(): DetoxElement { + return Matchers.getElementByText(SlippageModalSelectorText.CONFIRM); + } + + get inputStepperInput(): DetoxElement { + return Matchers.getElementByID( + SlippageModalSelectorIDs.INPUT_STEPPER_INPUT, + ); + } + + get keypadDeleteButton(): DetoxElement { + return Matchers.getElementByID( + SlippageModalSelectorIDs.KEYPAD_DELETE_BUTTON, + ); + } + + /** + * Opens the slippage modal by tapping the edit slippage button + */ + async openSlippageModal(): Promise { + await Assertions.expectElementToBeVisible(this.editSlippageButton, { + timeout: 30000, + description: 'Edit slippage button should be visible', + }); + await Gestures.waitAndTap(this.editSlippageButton, { + elemDescription: 'Tap edit slippage button', + }); + } + + /** + * Taps the custom slippage option to open the custom slippage modal + */ + async tapCustomOption(): Promise { + await Assertions.expectElementToBeVisible(this.customButton, { + description: 'Custom slippage option should be visible', + }); + await Gestures.waitAndTap(this.customButton, { + elemDescription: 'Tap custom slippage option', + }); + } + + /** + * Enters a custom slippage value using the keypad + * @param value - The slippage value to enter (e.g., "2.5" for 2.5%) + */ + async enterCustomSlippage(value: string): Promise { + // Wait for the custom slippage modal to be visible + await Assertions.expectElementToBeVisible(this.inputStepperInput, { + description: 'Custom slippage input should be visible', + }); + + // Clear the existing value first by long pressing delete + await Gestures.longPress(this.keypadDeleteButton, { + duration: 1000, + elemDescription: 'Long press delete to clear slippage input', + }); + + // Enter each character of the value using the keypad + for (const char of value) { + const button = Matchers.getElementByText(char); + await Gestures.waitAndTap(button, { + elemDescription: `Tap keypad button ${char}`, + }); + } + } + + /** + * Confirms the custom slippage selection + */ + async confirmCustomSlippage(): Promise { + await Gestures.waitAndTap(this.confirmButton, { + elemDescription: 'Confirm custom slippage', + }); + } + + /** + * Sets a custom slippage value end-to-end + * @param value - The slippage value to set (e.g., "2.5" for 2.5%) + */ + async setCustomSlippage(value: string): Promise { + await this.openSlippageModal(); + await this.tapCustomOption(); + await this.enterCustomSlippage(value); + await this.confirmCustomSlippage(); + } +} + +export default new SlippageModal(); diff --git a/e2e/selectors/Bridge/SlippageModal.selectors.ts b/e2e/selectors/Bridge/SlippageModal.selectors.ts new file mode 100644 index 00000000000..04d1aeb57ee --- /dev/null +++ b/e2e/selectors/Bridge/SlippageModal.selectors.ts @@ -0,0 +1,16 @@ +import enContent from '../../../locales/languages/en.json'; + +export const SlippageModalSelectorIDs = { + EDIT_SLIPPAGE_BUTTON: 'edit-slippage-button', + INPUT_STEPPER_MINUS_BUTTON: 'input-stepper-minus-button', + INPUT_STEPPER_PLUS_BUTTON: 'input-stepper-plus-button', + INPUT_STEPPER_INPUT: 'input-stepper-input', + KEYPAD_DELETE_BUTTON: 'keypad-delete-button', +}; + +export const SlippageModalSelectorText = { + CUSTOM: enContent.bridge.custom, + CONFIRM: enContent.bridge.confirm, + SUBMIT: enContent.bridge.submit, + CANCEL: enContent.bridge.cancel, +}; diff --git a/scripts/testrail/testrail.api.js b/scripts/testrail/testrail.api.js deleted file mode 100644 index 8f5007295f9..00000000000 --- a/scripts/testrail/testrail.api.js +++ /dev/null @@ -1,45 +0,0 @@ -const axios = require('axios'); -require('dotenv').config({ path: './.js.env' }); - -const TESTRAIL_MM_API_URL = 'https://mmig.testrail.io/index.php?/api/v2'; -const TESTRAIL_PROJECT_ID = 4; -const AUTH_TOKEN = process.env.TESTRAIL_AUTH_TOKEN; -const automatedTestCasesEndpoint = `${TESTRAIL_MM_API_URL}/get_cases/${TESTRAIL_PROJECT_ID}&refs=@automated`; -const addTestRun = `${TESTRAIL_MM_API_URL}/add_run/${TESTRAIL_PROJECT_ID}`; -const getAutomatedTestRun = `${TESTRAIL_MM_API_URL}/get_tests/`; -const addResults = `${TESTRAIL_MM_API_URL}/add_results/`; - -axios.defaults.headers.common.Authorization = `Basic ${AUTH_TOKEN}`; -let runID; - -axios - .get(automatedTestCasesEndpoint) - .then((response) => { - const automatedcaseids = response.data.cases.map( - (automatedcase) => automatedcase.id, - ); - console.log(`test case id count: ${automatedcaseids.length}`); - return axios.post(addTestRun, { - name: 'Automated Test Run on bitrise release_e2e_pipline', - description: 'Automated test run on release branch', - include_all: false, - case_ids: automatedcaseids, - }); - }) - .then((response) => { - runID = response.data.id; - return axios.get(`${getAutomatedTestRun}${runID}`); - }) - .then((response) => { - const automatedResults = response.data.tests.map((test) => ({ - test_id: test.id, - status_id: 1, - comment: 'Passed on bitrise', - })); - return axios.post(`${addResults}${runID}`, { - results: automatedResults, - }); - }) - .catch((error) => { - console.error(`Error retrieving automated test cases: ${error}`); - }); diff --git a/tests/helpers/swap/constants.ts b/tests/helpers/swap/constants.ts index af534acf037..df905cbda80 100644 --- a/tests/helpers/swap/constants.ts +++ b/tests/helpers/swap/constants.ts @@ -111,6 +111,112 @@ export const GET_QUOTE_ETH_USDC_RESPONSE = [ }, ]; +export const GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE = [ + { + quote: { + requestId: + '0x8af512b95468bcb5b521462d943b3a57e08db167b59bac65cd7665be701b4f6e', + bridgeId: '1inch', + srcChainId: 1, + destChainId: 1, + aggregator: '1inch', + aggregatorType: 'AGG', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ether', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + srcTokenAmount: '991250000000000000', + destAsset: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: 1, + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + name: 'USDC', + coingeckoId: 'usd-coin', + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'socket', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 10, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + metadata: { + storage: { + balance: 9, + approval: 10, + }, + }, + }, + destTokenAmount: '2147920486', + minDestTokenAmount: '2072743268', + walletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + destWalletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ether', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + quoteBpsFee: 87.5, + baseBpsFee: 87.5, + }, + }, + bridges: ['1inch'], + protocols: ['1inch'], + steps: [], + slippage: 3.5, + gasSponsored: false, + gasIncluded7702: false, + priceData: { + totalFromAmountUsd: '2162.88', + totalToAmountUsd: '2147.121459579208', + priceImpact: '-0.0014770178826568753', + totalFeeAmountUsd: '18.925200000000004', + }, + }, + trade: { + chainId: 1, + to: '0x881D40237659C251811CEC9c364ef91dC08D300C', + from: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + value: '0xde0b6b3a7640000', + data: '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000136f6e65496e6368563646656544796e616d69630000000000000000000000000000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000007b8b8d640000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000f326e4de8f66a0bdc0970b79e0924e33c79f19150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000022807ed2379000000000000000000000000990636ecb3ff04d33d92e970d3d588bf5cd8d086000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000990636ecb3ff04d33d92e970d3d588bf5cd8d08600000000000000000000000074de5d4fcbf63e00296fd95d33236b97940166310000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000007b8b8d640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000d70000000000000000000000000000000000000000000000000000b900004e00a0744c8c09000000000000000000000000000000000000000090cbe4bdd538d6e9b379bff5fe72c3d67a521de500000000000000000000000000000000000000000000000000010e76033d760002a0000000000000000000000000000000000000000000000000000000007b8b8d6448c95033810000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480001f400000a0000111111125421ca6dc452d289314280a0f8842a650000000000000000007dcbea7c00000000000000000000000000000000000000000000000094', + gasLimit: 311852, + effectiveGas: 234172, + }, + estimatedProcessingTimeInSeconds: 0, + }, +]; + export const GET_QUOTE_ETH_DAI_RESPONSE = [ { quote: { diff --git a/tests/helpers/swap/swap-mocks.ts b/tests/helpers/swap/swap-mocks.ts index 342a6d6daf3..c4b40b7d4ea 100644 --- a/tests/helpers/swap/swap-mocks.ts +++ b/tests/helpers/swap/swap-mocks.ts @@ -6,6 +6,7 @@ import { } from '../../api-mocking/helpers/mockHelpers'; import { GET_QUOTE_ETH_USDC_RESPONSE, + GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE, GET_QUOTE_ETH_DAI_RESPONSE, GET_QUOTE_USDC_USDT_RESPONSE, GET_TOKENS_MAINNET_RESPONSE, @@ -29,14 +30,22 @@ export const testSpecificMock: TestSpecificMock = async ( responseCode: 200, }); - // Mock ETH->USDC + // Mock ETH->USDC with default 2% slippage await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/i, + url: /getQuote.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=2/i, response: GET_QUOTE_ETH_USDC_RESPONSE, responseCode: 200, }); + // Mock ETH->USDC with 3.5% custom slippage + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuote.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=3.5/i, + response: GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE, + responseCode: 200, + }); + // Mock ETH->DAI await setupMockRequest(mockServer, { requestMethod: 'GET', diff --git a/tests/helpers/swap/swap-unified-ui.ts b/tests/helpers/swap/swap-unified-ui.ts index 96297739449..7f2d91e813a 100644 --- a/tests/helpers/swap/swap-unified-ui.ts +++ b/tests/helpers/swap/swap-unified-ui.ts @@ -1,15 +1,23 @@ import TestHelpers from '../../../e2e/helpers'; import QuoteView from '../../../e2e/pages/swaps/QuoteView'; +import SlippageModal from '../../../e2e/pages/swaps/SlippageModal'; import Assertions from '../../framework/Assertions'; import ActivitiesView from '../../../e2e/pages/Transactions/ActivitiesView'; import { ActivitiesViewSelectorsText } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; +interface SwapOptions { + /** Custom slippage percentage (e.g., "2.5" for 2.5%) */ + slippage?: string; +} + export async function submitSwapUnifiedUI( quantity: string, sourceTokenSymbol: string, destTokenSymbol: string, chainId: string, + options?: SwapOptions, ) { + const DEFAULT_SLIPPAGE_VALUE = '2'; await device.disableSynchronization(); await Assertions.expectElementToBeVisible(QuoteView.selectAmountLabel); await QuoteView.enterAmount(quantity); @@ -23,7 +31,18 @@ export async function submitSwapUnifiedUI( await Assertions.expectElementToBeVisible(QuoteView.networkFeeLabel, { timeout: 60000, }); + + // Set custom slippage if provided + if (options?.slippage) { + await SlippageModal.setCustomSlippage(options.slippage); + // Verify the slippage has been updated in the quote view + await QuoteView.verifySlippageDisplayed(options.slippage); + } else { + await QuoteView.verifySlippageDisplayed(DEFAULT_SLIPPAGE_VALUE); + } + await Assertions.expectElementToBeVisible(QuoteView.confirmSwap); + await QuoteView.tapConfirmSwap(); } diff --git a/tests/smoke/swap/swap-action-smoke.spec.ts b/tests/smoke/swap/swap-action-smoke.spec.ts index 4c672053ae5..72fd0c36922 100644 --- a/tests/smoke/swap/swap-action-smoke.spec.ts +++ b/tests/smoke/swap/swap-action-smoke.spec.ts @@ -34,7 +34,7 @@ describe(SmokeTrade('Swap from Actions'), (): void => { jest.setTimeout(120000); }); - it('should swap ETH to USDC', async (): Promise => { + it('swaps ETH to USDC with custom slippage', async (): Promise => { const quantity = '1'; const sourceTokenSymbol = 'ETH'; const destTokenSymbol = 'USDC'; @@ -90,12 +90,13 @@ describe(SmokeTrade('Swap from Actions'), (): void => { await prepareSwapsTestEnvironment(); await WalletView.tapWalletSwapButton(); - // Submit the Swap + // Submit swap with 3.5% custom slippage await submitSwapUnifiedUI( quantity, sourceTokenSymbol, destTokenSymbol, chainId, + { slippage: '3.5' }, ); // Check the swap activity completed