diff --git a/.eslintrc.js b/.eslintrc.js index db10cce9e90..cce30f950b1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -122,11 +122,13 @@ module.exports = { 'app/components/Snaps/**/*.{js,jsx,ts,tsx}', 'app/components/UI/Predict/**/*.{js,jsx,ts,tsx}', 'app/components/UI/Rewards/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/Perps/**/*.{js,jsx,ts,tsx}', ], rules: { '@metamask/design-tokens/color-no-hex': 'error', }, }, + { files: [ 'app/components/UI/Name/**/*.{js,ts,tsx}', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43478944fd2..5031bc205a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,7 +183,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=12288 - name: Check bundle size - run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 52 + run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 53 - name: Upload iOS bundle uses: actions/upload-artifact@v4 diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index efac957c8c4..17126d1a23a 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -332,6 +332,7 @@ jobs: QUICKNODE_POLYGON_URL: ${{ secrets.QUICKNODE_POLYGON_URL }} QUICKNODE_BSC_URL: ${{ secrets.QUICKNODE_BSC_URL }} QUICKNODE_SEI_URL: ${{ secrets.QUICKNODE_SEI_URL }} + MM_CHARTING_LIBRARY_URL: ${{ secrets.MM_CHARTING_LIBRARY_URL }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/app/component-library/components-temp/Skeleton/Skeleton.tsx b/app/component-library/components-temp/Skeleton/Skeleton.tsx new file mode 100644 index 00000000000..1715acc1533 --- /dev/null +++ b/app/component-library/components-temp/Skeleton/Skeleton.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { + Skeleton as DSRNSkeleton, + SkeletonProps, +} from '@metamask/design-system-react-native'; +import { isE2E } from '../../../util/test/utils'; + +const Skeleton: React.FC = (props) => ( + +); + +export default Skeleton; diff --git a/app/component-library/components-temp/Skeleton/index.ts b/app/component-library/components-temp/Skeleton/index.ts new file mode 100644 index 00000000000..dac673616cb --- /dev/null +++ b/app/component-library/components-temp/Skeleton/index.ts @@ -0,0 +1,2 @@ +export { default as Skeleton } from './Skeleton'; +export type { SkeletonProps } from '@metamask/design-system-react-native'; diff --git a/app/component-library/components/Skeleton/Skeleton.tsx b/app/component-library/components/Skeleton/Skeleton.tsx index 32ca02d1738..90aa05d11f9 100644 --- a/app/component-library/components/Skeleton/Skeleton.tsx +++ b/app/component-library/components/Skeleton/Skeleton.tsx @@ -13,7 +13,7 @@ import { SkeletonProps } from './Skeleton.types'; import { isE2E } from '../../../util/test/utils'; /** - * @deprecated Please update your code to use `Skeleton` from `@metamask/design-system-react-native`. + * @deprecated Please update your code to use `Skeleton` from `app/component-library/components-temp/Skeleton`. * The API may have changed — compare props before migrating. * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/Skeleton/README.md} * @since @metamask/design-system-react-native@0.7.0 diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx index 370fae1bf07..502b9bb944e 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx @@ -14,10 +14,11 @@ import { Hex } from '@metamask/utils'; import BridgeView from '.'; import type { BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation'; import { createBridgeTestState } from '../../testUtils'; +import { BridgeViewMode } from '../../types'; import { - MetaMetricsSwapsEventSource, RequestStatus, type QuoteResponse, + MetaMetricsSwapsEventSource, } from '@metamask/bridge-controller'; import { SolScope } from '@metamask/keyring-api'; import { mockUseBridgeQuoteData } from '../../_mocks_/useBridgeQuoteData.mock'; @@ -216,6 +217,17 @@ jest.mock('../../../../../selectors/bridge', () => ({ selectSourceWalletAddress: jest.fn(), })); +jest.mock( + '../../../../../selectors/featureFlagController/gasFeesSponsored', + () => ({ + getGasFeesSponsoredNetworkEnabled: jest.fn( + () => (chainId: string) => + // Return true for Polygon (0x89) to test sponsored quotes + chainId === '0x89', + ), + }), +); + const mockNavigate = jest.fn(); const mockRoute = { params: { @@ -1676,4 +1688,475 @@ describe('BridgeView', () => { expect(mockUseIsGasIncluded7702Supported).toHaveBeenCalledWith('0x1'); }); }); + + describe('Blockaid Security Alert', () => { + it('displays blockaid error banner when blockaid error exists', async () => { + const mockQuote = mockQuoteWithMetadata; + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuote as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + blockaidError: 'This transaction may be a security risk', + activeQuote: mockQuote, + })); + + const { getByText } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + expect(getByText(strings('bridge.blockaid_error_title'))).toBeTruthy(); + expect( + getByText('This transaction may be a security risk'), + ).toBeTruthy(); + }); + }); + }); + + describe('Approval Disclaimer', () => { + it('displays approval needed text when quote requires approval', async () => { + const mockQuote = { + ...mockQuoteWithMetadata, + approval: { + chainId: '0x1', + to: '0xToken', + data: '0xApprovalData', + }, + }; + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuote as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.5', + sourceToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: '0x1' as Hex, + decimals: 6, + image: '', + name: 'USD Coin', + symbol: 'USDC', + }, + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const { getByText } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + const approvalText = strings('bridge.approval_needed', { + amount: '1.5', + symbol: 'USDC', + }); + expect(getByText(approvalText, { exact: false })).toBeTruthy(); + }); + }); + + it('does not display approval text when quote does not require approval', async () => { + const mockQuote = { + ...mockQuoteWithMetadata, + approval: null, + }; + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuote as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const { queryByText } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + // Should not find approval text in the document + expect(queryByText(/approval needed/i, { exact: false })).toBeNull(); + }); + }); + }); + + describe('Quote Details Card', () => { + it('displays quote details card when active quote exists', async () => { + const mockQuote = mockQuoteWithMetadata; + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuote as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const { getByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + // QuoteDetailsCard should be rendered + expect( + getByTestId(BridgeViewSelectorsIDs.BRIDGE_VIEW_SCROLL), + ).toBeTruthy(); + }); + }); + + it('does not display quote details card when no active quote', () => { + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: null, + })); + + const { getByTestId, queryByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: mockState }, + ); + + // Verify ScrollView exists + expect( + getByTestId(BridgeViewSelectorsIDs.BRIDGE_VIEW_SCROLL), + ).toBeTruthy(); + + // QuoteDetailsCard should not be rendered when no quote + expect(queryByTestId('quote-details-card')).toBeNull(); + }); + }); + + describe('Tap Outside to Close Keypad', () => { + // TODO: Re-enable after fixing component tree navigation for onResponderRelease + it.skip('closes keypad when tapping outside input area', async () => { + const { getByTestId, queryByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: mockState }, + ); + + // Verify keypad is open initially (opened by useBridgeViewOnFocus on mount) + await waitFor(() => { + expect(queryByTestId('keypad-delete-button')).toBeTruthy(); + }); + + // Tap outside the input area - the onResponderRelease is on the main content Box + // which wraps the ScrollView, so we need to go up two parents + const scrollView = getByTestId(BridgeViewSelectorsIDs.BRIDGE_VIEW_SCROLL); + const container = scrollView.parent?.parent; + await act(async () => { + // Call the onResponderRelease handler from the parent Box + if (container?.props.onResponderRelease) { + container.props.onResponderRelease(); + } + }); + + // Keypad should be closed + await waitFor(() => { + expect(queryByTestId('keypad-delete-button')).toBeNull(); + }); + }); + }); + + describe('Sponsored Quote Badge', () => { + it('renders when quote is on a sponsored network', async () => { + const polygonChainId = '0x89' as Hex; + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuoteWithMetadata as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: polygonChainId, + decimals: 18, + image: '', + name: 'Polygon', + symbol: 'POL', + }, + destToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: polygonChainId, + decimals: 6, + image: '', + name: 'USD Coin', + symbol: 'USDC', + }, + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuoteWithMetadata, + })); + + const { getByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + // Verify the component renders with the sponsored network tokens + await waitFor(() => { + const sourceTokenArea = getByTestId( + BridgeViewSelectorsIDs.SOURCE_TOKEN_AREA, + ); + expect(sourceTokenArea).toBeTruthy(); + // Both tokens are on Polygon (0x89), which is mocked as a sponsored network + }); + }); + + it('renders when tokens are on different chains', () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, // Ethereum + decimals: 18, + image: '', + name: 'Ether', + symbol: 'ETH', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x89' as Hex, // Polygon + decimals: 18, + image: '', + name: 'Polygon', + symbol: 'POL', + }, + }, + }); + + const { getByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + // Verify the component renders with tokens on different chains + const sourceTokenArea = getByTestId( + BridgeViewSelectorsIDs.SOURCE_TOKEN_AREA, + ); + expect(sourceTokenArea).toBeTruthy(); + // Tokens are on different chains, so sponsored feature won't apply + }); + }); + + describe('Bridge View Mode', () => { + it('initializes bridge view mode from route params', async () => { + mockRoute.params = { + bridgeViewMode: BridgeViewMode.Bridge, + sourcePage: 'test', + location: MetaMetricsSwapsEventSource.MainView, + }; + + const { store } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: mockState }, + ); + + await waitFor(() => { + expect(store.getState().bridge.bridgeViewMode).toBe( + BridgeViewMode.Bridge, + ); + }); + }); + + it('does not override existing bridge view mode', async () => { + mockRoute.params = { + bridgeViewMode: BridgeViewMode.Bridge, + sourcePage: 'test', + location: MetaMetricsSwapsEventSource.MainView, + }; + + const testState = { + ...mockState, + bridge: { + ...mockState.bridge, + bridgeViewMode: BridgeViewMode.Swap, + }, + }; + + const { store } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + await waitFor(() => { + // Should keep existing mode + expect(store.getState().bridge.bridgeViewMode).toBe( + BridgeViewMode.Swap, + ); + }); + }); + }); + + describe('Keypad Input Constraints', () => { + it('prevents input when max length is reached', async () => { + // MAX_INPUT_LENGTH is 36 characters + const maxLengthAmount = '123456789012345678901234567890123456'; // 36 chars + const testState = { + ...mockState, + bridge: { + ...mockState.bridge, + sourceAmount: maxLengthAmount, + }, + }; + + const { getByText, getByTestId, store } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + const initialAmount = store.getState().bridge.sourceAmount; + expect(initialAmount).toBe(maxLengthAmount); + // Open the keypad first by pressing on the source token input + const sourceInput = getByTestId('source-token-area-input'); + await act(async () => { + sourceInput.props.onPressIn(); + }); + + // Try to add another digit + fireEvent.press(getByText('9')); + + await waitFor(() => { + const currentAmount = store.getState().bridge.sourceAmount; + // Amount should not change when max length is reached + expect(currentAmount).toBe(initialAmount); + }); + }); + }); + + describe('Missing Wallet Address', () => { + it('disables submit when wallet address is not available', async () => { + const mockQuote = mockQuoteWithMetadata; + + // Mock selectSourceWalletAddress to return undefined + const { selectSourceWalletAddress } = jest.requireMock( + '../../../../../selectors/bridge', + ); + selectSourceWalletAddress.mockReturnValueOnce(undefined); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [mockQuote as unknown as QuoteResponse], + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceAmount: '1.0', + }, + }); + + jest + .mocked(useBridgeQuoteData as unknown as jest.Mock) + .mockImplementation(() => ({ + ...mockUseBridgeQuoteData, + activeQuote: mockQuote, + })); + + const { queryByTestId } = renderScreen( + BridgeView, + { + name: Routes.BRIDGE.ROOT, + }, + { state: testState }, + ); + + // When wallet address is missing, the bottom content should not render the confirm button + await waitFor(() => { + // The confirm button should not be present or should be disabled + const confirmButton = queryByTestId('swaps-confirm-button'); + if (confirmButton) { + expect(confirmButton.props.accessibilityState.disabled).toBe(true); + } + // It's acceptable if the button doesn't render at all when wallet address is missing + }); + + // Restore the mock for other tests + selectSourceWalletAddress.mockReturnValue( + '0x1234567890123456789012345678901234567890', + ); + }); + }); }); diff --git a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts index 7e7c1160d8e..0c557415e99 100644 --- a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts +++ b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts @@ -37,4 +37,5 @@ export const mockBridgeReducerState: BridgeState = { isDestTokenManuallySet: false, tokenSelectorNetworkFilter: undefined, visiblePillChainIds: undefined, + selectedQuoteRequestId: undefined, }; diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index d29acb19232..2eb80a1f1bb 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -596,27 +596,32 @@ describe('QuoteDetailsCard', () => { }); }); - it('handles quote info navigation', () => { - const { getByLabelText, getByText, getByTestId } = renderScreen( + it('navigates to quote selector when rate info button is pressed', () => { + const { getByTestId } = renderScreen( QuoteDetailsCardTestScreen, { name: Routes.BRIDGE.ROOT }, { state: testState }, ); - const quoteTooltip = getByLabelText('Rate tooltip'); - fireEvent.press(quoteTooltip); + fireEvent.press(getByTestId('rate-info-button')); - expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', { - params: { - title: strings('bridge.quote_info_title'), - tooltip: strings('bridge.quote_info_content'), - footerText: undefined, - buttonText: undefined, - }, - screen: 'tooltipModal', - }); - expect(getByText('Price impact')).toBeTruthy(); - expect(getByTestId('price-impact-info-button')).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.BRIDGE.QUOTE_SELECTOR_VIEW, + ); + }); + + it('navigates to quote selector when rate arrow button is pressed', () => { + const { getByTestId } = renderScreen( + QuoteDetailsCardTestScreen, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + fireEvent.press(getByTestId('rate-arrow-button')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.BRIDGE.QUOTE_SELECTOR_VIEW, + ); }); it('renders price impact info button for low price impact values', () => { @@ -688,6 +693,72 @@ describe('QuoteDetailsCard', () => { expect(getByTestId('price-impact-info-button')).toBeTruthy(); }); + describe('minimum received row', () => { + it('displays minimum received row when minToTokenAmount is present', () => { + const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); + mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ + quoteFetchError: null, + activeQuote: { + ...mockQuotes[0], + minToTokenAmount: { + amount: '23.50', + usd: null, + valueInCurrency: null, + }, + }, + destTokenAmount: '24.44', + isLoading: false, + formattedQuoteData: { + networkFee: '0.01', + estimatedTime: '1 min', + rate: '1 ETH = 24.4 USDC', + priceImpact: '-0.06%', + slippage: '0.5%', + }, + shouldShowPriceImpactWarning: false, + })); + + const { getByText } = renderScreen( + QuoteDetailsCardTestScreen, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(getByText(strings('bridge.minimum_received'))).toBeOnTheScreen(); + // formatMinimumReceived formats "23.50" followed by the dest token symbol "ETH" + expect(getByText(/23\.5 ETH/)).toBeOnTheScreen(); + }); + + it('does not display minimum received row when minToTokenAmount is absent', () => { + const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); + mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ + quoteFetchError: null, + activeQuote: { + ...mockQuotes[0], + minToTokenAmount: undefined, + }, + destTokenAmount: '24.44', + isLoading: false, + formattedQuoteData: { + networkFee: '0.01', + estimatedTime: '1 min', + rate: '1 ETH = 24.4 USDC', + priceImpact: '-0.06%', + slippage: '0.5%', + }, + shouldShowPriceImpactWarning: false, + })); + + const { queryByText } = renderScreen( + QuoteDetailsCardTestScreen, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText(strings('bridge.minimum_received'))).toBeNull(); + }); + }); + describe('rewards functionality', () => { const { useRewards } = jest.requireMock('../../hooks/useRewards'); diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index ecbba26db57..aa3810b43bb 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { TouchableOpacity, Platform, UIManager } from 'react-native'; +import { TouchableOpacity, Platform, UIManager, Pressable } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { strings } from '../../../../../../locales/i18n'; import { useTheme } from '../../../../../util/theme'; @@ -43,6 +43,7 @@ import TagColored, { import { useShouldRenderGasSponsoredBanner } from '../../hooks/useShouldRenderGasSponsoredBanner'; import { isGaslessQuote } from '../../utils/isGaslessQuote'; import { QuoteDetailsCardProps } from './QuoteDetailsCard.types'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData'; import { TextVariant as TextVariantLegacy, @@ -63,6 +64,7 @@ const QuoteDetailsCard: React.FC = ({ hasInsufficientBalance, location, }) => { + const tw = useTailwind(); const theme = useTheme(); const navigation = useNavigation(); const styles = createStyles(theme); @@ -109,6 +111,10 @@ const QuoteDetailsCard: React.FC = ({ }); }; + const handleRatePress = () => { + navigation.navigate(Routes.BRIDGE.QUOTE_SELECTOR_VIEW); + }; + const handlePriceImpactPress = () => { navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, @@ -131,6 +137,7 @@ const QuoteDetailsCard: React.FC = ({ [formattedQuoteData?.priceImpact], ); + // Early return for invalid states if ( !sourceToken?.chainId || !destToken?.chainId || @@ -143,44 +150,45 @@ const QuoteDetailsCard: React.FC = ({ return ( - - - {strings('bridge.rate')} - - - - ), - tooltip: { - title: strings('bridge.quote_info_title'), - content: strings('bridge.quote_info_content'), - size: TooltipSizes.Sm, - iconName: IconNameLegacy.Info, - }, - }} - value={{ - label: ( - - {formattedQuoteData.rate} - - ), - }} - /> + + + {strings('bridge.rate')} + + + + + + + + {formattedQuoteData?.rate} + + + + + + {shouldShowGasSponsored ? ( + + Rate + - - + + + - - - Rate - - - - 0:30 - - - - - - - - + "color": "#66676a", + "height": 16, + "width": 16, + }, + undefined, + ] + } + /> + + + + 1 ETH = 24.4 USDC + - - - - 1 ETH = 24.4 USDC - - - + "color": "#66676a", + "height": 16, + "width": 16, + }, + undefined, + ] + } + /> ({ + QuoteRow: ({ provider, quoteRequestId }: QuoteRowProps) => { + const { Text } = jest.requireActual('react-native'); + return ( + + {provider.name} - {quoteRequestId} + + ); + }, +})); + +jest.mock('../../hooks/useShouldRenderGasSponsoredBanner', () => ({ + useShouldRenderGasSponsoredBanner: jest.fn(), +})); + +describe('QuoteList', () => { + const mockOnPress = jest.fn(); + + const createMockQuote = ( + overrides: Partial = {}, + ): QuoteRowProps => ({ + provider: { name: 'Lifi' }, + formattedTotalCost: '$100.00', + quoteRequestId: 'quote-123', + onPress: mockOnPress, + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders empty list when data is empty array', () => { + const { queryByTestId } = render(); + + expect(queryByTestId(/quote-row-/)).toBeNull(); + }); + + it('renders single QuoteRow when data has one item', () => { + const quote = createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'quote-1', + }); + + const { getByTestId, getByText } = render(); + + expect(getByTestId('quote-row-quote-1')).toBeTruthy(); + expect(getByText('Lifi - quote-1')).toBeTruthy(); + }); + + it('renders multiple QuoteRows when data has multiple items', () => { + const quotes = [ + createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'quote-1', + }), + createMockQuote({ + provider: { name: 'Socket' }, + quoteRequestId: 'quote-2', + }), + createMockQuote({ + provider: { name: 'Hop' }, + quoteRequestId: 'quote-3', + }), + ]; + + const { getByTestId, getByText } = render(); + + expect(getByTestId('quote-row-quote-1')).toBeTruthy(); + expect(getByTestId('quote-row-quote-2')).toBeTruthy(); + expect(getByTestId('quote-row-quote-3')).toBeTruthy(); + expect(getByText('Lifi - quote-1')).toBeTruthy(); + expect(getByText('Socket - quote-2')).toBeTruthy(); + expect(getByText('Hop - quote-3')).toBeTruthy(); + }); + + it('renders correct number of QuoteRows matching data length', () => { + const quotes = [ + createMockQuote({ quoteRequestId: 'quote-1' }), + createMockQuote({ quoteRequestId: 'quote-2' }), + createMockQuote({ quoteRequestId: 'quote-3' }), + createMockQuote({ quoteRequestId: 'quote-4' }), + createMockQuote({ quoteRequestId: 'quote-5' }), + ]; + + const { getAllByText } = render(); + + const renderedRows = getAllByText(/Lifi - quote-/); + expect(renderedRows).toHaveLength(5); + }); + }); + + describe('prop passing', () => { + it('passes all props to QuoteRow components', () => { + const quote = createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'quote-1', + formattedTotalCost: '$200.50', + isLowestCost: true, + selected: true, + }); + + const { getByText } = render(); + + expect(getByText('Lifi - quote-1')).toBeTruthy(); + }); + + it('passes different props to different QuoteRow components', () => { + const quotes = [ + createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'quote-1', + formattedTotalCost: '$100.00', + }), + createMockQuote({ + provider: { name: 'Socket' }, + quoteRequestId: 'quote-2', + formattedTotalCost: '$200.00', + }), + ]; + + const { getByText } = render(); + + expect(getByText('Lifi - quote-1')).toBeTruthy(); + expect(getByText('Socket - quote-2')).toBeTruthy(); + }); + }); + + describe('key generation', () => { + it('uses quoteRequestId as key for each QuoteRow', () => { + const quotes = [ + createMockQuote({ quoteRequestId: 'unique-id-1' }), + createMockQuote({ quoteRequestId: 'unique-id-2' }), + createMockQuote({ quoteRequestId: 'unique-id-3' }), + ]; + + const { getByTestId } = render(); + + expect(getByTestId('quote-row-unique-id-1')).toBeTruthy(); + expect(getByTestId('quote-row-unique-id-2')).toBeTruthy(); + expect(getByTestId('quote-row-unique-id-3')).toBeTruthy(); + }); + }); + + describe('edge cases', () => { + it('handles quotes with same provider but different IDs', () => { + const quotes = [ + createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'lifi-quote-1', + }), + createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'lifi-quote-2', + }), + ]; + + const { getByTestId } = render(); + + expect(getByTestId('quote-row-lifi-quote-1')).toBeTruthy(); + expect(getByTestId('quote-row-lifi-quote-2')).toBeTruthy(); + }); + + it('handles quotes with all optional props undefined', () => { + const quote = createMockQuote({ + provider: { name: 'Socket' }, + quoteRequestId: 'minimal-quote', + isLowestCost: undefined, + selected: undefined, + loading: undefined, + }); + + const { getByText } = render(); + + expect(getByText('Socket - minimal-quote')).toBeTruthy(); + }); + + it('handles large list of quotes', () => { + const quotes = Array.from({ length: 50 }, (_, i) => + createMockQuote({ + provider: { name: `Provider${i}` }, + quoteRequestId: `quote-${i}`, + }), + ); + + const { getByTestId } = render(); + + expect(getByTestId('quote-row-quote-0')).toBeTruthy(); + expect(getByTestId('quote-row-quote-25')).toBeTruthy(); + expect(getByTestId('quote-row-quote-49')).toBeTruthy(); + }); + }); + + describe('data updates', () => { + it('updates when data changes from empty to populated', () => { + const { queryByTestId, rerender } = render(); + + expect(queryByTestId(/quote-row-/)).toBeNull(); + + const quotes = [createMockQuote({ quoteRequestId: 'new-quote' })]; + rerender(); + + expect(queryByTestId('quote-row-new-quote')).toBeTruthy(); + }); + + it('updates when data changes from populated to empty', () => { + const quotes = [createMockQuote({ quoteRequestId: 'quote-1' })]; + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + expect(getByTestId('quote-row-quote-1')).toBeTruthy(); + + rerender(); + + expect(queryByTestId('quote-row-quote-1')).toBeNull(); + }); + + it('updates when data array changes', () => { + const initialQuotes = [ + createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'quote-1', + }), + ]; + + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + expect(getByTestId('quote-row-quote-1')).toBeTruthy(); + + const updatedQuotes = [ + createMockQuote({ + provider: { name: 'Socket' }, + quoteRequestId: 'quote-2', + }), + createMockQuote({ + provider: { name: 'Hop' }, + quoteRequestId: 'quote-3', + }), + ]; + + rerender(); + + expect(queryByTestId('quote-row-quote-1')).toBeNull(); + expect(getByTestId('quote-row-quote-2')).toBeTruthy(); + expect(getByTestId('quote-row-quote-3')).toBeTruthy(); + }); + }); + + describe('ordering', () => { + it('renders QuoteRows in the same order as data array', () => { + const quotes = [ + createMockQuote({ + provider: { name: 'First' }, + quoteRequestId: 'quote-1', + }), + createMockQuote({ + provider: { name: 'Second' }, + quoteRequestId: 'quote-2', + }), + createMockQuote({ + provider: { name: 'Third' }, + quoteRequestId: 'quote-3', + }), + ]; + + const { getAllByText } = render(); + + const rows = getAllByText(/- quote-/); + expect(rows[0]).toHaveTextContent('First - quote-1'); + expect(rows[1]).toHaveTextContent('Second - quote-2'); + expect(rows[2]).toHaveTextContent('Third - quote-3'); + }); + + it('maintains order when data is reversed', () => { + const quotes = [ + createMockQuote({ + provider: { name: 'First' }, + quoteRequestId: 'quote-1', + }), + createMockQuote({ + provider: { name: 'Second' }, + quoteRequestId: 'quote-2', + }), + ]; + + const { getAllByText, rerender } = render(); + + let rows = getAllByText(/- quote-/); + expect(rows[0]).toHaveTextContent('First - quote-1'); + expect(rows[1]).toHaveTextContent('Second - quote-2'); + + const reversedQuotes = [...quotes].reverse(); + rerender(); + + rows = getAllByText(/- quote-/); + expect(rows[0]).toHaveTextContent('Second - quote-2'); + expect(rows[1]).toHaveTextContent('First - quote-1'); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx new file mode 100644 index 00000000000..73c2ca6c91d --- /dev/null +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { QuoteRow, QuoteRowProps } from './QuoteRow'; + +interface Props { + data: QuoteRowProps[]; +} + +export const QuoteList = ({ data }: Props) => + data.map((quote) => ); diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.test.tsx new file mode 100644 index 00000000000..1a954ce1910 --- /dev/null +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.test.tsx @@ -0,0 +1,349 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { QuoteRow, QuoteRowProps } from './QuoteRow'; +import { strings } from '../../../../../../locales/i18n'; +import { BridgeToken } from '../../types'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +jest.mock('../../../../../util/theme', () => ({ + useTheme: jest.fn(() => ({ + colors: { + success: { default: '#28A745' }, + }, + })), +})); + +const mockDestToken: BridgeToken = { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + chainId: CHAIN_IDS.MAINNET, +}; + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useSelector: jest.fn((selector) => { + const mockState = { + bridge: { destToken: mockDestToken }, + }; + try { + return selector(mockState); + } catch { + return null; + } + }), + }; +}); + +jest.mock('../../hooks/useDisplayCurrencyValue', () => ({ + useDisplayCurrencyValue: jest.fn(() => '$50.00'), +})); + +import { useSelector } from 'react-redux'; +import { useDisplayCurrencyValue } from '../../hooks/useDisplayCurrencyValue'; + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDisplayCurrencyValue = + useDisplayCurrencyValue as jest.MockedFunction< + typeof useDisplayCurrencyValue + >; + +describe('QuoteRow', () => { + const mockOnPress = jest.fn(); + + const defaultProps: QuoteRowProps = { + provider: { name: 'Lifi' }, + formattedTotalCost: '$100.50', + quoteRequestId: 'quote-123', + onPress: mockOnPress, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDisplayCurrencyValue.mockReturnValue('$50.00'); + mockUseSelector.mockImplementation((selector) => { + const mockState = { bridge: { destToken: mockDestToken } }; + try { + return selector(mockState); + } catch { + return null; + } + }); + }); + + describe('rendering', () => { + it('renders provider name', () => { + // Act + const { getByText } = render(); + + // Assert + expect(getByText('Lifi')).toBeTruthy(); + }); + + it('renders formatted total cost with label', () => { + // Act + const { getByText } = render(); + + // Assert + expect( + getByText(`${strings('bridge.total_cost')}: $100.50`), + ).toBeTruthy(); + }); + + it('renders receive amount and destToken symbol', () => { + // Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText(/500.*USDC/)).toBeTruthy(); + }); + + it('renders fiat currency value with tilde prefix', () => { + // Arrange + mockUseDisplayCurrencyValue.mockReturnValue('$50.00'); + + // Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('~ $50.00')).toBeTruthy(); + }); + + it('passes receiveAmount and destToken to useDisplayCurrencyValue', () => { + // Act + render(); + + // Assert + expect(mockUseDisplayCurrencyValue).toHaveBeenCalledWith( + '500', + mockDestToken, + ); + }); + + it('passes undefined receiveAmount to useDisplayCurrencyValue when not provided', () => { + // Act + render(); + + // Assert + expect(mockUseDisplayCurrencyValue).toHaveBeenCalledWith( + undefined, + mockDestToken, + ); + }); + }); + + describe('receive amount formatting', () => { + it('formats large receive amounts with locale separators', () => { + // Act + const { getByText } = render( + , + ); + + // Assert — formatAmountWithLocaleSeparators('1000.5') → '~ 1,000.5 USDC' in en locale + expect(getByText('~ 1,000.5 USDC')).toBeTruthy(); + }); + + it('does not format when receiveAmount is 0', () => { + // Act + const { getByText } = render( + , + ); + + // Assert — raw amount returned when value is 0, tilde prefix still present + expect(getByText('~ 0 USDC')).toBeTruthy(); + }); + + it('does not format when destToken is undefined', () => { + // Arrange + mockUseSelector.mockReturnValue(undefined); + + // Act + const { queryByText, getByText } = render( + , + ); + + // Assert — raw amount rendered without symbol; no formatted grouping + expect(queryByText('1,000')).toBeNull(); + expect(getByText(/~ 1000/)).toBeTruthy(); + }); + + it('renders nothing for destToken symbol when destToken is undefined', () => { + // Arrange + mockUseSelector.mockReturnValue(undefined); + + // Act + const { queryByText } = render( + , + ); + + // Assert — no USDC symbol rendered + expect(queryByText(/USDC/)).toBeNull(); + }); + }); + + describe('lowest cost badge', () => { + it('renders lowest cost badge when isLowestCost is true', () => { + // Act + const { getByText } = render(); + + // Assert + expect(getByText(strings('bridge.lowest_cost'))).toBeTruthy(); + }); + + it('does not render lowest cost badge when isLowestCost is false', () => { + // Act + const { queryByText } = render(); + + // Assert + expect(queryByText(strings('bridge.lowest_cost'))).toBeNull(); + }); + + it('does not render lowest cost badge when loading is true even if isLowestCost', () => { + // Act + const { queryByText } = render( + , + ); + + // Assert + expect(queryByText(strings('bridge.lowest_cost'))).toBeNull(); + }); + }); + + describe('loading state', () => { + it('hides provider name via Skeleton when loading is true', () => { + // Act + const { queryByText } = render(); + + // Assert — Skeleton with hideChildren hides text content + expect(queryByText('Lifi')).toBeNull(); + }); + + it('hides total cost via Skeleton when loading is true', () => { + // Act + const { queryByText } = render(); + + // Assert + expect( + queryByText(`${strings('bridge.total_cost')}: $100.50`), + ).toBeNull(); + }); + + it('shows provider name and cost when loading is false', () => { + // Act + const { getByText } = render(); + + // Assert + expect(getByText('Lifi')).toBeTruthy(); + expect( + getByText(`${strings('bridge.total_cost')}: $100.50`), + ).toBeTruthy(); + }); + }); + + describe('interactions', () => { + it('calls onPress with quoteRequestId when pressed', () => { + // Act + const { getByText } = render(); + fireEvent.press(getByText('Lifi')); + + // Assert + expect(mockOnPress).toHaveBeenCalledWith('quote-123'); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPress when pressing the total cost text', () => { + // Act + const { getByText } = render(); + fireEvent.press(getByText(`${strings('bridge.total_cost')}: $100.50`)); + + // Assert + expect(mockOnPress).toHaveBeenCalledWith('quote-123'); + }); + + it('calls onPress with correct id for different quoteRequestId values', () => { + // Arrange + const { rerender, getByText } = render( + , + ); + + // Act + fireEvent.press(getByText('Lifi')); + expect(mockOnPress).toHaveBeenCalledWith('quote-456'); + + rerender(); + fireEvent.press(getByText('Lifi')); + + // Assert + expect(mockOnPress).toHaveBeenCalledWith('quote-789'); + }); + }); + + describe('selected state', () => { + it('renders correctly when selected is true', () => { + // Act + const { UNSAFE_root } = render(); + + // Assert + expect(UNSAFE_root).toBeTruthy(); + }); + + it('renders correctly when selected is false', () => { + // Act + const { UNSAFE_root } = render( + , + ); + + // Assert + expect(UNSAFE_root).toBeTruthy(); + }); + }); + + describe('complex scenarios', () => { + it('renders correctly with all props provided', () => { + // Arrange + mockUseDisplayCurrencyValue.mockReturnValue('$25.00'); + + // Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('Lifi')).toBeTruthy(); + expect( + getByText(`${strings('bridge.total_cost')}: $100.50`), + ).toBeTruthy(); + expect(getByText(strings('bridge.lowest_cost'))).toBeTruthy(); + expect(getByText('~ 250.5 USDC')).toBeTruthy(); + expect(getByText('~ $25.00')).toBeTruthy(); + }); + + it('renders correctly with minimal props', () => { + // Act + const { getByText, queryByText } = render( + , + ); + + // Assert + expect(getByText('Socket')).toBeTruthy(); + expect(getByText(`${strings('bridge.total_cost')}: $50.00`)).toBeTruthy(); + expect(queryByText(strings('bridge.lowest_cost'))).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx new file mode 100644 index 00000000000..759272b2c4a --- /dev/null +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx @@ -0,0 +1,145 @@ +import React, { useMemo } from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import { + Box, + BoxAlignItems, + BoxBackgroundColor, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Skeleton, + Text, + TextColor, + TextVariant as TextVariantDS, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useTheme } from '../../../../../util/theme'; +import TagBase, { + TagSeverity, + TagShape, +} from '../../../../../component-library/base-components/TagBase'; +import { TextVariant } from '../../../../../component-library/components/Texts/Text'; +import { useDisplayCurrencyValue } from '../../hooks/useDisplayCurrencyValue'; +import { useSelector } from 'react-redux'; +import { selectDestToken } from '../../../../../core/redux/slices/bridge'; +import { formatAmountWithLocaleSeparators } from '../../utils/formatAmountWithLocaleSeparators'; +import { limitToMaximumDecimalPlaces } from '../../../../../util/number'; + +export interface QuoteRowProps { + provider: { + name: string; + }; + isLowestCost?: boolean; + formattedTotalCost: string; + selected?: boolean; + quoteRequestId: string; + onPress: (quoteRequestId: string) => void; + loading?: boolean; + receiveAmount?: string; +} + +export const QuoteRow = ({ + selected, + provider, + isLowestCost, + formattedTotalCost, + onPress, + quoteRequestId, + loading, + receiveAmount, +}: QuoteRowProps) => { + const theme = useTheme(); + const destToken = useSelector(selectDestToken); + const formattedReceiveAmountFiat = useDisplayCurrencyValue( + receiveAmount, + destToken, + ); + const formattedReceiveAmount = useMemo( + () => + receiveAmount && receiveAmount !== '0' && destToken + ? formatAmountWithLocaleSeparators( + limitToMaximumDecimalPlaces(parseFloat(receiveAmount)), + ) + : receiveAmount, + [receiveAmount, destToken], + ); + + return ( + onPress(quoteRequestId)}> + + + + + + + {provider.name} + + + + + {isLowestCost && !loading && ( + + {strings('bridge.lowest_cost')} + + )} + + + + + {strings('bridge.total_cost')}: {formattedTotalCost} + + + + + + + ~ {formattedReceiveAmount} {destToken?.symbol} + + + + + ~ {formattedReceiveAmountFiat} + + + + + + ); +}; diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/constants.ts b/app/components/UI/Bridge/components/QuoteSelectorView/constants.ts new file mode 100644 index 00000000000..27bb4ead29a --- /dev/null +++ b/app/components/UI/Bridge/components/QuoteSelectorView/constants.ts @@ -0,0 +1,26 @@ +import { QuoteRowProps } from './QuoteRow'; + +export const QUOTES_PLACEHOLDER_DATA = [ + { + provider: { + name: 'test_provider', + }, + formattedTotalCost: '1234.2', + quoteRequestId: '1', + onPress: () => { + // Placeholder for loading state + }, + loading: true, + }, + { + provider: { + name: 'test_2', + }, + formattedTotalCost: '23.0', + quoteRequestId: '2', + onPress: () => { + // Placeholder for loading state + }, + loading: true, + }, +] satisfies QuoteRowProps[]; diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx new file mode 100644 index 00000000000..c7df7f9dd6e --- /dev/null +++ b/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx @@ -0,0 +1,655 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { QuoteSelectorView } from './index'; +import { strings } from '../../../../../../locales/i18n'; +import { BigNumber } from 'ethers'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockSetOptions = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + setOptions: mockSetOptions, + }), +})); + +const mockUseBridgeQuoteData = jest.fn(); +jest.mock('../../hooks/useBridgeQuoteData', () => ({ + useBridgeQuoteData: () => mockUseBridgeQuoteData(), +})); + +const mockUseLatestBalance = jest.fn(); +jest.mock('../../hooks/useLatestBalance', () => ({ + useLatestBalance: (params: unknown) => mockUseLatestBalance(params), +})); + +const mockFormatFiat = jest.fn(); +jest.mock('../../../../../util/formatFiat', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockFormatFiat(...args), +})); + +const mockIsGaslessQuote = jest.fn(); +jest.mock('../../utils/isGaslessQuote', () => ({ + isGaslessQuote: (quote: unknown) => mockIsGaslessQuote(quote), +})); + +const mockTrackAllQuotesSortedEvent = jest.fn(); +const mockUseTrackAllQuotesSortedEvent = jest.fn(); +jest.mock('../../hooks/useTrackAllQuotesSortedEvent', () => ({ + useTrackAllQuotesSortedEvent: (params: unknown) => + mockUseTrackAllQuotesSortedEvent(params), +})); + +const mockSourceToken = { + address: '0x1234', + decimals: 18, + chainId: '0x1', + symbol: 'ETH', +}; + +const mockCurrency = 'USD'; +const mockDispatch = jest.fn(); + +const mockSelectedQuoteRequestId: string | null = null; + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useSelector: jest.fn((selector) => { + // Call the selector with a mock state to see which selector it is + const mockState = { + bridge: { + sourceToken: mockSourceToken, + selectedQuoteRequestId: mockSelectedQuoteRequestId, + }, + engine: { + backgroundState: { + CurrencyRateController: { + currentCurrency: mockCurrency, + }, + }, + }, + }; + + try { + return selector(mockState); + } catch { + return null; + } + }), + useDispatch: () => mockDispatch, + }; +}); + +jest.mock('../../../../Base/ScreenView', () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }; +}); + +jest.mock('./QuoteList', () => ({ + QuoteList: ({ data }: { data: unknown[] }) => { + const { View, Text } = jest.requireActual('react-native'); + return ( + + {data.length} + + ); + }, +})); + +describe('QuoteSelectorView', () => { + const mockQuote = { + quote: { + requestId: 'quote-1', + srcChainId: 1, + destChainId: 137, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '1000000', + srcAsset: { + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: '', + }, + destAsset: { + chainId: 137, + address: '0x0000000000000000000000000000000000000001', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: '', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: '', + }, + }, + }, + bridges: ['lifi'], + steps: [], + refuel: undefined, + }, + sentAmount: { + amount: '1', + usd: '9999', + valueInCurrency: '2000', + }, + totalNetworkFee: { + amount: '0.01', + usd: '9999', + valueInCurrency: '20', + }, + estimatedProcessingTimeInSeconds: 60, + adjustedReturn: { + usd: '9999', + valueInCurrency: '1980', + }, + }; + + const mockLatestBalance = { + displayBalance: '1000', + atomicBalance: BigNumber.from('1000000000000000000'), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [], + bestQuote: null, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + mockUseLatestBalance.mockReturnValue(mockLatestBalance); + mockFormatFiat.mockReturnValue('$2,020.00'); + mockIsGaslessQuote.mockReturnValue(false); + mockUseTrackAllQuotesSortedEvent.mockReturnValue( + mockTrackAllQuotesSortedEvent, + ); + }); + + describe('rendering', () => { + it('renders ScreenView component', () => { + const { getByTestId } = render(); + + expect(getByTestId('screen-view')).toBeTruthy(); + }); + + it('renders info text with correct translation', () => { + const { getByText } = render(); + + expect(getByText(strings('bridge.select_quote_info'))).toBeTruthy(); + }); + + it('renders QuoteList component', () => { + const { getByTestId } = render(); + + expect(getByTestId('quote-list')).toBeTruthy(); + }); + }); + + describe('navigation setup', () => { + it('sets navigation options on mount', () => { + render(); + + expect(mockSetOptions).toHaveBeenCalled(); + }); + + it('calls goBack when back action is triggered', () => { + render(); + + expect(mockSetOptions).toHaveBeenCalled(); + }); + }); + + describe('quote data transformation', () => { + it('shows placeholder data when validQuotes is empty', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [], + bestQuote: null, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + // QUOTES_PLACEHOLDER_DATA has 2 items + expect(getByTestId('quote-list-count')).toHaveTextContent('2'); + }); + + it('transforms single validQuote to data array with one item', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list-count')).toHaveTextContent('1'); + }); + + it('transforms multiple validQuotes to data array with multiple items', () => { + const quotes = [ + mockQuote, + { ...mockQuote, quote: { ...mockQuote.quote, requestId: 'quote-2' } }, + { ...mockQuote, quote: { ...mockQuote.quote, requestId: 'quote-3' } }, + ]; + + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: quotes, + bestQuote: quotes[0], + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list-count')).toHaveTextContent('3'); + }); + }); + + describe('loading state', () => { + it('sets loading to true when isLoading is true', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: true, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list')).toBeTruthy(); + }); + + it('sets loading to false when isLoading is false', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list')).toBeTruthy(); + }); + }); + + describe('useLatestBalance integration', () => { + it('calls useLatestBalance hook with correct parameters', () => { + render(); + + expect(mockUseLatestBalance).toHaveBeenCalledWith({ + address: mockSourceToken.address, + decimals: mockSourceToken.decimals, + chainId: mockSourceToken.chainId, + }); + }); + + it('uses latestBalance in quote data', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockUseLatestBalance).toHaveBeenCalled(); + }); + }); + + describe('isGaslessQuote integration', () => { + it('calls isGaslessQuote for each quote', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockIsGaslessQuote).toHaveBeenCalledWith(mockQuote.quote); + }); + + it('passes isGasless result to quote data', () => { + mockIsGaslessQuote.mockReturnValue(true); + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockIsGaslessQuote).toHaveBeenCalled(); + }); + }); + + describe('formattedTotalCost calculation', () => { + it('uses valueInCurrency (not usd) for sentAmount in non-gasless quotes', () => { + mockIsGaslessQuote.mockReturnValue(false); + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockIsGaslessQuote).toHaveBeenCalledWith(mockQuote.quote); + expect(mockFormatFiat).toHaveBeenCalled(); + const [totalCostArg] = mockFormatFiat.mock.calls[0]; + // sentAmount.valueInCurrency (2000) + totalNetworkFee.valueInCurrency (20) = 2020 + // NOT sentAmount.usd (9999) + totalNetworkFee.usd (9999) = 19998 + expect(totalCostArg.toString()).toBe('2020'); + }); + + it('uses valueInCurrency (not usd) for sentAmount in gasless quotes', () => { + mockIsGaslessQuote.mockReturnValue(true); + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockIsGaslessQuote).toHaveBeenCalledWith(mockQuote.quote); + expect(mockFormatFiat).toHaveBeenCalled(); + const [totalCostArg] = mockFormatFiat.mock.calls[0]; + // sentAmount.valueInCurrency (2000) + includedTxFees.valueInCurrency (absent → 0) = 2000 + // NOT sentAmount.usd (9999) + expect(totalCostArg.toString()).toBe('2000'); + }); + + it('handles multiple quotes with mixed gasless and non-gasless', () => { + const gaslessQuote = { + ...mockQuote, + quote: { ...mockQuote.quote, requestId: 'gasless-quote' }, + }; + const regularQuote = { + ...mockQuote, + quote: { ...mockQuote.quote, requestId: 'regular-quote' }, + }; + + mockIsGaslessQuote.mockReturnValueOnce(true).mockReturnValueOnce(false); + + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [gaslessQuote, regularQuote], + bestQuote: gaslessQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list-count')).toHaveTextContent('2'); + expect(mockIsGaslessQuote).toHaveBeenCalledTimes(2); + // formatFiat called once per quote — verify both use valueInCurrency + expect(mockFormatFiat.mock.calls[0][0].toString()).toBe('2000'); // gasless: sentAmount only + expect(mockFormatFiat.mock.calls[1][0].toString()).toBe('2020'); // non-gasless: + network fee + }); + }); + + describe('bestQuote identification', () => { + it('marks quote as isLowestCost when it matches bestQuote', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockUseBridgeQuoteData).toHaveBeenCalled(); + }); + + it('does not mark quote as isLowestCost when it does not match bestQuote', () => { + const anotherQuote = { + ...mockQuote, + quote: { ...mockQuote.quote, requestId: 'quote-2' }, + }; + + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote, anotherQuote], + bestQuote: anotherQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list-count')).toHaveTextContent('2'); + }); + }); + + describe('quote data fields', () => { + it('includes provider name from bridges', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockUseBridgeQuoteData).toHaveBeenCalled(); + }); + + it('includes quoteRequestId from quote', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockUseBridgeQuoteData).toHaveBeenCalled(); + }); + + it('includes gasSponsored flag from quote', () => { + const sponsoredQuote = { + ...mockQuote, + quote: { ...mockQuote.quote, gasSponsored: true }, + }; + + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [sponsoredQuote], + bestQuote: sponsoredQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list-count')).toHaveTextContent('1'); + }); + }); + + describe('memoization', () => { + it('recalculates data when validQuotes changes', () => { + const { rerender, getByTestId } = render(); + + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + rerender(); + + expect(getByTestId('quote-list')).toBeTruthy(); + }); + + it('renders correctly when quotes are available', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [mockQuote], + bestQuote: mockQuote, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('quote-list-count')).toHaveTextContent('1'); + }); + }); + + describe('navigation back behavior', () => { + it('navigates back when quoteFetchError exists and not loading', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [], + bestQuote: null, + isLoading: false, + blockaidError: null, + quoteFetchError: 'Network error', + isExpired: false, + }); + + render(); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('navigates back when blockaidError exists and not loading', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [], + bestQuote: null, + isLoading: false, + blockaidError: 'Blockaid validation failed', + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('navigates back when quotes are expired and not loading', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [], + bestQuote: null, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: true, + }); + + render(); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('navigates back when loading and error exists', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [], + bestQuote: null, + isLoading: true, + blockaidError: null, + quoteFetchError: 'Network error', + isExpired: false, + }); + + render(); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('does not navigate back when no errors and not expired', () => { + mockUseBridgeQuoteData.mockReturnValue({ + validQuotes: [], + bestQuote: null, + isLoading: false, + blockaidError: null, + quoteFetchError: null, + isExpired: false, + }); + + render(); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); + }); + + describe('useTrackAllQuotesSortedEvent integration', () => { + it('calls useTrackAllQuotesSortedEvent with latestSourceBalance', () => { + render(); + + expect(mockUseTrackAllQuotesSortedEvent).toHaveBeenCalledWith( + mockLatestBalance, + ); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx new file mode 100644 index 00000000000..a416c12c30d --- /dev/null +++ b/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx @@ -0,0 +1,149 @@ +import ScreenView from '../../../../Base/ScreenView'; +import { + Box, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { strings } from '../../../../../../locales/i18n'; +import { useNavigation } from '@react-navigation/native'; +import { getHeaderCompactStandardNavbarOptions } from '../../../../../component-library/components-temp/HeaderCompactStandard'; +import { useDispatch, useSelector } from 'react-redux'; +import { + selectDestToken, + selectSelectedQuoteRequestId, + selectSourceToken, + setSelectedQuoteRequestId, +} from '../../../../../core/redux/slices/bridge'; +import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; +import { BigNumber } from 'bignumber.js'; +import { QuoteList } from './QuoteList'; +import { QuoteRowProps } from './QuoteRow'; +import { isGaslessQuote } from '../../utils/isGaslessQuote'; +import { useLatestBalance } from '../../hooks/useLatestBalance'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; +import formatFiat from '../../../../../util/formatFiat'; +import { startCase } from 'lodash'; +import { QUOTES_PLACEHOLDER_DATA } from './constants'; +import { useTrackAllQuotesSortedEvent } from '../../hooks/useTrackAllQuotesSortedEvent'; +import { fromTokenMinimalUnit } from '../../../../../util/number'; + +export const QuoteSelectorView = () => { + const navigation = useNavigation(); + const dispatch = useDispatch(); + const selectedQuoteRequestId = useSelector(selectSelectedQuoteRequestId); + const currency = useSelector(selectCurrentCurrency); + const { + validQuotes, + bestQuote, + isLoading, + blockaidError, + quoteFetchError, + isExpired, + willRefresh, + } = useBridgeQuoteData(); + const sourceToken = useSelector(selectSourceToken); + const destToken = useSelector(selectDestToken); + const latestSourceBalance = useLatestBalance({ + address: sourceToken?.address, + decimals: sourceToken?.decimals, + chainId: sourceToken?.chainId, + }); + + const trackAllQuotesSortedEvent = + useTrackAllQuotesSortedEvent(latestSourceBalance); + + const onQuoteSelect = useCallback( + (requestId: string) => { + const quote = validQuotes.find( + ({ quote: _quote }) => _quote.requestId === requestId, + ); + + if (!quote) { + return; + } + + trackAllQuotesSortedEvent(quote.quote); + dispatch(setSelectedQuoteRequestId(requestId)); + navigation.goBack(); + }, + [dispatch, navigation, trackAllQuotesSortedEvent, validQuotes], + ); + + const data = useMemo(() => { + if (validQuotes.length === 0) { + return QUOTES_PLACEHOLDER_DATA; + } + + return validQuotes.map( + (quote) => + ({ + formattedTotalCost: formatFiat( + new BigNumber(quote.sentAmount.valueInCurrency ?? '0').plus( + isGaslessQuote(quote.quote) + ? (quote.includedTxFees?.valueInCurrency ?? '0') + : (quote.totalNetworkFee?.valueInCurrency ?? + quote.gasFee?.effective?.valueInCurrency ?? + '0'), + ), + currency, + ), + receiveAmount: destToken + ? fromTokenMinimalUnit( + quote.quote.destTokenAmount, + destToken.decimals, + ) + : undefined, + provider: { + name: startCase(quote.quote.bridges[0]), + }, + quoteRequestId: quote.quote.requestId, + onPress: onQuoteSelect, + loading: isLoading, + isLowestCost: quote.quote.requestId === bestQuote?.quote.requestId, + selected: + !isLoading && + (!selectedQuoteRequestId + ? quote.quote.requestId === bestQuote?.quote.requestId + : quote.quote.requestId === selectedQuoteRequestId), + }) satisfies QuoteRowProps, + ); + }, [ + validQuotes, + onQuoteSelect, + bestQuote, + isLoading, + currency, + selectedQuoteRequestId, + destToken, + ]); + + useEffect(() => { + navigation.setOptions( + getHeaderCompactStandardNavbarOptions({ + title: strings('bridge.select_quote'), + onBack: () => navigation.goBack(), + includesTopInset: true, + }), + ); + }, [navigation]); + + // Go back to bridge view only if there's an error or quotes are expired + useEffect(() => { + if (quoteFetchError || blockaidError || (isExpired && !willRefresh)) { + navigation.goBack(); + } + }, [quoteFetchError, blockaidError, isExpired, navigation, willRefresh]); + + return ( + + + + {strings('bridge.select_quote_info')} + + + + + ); +}; diff --git a/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx b/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx index c1cc53cd1ef..08c67960123 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx @@ -43,8 +43,12 @@ jest.mock('../../hooks/useShouldRenderMaxOption', () => ({ useShouldRenderMaxOption: jest.fn(() => true), })); -jest.mock('../../hooks/useTokenInputAreaFormattedBalance', () => ({ - useTokenInputAreaFormattedBalance: jest.fn(() => '100'), +jest.mock('../../hooks/useFormattedBalanceWithThreshold', () => ({ + useFormattedBalanceWithThreshold: jest.fn(() => '100'), +})); + +jest.mock('../../hooks/useDisplayCurrencyValue', () => ({ + useDisplayCurrencyValue: jest.fn(() => '$100.00'), })); import { useShouldRenderMaxOption } from '../../hooks/useShouldRenderMaxOption'; @@ -53,10 +57,16 @@ const mockUseShouldRenderMaxOption = typeof useShouldRenderMaxOption >; -import { useTokenInputAreaFormattedBalance } from '../../hooks/useTokenInputAreaFormattedBalance'; -const mockUseTokenInputAreaFormattedBalance = - useTokenInputAreaFormattedBalance as jest.MockedFunction< - typeof useTokenInputAreaFormattedBalance +import { useFormattedBalanceWithThreshold } from '../../hooks/useFormattedBalanceWithThreshold'; +const mockUseFormattedBalanceWithThreshold = + useFormattedBalanceWithThreshold as jest.MockedFunction< + typeof useFormattedBalanceWithThreshold + >; + +import { useDisplayCurrencyValue } from '../../hooks/useDisplayCurrencyValue'; +const mockUseDisplayCurrencyValue = + useDisplayCurrencyValue as jest.MockedFunction< + typeof useDisplayCurrencyValue >; const mockOnTokenPress = jest.fn(); @@ -69,7 +79,8 @@ describe('TokenInputArea', () => { beforeEach(() => { jest.clearAllMocks(); mockUseShouldRenderMaxOption.mockReturnValue(true); - mockUseTokenInputAreaFormattedBalance.mockReturnValue('100'); + mockUseFormattedBalanceWithThreshold.mockReturnValue('100'); + mockUseDisplayCurrencyValue.mockReturnValue('$100.00'); }); it('renders with initial state', () => { @@ -668,4 +679,361 @@ describe('TokenInputArea', () => { expect(mockInputBlur).toHaveBeenCalledTimes(1); }); }); + + describe('currency value display', () => { + const mockToken: BridgeToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + decimals: 18, + chainId: '0x1' as `0x${string}`, + }; + + it('shows currency value when token, amount > 0, and currencyValue are all provided', () => { + // Arrange + mockUseDisplayCurrencyValue.mockReturnValue('$50.00'); + + // Act + const { getByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(getByText('$50.00')).toBeTruthy(); + expect(mockUseDisplayCurrencyValue).toHaveBeenCalledWith('1', mockToken); + }); + + it('hides currency value when amount is 0', () => { + // Arrange + mockUseDisplayCurrencyValue.mockReturnValue('$0.00'); + + // Act + const { queryByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert — condition Number(amount) > 0 is false + expect(queryByText('$0.00')).toBeNull(); + }); + + it('hides currency value when amount is undefined', () => { + // Act + const { queryByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(queryByText('$100.00')).toBeNull(); + }); + + it('hides currency value when no token is provided', () => { + // Act + const { queryByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(queryByText('$100.00')).toBeNull(); + }); + + it('passes amount and token to useDisplayCurrencyValue', () => { + // Act + renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(mockUseDisplayCurrencyValue).toHaveBeenCalledWith('5', mockToken); + }); + + it('passes undefined amount and token when both omitted', () => { + // Act + renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(mockUseDisplayCurrencyValue).toHaveBeenCalledWith( + undefined, + undefined, + ); + }); + }); + + describe('token button vs select button', () => { + const mockToken: BridgeToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + decimals: 18, + chainId: '0x1' as `0x${string}`, + }; + + it('shows TokenButton with symbol when token is provided', () => { + // Act + const { getByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(getByText('TEST')).toBeTruthy(); + }); + + it('shows "Swap from" button when no token and isSourceToken is true', () => { + // Act + const { getByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(getByText('Swap from')).toBeTruthy(); + }); + + it('shows "Swap to" button when no token and isSourceToken is false', () => { + // Act + const { getByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(getByText('Swap to')).toBeTruthy(); + }); + }); + + describe('loading state', () => { + it('does not render input when isLoading is true', () => { + // Act + const { queryByTestId } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert — input replaced by Skeleton + expect(queryByTestId('token-input-input')).toBeNull(); + }); + + it('renders input when isLoading is false', () => { + // Act + const { getByTestId } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(getByTestId('token-input-input')).toBeTruthy(); + }); + }); + + describe('subtitle display', () => { + it('source token shows formattedBalance as subtitle', () => { + // Arrange + mockUseFormattedBalanceWithThreshold.mockReturnValue('42.5 ETH'); + const mockToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + decimals: 18, + chainId: '0x1' as `0x${string}`, + }; + + // Act + const { getByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert + expect(getByText('42.5 ETH')).toBeTruthy(); + }); + + it('destination token shows formatted token address as subtitle', () => { + // Arrange — use a non-native EVM token address + const mockToken: BridgeToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'USDC', + decimals: 6, + chainId: '0x1' as `0x${string}`, + }; + + // Act + const { queryByText, getByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert — shows formatted address (0x1234...7890), not formattedBalance ('100') + expect(queryByText('100')).toBeNull(); + expect(getByText(/0x.*\.\.\./)).toBeTruthy(); + }); + + it('destination native token shows no address subtitle', () => { + // Arrange — native (zero) address should produce no subtitle + const nativeToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + decimals: 18, + chainId: '0x1' as `0x${string}`, + }; + + // Act + const { queryByText } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + // Assert — no formatted address rendered for native asset + expect(queryByText(/0x.*\.\.\./)).toBeNull(); + }); + }); + + describe('onInputPress callback', () => { + it('fires onInputPress when the input is pressed', () => { + // Act + const { getByTestId } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + fireEvent(getByTestId('token-input-input'), 'pressIn'); + + // Assert + expect(mockOnInputPress).toHaveBeenCalledTimes(1); + }); + + it('fires onInputPress on input focus', () => { + // Act + const { getByTestId } = renderScreen( + () => ( + + ), + { name: 'TokenInputArea' }, + { state: initialState }, + ); + + fireEvent(getByTestId('token-input-input'), 'focus'); + + // Assert — both onFocus and onInputPress are fired + expect(mockOnFocus).toHaveBeenCalledTimes(1); + expect(mockOnInputPress).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index 3b7b4402c6c..6cee31cc355 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -15,12 +15,7 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import Input from '../../../../../component-library/components/Form/TextField/foundation/Input'; import { TokenButton } from '../TokenButton'; -import { - selectCurrentCurrency, - selectCurrencyRates, -} from '../../../../../selectors/currencyRateController'; -import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController'; -import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; import { BigNumber } from 'ethers'; import { BridgeToken } from '../../types'; import { Skeleton } from '../../../../../component-library/components/Skeleton'; @@ -34,10 +29,6 @@ import { setDestTokenExchangeRate, setSourceTokenExchangeRate, } from '../../../../../core/redux/slices/bridge'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) -import { selectMultichainAssetsRates } from '../../../../../selectors/multichain'; -///: END:ONLY_INCLUDE_IF(keyring-snaps) -import { getDisplayCurrencyValue } from '../../utils/exchange-rates'; import { useBridgeExchangeRates } from '../../hooks/useBridgeExchangeRates'; import useIsInsufficientBalance from '../../hooks/useInsufficientBalance'; import { isCaipAssetType, parseCaipAssetType } from '@metamask/utils'; @@ -49,7 +40,8 @@ import { useTokenAddress } from '../../hooks/useTokenAddress'; import { useShouldRenderMaxOption } from '../../hooks/useShouldRenderMaxOption'; import { useAutoSizingFont } from '../../hooks/useAutoSizingFont'; import { formatAmountWithLocaleSeparators } from '../../utils/formatAmountWithLocaleSeparators'; -import { useTokenInputAreaFormattedBalance } from '../../hooks/useTokenInputAreaFormattedBalance'; +import { useFormattedBalanceWithThreshold } from '../../hooks/useFormattedBalanceWithThreshold'; +import { useDisplayCurrencyValue } from '../../hooks/useDisplayCurrencyValue'; export const MAX_INPUT_LENGTH = 36; @@ -201,35 +193,15 @@ export const TokenInputArea = forwardRef< }); }; - // // Data for fiat value calculation - const evmMultiChainMarketData = useSelector(selectTokenMarketData); - const evmMultiChainCurrencyRates = useSelector(selectCurrencyRates); - const networkConfigurationsByChainId = useSelector( - selectNetworkConfigurations, - ); - const isInsufficientBalance = useIsInsufficientBalance({ amount, token, latestAtomicBalance, }); - let nonEvmMultichainAssetRates = {}; - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - nonEvmMultichainAssetRates = useSelector(selectMultichainAssetsRates); - ///: END:ONLY_INCLUDE_IF(keyring-snaps) - - const currencyValue = getDisplayCurrencyValue({ - token, - amount, - evmMultiChainMarketData, - networkConfigurationsByChainId, - evmMultiChainCurrencyRates, - currentCurrency, - nonEvmMultichainAssetRates, - }); + const currencyValue = useDisplayCurrencyValue(amount, token); - const formattedBalance = useTokenInputAreaFormattedBalance( + const formattedBalance = useFormattedBalanceWithThreshold( tokenBalance, token, ); diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts index c6d17ece693..2ee49117a64 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts @@ -1,4 +1,4 @@ -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { selectBridgeControllerState, selectSourceToken, @@ -10,6 +10,8 @@ import { selectBridgeFeatureFlags, selectIsSolanaSwap, selectIsSolanaToNonSolana, + selectSelectedQuoteRequestId, + setSelectedQuoteRequestId, } from '../../../../../core/redux/slices/bridge'; import { RequestStatus, isNonEvmChainId } from '@metamask/bridge-controller'; import { areAddressesEqual } from '../../../../../util/address'; @@ -37,6 +39,7 @@ interface UseBridgeQuoteDataParams { export const useBridgeQuoteData = ({ latestSourceAtomicBalance, }: UseBridgeQuoteDataParams = {}) => { + const dispatch = useDispatch(); const bridgeControllerState = useSelector(selectBridgeControllerState); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); @@ -48,6 +51,7 @@ export const useBridgeQuoteData = ({ const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); const isSolanaSwap = useSelector(selectIsSolanaSwap); const isSolanaToNonSolana = useSelector(selectIsSolanaToNonSolana); + const selectedQuoteRequestId = useSelector(selectSelectedQuoteRequestId); const { validateBridgeTx } = useValidateBridgeTx(); const [blockaidError, setBlockaidError] = useState(null); @@ -84,8 +88,20 @@ export const useBridgeQuoteData = ({ [quotes?.sortedQuotes], ); + // Determine the active quote: + // 1. If user manually selected a quote, use that + // 2. Otherwise, use the best quote + // 3. If expired and not refreshing, use undefined + const manuallySelectedQuote = selectedQuoteRequestId + ? allQuotes.find( + (quote) => quote.quote.requestId === selectedQuoteRequestId, + ) + : undefined; + const activeQuote = - isExpired && !willRefresh && !isSubmittingTx ? undefined : bestQuote; + isExpired && !willRefresh && !isSubmittingTx + ? undefined + : (manuallySelectedQuote ?? bestQuote); // Validate that the quote's source asset matches the selected source token // This prevents showing stale quote data when user changes source token on the same chain @@ -287,6 +303,12 @@ export const useBridgeQuoteData = ({ validateQuote(); }, [validateQuote]); + useEffect(() => { + if (!manuallySelectedQuote) { + dispatch(setSelectedQuoteRequestId(undefined)); + } + }, [manuallySelectedQuote, dispatch]); + return { bestQuote, quoteFetchError, diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts index 8c8ac39d6b7..fa059f89a81 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts @@ -1292,6 +1292,189 @@ describe('useBridgeQuoteData', () => { }); }); + // Test manually selected quote via selectedQuoteRequestId + describe('manually selected quote', () => { + it('uses manually selected quote when selectedQuoteRequestId matches a quote in sortedQuotes', () => { + const manuallySelectedQuote = { + ...mockQuoteWithMetadata, + quote: { + ...mockQuoteWithMetadata.quote, + requestId: 'selected-quote-id', + }, + }; + + const recommendedQuote = { + ...mockQuoteWithMetadata, + quote: { + ...mockQuoteWithMetadata.quote, + requestId: 'best-quote-id', + }, + }; + + (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ + recommendedQuote, + sortedQuotes: [recommendedQuote, manuallySelectedQuote], + alternativeQuotes: [], + })); + + const bridgeReducerOverrides = { + selectedQuoteRequestId: 'selected-quote-id', + }; + + const testState = createBridgeTestState({ + bridgeReducerOverrides, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.activeQuote).toEqual(manuallySelectedQuote); + expect(result.current.bestQuote).toEqual(recommendedQuote); + }); + + it('falls back to bestQuote when selectedQuoteRequestId does not match any sortedQuote', () => { + const recommendedQuote = { ...mockQuoteWithMetadata }; + + (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ + recommendedQuote, + sortedQuotes: [recommendedQuote], + alternativeQuotes: [], + })); + + const bridgeReducerOverrides = { + selectedQuoteRequestId: 'non-existent-quote-id', + }; + + const testState = createBridgeTestState({ + bridgeReducerOverrides, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.activeQuote).toEqual(recommendedQuote); + expect(result.current.bestQuote).toEqual(recommendedQuote); + }); + + it('dispatches setSelectedQuoteRequestId(undefined) when manuallySelectedQuote is undefined', async () => { + (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ + recommendedQuote: mockQuoteWithMetadata, + sortedQuotes: [], + alternativeQuotes: [], + })); + + // selectedQuoteRequestId is set but sortedQuotes is empty so manuallySelectedQuote will be undefined + const bridgeReducerOverrides = { + selectedQuoteRequestId: 'some-quote-id', + }; + + const testState = createBridgeTestState({ + bridgeReducerOverrides, + }); + + const { store } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + // After the effect runs, selectedQuoteRequestId should be cleared in the store + await waitFor(() => { + expect( + (store.getState() as { bridge: { selectedQuoteRequestId?: string } }) + .bridge.selectedQuoteRequestId, + ).toBeUndefined(); + }); + }); + + it('does not override activeQuote with manually selected when expired and not refreshing', () => { + const manuallySelectedQuote = { + ...mockQuoteWithMetadata, + quote: { + ...mockQuoteWithMetadata.quote, + requestId: 'selected-quote-id', + }, + }; + + const recommendedQuote = { + ...mockQuoteWithMetadata, + quote: { + ...mockQuoteWithMetadata.quote, + requestId: 'best-quote-id', + }, + }; + + (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ + recommendedQuote, + sortedQuotes: [recommendedQuote, manuallySelectedQuote], + alternativeQuotes: [], + })); + + (isQuoteExpired as jest.Mock).mockReturnValue(true); + (shouldRefreshQuote as jest.Mock).mockReturnValue(false); + + const bridgeReducerOverrides = { + selectedQuoteRequestId: 'selected-quote-id', + isSubmittingTx: false, + }; + + const testState = createBridgeTestState({ + bridgeReducerOverrides, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + // When expired and not refreshing and not submitting, activeQuote should be undefined + expect(result.current.activeQuote).toBeUndefined(); + expect(result.current.isExpired).toBe(true); + }); + + it('keeps activeQuote as manually selected when expired but still submitting', () => { + const manuallySelectedQuote = { + ...mockQuoteWithMetadata, + quote: { + ...mockQuoteWithMetadata.quote, + requestId: 'selected-quote-id', + }, + }; + + const recommendedQuote = { + ...mockQuoteWithMetadata, + quote: { + ...mockQuoteWithMetadata.quote, + requestId: 'best-quote-id', + }, + }; + + (selectBridgeQuotes as unknown as jest.Mock).mockImplementation(() => ({ + recommendedQuote, + sortedQuotes: [recommendedQuote, manuallySelectedQuote], + alternativeQuotes: [], + })); + + (isQuoteExpired as jest.Mock).mockReturnValue(true); + (shouldRefreshQuote as jest.Mock).mockReturnValue(false); + + const bridgeReducerOverrides = { + selectedQuoteRequestId: 'selected-quote-id', + isSubmittingTx: true, + }; + + const testState = createBridgeTestState({ + bridgeReducerOverrides, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + // When isSubmittingTx is true, activeQuote should remain (even if expired) + expect(result.current.activeQuote).toEqual(manuallySelectedQuote); + }); + }); + // Test willRefresh scenarios describe('willRefresh behavior', () => { it('sets willRefresh to true when conditions are met', () => { diff --git a/app/components/UI/Bridge/hooks/useDisplayCurrencyValue/index.test.ts b/app/components/UI/Bridge/hooks/useDisplayCurrencyValue/index.test.ts new file mode 100644 index 00000000000..58c1d2b14dc --- /dev/null +++ b/app/components/UI/Bridge/hooks/useDisplayCurrencyValue/index.test.ts @@ -0,0 +1,322 @@ +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useDisplayCurrencyValue } from './index'; +import { getDisplayCurrencyValue } from '../../utils/exchange-rates'; +import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../selectors/currencyRateController'; +import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; +import { selectMultichainAssetsRates } from '../../../../../selectors/multichain'; +import { BridgeToken } from '../../types'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +jest.mock('../../utils/exchange-rates'); +jest.mock('../../../../../selectors/tokenRatesController'); +jest.mock('../../../../../selectors/currencyRateController'); +jest.mock('../../../../../selectors/networkController'); +jest.mock('../../../../../selectors/multichain', () => ({ + selectMultichainAssetsRates: jest.fn(), +})); + +const mockGetDisplayCurrencyValue = + getDisplayCurrencyValue as jest.MockedFunction< + typeof getDisplayCurrencyValue + >; +const mockSelectTokenMarketData = selectTokenMarketData as jest.MockedFunction< + typeof selectTokenMarketData +>; +const mockSelectCurrencyRates = selectCurrencyRates as jest.MockedFunction< + typeof selectCurrencyRates +>; +const mockSelectCurrentCurrency = selectCurrentCurrency as jest.MockedFunction< + typeof selectCurrentCurrency +>; +const mockSelectNetworkConfigurations = + selectNetworkConfigurations as jest.MockedFunction< + typeof selectNetworkConfigurations + >; +const mockSelectMultichainAssetsRates = + selectMultichainAssetsRates as jest.MockedFunction< + typeof selectMultichainAssetsRates + >; + +const MOCK_MARKET_DATA = { + '0x1': { '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { price: 1 } }, +} as unknown as ReturnType; + +const MOCK_CURRENCY_RATES = { + ETH: { conversionRate: 2500, usdConversionRate: 2500 }, +} as unknown as ReturnType; + +const MOCK_NETWORK_CONFIGS = { + '0x1': { nativeCurrency: 'ETH' }, +} as unknown as ReturnType; + +const MOCK_MULTICHAIN_RATES = {} as ReturnType< + typeof selectMultichainAssetsRates +>; + +const makeToken = (overrides?: Partial): BridgeToken => ({ + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + chainId: CHAIN_IDS.MAINNET, + ...overrides, +}); + +describe('useDisplayCurrencyValue', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectTokenMarketData.mockReturnValue(MOCK_MARKET_DATA); + mockSelectCurrencyRates.mockReturnValue(MOCK_CURRENCY_RATES); + mockSelectCurrentCurrency.mockReturnValue('USD'); + mockSelectNetworkConfigurations.mockReturnValue(MOCK_NETWORK_CONFIGS); + mockSelectMultichainAssetsRates.mockReturnValue(MOCK_MULTICHAIN_RATES); + mockGetDisplayCurrencyValue.mockReturnValue('$0.00'); + }); + + describe('return value', () => { + it('returns the value from getDisplayCurrencyValue', () => { + // Arrange + mockGetDisplayCurrencyValue.mockReturnValue('$100.00'); + const token = makeToken(); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue('100', token), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('$100.00'); + }); + + it('returns "$0.00" when amount is undefined', () => { + // Arrange + mockGetDisplayCurrencyValue.mockReturnValue('$0.00'); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue(undefined, makeToken()), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('$0.00'); + }); + + it('returns "$0.00" when token is undefined', () => { + // Arrange + mockGetDisplayCurrencyValue.mockReturnValue('$0.00'); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue('100', undefined), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('$0.00'); + }); + + it('returns "$0.00" when both amount and token are undefined', () => { + // Arrange + mockGetDisplayCurrencyValue.mockReturnValue('$0.00'); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue(undefined, undefined), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('$0.00'); + }); + }); + + describe('selector forwarding', () => { + it('forwards amount and token to getDisplayCurrencyValue', () => { + // Arrange + const token = makeToken(); + + // Act + renderHookWithProvider(() => useDisplayCurrencyValue('50', token), { + state: {}, + }); + + // Assert + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ amount: '50', token }), + ); + }); + + it('forwards evmMultiChainMarketData from selectTokenMarketData', () => { + // Arrange + const customMarketData = { + '0x1': { '0xabc': { price: 2.5 } }, + } as unknown as ReturnType; + mockSelectTokenMarketData.mockReturnValue(customMarketData); + + // Act + renderHookWithProvider(() => useDisplayCurrencyValue('1', makeToken()), { + state: {}, + }); + + // Assert + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ evmMultiChainMarketData: customMarketData }), + ); + }); + + it('forwards evmMultiChainCurrencyRates from selectCurrencyRates', () => { + // Arrange + const customRates = { + ETH: { conversionRate: 3000, usdConversionRate: 3000 }, + } as unknown as ReturnType; + mockSelectCurrencyRates.mockReturnValue(customRates); + + // Act + renderHookWithProvider(() => useDisplayCurrencyValue('1', makeToken()), { + state: {}, + }); + + // Assert + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ + evmMultiChainCurrencyRates: customRates, + }), + ); + }); + + it('forwards networkConfigurationsByChainId from selectNetworkConfigurations', () => { + // Arrange + const customNetworks = { + '0x89': { nativeCurrency: 'POL' }, + } as unknown as ReturnType; + mockSelectNetworkConfigurations.mockReturnValue(customNetworks); + + // Act + renderHookWithProvider(() => useDisplayCurrencyValue('1', makeToken()), { + state: {}, + }); + + // Assert + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ + networkConfigurationsByChainId: customNetworks, + }), + ); + }); + + it('forwards currentCurrency from selectCurrentCurrency', () => { + // Arrange + mockSelectCurrentCurrency.mockReturnValue('EUR'); + + // Act + renderHookWithProvider(() => useDisplayCurrencyValue('1', makeToken()), { + state: {}, + }); + + // Assert + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ currentCurrency: 'EUR' }), + ); + }); + + it('forwards nonEvmMultichainAssetRates from selectMultichainAssetsRates', () => { + // Arrange + const customMultichainRates = { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { rate: '150' }, + } as unknown as ReturnType; + mockSelectMultichainAssetsRates.mockReturnValue(customMultichainRates); + + // Act + renderHookWithProvider(() => useDisplayCurrencyValue('1', makeToken()), { + state: {}, + }); + + // Assert + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ + nonEvmMultichainAssetRates: customMultichainRates, + }), + ); + }); + }); + + describe('different currencies', () => { + it('returns EUR-formatted value when current currency is EUR', () => { + // Arrange + mockSelectCurrentCurrency.mockReturnValue('EUR'); + mockGetDisplayCurrencyValue.mockReturnValue('€42.00'); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue('42', makeToken()), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('€42.00'); + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ currentCurrency: 'EUR' }), + ); + }); + + it('returns GBP-formatted value when current currency is GBP', () => { + // Arrange + mockSelectCurrentCurrency.mockReturnValue('GBP'); + mockGetDisplayCurrencyValue.mockReturnValue('£10.50'); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue('10', makeToken()), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('£10.50'); + }); + + it('returns small-value threshold string', () => { + // Arrange + mockGetDisplayCurrencyValue.mockReturnValue('< $0.01'); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue('0.000001', makeToken()), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('< $0.01'); + }); + }); + + describe('non-EVM token', () => { + it('forwards a non-EVM token to getDisplayCurrencyValue', () => { + // Arrange + const solanaToken = makeToken({ + chainId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as BridgeToken['chainId'], + address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + symbol: 'SOL', + decimals: 9, + }); + mockGetDisplayCurrencyValue.mockReturnValue('$15.00'); + + // Act + const { result } = renderHookWithProvider( + () => useDisplayCurrencyValue('0.1', solanaToken), + { state: {} }, + ); + + // Assert + expect(result.current).toBe('$15.00'); + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledWith( + expect.objectContaining({ token: solanaToken, amount: '0.1' }), + ); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useDisplayCurrencyValue/index.ts b/app/components/UI/Bridge/hooks/useDisplayCurrencyValue/index.ts new file mode 100644 index 00000000000..954258b5fa4 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useDisplayCurrencyValue/index.ts @@ -0,0 +1,41 @@ +import { useSelector } from 'react-redux'; +import { BridgeToken } from '../../types'; +import { getDisplayCurrencyValue } from '../../utils/exchange-rates'; +import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../selectors/currencyRateController'; +import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +import { selectMultichainAssetsRates } from '../../../../../selectors/multichain'; +///: END:ONLY_INCLUDE_IF(keyring-snaps) + +export const useDisplayCurrencyValue = ( + amount?: string, + token?: BridgeToken, +) => { + const evmMultiChainMarketData = useSelector(selectTokenMarketData); + const evmMultiChainCurrencyRates = useSelector(selectCurrencyRates); + const networkConfigurationsByChainId = useSelector( + selectNetworkConfigurations, + ); + const currentCurrency = useSelector(selectCurrentCurrency); + + let nonEvmMultichainAssetRates = {}; + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + nonEvmMultichainAssetRates = useSelector(selectMultichainAssetsRates); + ///: END:ONLY_INCLUDE_IF(keyring-snaps) + + const currencyValue = getDisplayCurrencyValue({ + token, + amount, + evmMultiChainMarketData, + networkConfigurationsByChainId, + evmMultiChainCurrencyRates, + currentCurrency, + nonEvmMultichainAssetRates, + }); + + return currencyValue; +}; diff --git a/app/components/UI/Bridge/hooks/useTokenInputAreaFormattedBalance/index.test.ts b/app/components/UI/Bridge/hooks/useFormattedBalanceWithThreshold/index.test.ts similarity index 78% rename from app/components/UI/Bridge/hooks/useTokenInputAreaFormattedBalance/index.test.ts rename to app/components/UI/Bridge/hooks/useFormattedBalanceWithThreshold/index.test.ts index 9ffaf4fb7dc..9d52402f158 100644 --- a/app/components/UI/Bridge/hooks/useTokenInputAreaFormattedBalance/index.test.ts +++ b/app/components/UI/Bridge/hooks/useFormattedBalanceWithThreshold/index.test.ts @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useTokenInputAreaFormattedBalance } from '.'; +import { useFormattedBalanceWithThreshold } from '.'; import { BridgeToken } from '../../types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import I18n from '../../../../../../locales/i18n'; @@ -18,7 +18,7 @@ const makeToken = (overrides?: Partial): BridgeToken => ({ ...overrides, }); -describe('useTokenInputAreaFormattedBalance', () => { +describe('useFormattedBalanceWithThreshold', () => { beforeEach(() => { mockI18n.locale = 'en'; }); @@ -26,7 +26,7 @@ describe('useTokenInputAreaFormattedBalance', () => { describe('guard clauses', () => { it('returns undefined when token is undefined', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('100', undefined), + useFormattedBalanceWithThreshold('100', undefined), ); expect(result.current).toBeUndefined(); @@ -36,7 +36,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const token = makeToken({ symbol: '' }); const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('100', token), + useFormattedBalanceWithThreshold('100', token), ); expect(result.current).toBeUndefined(); @@ -46,7 +46,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const token = makeToken(); const { result } = renderHook(() => - useTokenInputAreaFormattedBalance(undefined, token), + useFormattedBalanceWithThreshold(undefined, token), ); expect(result.current).toBeUndefined(); @@ -56,7 +56,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const token = makeToken(); const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('', token), + useFormattedBalanceWithThreshold('', token), ); expect(result.current).toBeUndefined(); @@ -64,7 +64,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('returns undefined when both token and tokenBalance are missing', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance(undefined, undefined), + useFormattedBalanceWithThreshold(undefined, undefined), ); expect(result.current).toBeUndefined(); @@ -76,7 +76,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('returns "< 0.00001" for a value just below threshold', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0.000009', token), + useFormattedBalanceWithThreshold('0.000009', token), ); expect(result.current).toBe('< 0.00001'); @@ -84,7 +84,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('returns "< 0.00001" for extremely small positive values', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0.000000000000000001', token), + useFormattedBalanceWithThreshold('0.000000000000000001', token), ); expect(result.current).toBe('< 0.00001'); @@ -92,7 +92,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('does not trigger for value equal to threshold (0.00001)', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0.00001', token), + useFormattedBalanceWithThreshold('0.00001', token), ); expect(result.current).not.toBe('< 0.00001'); @@ -101,7 +101,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('does not trigger for value above threshold', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0.0001', token), + useFormattedBalanceWithThreshold('0.0001', token), ); expect(result.current).not.toBe('< 0.00001'); @@ -110,7 +110,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('does not trigger for zero', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0', token), + useFormattedBalanceWithThreshold('0', token), ); expect(result.current).toBe('0 USDC'); @@ -118,7 +118,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('does not trigger for negative values', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('-0.000001', token), + useFormattedBalanceWithThreshold('-0.000001', token), ); // parseAmount can't parse negative numbers (regex doesn't match "-") @@ -132,7 +132,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats a single digit', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('5', token), + useFormattedBalanceWithThreshold('5', token), ); expect(result.current).toBe('5 USDC'); @@ -140,7 +140,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats a three-digit number without grouping', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('999', token), + useFormattedBalanceWithThreshold('999', token), ); expect(result.current).toBe('999 USDC'); @@ -148,7 +148,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats thousands with grouping separator', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1000', token), + useFormattedBalanceWithThreshold('1000', token), ); expect(result.current).toBe('1,000 USDC'); @@ -156,7 +156,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats millions with grouping separators', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1234567', token), + useFormattedBalanceWithThreshold('1234567', token), ); expect(result.current).toBe('1,234,567 USDC'); @@ -164,7 +164,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats billions', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1000000000', token), + useFormattedBalanceWithThreshold('1000000000', token), ); expect(result.current).toBe('1,000,000,000 USDC'); @@ -172,7 +172,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('strips leading zeros from integers', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('007', token), + useFormattedBalanceWithThreshold('007', token), ); expect(result.current).toBe('7 USDC'); @@ -184,7 +184,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('preserves up to 5 decimal places', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1.12345', token), + useFormattedBalanceWithThreshold('1.12345', token), ); expect(result.current).toBe('1.12345 USDC'); @@ -192,7 +192,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('truncates beyond 5 decimal places', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1.123456789', token), + useFormattedBalanceWithThreshold('1.123456789', token), ); expect(result.current).toBe('1.12345 USDC'); @@ -200,7 +200,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('trims trailing zeros after truncation', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1.10000', token), + useFormattedBalanceWithThreshold('1.10000', token), ); expect(result.current).toBe('1.1 USDC'); @@ -208,7 +208,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats decimal with thousands in integer part', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('12345.6789', token), + useFormattedBalanceWithThreshold('12345.6789', token), ); expect(result.current).toBe('12,345.6789 USDC'); @@ -216,7 +216,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('handles value with only a fractional part', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('.5', token), + useFormattedBalanceWithThreshold('.5', token), ); expect(result.current).toBe('0.5 USDC'); @@ -228,7 +228,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats a 12-digit integer', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('999999999999', token), + useFormattedBalanceWithThreshold('999999999999', token), ); expect(result.current).toBe('999,999,999,999 USDC'); @@ -236,7 +236,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('formats a large number with decimals', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('123456789.12345', token), + useFormattedBalanceWithThreshold('123456789.12345', token), ); expect(result.current).toBe('123,456,789.12345 USDC'); @@ -244,7 +244,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('handles numbers beyond safe integer range', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('99999999999999999999', token), + useFormattedBalanceWithThreshold('99999999999999999999', token), ); expect(result.current).toMatch(/^[\d,]+ USDC$/); @@ -256,7 +256,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('returns raw tokenBalance with symbol for strings with commas', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1,234.56', token), + useFormattedBalanceWithThreshold('1,234.56', token), ); expect(result.current).toBe('1,234.56 USDC'); @@ -264,7 +264,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('returns raw tokenBalance with symbol for scientific notation', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1e3', token), + useFormattedBalanceWithThreshold('1e3', token), ); expect(result.current).toBe('1e3 USDC'); @@ -272,7 +272,7 @@ describe('useTokenInputAreaFormattedBalance', () => { it('returns raw tokenBalance with symbol for non-numeric strings', () => { const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('abc', token), + useFormattedBalanceWithThreshold('abc', token), ); expect(result.current).toBe('abc USDC'); @@ -284,7 +284,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const token = makeToken({ symbol: 'ETH' }); const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1.5', token), + useFormattedBalanceWithThreshold('1.5', token), ); expect(result.current).toBe('1.5 ETH'); @@ -294,7 +294,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const wbtc = makeToken({ symbol: 'WBTC' }); const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0.00123', wbtc), + useFormattedBalanceWithThreshold('0.00123', wbtc), ); expect(result.current).toBe('0.00123 WBTC'); @@ -304,7 +304,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const token = makeToken({ symbol: 'ETH' }); const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0.000001', token), + useFormattedBalanceWithThreshold('0.000001', token), ); expect(result.current).toBe('< 0.00001'); @@ -315,7 +315,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const token = makeToken({ symbol: 'DAI' }); const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1e3', token), + useFormattedBalanceWithThreshold('1e3', token), ); expect(result.current).toBe('1e3 DAI'); @@ -329,7 +329,7 @@ describe('useTokenInputAreaFormattedBalance', () => { mockI18n.locale = 'de-DE'; const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1234567.89', token), + useFormattedBalanceWithThreshold('1234567.89', token), ); expect(result.current).toMatch(/1\.234\.567/); @@ -340,7 +340,7 @@ describe('useTokenInputAreaFormattedBalance', () => { mockI18n.locale = 'fr-FR'; const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1234567.89', token), + useFormattedBalanceWithThreshold('1234567.89', token), ); // French uses narrow no-break space (U+202F) or non-breaking space for grouping @@ -352,7 +352,7 @@ describe('useTokenInputAreaFormattedBalance', () => { mockI18n.locale = 'de-DE'; const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('50000', token), + useFormattedBalanceWithThreshold('50000', token), ); expect(result.current).toBe('50.000 USDC'); @@ -362,7 +362,7 @@ describe('useTokenInputAreaFormattedBalance', () => { mockI18n.locale = 'ja-JP'; const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1000000', token), + useFormattedBalanceWithThreshold('1000000', token), ); expect(result.current).toBe('1,000,000 USDC'); @@ -372,7 +372,7 @@ describe('useTokenInputAreaFormattedBalance', () => { mockI18n.locale = 'de-DE'; const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('999', token), + useFormattedBalanceWithThreshold('999', token), ); expect(result.current).toBe('999 USDC'); @@ -382,7 +382,7 @@ describe('useTokenInputAreaFormattedBalance', () => { mockI18n.locale = 'de-DE'; const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('0.000001', token), + useFormattedBalanceWithThreshold('0.000001', token), ); expect(result.current).toBe('< 0.00001'); @@ -392,7 +392,7 @@ describe('useTokenInputAreaFormattedBalance', () => { mockI18n.locale = 'pt-BR'; const { result } = renderHook(() => - useTokenInputAreaFormattedBalance('1234.56', token), + useFormattedBalanceWithThreshold('1234.56', token), ); expect(result.current).toMatch(/1\.234/); @@ -405,7 +405,7 @@ describe('useTokenInputAreaFormattedBalance', () => { const token = makeToken(); const { result, rerender } = renderHook(() => - useTokenInputAreaFormattedBalance('1000', token), + useFormattedBalanceWithThreshold('1000', token), ); const firstResult = result.current; diff --git a/app/components/UI/Bridge/hooks/useTokenInputAreaFormattedBalance/index.ts b/app/components/UI/Bridge/hooks/useFormattedBalanceWithThreshold/index.ts similarity index 95% rename from app/components/UI/Bridge/hooks/useTokenInputAreaFormattedBalance/index.ts rename to app/components/UI/Bridge/hooks/useFormattedBalanceWithThreshold/index.ts index d0c0ef10880..7cc4259d6b8 100644 --- a/app/components/UI/Bridge/hooks/useTokenInputAreaFormattedBalance/index.ts +++ b/app/components/UI/Bridge/hooks/useFormattedBalanceWithThreshold/index.ts @@ -4,7 +4,7 @@ import { MINIMUM_DISPLAY_THRESHOLD } from '../../../../../util/number'; import { formatAmountWithLocaleSeparators } from '../../utils/formatAmountWithLocaleSeparators'; import parseAmount from '../../../../../util/parseAmount'; -export const useTokenInputAreaFormattedBalance = ( +export const useFormattedBalanceWithThreshold = ( tokenBalance?: string, token?: BridgeToken, ) => diff --git a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts new file mode 100644 index 00000000000..67194901d4a --- /dev/null +++ b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts @@ -0,0 +1,517 @@ +import { renderHook } from '@testing-library/react-native'; +import { useTrackAllQuotesSortedEvent } from './index'; +import Engine from '../../../../../core/Engine'; +import { + SortOrder, + UnifiedSwapBridgeEventName, + type Quote, +} from '@metamask/bridge-controller'; +import { BigNumber } from 'ethers'; +import { useSelector } from 'react-redux'; +import { + selectSourceAmount, + selectSourceToken, + selectDestToken, + selectIsBridge, +} from '../../../../../core/redux/slices/bridge'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; + +// Mock Engine +jest.mock('../../../../../core/Engine', () => ({ + context: { + BridgeController: { + trackUnifiedSwapBridgeEvent: jest.fn(), + }, + }, +})); + +// Mock useLatestBalance +const mockUseLatestBalance = jest.fn(); +jest.mock('../useLatestBalance', () => ({ + useLatestBalance: (params: unknown) => mockUseLatestBalance(params), +})); + +// Mock useIsInsufficientBalance +const mockUseIsInsufficientBalance = jest.fn(); +jest.mock('../useInsufficientBalance', () => ({ + __esModule: true, + default: (params: unknown) => mockUseIsInsufficientBalance(params), +})); + +// Mock Redux selectors - use a mutable object so we can change values in tests +const mockSelectorValues = { + sourceToken: { + symbol: 'ETH', + chainId: '0x1', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }, + destToken: { + symbol: 'USDC', + chainId: '0x89', + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + decimals: 6, + }, + sourceAmount: '1.5', + smartTransactionsEnabled: true, + isBridge: true, +}; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.Mock; + +// Mock getNativeAssetForChainId +jest.mock('@metamask/bridge-controller', () => ({ + ...jest.requireActual('@metamask/bridge-controller'), + getNativeAssetForChainId: jest.fn((chainId: string) => ({ + symbol: chainId === '0x1' ? 'ETH' : 'MATIC', + })), + formatProviderLabel: jest.fn((quote: Quote) => quote.bridges[0]), +})); + +describe('useTrackAllQuotesSortedEvent', () => { + const mockQuote = { + requestId: 'test-request-id', + srcChainId: 1, + destChainId: 137, + srcTokenAmount: '1500000000000000000', + destTokenAmount: '3000000000', + minDestTokenAmount: '2900000000', + bridgeId: 'lifi', + srcAsset: { + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: '', + assetId: 'eip155:1/slip44:60' as const, + }, + destAsset: { + chainId: 137, + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: '', + assetId: + 'eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174' as const, + }, + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: '', + assetId: 'eip155:1/slip44:60' as const, + }, + }, + }, + bridges: ['lifi'], + steps: [], + priceData: { + priceImpact: '0.05', + }, + gasIncluded: false, + } as unknown as Quote; + + const mockLatestBalance = { + displayBalance: '10', + atomicBalance: BigNumber.from('10000000000000000000'), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseIsInsufficientBalance.mockReturnValue(false); + ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mockClear(); + + // Reset mock values to defaults + mockSelectorValues.sourceToken = { + symbol: 'ETH', + chainId: '0x1', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }; + mockSelectorValues.destToken = { + symbol: 'USDC', + chainId: '0x89', + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + decimals: 6, + }; + mockSelectorValues.sourceAmount = '1.5'; + mockSelectorValues.smartTransactionsEnabled = true; + mockSelectorValues.isBridge = true; + + // Setup useSelector mock to read from mockSelectorValues + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSourceAmount) { + return mockSelectorValues.sourceAmount; + } + if (selector === selectSourceToken) { + return mockSelectorValues.sourceToken; + } + if (selector === selectDestToken) { + return mockSelectorValues.destToken; + } + if (selector === selectShouldUseSmartTransaction) { + return mockSelectorValues.smartTransactionsEnabled; + } + if (selector === selectIsBridge) { + return mockSelectorValues.isBridge; + } + return null; + }); + }); + + describe('return value', () => { + it('returns a function', () => { + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + expect(typeof result.current).toBe('function'); + }); + }); + + describe('event tracking for bridge transactions', () => { + it('calls trackUnifiedSwapBridgeEvent with correct parameters for bridge', () => { + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + expect( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent, + ).toHaveBeenCalledWith(UnifiedSwapBridgeEventName.AllQuotesSorted, { + can_submit: true, + price_impact: 0.05, + gas_included: false, + gas_included_7702: false, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + stx_enabled: true, + sort_order: SortOrder.COST_ASC, + best_quote_provider: 'lifi', + }); + }); + + it('includes sort_order and best_quote_provider for bridge transactions', () => { + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData).toHaveProperty('sort_order', SortOrder.COST_ASC); + expect(eventData).toHaveProperty('best_quote_provider', 'lifi'); + }); + }); + + describe('event tracking for swap transactions', () => { + it('excludes sort_order and best_quote_provider for swap transactions', () => { + mockSelectorValues.isBridge = false; // Swap transaction + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData).not.toHaveProperty('sort_order'); + expect(eventData).not.toHaveProperty('best_quote_provider'); + }); + }); + + describe('insufficient balance handling', () => { + it('sets can_submit to false when balance is insufficient', () => { + mockUseIsInsufficientBalance.mockReturnValue(true); + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.can_submit).toBe(false); + }); + + it('sets can_submit to true when balance is sufficient', () => { + mockUseIsInsufficientBalance.mockReturnValue(false); + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.can_submit).toBe(true); + }); + }); + + describe('quote data handling', () => { + it('handles missing priceData gracefully', () => { + const quoteWithoutPriceData = { + ...mockQuote, + priceData: undefined, + } as Quote; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(quoteWithoutPriceData); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.price_impact).toBe(0); + }); + + it('handles missing priceImpact in priceData', () => { + const quoteWithoutPriceImpact = { + ...mockQuote, + priceData: {}, + } as Quote; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(quoteWithoutPriceImpact); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.price_impact).toBe(0); + }); + + it('converts priceImpact string to number', () => { + const quoteWithStringPriceImpact = { + ...mockQuote, + priceData: { + priceImpact: '0.123', + }, + } as Quote; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(quoteWithStringPriceImpact); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.price_impact).toBe(0.123); + }); + }); + + describe('gas included handling', () => { + it('sets gas_included to true when quote has gasIncluded', () => { + const quoteWithGasIncluded = { + ...mockQuote, + gasIncluded: true, + } as Quote; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(quoteWithGasIncluded); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.gas_included).toBe(true); + }); + + it('sets gas_included_7702 to true when quote has gasIncluded7702', () => { + const quoteWithGasIncluded7702 = { + ...mockQuote, + gasIncluded7702: true, + } as unknown as Quote; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(quoteWithGasIncluded7702); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.gas_included_7702).toBe(true); + }); + }); + + describe('token symbol handling', () => { + it('uses sourceToken symbol when available', () => { + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.token_symbol_source).toBe('ETH'); + }); + + it('falls back to native asset symbol when sourceToken has no symbol', () => { + mockSelectorValues.sourceToken = { + ...mockSelectorValues.sourceToken, + symbol: undefined as unknown as string, + }; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.token_symbol_source).toBe('ETH'); + }); + + it('uses space string when sourceToken is undefined', () => { + mockSelectorValues.sourceToken = + undefined as unknown as typeof mockSelectorValues.sourceToken; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.token_symbol_source).toBe(' '); + }); + + it('uses destToken symbol when available', () => { + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.token_symbol_destination).toBe('USDC'); + }); + + it('uses null when destToken is undefined', () => { + mockSelectorValues.destToken = + undefined as unknown as typeof mockSelectorValues.destToken; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.token_symbol_destination).toBe(null); + }); + }); + + describe('smart transactions handling', () => { + it('sets stx_enabled to true when smart transactions are enabled', () => { + mockSelectorValues.smartTransactionsEnabled = true; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.stx_enabled).toBe(true); + }); + + it('sets stx_enabled to false when smart transactions are disabled', () => { + mockSelectorValues.smartTransactionsEnabled = false; + + const { result } = renderHook(() => + useTrackAllQuotesSortedEvent(mockLatestBalance), + ); + + result.current(mockQuote); + + const eventData = ( + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent as jest.Mock + ).mock.calls[0][1]; + + expect(eventData.stx_enabled).toBe(false); + }); + }); + + describe('latestSourceBalance parameter', () => { + it('passes latestSourceBalance to useIsInsufficientBalance', () => { + renderHook(() => useTrackAllQuotesSortedEvent(mockLatestBalance)); + + expect(mockUseIsInsufficientBalance).toHaveBeenCalledWith({ + amount: mockSelectorValues.sourceAmount, + token: mockSelectorValues.sourceToken, + latestAtomicBalance: mockLatestBalance.atomicBalance, + }); + }); + + it('handles undefined latestSourceBalance', () => { + renderHook(() => useTrackAllQuotesSortedEvent(undefined)); + + expect(mockUseIsInsufficientBalance).toHaveBeenCalledWith({ + amount: mockSelectorValues.sourceAmount, + token: mockSelectorValues.sourceToken, + latestAtomicBalance: undefined, + }); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts new file mode 100644 index 00000000000..ffb9106181f --- /dev/null +++ b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts @@ -0,0 +1,58 @@ +import { + formatProviderLabel, + getNativeAssetForChainId, + Quote, + SortOrder, + UnifiedSwapBridgeEventName, +} from '@metamask/bridge-controller'; +import Engine from '../../../../../core/Engine'; +import { useLatestBalance } from '../useLatestBalance'; +import { useSelector } from 'react-redux'; +import { + selectDestToken, + selectIsBridge, + selectSourceAmount, + selectSourceToken, +} from '../../../../../core/redux/slices/bridge'; +import useIsInsufficientBalance from '../useInsufficientBalance'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; + +export const useTrackAllQuotesSortedEvent = ( + latestSourceBalance?: ReturnType, +) => { + const sourceAmount = useSelector(selectSourceAmount); + const sourceToken = useSelector(selectSourceToken); + const destToken = useSelector(selectDestToken); + const smartTransactionsEnabled = useSelector(selectShouldUseSmartTransaction); + const isBridge = useSelector(selectIsBridge); + + const hasInsufficientBalance = useIsInsufficientBalance({ + amount: sourceAmount, + token: sourceToken, + latestAtomicBalance: latestSourceBalance?.atomicBalance, + }); + + return (quote: Quote) => { + Engine.context.BridgeController.trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.AllQuotesSorted, + { + can_submit: !hasInsufficientBalance, + price_impact: Number(quote?.priceData?.priceImpact ?? '0'), + gas_included: Boolean(quote?.gasIncluded), + // @ts-expect-error gas_included_7702 needs to be added to bridge-controller types + gas_included_7702: Boolean(quote?.gasIncluded7702), + token_symbol_source: + sourceToken?.symbol ?? + (sourceToken + ? getNativeAssetForChainId(sourceToken.chainId).symbol + : ' '), + token_symbol_destination: destToken?.symbol ?? null, + stx_enabled: smartTransactionsEnabled, + ...(isBridge && { + sort_order: SortOrder.COST_ASC, + best_quote_provider: formatProviderLabel(quote), + }), + }, + ); + }; +}; diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx index 5a6daffdd06..163f44e9d3c 100644 --- a/app/components/UI/Bridge/routes.tsx +++ b/app/components/UI/Bridge/routes.tsx @@ -11,6 +11,7 @@ import MarketClosedBottomSheet from './components/MarketClosedBottomSheets/Marke import { DefaultSlippageModal } from './components/SlippageModal/DefaultSlippageModal'; import { CustomSlippageModal } from './components/SlippageModal/CustomSlippageModal'; import NetworkListModal from './components/BridgeTokenSelector/NetworkListModal'; +import { QuoteSelectorView } from './components/QuoteSelectorView'; import { PriceImpactModal } from './components/PriceImpactModal'; const clearStackNavigatorOptions = { @@ -39,6 +40,11 @@ export const BridgeScreenStack = () => ( component={BridgeTokenSelector} options={{ title: '' }} /> + ); diff --git a/app/components/UI/Bridge/utils/formatNetworkFee.test.ts b/app/components/UI/Bridge/utils/formatNetworkFee.test.ts index 5bc13ae70d5..3994c36ed06 100644 --- a/app/components/UI/Bridge/utils/formatNetworkFee.test.ts +++ b/app/components/UI/Bridge/utils/formatNetworkFee.test.ts @@ -3,20 +3,26 @@ import { BigNumber } from 'bignumber.js'; import formatFiat from '../../../../util/formatFiat'; import { isNumberValue } from '../../../../util/number'; import { formatNetworkFee } from './formatNetworkFee'; +import { isGaslessQuote } from './isGaslessQuote'; jest.mock('../../../../util/formatFiat'); jest.mock('../../../../util/number'); +jest.mock('./isGaslessQuote'); const mockFormatFiat = formatFiat as jest.MockedFunction; const mockIsNumberValue = isNumberValue as jest.MockedFunction< typeof isNumberValue >; +const mockIsGaslessQuote = isGaslessQuote as jest.MockedFunction< + typeof isGaslessQuote +>; describe('formatNetworkFee', () => { beforeEach(() => { jest.clearAllMocks(); mockIsNumberValue.mockReset(); mockFormatFiat.mockReset(); + mockIsGaslessQuote.mockReturnValue(false); }); describe('when quote is null or undefined', () => { @@ -31,58 +37,143 @@ describe('formatNetworkFee', () => { }); }); - describe('when totalNetworkFee is missing', () => { - it('returns "-" when totalNetworkFee is undefined', () => { - const quote = {} as QuoteResponse & QuoteMetadata; + describe('gasless quotes', () => { + beforeEach(() => { + mockIsGaslessQuote.mockReturnValue(true); + }); + + it('returns formatted fiat when includedTxFees has valid amount and valueInCurrency', () => { + mockIsNumberValue.mockReturnValue(true); + mockFormatFiat.mockReturnValue('$5.00'); + + const quote = { + quote: { gasIncluded: true }, + includedTxFees: { + amount: '0.002', + valueInCurrency: '5.00', + }, + } as unknown as QuoteResponse & QuoteMetadata; + const result = formatNetworkFee('USD', quote); + + expect(mockIsGaslessQuote).toHaveBeenCalledWith(quote.quote); + expect(mockFormatFiat).toHaveBeenCalledWith(new BigNumber('5.00'), 'USD'); + expect(result).toBe('$5.00'); + }); + + it('returns "-" when includedTxFees.valueInCurrency is null', () => { + const quote = { + quote: { gasIncluded: true }, + includedTxFees: { + amount: '0.002', + valueInCurrency: null, + }, + } as unknown as QuoteResponse & QuoteMetadata; + + const result = formatNetworkFee('USD', quote); + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); }); - it('returns "-" when totalNetworkFee is null', () => { + it('returns "-" when includedTxFees.amount is null', () => { const quote = { - totalNetworkFee: null, + quote: { gasIncluded: true }, + includedTxFees: { + amount: null, + valueInCurrency: '5.00', + }, } as unknown as QuoteResponse & QuoteMetadata; + const result = formatNetworkFee('USD', quote); + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); }); - }); - describe('when totalNetworkFee properties are invalid', () => { - it('returns "-" when amount is null', () => { + it('returns "-" when includedTxFees.amount is not a valid number', () => { mockIsNumberValue.mockReturnValueOnce(false); const quote = { - totalNetworkFee: { - amount: null, - valueInCurrency: '100', + quote: { gasIncluded: true }, + includedTxFees: { + amount: 'invalid', + valueInCurrency: '5.00', }, } as unknown as QuoteResponse & QuoteMetadata; const result = formatNetworkFee('USD', quote); + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); }); - it('returns "-" when valueInCurrency is null', () => { + it('returns "-" when includedTxFees.valueInCurrency is not a valid number', () => { mockIsNumberValue.mockReturnValueOnce(true); mockIsNumberValue.mockReturnValueOnce(false); const quote = { + quote: { gasIncluded: true }, + includedTxFees: { + amount: '0.002', + valueInCurrency: 'invalid', + }, + } as unknown as QuoteResponse & QuoteMetadata; + + const result = formatNetworkFee('USD', quote); + + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); + }); + + it('returns "-" when includedTxFees is not set', () => { + const quote = { + quote: { gasIncluded: true }, + } as unknown as QuoteResponse & QuoteMetadata; + + const result = formatNetworkFee('USD', quote); + + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); + }); + + it('does not fall through to totalNetworkFee when gasless', () => { + const quote = { + quote: { gasIncluded: true }, totalNetworkFee: { amount: '0.01', - valueInCurrency: null, + valueInCurrency: '10.00', }, } as unknown as QuoteResponse & QuoteMetadata; + const result = formatNetworkFee('USD', quote); + + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); + }); + }); + + describe('non-gasless quotes — totalNetworkFee path', () => { + it('returns "-" when totalNetworkFee is undefined', () => { + const quote = {} as QuoteResponse & QuoteMetadata; + const result = formatNetworkFee('USD', quote); + expect(result).toBe('-'); + }); + + it('returns "-" when totalNetworkFee is null', () => { + const quote = { + totalNetworkFee: null, + } as unknown as QuoteResponse & QuoteMetadata; const result = formatNetworkFee('USD', quote); expect(result).toBe('-'); }); - it('returns "-" when amount is undefined', () => { + it('returns "-" when totalNetworkFee.amount is null', () => { mockIsNumberValue.mockReturnValueOnce(false); const quote = { totalNetworkFee: { - amount: undefined, + amount: null, valueInCurrency: '100', }, } as unknown as QuoteResponse & QuoteMetadata; @@ -91,14 +182,14 @@ describe('formatNetworkFee', () => { expect(result).toBe('-'); }); - it('returns "-" when valueInCurrency is undefined', () => { + it('returns "-" when totalNetworkFee.valueInCurrency is null', () => { mockIsNumberValue.mockReturnValueOnce(true); mockIsNumberValue.mockReturnValueOnce(false); const quote = { totalNetworkFee: { amount: '0.01', - valueInCurrency: undefined, + valueInCurrency: null, }, } as unknown as QuoteResponse & QuoteMetadata; @@ -106,7 +197,7 @@ describe('formatNetworkFee', () => { expect(result).toBe('-'); }); - it('returns "-" when amount is not a valid number value', () => { + it('returns "-" when totalNetworkFee.amount is not a valid number', () => { mockIsNumberValue.mockReturnValueOnce(false); const quote = { @@ -121,7 +212,7 @@ describe('formatNetworkFee', () => { expect(isNumberValue).toHaveBeenCalledWith('invalid'); }); - it('returns "-" when valueInCurrency is not a valid number value', () => { + it('returns "-" when totalNetworkFee.valueInCurrency is not a valid number', () => { mockIsNumberValue.mockReturnValueOnce(true); mockIsNumberValue.mockReturnValueOnce(false); @@ -137,10 +228,8 @@ describe('formatNetworkFee', () => { expect(isNumberValue).toHaveBeenCalledWith('0.01'); expect(isNumberValue).toHaveBeenCalledWith('invalid'); }); - }); - describe('when totalNetworkFee is valid', () => { - it('formats the network fee correctly with USD', () => { + it('formats fee with USD currency', () => { mockIsNumberValue.mockImplementation( (value) => value === '0.01' || value === '10.50', ); @@ -161,7 +250,7 @@ describe('formatNetworkFee', () => { expect(result).toBe('$10.50'); }); - it('formats the network fee correctly with EUR', () => { + it('formats fee with EUR currency', () => { mockIsNumberValue.mockImplementation( (value) => value === '0.02' || value === '25.00', ); @@ -180,120 +269,148 @@ describe('formatNetworkFee', () => { expect(result).toBe('€25.00'); }); - it('formats the network fee correctly with GBP', () => { + it('handles small network fees', () => { mockIsNumberValue.mockImplementation( - (value) => value === '0.005' || value === '5.25', + (value) => value === '0.000001' || value === '0.005', ); - mockFormatFiat.mockReturnValue('£5.25'); + mockFormatFiat.mockReturnValue('<$0.01'); const quote = { totalNetworkFee: { - amount: '0.005', - valueInCurrency: '5.25', + amount: '0.000001', + valueInCurrency: '0.005', }, } as unknown as QuoteResponse & QuoteMetadata; - const result = formatNetworkFee('GBP', quote); + const result = formatNetworkFee('USD', quote); - expect(formatFiat).toHaveBeenCalledWith(new BigNumber('5.25'), 'GBP'); - expect(result).toBe('£5.25'); + expect(formatFiat).toHaveBeenCalledWith(new BigNumber('0.005'), 'USD'); + expect(result).toBe('<$0.01'); }); - it('handles small network fees', () => { - mockIsNumberValue.mockImplementation( - (value) => value === '0.000001' || value === '0.005', - ); - mockFormatFiat.mockReturnValue('<$0.01'); + it('handles zero network fee', () => { + mockIsNumberValue.mockImplementation((value) => value === '0'); + mockFormatFiat.mockReturnValue('$0'); const quote = { totalNetworkFee: { - amount: '0.000001', - valueInCurrency: '0.005', + amount: '0', + valueInCurrency: '0', }, } as unknown as QuoteResponse & QuoteMetadata; const result = formatNetworkFee('USD', quote); - expect(formatFiat).toHaveBeenCalledWith(new BigNumber('0.005'), 'USD'); - expect(result).toBe('<$0.01'); + expect(formatFiat).toHaveBeenCalledWith(new BigNumber('0'), 'USD'); + expect(result).toBe('$0'); }); + }); - it('handles large network fees', () => { + describe('non-gasless quotes — gasFee.effective fallback path', () => { + it('returns formatted fiat from gasFee.effective when totalNetworkFee is missing', () => { mockIsNumberValue.mockImplementation( - (value) => value === '1.5' || value === '1234.56', + (value) => value === '0.005' || value === '8.00', ); - mockFormatFiat.mockReturnValue('$1,234.56'); + mockFormatFiat.mockReturnValue('$8.00'); const quote = { - totalNetworkFee: { - amount: '1.5', - valueInCurrency: '1234.56', + gasFee: { + effective: { + amount: '0.005', + valueInCurrency: '8.00', + }, }, } as unknown as QuoteResponse & QuoteMetadata; const result = formatNetworkFee('USD', quote); - expect(formatFiat).toHaveBeenCalledWith(new BigNumber('1234.56'), 'USD'); - expect(result).toBe('$1,234.56'); + expect(formatFiat).toHaveBeenCalledWith(new BigNumber('8.00'), 'USD'); + expect(result).toBe('$8.00'); }); - it('handles zero network fee', () => { - mockIsNumberValue.mockImplementation((value) => value === '0'); - mockFormatFiat.mockReturnValue('$0'); + it('returns formatted fiat from gasFee.effective when totalNetworkFee values are invalid', () => { + mockIsNumberValue + .mockReturnValueOnce(false) // totalNetworkFee.amount invalid + .mockReturnValueOnce(true) // gasFee.effective.amount + .mockReturnValueOnce(true); // gasFee.effective.valueInCurrency + mockFormatFiat.mockReturnValue('$3.50'); const quote = { totalNetworkFee: { - amount: '0', - valueInCurrency: '0', + amount: 'invalid', + valueInCurrency: '10.00', + }, + gasFee: { + effective: { + amount: '0.002', + valueInCurrency: '3.50', + }, }, } as unknown as QuoteResponse & QuoteMetadata; const result = formatNetworkFee('USD', quote); - expect(formatFiat).toHaveBeenCalledWith(new BigNumber('0'), 'USD'); - expect(result).toBe('$0'); + expect(formatFiat).toHaveBeenCalledWith(new BigNumber('3.50'), 'USD'); + expect(result).toBe('$3.50'); }); - }); - describe('edge cases', () => { - it('passes the currency parameter correctly to formatFiat', () => { - mockIsNumberValue.mockImplementation( - (value) => value === '1' || value === '100', - ); - mockFormatFiat.mockReturnValue('100 XYZ'); + it('returns "-" when gasFee.effective.valueInCurrency is null', () => { + const quote = { + gasFee: { + effective: { + amount: '0.002', + valueInCurrency: null, + }, + }, + } as unknown as QuoteResponse & QuoteMetadata; + + const result = formatNetworkFee('USD', quote); + + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); + }); + it('returns "-" when gasFee.effective.amount is null', () => { const quote = { - totalNetworkFee: { - amount: '1', - valueInCurrency: '100', + gasFee: { + effective: { + amount: null, + valueInCurrency: '8.00', + }, }, } as unknown as QuoteResponse & QuoteMetadata; - formatNetworkFee('XYZ', quote); + const result = formatNetworkFee('USD', quote); - expect(formatFiat).toHaveBeenCalledWith(new BigNumber('100'), 'XYZ'); + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); }); - it('creates a new BigNumber instance with the valueInCurrency', () => { - mockIsNumberValue.mockImplementation( - (value) => value === '0.05' || value === '42.99', - ); - mockFormatFiat.mockImplementation((value) => { - expect(value).toBeInstanceOf(BigNumber); - expect(value.toString()).toBe('42.99'); - return '$42.99'; - }); + it('returns "-" when gasFee.effective has invalid number values', () => { + mockIsNumberValue.mockReturnValue(false); const quote = { - totalNetworkFee: { - amount: '0.05', - valueInCurrency: '42.99', + gasFee: { + effective: { + amount: 'bad', + valueInCurrency: 'bad', + }, }, } as unknown as QuoteResponse & QuoteMetadata; - formatNetworkFee('USD', quote); + const result = formatNetworkFee('USD', quote); + + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); + }); + + it('returns "-" when neither totalNetworkFee nor gasFee.effective is available', () => { + const quote = {} as QuoteResponse & QuoteMetadata; + + const result = formatNetworkFee('USD', quote); - expect(formatFiat).toHaveBeenCalled(); + expect(result).toBe('-'); + expect(mockFormatFiat).not.toHaveBeenCalled(); }); }); }); diff --git a/app/components/UI/Bridge/utils/formatNetworkFee.ts b/app/components/UI/Bridge/utils/formatNetworkFee.ts index ab55146cd94..283ab86bed0 100644 --- a/app/components/UI/Bridge/utils/formatNetworkFee.ts +++ b/app/components/UI/Bridge/utils/formatNetworkFee.ts @@ -2,30 +2,55 @@ import { QuoteMetadata, QuoteResponse } from '@metamask/bridge-controller'; import { isNumberValue } from '../../../../util/number'; import formatFiat from '../../../../util/formatFiat'; import { BigNumber } from 'bignumber.js'; +import { isGaslessQuote } from './isGaslessQuote'; export const formatNetworkFee = ( currency: string, quote?: (QuoteResponse & QuoteMetadata) | null, ) => { - if (!quote?.totalNetworkFee) return '-'; - - const { totalNetworkFee } = quote; - - const { amount, valueInCurrency } = totalNetworkFee; + if (!quote) return '-'; if ( - amount == null || - valueInCurrency == null || - !isNumberValue(amount) || - !isNumberValue(valueInCurrency) + isGaslessQuote(quote.quote) && + quote.includedTxFees?.valueInCurrency != null && + quote.includedTxFees?.amount != null && + isNumberValue(quote.includedTxFees.amount) && + isNumberValue(quote.includedTxFees.valueInCurrency) ) { + return formatFiat( + new BigNumber(quote.includedTxFees.valueInCurrency), + currency, + ); + } else if (isGaslessQuote(quote.quote)) { + // Quote is gasless but includedTxFees is not set. + // Return "uknown" gas fee to keep the same behavior + // as the previous vesrions of this utility. return '-'; } - const formattedValueInCurrency = formatFiat( - new BigNumber(valueInCurrency), - currency, - ); + if ( + quote.totalNetworkFee?.valueInCurrency != null && + quote.totalNetworkFee?.amount != null && + isNumberValue(quote.totalNetworkFee.amount) && + isNumberValue(quote.totalNetworkFee.valueInCurrency) + ) { + return formatFiat( + new BigNumber(quote.totalNetworkFee.valueInCurrency), + currency, + ); + } + + if ( + quote.gasFee?.effective?.valueInCurrency != null && + quote.gasFee?.effective?.amount != null && + isNumberValue(quote.gasFee.effective.amount) && + isNumberValue(quote.gasFee.effective.valueInCurrency) + ) { + return formatFiat( + new BigNumber(quote.gasFee.effective.valueInCurrency), + currency, + ); + } - return formattedValueInCurrency; + return '-'; }; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index c0d33dcd3cc..2c9b0efc8e8 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -65,7 +65,7 @@ import { import { useOpenSwaps } from '../../hooks/useOpenSwaps'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { DEPOSIT_SUPPORTED_TOKENS, SPENDING_LIMIT_UNSUPPORTED_TOKENS, diff --git a/app/components/UI/Card/components/SpendingLimitProgressBar/SpendingLimitProgressBar.tsx b/app/components/UI/Card/components/SpendingLimitProgressBar/SpendingLimitProgressBar.tsx index 944ca769855..9cb24135458 100644 --- a/app/components/UI/Card/components/SpendingLimitProgressBar/SpendingLimitProgressBar.tsx +++ b/app/components/UI/Card/components/SpendingLimitProgressBar/SpendingLimitProgressBar.tsx @@ -7,7 +7,7 @@ import Text, { import createStyles from './SpendingLimitProgressBar.styles'; import { useTheme } from '../../../../../util/theme'; import ProgressBar from 'react-native-progress/Bar'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { CardHomeSelectors } from '../../Views/CardHome/CardHome.testIds'; interface SpendingLimitProgressBarProps { diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx index e02d584884e..2d2b9e1659c 100644 --- a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx @@ -103,13 +103,12 @@ jest.mock('./PerpsAdjustMarginView.styles', () => ({ }), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - icon: { alternative: '#888' }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key) => key), diff --git a/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.test.tsx b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.test.tsx index fd26d927a81..9da2c1efae7 100644 --- a/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.test.tsx @@ -22,16 +22,12 @@ jest.mock('../../hooks/usePerpsToasts', () => ({ default: jest.fn(() => ({ showToast: jest.fn() })), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - accent03: { normal: '#00ff00', dark: '#008800' }, - accent01: { light: '#ffcccc', dark: '#cc0000' }, - primary: { default: '#0000ff' }, - background: { default: '#ffffff' }, - }, - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock('../../hooks/usePerpsEventTracking', () => ({ usePerpsEventTracking: jest.fn(), diff --git a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx index fcbe9008403..89807a5f46c 100644 --- a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx @@ -34,16 +34,12 @@ jest.mock('../../hooks/usePerpsToasts', () => ({ default: jest.fn(() => ({ showToast: jest.fn() })), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - accent03: { normal: '#00ff00', dark: '#008800' }, - accent01: { light: '#ffcccc', dark: '#cc0000' }, - primary: { default: '#0000ff' }, - background: { default: '#ffffff' }, - }, - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock('../../hooks/usePerpsEventTracking', () => ({ usePerpsEventTracking: jest.fn(), diff --git a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx index 3b1250cf732..f8260921051 100644 --- a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx @@ -24,24 +24,12 @@ const mockGoBack = jest.fn(); const mockShowToast = jest.fn(); const mockTrack = jest.fn(); -jest.mock('../../../../../util/theme', () => ({ - useAppThemeFromContext: jest.fn(() => ({ - colors: { - text: { default: '#000000', alternative: '#000000' }, - primary: { inverse: '#FFFFFF', default: '#037DD6' }, - background: { default: '#FFFFFF', alternative: '#F2F4F6' }, - border: { default: '#BBC0C5', muted: '#D6D9DC' }, - icon: { default: '#24272A', alternative: '#6A737D' }, - overlay: { default: '#00000099' }, - shadow: { default: '#00000026' }, - error: { default: '#D73A49', muted: '#F97583' }, - warning: { default: '#F66A0A', muted: '#F8AA4B' }, - success: { default: '#28A745', muted: '#85E29D' }, - info: { default: '#037DD6', muted: '#66CAFF' }, - }, - themeAppearance: 'light', - })), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useAppThemeFromContext: jest.fn(() => mockTheme), + }; +}); jest.mock('@react-navigation/native'); jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: ({ children }: { children: React.ReactNode }) => children, @@ -96,71 +84,15 @@ jest.mock('../../../Rewards/hooks/useReferralDetails', () => ({ jest.mock('../../../Rewards/hooks/useSeasonStatus', () => ({ useSeasonStatus: jest.fn(), })); -jest.mock('@metamask/design-tokens', () => ({ - brandColor: { - black: '#000000', - white: '#FFFFFF', - }, - darkTheme: { - colors: { - background: { - mutedHover: '#color1', - }, - accent04: { - light: '#color2', - }, - }, - }, -})); -jest.mock('../../../../../component-library/hooks', () => ({ - useStyles: jest.fn(() => ({ - styles: { - safeAreaContainer: {}, - header: {}, - closeButton: {}, - headerTitle: {}, - carouselWrapper: {}, - carousel: {}, - cardContainer: {}, - backgroundImage: {}, - heroCardTopRow: {}, - metamaskLogo: {}, - heroCardAssetRow: {}, - assetIcon: {}, - assetName: {}, - directionBadge: {}, - directionBadgeText: {}, - pnlText: {}, - pnlPositive: {}, - pnlNegative: {}, - priceRowsContainer: {}, - priceRow: {}, - priceLabel: {}, - priceValue: {}, - qrCodeContainer: {}, - carouselDotIndicator: {}, - progressDot: {}, - progressDotActive: {}, - footerButtonContainer: {}, - }, - theme: { - colors: { - text: { default: '#000000', alternative: '#000000' }, - primary: { inverse: '#FFFFFF', default: '#037DD6' }, - background: { default: '#FFFFFF', alternative: '#F2F4F6' }, - border: { default: '#BBC0C5', muted: '#D6D9DC' }, - icon: { default: '#24272A', alternative: '#6A737D' }, - overlay: { default: '#00000099' }, - shadow: { default: '#00000026' }, - error: { default: '#D73A49', muted: '#F97583' }, - warning: { default: '#F66A0A', muted: '#F8AA4B' }, - success: { default: '#28A745', muted: '#85E29D' }, - info: { default: '#037DD6', muted: '#66CAFF' }, - }, - themeAppearance: 'light', - }, - })), -})); +jest.mock('../../../../../component-library/hooks', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useStyles: jest.fn((styleFn, vars) => ({ + styles: styleFn({ theme: mockTheme, vars }), + theme: mockTheme, + })), + }; +}); jest.mock('../../components/PerpsTokenLogo', () => 'PerpsTokenLogo'); jest.mock( '../../../Rewards/components/RewardsReferralCodeTag', diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx index 87b5af4f31c..e89f77d9c63 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -3,6 +3,7 @@ import { render, fireEvent } from '@testing-library/react-native'; import PerpsHomeView from './PerpsHomeView'; import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; import { selectPerpsFeedbackEnabledFlag } from '../../selectors/featureFlags'; +import { mockTheme } from '../../../../../util/theme'; // Mock navigation const mockNavigate = jest.fn(); @@ -193,12 +194,7 @@ jest.mock('../../../../../component-library/hooks', () => ({ bottomSpacer: {}, tabBarContainer: {}, }, - theme: { - colors: { - primary: { default: '#0000ff' }, - icon: { default: '#000000' }, - }, - }, + theme: mockTheme, }), })); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 9e7facc3996..558dbd0a670 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -8,6 +8,7 @@ import { PerpsOrderViewSelectorsIDs, } from '../../Perps.testIds'; import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; +import { useDefaultPayWithTokenWhenNoPerpsBalance } from '../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance'; import { Linking } from 'react-native'; // Mock Linking @@ -95,6 +96,8 @@ jest.mock('../../../../../util/Logger', () => ({ const mockUsePerpsAccount = jest.fn(); const mockUsePerpsLiveAccount = jest.fn(); const mockUseHasExistingPosition = jest.fn(); +const mockNavigateToConfirmation = jest.fn(); +const mockDepositWithConfirmation = jest.fn(() => Promise.resolve()); const mockUsePerpsLiveOrders = jest.fn(); const mockUsePerpsLivePrices = jest.fn(); @@ -103,6 +106,21 @@ jest.mock('../../hooks/stream/usePerpsLiveAccount', () => ({ usePerpsLiveAccount: mockUsePerpsLiveAccount, })); +jest.mock('../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance', () => ({ + useDefaultPayWithTokenWhenNoPerpsBalance: jest.fn(() => null), +})); + +const mockUseDefaultPayWithTokenWhenNoPerpsBalance = + useDefaultPayWithTokenWhenNoPerpsBalance as jest.MockedFunction< + typeof useDefaultPayWithTokenWhenNoPerpsBalance + >; + +jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ + useConfirmNavigation: () => ({ + navigateToConfirmation: mockNavigateToConfirmation, + }), +})); + // Mock usePerpsMarketFills to avoid Redux selector issues jest.mock('../../hooks/usePerpsMarketFills', () => ({ usePerpsMarketFills: jest.fn(() => ({ @@ -425,7 +443,7 @@ jest.mock('../../hooks', () => ({ placeOrder: jest.fn(), cancelOrder: jest.fn(), getAccountState: jest.fn(), - depositWithConfirmation: jest.fn(() => Promise.resolve()), + depositWithConfirmation: mockDepositWithConfirmation, withdrawWithConfirmation: jest.fn(), })), usePerpsNetworkManagement: jest.fn(() => ({ @@ -681,6 +699,8 @@ describe('PerpsMarketDetailsView', () => { isInitialLoading: false, }); + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUseHasExistingPosition.mockReturnValue({ hasPosition: false, isLoading: false, @@ -884,7 +904,13 @@ describe('PerpsMarketDetailsView', () => { describe('Button rendering scenarios', () => { it('shows long/short buttons when user balance is zero so user can trade', () => { - // Override with zero balance + // Override with zero balance; return a default pay token so Add funds CTA is not shown + // (when user has allowlist token they can pay with, we show Long/Short) + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue({ + address: '0xUSDC' as const, + chainId: '0xa4b1' as const, + description: 'USDC', + }); mockUsePerpsAccount.mockReturnValue({ account: { availableBalance: '0.00', @@ -930,6 +956,137 @@ describe('PerpsMarketDetailsView', () => { ).toBeNull(); }); + it('shows add funds CTA when user balance is below threshold and no allowlist token', () => { + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUsePerpsAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '0.00', + }, + isInitialLoading: false, + }); + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '0', + }, + isInitialLoading: false, + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + expect( + getByTestId(PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON), + ).toBeTruthy(); + expect( + queryByTestId(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON), + ).toBeNull(); + expect( + queryByTestId(PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON), + ).toBeNull(); + }); + + it('calls navigateToConfirmation and depositWithConfirmation when add funds is pressed', async () => { + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUsePerpsAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '0.00', + }, + isInitialLoading: false, + }); + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '0', + }, + isInitialLoading: false, + }); + mockNavigateToConfirmation.mockClear(); + mockDepositWithConfirmation.mockClear(); + mockDepositWithConfirmation.mockResolvedValue(undefined); + + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + const addFundsButton = getByTestId( + PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON, + ); + await act(async () => { + fireEvent.press(addFundsButton); + }); + + expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ + stack: 'Perps', + }); + expect(mockDepositWithConfirmation).toHaveBeenCalled(); + }); + + it('handles depositWithConfirmation rejection without throwing', async () => { + mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); + mockUsePerpsAccount.mockReturnValue({ + account: { + availableBalance: '0.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '0.00', + }, + isInitialLoading: false, + }); + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + availableBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '0', + }, + isInitialLoading: false, + }); + mockDepositWithConfirmation.mockRejectedValueOnce( + new Error('Deposit failed'), + ); + + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + const addFundsButton = getByTestId( + PerpsMarketDetailsViewSelectorsIDs.ADD_FUNDS_BUTTON, + ); + await act(async () => { + fireEvent.press(addFundsButton); + }); + await waitFor(() => { + expect(mockDepositWithConfirmation).toHaveBeenCalled(); + }); + }); + it('renders modify/close buttons when user has balance and existing position', () => { // Override with non-zero balance and existing position mockUsePerpsAccount.mockReturnValue({ diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 27392b8c326..0f79b08c7ea 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -78,12 +78,20 @@ import TradingViewChart, { type TradingViewChartRef, } from '../../components/TradingViewChart'; import { PERPS_CHART_CONFIG } from '../../constants/chartConfig'; +import { PERPS_MIN_BALANCE_THRESHOLD } from '../../constants/perpsConfig'; import { usePerpsConnection, usePerpsNavigation, usePositionManagement, + usePerpsTrading, } from '../../hooks'; -import { usePerpsLiveOrders, usePerpsLivePrices } from '../../hooks/stream'; +import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useConfirmNavigation'; +import { useDefaultPayWithTokenWhenNoPerpsBalance } from '../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance'; +import { + usePerpsLiveAccount, + usePerpsLiveOrders, + usePerpsLivePrices, +} from '../../hooks/stream'; import { usePerpsLiveCandles } from '../../hooks/stream/usePerpsLiveCandles'; import { useHasExistingPosition } from '../../hooks/useHasExistingPosition'; import { useIsPriceDeviatedAboveThreshold } from '../../hooks/useIsPriceDeviatedAboveThreshold'; @@ -389,6 +397,44 @@ const PerpsMarketDetailsView: React.FC = () => { loadOnMount: true, }); + const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount(); + const defaultPayTokenWhenNoPerpsBalance = + useDefaultPayWithTokenWhenNoPerpsBalance(); + const { depositWithConfirmation } = usePerpsTrading(); + const { navigateToConfirmation } = useConfirmNavigation(); + const availableBalance = Number.parseFloat( + account?.availableBalance?.toString() ?? '0', + ); + const showAddFundsCTA = + isEligible && + !isLoadingPosition && + !existingPosition && + !isAtOICap && + !isLoadingAccount && + availableBalance < PERPS_MIN_BALANCE_THRESHOLD && + defaultPayTokenWhenNoPerpsBalance === null; + + const handleAddFunds = useCallback(async () => { + if (!isEligible) { + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.ADD_FUNDS_ACTION, + }); + setIsEligibilityModalVisible(true); + return; + } + navigateToConfirmation({ stack: Routes.PERPS.ROOT }); + try { + await depositWithConfirmation(); + } catch (err) { + Logger.error(ensureError(err, 'PerpsMarketDetailsView.handleAddFunds'), { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + }); + } + }, [isEligible, track, navigateToConfirmation, depositWithConfirmation]); + // Keep current position ref in sync for callbacks stored in route params // This must be after useHasExistingPosition since it depends on existingPosition useEffect(() => { @@ -1031,6 +1077,13 @@ const PerpsMarketDetailsView: React.FC = () => { ); } + const shouldShowNewPositionActions = + hasLongShortButtons && !existingPosition && !isAtOICap; + const shouldShowAddFundsCTASection = + shouldShowNewPositionActions && showAddFundsCTA; + const shouldShowLongShortButtonsOnly = + shouldShowNewPositionActions && !showAddFundsCTA; + return ( = () => { )} - {/* Show Long/Short buttons when no position exists */} - {hasLongShortButtons && !existingPosition && !isAtOICap && ( + {/* Show Add funds CTA when no perps balance and no allowlist token to preselect */} + {shouldShowAddFundsCTASection && ( + + +