diff --git a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts index cab9c1ef187c..fe774135b87b 100644 --- a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts +++ b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts @@ -1,7 +1,7 @@ +import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; import { RootState } from '../../../../../reducers'; -import { selectIsGaslessSwapEnabled } from '../../../../../core/redux/slices/bridge'; import { BridgeToken } from '../../types'; import { useTokenAddress } from '../useTokenAddress'; import { @@ -10,14 +10,23 @@ import { isNonEvmChainId, } from '@metamask/bridge-controller'; import { BigNumber } from 'bignumber.js'; +import { useIsSendBundleSupported } from '../useIsSendBundleSupported'; export const useShouldRenderMaxOption = ( token?: BridgeToken, displayBalance?: string, isQuoteSponsored = false, ) => { - const isGaslessSwapEnabled = useSelector((state: RootState) => - token?.chainId ? selectIsGaslessSwapEnabled(state, token.chainId) : false, + const evmChainId = useMemo(() => { + if (!token?.chainId || isNonEvmChainId(token.chainId)) { + return undefined; + } + return formatChainIdToHex(token.chainId); + }, [token?.chainId]); + const isSendBundleSupported = useIsSendBundleSupported(evmChainId); + const isGaslessSwapEnabled = useMemo( + () => Boolean(isSendBundleSupported), + [isSendBundleSupported], ); const stxEnabled = useSelector((state: RootState) => token?.chainId && !isNonEvmChainId(token.chainId) diff --git a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts index 68d0c3cd7474..04fd28b2ad42 100644 --- a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts +++ b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts @@ -1,12 +1,16 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useShouldRenderMaxOption } from '.'; -import { BridgeToken } from '../../types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { + formatChainIdToHex, + isNativeAddress, + isNonEvmChainId, +} from '@metamask/bridge-controller'; import { useSelector } from 'react-redux'; +import { useShouldRenderMaxOption } from '.'; +import { BridgeToken } from '../../types'; import { useTokenAddress } from '../useTokenAddress'; -import { isNativeAddress } from '@metamask/bridge-controller'; +import { useIsSendBundleSupported } from '../useIsSendBundleSupported'; -// Mock dependencies jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); @@ -15,580 +19,189 @@ jest.mock('../useTokenAddress', () => ({ useTokenAddress: jest.fn(), })); -jest.mock('@metamask/bridge-controller', () => ({ - isNativeAddress: jest.fn(), +jest.mock('../useIsSendBundleSupported', () => ({ + useIsSendBundleSupported: jest.fn(), })); -jest.mock('../../../../../core/redux/slices/bridge', () => ({ - selectIsGaslessSwapEnabled: jest.fn(), -})); - -jest.mock('../../../../../selectors/smartTransactionsController', () => ({ - selectShouldUseSmartTransaction: jest.fn(), -})); +jest.mock('@metamask/bridge-controller', () => { + const actual = jest.requireActual('@metamask/bridge-controller'); + return { + ...actual, + formatChainIdToHex: jest.fn(), + isNativeAddress: jest.fn(), + isNonEvmChainId: jest.fn(), + }; +}); const mockUseSelector = useSelector as jest.MockedFunction; const mockUseTokenAddress = useTokenAddress as jest.MockedFunction< typeof useTokenAddress >; +const mockUseIsSendBundleSupported = + useIsSendBundleSupported as jest.MockedFunction< + typeof useIsSendBundleSupported + >; +const mockFormatChainIdToHex = formatChainIdToHex as jest.MockedFunction< + typeof formatChainIdToHex +>; const mockIsNativeAddress = isNativeAddress as jest.MockedFunction< typeof isNativeAddress >; +const mockIsNonEvmChainId = isNonEvmChainId as jest.MockedFunction< + typeof isNonEvmChainId +>; -/** - * IMPORTANT: useSelector call order in the hook: - * 1. First call: isGaslessSwapEnabled (line 12 in hook) - * 2. Second call: stxEnabled (line 15 in hook) - */ -describe('useShouldRenderMaxOption', () => { - const mockToken: BridgeToken = { - address: '0x1234567890123456789012345678901234567890', - symbol: 'TEST', - decimals: 18, - chainId: CHAIN_IDS.MAINNET, - }; +const mockToken: BridgeToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + decimals: 18, + chainId: CHAIN_IDS.MAINNET, +}; - const nativeToken: BridgeToken = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'ETH', - decimals: 18, - chainId: CHAIN_IDS.MAINNET, - }; +const nativeToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + decimals: 18, + chainId: CHAIN_IDS.MAINNET, +}; +const setSelectorValues = ({ stxEnabled = true }: { stxEnabled?: boolean }) => { + mockUseSelector.mockImplementation(() => stxEnabled); +}; + +describe('useShouldRenderMaxOption', () => { beforeEach(() => { jest.clearAllMocks(); + + setSelectorValues({ stxEnabled: true }); mockUseTokenAddress.mockReturnValue(mockToken.address); + mockUseIsSendBundleSupported.mockReturnValue(false); + mockFormatChainIdToHex.mockImplementation( + (chainId) => chainId as `0x${string}`, + ); mockIsNativeAddress.mockReturnValue(false); - // Default: isGaslessSwapEnabled = false, stxEnabled = true - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); + mockIsNonEvmChainId.mockReturnValue(false); }); - describe('Zero balance scenarios', () => { - it('returns false when displayBalance is undefined', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, undefined, false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when displayBalance is "0"', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0', false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when displayBalance is "0.0"', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.0', false), - ); + it('returns false when token is undefined', () => { + const { result } = renderHook(() => + useShouldRenderMaxOption(undefined, '10'), + ); - expect(result.current).toBe(false); - }); - - it('returns false when displayBalance is empty string', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '', false), - ); - - expect(result.current).toBe(false); - }); + expect(result.current).toBe(false); }); - describe('Non-native token scenarios', () => { - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - }); - - it('returns true for non-native token with balance regardless of gasless', () => { - mockUseSelector.mockImplementation(() => false); // Both selectors false - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '100.5', false), - ); - - expect(result.current).toBe(true); - }); - - it('returns true for non-native token with balance regardless of stxEnabled', () => { - mockUseSelector.mockImplementation(() => false); // stxEnabled = false - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '50', false), - ); + it('returns false when display balance is zero', () => { + const { result } = renderHook(() => + useShouldRenderMaxOption(mockToken, '0'), + ); - expect(result.current).toBe(true); - }); - - it('returns true for non-native token with balance regardless of isQuoteSponsored', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '25.75', true), - ); - - expect(result.current).toBe(true); - }); - - it('returns true for non-native token with very small balance', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.000001', false), - ); - - expect(result.current).toBe(true); - }); - - it('returns false for non-native token with zero balance', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0', false), - ); - - expect(result.current).toBe(false); - }); + expect(result.current).toBe(false); }); - describe('Native token scenarios', () => { - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - }); - - it('returns false when native token has zero balance', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '0', false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when native token has zero balance even with all conditions favorable', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - mockUseSelector.mockReturnValue(true); // stxEnabled=true, gasless=true + it('returns true for non-native token with positive balance', () => { + mockIsNativeAddress.mockReturnValue(false); - const { result } = renderHook( - () => useShouldRenderMaxOption(nativeToken, '0', true), // sponsored=true - ); + const { result } = renderHook(() => + useShouldRenderMaxOption(mockToken, '10'), + ); - // Zero balance always returns false - expect(result.current).toBe(false); - }); + expect(result.current).toBe(true); }); - describe('Edge cases', () => { - it('returns false when token is undefined', () => { - mockUseTokenAddress.mockReturnValue(undefined); - - const { result } = renderHook(() => - useShouldRenderMaxOption(undefined, '100', false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when token is undefined but has balance', () => { - mockUseTokenAddress.mockReturnValue(undefined); - mockIsNativeAddress.mockReturnValue(false); - - const { result } = renderHook(() => - useShouldRenderMaxOption(undefined, '500', false), - ); + it('returns true for native token when stx and sendBundle are enabled', () => { + setSelectorValues({ stxEnabled: true }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(true); - expect(result.current).toBe(false); - }); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25'), + ); - it('handles large balance values correctly for non-native tokens', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '1000000.123456789', false), - ); - - expect(result.current).toBe(true); - }); - - it('handles very small but non-zero balance for non-native tokens', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.000000001', false), - ); - - expect(result.current).toBe(true); - }); - - it('correctly identifies native token without chainId in token object', () => { - const tokenWithoutChainId = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'ETH', - decimals: 18, - } as BridgeToken; + expect(result.current).toBe(true); + }); - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(tokenWithoutChainId.address); - mockUseSelector.mockReturnValue(false); // stxEnabled = false, isGaslessSwapEnabled = false + it('returns false for native token when sendBundle is disabled', () => { + setSelectorValues({ stxEnabled: true }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(false); - const { result } = renderHook(() => - useShouldRenderMaxOption(tokenWithoutChainId, '100', false), - ); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25'), + ); - // Should return false (native + no gasless + no sponsored + no stx) - expect(result.current).toBe(false); - }); + expect(result.current).toBe(false); }); - describe('Hook parameter validation', () => { - it('uses useTokenAddress hook to get token address', () => { - const customToken = { - ...mockToken, - address: '0xabcdef1234567890abcdef1234567890abcdef12', - }; - mockUseTokenAddress.mockReturnValue(customToken.address); + it('returns false for native token when stx is disabled even if sendBundle is enabled', () => { + setSelectorValues({ stxEnabled: false }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(true); - renderHook(() => useShouldRenderMaxOption(customToken, '100', false)); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25'), + ); - expect(mockUseTokenAddress).toHaveBeenCalledWith(customToken); - }); + expect(result.current).toBe(false); + }); - it('checks if token address is native using isNativeAddress', () => { - const tokenAddress = '0x1234567890123456789012345678901234567890'; - mockUseTokenAddress.mockReturnValue(tokenAddress); - mockIsNativeAddress.mockReturnValue(false); + it('returns true for sponsored native quote when stx is enabled', () => { + setSelectorValues({ stxEnabled: true }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(false); - renderHook(() => useShouldRenderMaxOption(mockToken, '100', false)); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25', true), + ); - expect(mockIsNativeAddress).toHaveBeenCalledWith(tokenAddress); - }); + expect(result.current).toBe(true); + }); - it('calls selectIsGaslessSwapEnabled with correct chainId', () => { - const tokenWithChainId = { - ...mockToken, - chainId: '0xa' as `0x${string}`, // Optimism - }; - mockUseSelector.mockReturnValue(true); + it('returns false for sponsored native quote when stx is disabled', () => { + setSelectorValues({ stxEnabled: false }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); - renderHook(() => - useShouldRenderMaxOption(tokenWithChainId, '100', false), - ); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25', true), + ); - // Verify useSelector was called (it uses selectIsGaslessSwapEnabled) - expect(mockUseSelector).toHaveBeenCalled(); - }); + expect(result.current).toBe(false); }); - describe('Default parameter values', () => { - it('uses false as default for isQuoteSponsored when not provided', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); - - // Call without isQuoteSponsored parameter - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '100'), - ); - - // Should return false (native + stx=true + gasless=false + sponsored=false) - expect(result.current).toBe(false); - }); - }); + it('passes formatted EVM chain id to sendBundle hook', () => { + const chainId = '0xa' as `0x${string}`; + const formattedChainId = '0xa' as `0x${string}`; + const token = { ...nativeToken, chainId }; + mockUseTokenAddress.mockReturnValue(token.address); + mockIsNativeAddress.mockReturnValue(true); + mockFormatChainIdToHex.mockReturnValue(formattedChainId); - describe('Integration scenarios', () => { - it('returns correct value for typical non-native ERC20 token', () => { - const usdcToken: BridgeToken = { - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - symbol: 'USDC', - decimals: 6, - chainId: CHAIN_IDS.MAINNET, - }; - - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(usdcToken.address); - mockUseSelector.mockReturnValue(false); // Everything disabled - - const { result } = renderHook(() => - useShouldRenderMaxOption(usdcToken, '1000', false), - ); - - // Non-native tokens always show max (even with everything disabled) - expect(result.current).toBe(true); - }); - - it('returns correct value for native ETH with gasless swap enabled', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - mockUseSelector.mockReturnValue(true); // stxEnabled=true, gasless=true - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '5.5', false), - ); - - expect(result.current).toBe(true); - }); - - it('returns correct value for native ETH with sponsored quote', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '2.25', true), - ); - - expect(result.current).toBe(true); - }); - - it('returns correct value for native ETH without gasless or sponsored but stx enabled', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '10', false), - ); - - // Should be false (native + stx=true + gasless=false + sponsored=false) - expect(result.current).toBe(false); - }); - }); + renderHook(() => useShouldRenderMaxOption(token, '1.25')); - describe('Boundary conditions', () => { - it('handles balance exactly equal to zero', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.00000', false), - ); - - expect(result.current).toBe(false); - }); - - it('handles extremely small but non-zero balance', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.00000001', false), - ); - - expect(result.current).toBe(true); - }); - - it('handles extremely large balance', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption( - mockToken, - '999999999999999999999999999.999999', - false, - ), - ); - - expect(result.current).toBe(true); - }); - - it('handles balance with many decimal places', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption( - mockToken, - '123.456789012345678901234567', - false, - ), - ); - - expect(result.current).toBe(true); - }); + expect(mockUseIsSendBundleSupported).toHaveBeenCalledWith(formattedChainId); }); - describe('Truth table - Native token with all combinations', () => { - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - }); - - const truthTable = [ - { stxEnabled: true, gasless: true, sponsored: false, expected: true }, - { stxEnabled: true, gasless: false, sponsored: true, expected: true }, - { stxEnabled: true, gasless: true, sponsored: true, expected: true }, - { stxEnabled: true, gasless: false, sponsored: false, expected: false }, - { stxEnabled: false, gasless: true, sponsored: false, expected: false }, - { stxEnabled: false, gasless: false, sponsored: true, expected: false }, - { stxEnabled: false, gasless: true, sponsored: true, expected: false }, - { stxEnabled: false, gasless: false, sponsored: false, expected: false }, - ]; - - truthTable.forEach(({ stxEnabled, gasless, sponsored, expected }) => { - it(`stxEnabled=${stxEnabled}, gasless=${gasless}, sponsored=${sponsored} → returns ${expected}`, () => { - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled (line 12 in hook) - // Second call: stxEnabled (line 15 in hook) - if (callCount === 1) { - return gasless; - } - return stxEnabled; - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '100', sponsored), - ); - - expect(result.current).toBe(expected); - }); - }); - }); - - describe('Non-EVM native token scenarios', () => { + it('passes undefined chain id to sendBundle hook for non-EVM token', () => { const solanaToken: BridgeToken = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'SOL', - decimals: 9, - chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // Solana mainnet CAIP-2 + ...nativeToken, + chainId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as `${string}:${string}`, }; + mockUseTokenAddress.mockReturnValue(solanaToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockIsNonEvmChainId.mockReturnValue(true); + setSelectorValues({ stxEnabled: false }); - const bitcoinToken: BridgeToken = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'BTC', - decimals: 8, - chainId: 'bip122:000000000019d6689c085ae165831e93', // Bitcoin mainnet CAIP-2 - }; + const { result } = renderHook(() => + useShouldRenderMaxOption(solanaToken, '3'), + ); - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(true); - }); - - it('returns false for Solana native token even with gasless enabled', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = true - // Second call: stxEnabled = false (non-EVM chain) - if (callCount === 1) { - return true; // gasless enabled - } - return false; // stxEnabled is false for non-EVM - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(solanaToken, '100', false), - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Solana native token even with sponsored quote', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - mockUseSelector.mockImplementation( - () => - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = false (non-EVM chain) - false, - ); - - const { result } = renderHook( - () => useShouldRenderMaxOption(solanaToken, '50', true), // sponsored = true - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Solana native token with both gasless and sponsored enabled', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = true - // Second call: stxEnabled = false (non-EVM chain) - if (callCount === 1) { - return true; // gasless enabled - } - return false; // stxEnabled is false for non-EVM - }); - - const { result } = renderHook( - () => useShouldRenderMaxOption(solanaToken, '25.5', true), // sponsored = true - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Bitcoin native token with gasless enabled', () => { - mockUseTokenAddress.mockReturnValue(bitcoinToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = true - // Second call: stxEnabled = false (non-EVM chain) - if (callCount === 1) { - return true; // gasless enabled - } - return false; // stxEnabled is false for non-EVM - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(bitcoinToken, '1.5', false), - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Bitcoin native token without any flags enabled', () => { - mockUseTokenAddress.mockReturnValue(bitcoinToken.address); - mockUseSelector.mockReturnValue(false); // All flags disabled - - const { result } = renderHook(() => - useShouldRenderMaxOption(bitcoinToken, '0.5', false), - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for non-EVM native token with zero balance', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - mockUseSelector.mockReturnValue(false); - - const { result } = renderHook(() => - useShouldRenderMaxOption(solanaToken, '0', false), - ); - - // Should return false due to zero balance (checked before non-EVM logic) - expect(result.current).toBe(false); - }); + expect(mockUseIsSendBundleSupported).toHaveBeenCalledWith(undefined); + expect(result.current).toBe(false); }); }); diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index 68c0e577231a..445ae7ec8891 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -11,6 +11,8 @@ import { useMusdConversionEligibility } from './useMusdConversionEligibility'; import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; import { useNetworksByCustomNamespace } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; +import useRampsTokens from '../../Ramp/hooks/useRampsTokens'; +import useRampsUnifiedV2Enabled from '../../Ramp/hooks/useRampsUnifiedV2Enabled'; import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; import { createMockToken } from '../../Stake/testUtils'; import { @@ -31,6 +33,8 @@ jest.mock('./useMusdConversionEligibility'); jest.mock('../../../hooks/useCurrentNetworkInfo'); jest.mock('../../../hooks/useNetworksByNamespace/useNetworksByNamespace'); jest.mock('../../Ramp/hooks/useRampTokens'); +jest.mock('../../Ramp/hooks/useRampsTokens'); +jest.mock('../../Ramp/hooks/useRampsUnifiedV2Enabled'); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest.fn(), @@ -61,6 +65,13 @@ const mockUseNetworksByCustomNamespace = const mockUseRampTokens = useRampTokens as jest.MockedFunction< typeof useRampTokens >; +const mockUseRampsTokens = useRampsTokens as jest.MockedFunction< + typeof useRampsTokens +>; +const mockUseRampsUnifiedV2Enabled = + useRampsUnifiedV2Enabled as jest.MockedFunction< + typeof useRampsUnifiedV2Enabled + >; const mockUseMusdConversionTokens = useMusdConversionTokens as jest.MockedFunction< typeof useMusdConversionTokens @@ -195,6 +206,14 @@ describe('useMusdCtaVisibility', () => { mockUseNetworksByCustomNamespace.mockReturnValue( defaultNetworksByNamespace, ); + mockUseRampsUnifiedV2Enabled.mockReturnValue(false); + mockUseRampsTokens.mockReturnValue({ + tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), + isLoading: false, + error: null, + }); mockUseRampTokens.mockReturnValue(defaultRampTokens); mockUseMusdConversionTokens.mockReturnValue({ tokens: [], diff --git a/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts b/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts index 27641a1dfe20..a0ad78b53c6d 100644 --- a/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts +++ b/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts @@ -2,233 +2,174 @@ import { renderHook } from '@testing-library/react-hooks'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import { useMusdRampAvailability } from './useMusdRampAvailability'; -import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; -import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; - -jest.mock('../../Ramp/hooks/useRampTokens'); +import { + MUSD_BUYABLE_CHAIN_IDS, + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS_BY_CHAIN, +} from '../constants/musd'; +import { + getTokenBuyabilityKey, + useTokensBuyability, +} from '../../Ramp/hooks/useTokenBuyability'; +import { TokenI } from '../../Tokens/types'; + +jest.mock('../../Ramp/hooks/useTokenBuyability', () => { + const actual = jest.requireActual('../../Ramp/hooks/useTokenBuyability'); + return { + ...actual, + useTokensBuyability: jest.fn(), + }; +}); -const mockUseRampTokens = useRampTokens as jest.MockedFunction< - typeof useRampTokens +const mockUseTokensBuyability = useTokensBuyability as jest.MockedFunction< + typeof useTokensBuyability >; describe('useMusdRampAvailability', () => { - const createMusdRampToken = ( - chainId: Hex, - tokenSupported = true, - ): RampsToken => { - const assetId = MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId].toLowerCase(); - const caipChainId = assetId.split('/')[0] as `${string}:${string}`; - return { - assetId: assetId as `${string}:${string}/${string}:${string}`, - symbol: 'MUSD', - chainId: caipChainId, - tokenSupported, - name: 'MetaMask USD', - decimals: 6, - iconUrl: 'https://example.com/musd.png', - }; - }; + const getMusdToken = (chainId: Hex): TokenI => ({ + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId], + chainId, + symbol: MUSD_TOKEN.symbol, + name: MUSD_TOKEN.name, + decimals: MUSD_TOKEN.decimals, + image: '', + logo: undefined, + balance: '0', + isETH: false, + isNative: false, + }); - const defaultRampTokens = { - topTokens: null, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex), - ], - isLoading: false, - error: null, + const MAINNET_MUSD_KEY = getTokenBuyabilityKey( + getMusdToken(CHAIN_IDS.MAINNET as Hex), + ); + const LINEA_MUSD_KEY = getTokenBuyabilityKey( + getMusdToken(CHAIN_IDS.LINEA_MAINNET as Hex), + ); + + const setBuyability = ( + buyabilityByTokenKey: Record = {}, + ) => { + mockUseTokensBuyability.mockReturnValue({ + buyabilityByTokenKey, + isLoading: false, + }); }; beforeEach(() => { jest.clearAllMocks(); - mockUseRampTokens.mockReturnValue(defaultRampTokens); - }); - - afterEach(() => { - jest.resetAllMocks(); + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: true, + }); }); - describe('hook structure', () => { - it('returns object with all required properties', () => { - const { result } = renderHook(() => useMusdRampAvailability()); - - expect(result.current).toHaveProperty('isMusdBuyableOnChain'); - expect(result.current).toHaveProperty('isMusdBuyableOnAnyChain'); - expect(result.current).toHaveProperty('getIsMusdBuyable'); - }); + it('returns false for all mUSD buyable chains when no tokens are buyable', () => { + setBuyability({}); - it('returns getIsMusdBuyable as a function', () => { - const { result } = renderHook(() => useMusdRampAvailability()); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(typeof result.current.getIsMusdBuyable).toBe('function'); + MUSD_BUYABLE_CHAIN_IDS.forEach((chainId) => { + expect(result.current.isMusdBuyableOnChain[chainId]).toBe(false); }); + expect(result.current.isMusdBuyableOnAnyChain).toBe(false); }); - describe('isMusdBuyableOnChain', () => { - it('returns empty object when allTokens is null', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: null, - }); + it('marks remaining chains as not buyable when buyability results are missing', () => { + setBuyability({ [MAINNET_MUSD_KEY]: true }); - const { result } = renderHook(() => useMusdRampAvailability()); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(result.current.isMusdBuyableOnChain).toEqual({}); - }); + expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.MAINNET]).toBe(true); + expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.LINEA_MAINNET]).toBe( + false, + ); + }); - it('returns buyability status for each chain', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, true), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.MAINNET]).toBe(true); - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.LINEA_MAINNET]).toBe( - false, - ); + it('returns true when at least one chain has buyable mUSD', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: false, + [LINEA_MUSD_KEY]: true, }); - it('returns false for chain when token not supported', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.MAINNET]).toBe( - false, - ); - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.LINEA_MAINNET]).toBe( - false, - ); - }); - }); + const { result } = renderHook(() => useMusdRampAvailability()); - describe('isMusdBuyableOnAnyChain', () => { - it('returns true when at least one chain has buyable mUSD', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, true), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); + expect(result.current.isMusdBuyableOnAnyChain).toBe(true); + }); - expect(result.current.isMusdBuyableOnAnyChain).toBe(true); + it('returns chain-specific buyability when a single chain is selected', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: false, }); - it('returns false when no chains have buyable mUSD', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); + const { result } = renderHook(() => useMusdRampAvailability()); - const { result } = renderHook(() => useMusdRampAvailability()); + const isMusdBuyable = result.current.getIsMusdBuyable( + CHAIN_IDS.MAINNET as Hex, + false, + ); + + expect(isMusdBuyable).toBe(true); + }); - expect(result.current.isMusdBuyableOnAnyChain).toBe(false); + it('returns false when selected chain is not buyable', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: false, }); - it('returns false when allTokens is empty', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [], - }); + const { result } = renderHook(() => useMusdRampAvailability()); - const { result } = renderHook(() => useMusdRampAvailability()); + const isMusdBuyable = result.current.getIsMusdBuyable( + CHAIN_IDS.LINEA_MAINNET as Hex, + false, + ); - expect(result.current.isMusdBuyableOnAnyChain).toBe(false); - }); + expect(isMusdBuyable).toBe(false); }); - describe('getIsMusdBuyable', () => { - it('returns isMusdBuyableOnAnyChain when popular networks filter is active', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, true), - ], - }); + it('returns false when no chain is selected and popular networks filter is inactive', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: true, + }); - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable(null, true); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(isMusdBuyable).toBe(true); - }); + const isMusdBuyable = result.current.getIsMusdBuyable(null, false); - it('returns chain-specific buyability when single chain selected', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, true), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable( - CHAIN_IDS.MAINNET as Hex, - false, - ); - - expect(isMusdBuyable).toBe(true); - }); + expect(isMusdBuyable).toBe(false); + }); - it('returns false when selected chain not buyable', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, true), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable( - CHAIN_IDS.LINEA_MAINNET as Hex, - false, - ); - - expect(isMusdBuyable).toBe(false); + it('returns false for an unknown chain ID', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: true, }); - it('returns false when no chain selected and popular networks filter is inactive', () => { - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable(null, false); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(isMusdBuyable).toBe(false); - }); + const isMusdBuyable = result.current.getIsMusdBuyable( + '0x999' as Hex, + false, + ); - it('returns false for unknown chain ID', () => { - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable( - '0x999' as Hex, - false, - ); + expect(isMusdBuyable).toBe(false); + }); - expect(isMusdBuyable).toBe(false); + it('uses aggregate buyability when popular networks filter is active even with selected chain', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: false, + [LINEA_MUSD_KEY]: true, }); - }); - describe('integration with useRampTokens', () => { - it('calls useRampTokens hook', () => { - renderHook(() => useMusdRampAvailability()); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(mockUseRampTokens).toHaveBeenCalled(); - }); + const isMusdBuyable = result.current.getIsMusdBuyable( + CHAIN_IDS.MAINNET as Hex, + true, + ); + + expect(isMusdBuyable).toBe(true); }); }); diff --git a/app/components/UI/Earn/hooks/useMusdRampAvailability.ts b/app/components/UI/Earn/hooks/useMusdRampAvailability.ts index 2eeb16eebe8b..93bca09ad050 100644 --- a/app/components/UI/Earn/hooks/useMusdRampAvailability.ts +++ b/app/components/UI/Earn/hooks/useMusdRampAvailability.ts @@ -1,11 +1,15 @@ import { useCallback, useMemo } from 'react'; import { Hex } from '@metamask/utils'; import { + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS_BY_CHAIN, MUSD_BUYABLE_CHAIN_IDS, - MUSD_TOKEN_ASSET_ID_BY_CHAIN, } from '../constants/musd'; -import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; -import { toLowerCaseEquals } from '../../../../util/general'; +import { TokenI } from '../../Tokens/types'; +import { + getTokenBuyabilityKey, + useTokensBuyability, +} from '../../Ramp/hooks/useTokenBuyability'; export interface MusdRampAvailability { isMusdBuyableOnChain: Record; @@ -26,34 +30,56 @@ export interface MusdRampAvailability { * @returns {MusdRampAvailability} Ramp availability state and helpers */ export const useMusdRampAvailability = (): MusdRampAvailability => { - const { allTokens } = useRampTokens(); + const musdTokensByChain = useMemo( + () => + MUSD_BUYABLE_CHAIN_IDS.map((chainId) => { + const address = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!address) { + return null; + } + + const token: TokenI = { + address, + chainId, + symbol: MUSD_TOKEN.symbol, + name: MUSD_TOKEN.name, + decimals: MUSD_TOKEN.decimals, + image: '', + logo: undefined, + balance: '0', + isETH: false, + isNative: false, + }; + return token; + }), + [], + ); + + const musdTokens = useMemo( + () => musdTokensByChain.filter((token): token is TokenI => token !== null), + [musdTokensByChain], + ); + + const { buyabilityByTokenKey } = useTokensBuyability(musdTokens); // Check if mUSD is buyable on a specific chain based on ramp availability const isMusdBuyableOnChain = useMemo(() => { - if (!allTokens) { - return {}; - } - const buyableByChain: Record = {}; - MUSD_BUYABLE_CHAIN_IDS.forEach((chainId) => { - const musdAssetId = MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId]; - if (!musdAssetId) { - buyableByChain[chainId] = false; - return; - } - - const musdToken = allTokens.find( - (token) => - toLowerCaseEquals(token.assetId, musdAssetId) && - token.tokenSupported === true, - ); + buyableByChain[chainId] = false; + }); - buyableByChain[chainId] = Boolean(musdToken); + musdTokens.forEach((token) => { + if (token.chainId) { + const tokenKey = getTokenBuyabilityKey(token); + buyableByChain[token.chainId as Hex] = Boolean( + buyabilityByTokenKey[tokenKey], + ); + } }); return buyableByChain; - }, [allTokens]); + }, [buyabilityByTokenKey, musdTokens]); // Check if mUSD is buyable on any chain (for "all networks" view) const isMusdBuyableOnAnyChain = useMemo( diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts index 1117c9af2134..093f484227e6 100644 --- a/app/components/UI/Perps/__mocks__/serviceMocks.ts +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -11,6 +11,23 @@ import { type PerpsPlatformDependencies, } from '@metamask/perps-controller'; +/** + * Create a mock EVM account (KeyringAccount) + */ +export const createMockEvmAccount = () => ({ + id: '00000000-0000-0000-0000-000000000000', + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + type: 'eip155:eoa' as const, + options: {}, + scopes: ['eip155:1'], + methods: ['eth_signTransaction', 'eth_sign'], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, +}); + /** * Create a mock PerpsPlatformDependencies instance. * Returns a type-safe mock with jest.Mock functions for all methods. @@ -53,11 +70,6 @@ export const createMockInfrastructure = clearAllChannels: jest.fn(), }, - // === Rewards (no standard messenger action in core) === - rewards: { - getFeeDiscount: jest.fn().mockResolvedValue(0), - }, - // === Feature Flags (platform-specific version gating) === featureFlags: { validateVersionGated: jest.fn().mockReturnValue(undefined), @@ -76,6 +88,11 @@ export const createMockInfrastructure = invalidate: jest.fn(), invalidateAll: jest.fn(), }, + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), + }, }) as unknown as jest.Mocked; /** @@ -161,23 +178,6 @@ export const createMockServiceContext = ( ...overrides, }); -/** - * Create a mock EVM account (KeyringAccount) - */ -export const createMockEvmAccount = () => ({ - id: '00000000-0000-0000-0000-000000000000', - address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, - type: 'eip155:eoa' as const, - options: {}, - scopes: ['eip155:1'], - methods: ['eth_signTransaction', 'eth_sign'], - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, -}); - /** * Create a mock PerpsControllerMessenger for testing inter-controller communication. * The messenger.call() method should be configured in each test to return appropriate values. diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts index baad6cc4f596..eb70ccec4720 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts @@ -3,6 +3,7 @@ import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEvent import { analytics } from '../../../../util/analytics/analytics'; import type { PerpsAnalyticsEvent } from '@metamask/perps-controller'; import { createMobileInfrastructure } from './mobileInfrastructure'; +import Engine from '../../../../core/Engine'; jest.mock('../../../../util/analytics/analytics', () => ({ analytics: { @@ -55,7 +56,7 @@ jest.mock('../providers/PerpsStreamManager', () => ({ jest.mock('../../../../core/Engine', () => ({ context: { RewardsController: { - getPerpsDiscountForAccount: jest.fn(), + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(5), }, }, })); @@ -159,4 +160,20 @@ describe('createMobileInfrastructure', () => { }); }); }); + + describe('rewards', () => { + it('delegates getPerpsDiscountForAccount to RewardsController', async () => { + const infra = createMobileInfrastructure(); + const caipAccountId = + 'eip155:42161:0x1234' as `${string}:${string}:${string}`; + + const result = + await infra.rewards.getPerpsDiscountForAccount(caipAccountId); + + expect( + Engine.context.RewardsController.getPerpsDiscountForAccount, + ).toHaveBeenCalledWith(caipAccountId); + expect(result).toBe(5); + }); + }); }); diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index 91e873d26126..2e51bee9e55c 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -216,14 +216,6 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { // === Platform Services === streamManager: createStreamManagerAdapter(), - // === Rewards === - rewards: { - getFeeDiscount: (caipAccountId: `${string}:${string}:${string}`) => - Engine.context.RewardsController.getPerpsDiscountForAccount( - caipAccountId, - ), - }, - // === Feature Flags === featureFlags: { validateVersionGated(flag: VersionGatedFeatureFlag): boolean | undefined { @@ -236,6 +228,17 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { // === Cache Invalidation === cacheInvalidator: createCacheInvalidatorAdapter(), + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount( + caipAccountId: `${string}:${string}:${string}`, + ) { + return Engine.context.RewardsController.getPerpsDiscountForAccount( + caipAccountId, + ); + }, + }, }; } diff --git a/app/components/UI/Perps/utils/accountUtils.test.ts b/app/components/UI/Perps/utils/accountUtils.test.ts index 060db48acc8d..f3bf3b73c6ea 100644 --- a/app/components/UI/Perps/utils/accountUtils.test.ts +++ b/app/components/UI/Perps/utils/accountUtils.test.ts @@ -8,7 +8,6 @@ import { getEvmAccountFromAccountGroup, getSelectedEvmAccount, calculateWeightedReturnOnEquity, - PerpsControllerMessenger, } from '@metamask/perps-controller'; describe('accountUtils', () => { @@ -242,7 +241,7 @@ describe('accountUtils', () => { }); describe('getSelectedEvmAccount', () => { - it('returns EVM account when messenger returns accounts with EVM', () => { + it('returns EVM account when accounts array contains EVM account', () => { const mockAccounts = [ { address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', @@ -259,27 +258,14 @@ describe('accountUtils', () => { }, ] as unknown as InternalAccount[]; - const mockMessenger = { - call: jest.fn().mockReturnValue(mockAccounts) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); + const result = getSelectedEvmAccount(mockAccounts); + expect(result).toEqual({ address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', }); }); - it('returns undefined when no EVM account in selected group', () => { + it('returns undefined when no EVM account in accounts array', () => { const mockAccounts = [ { address: '0x1234567890123456789012345678901234567890', @@ -296,33 +282,13 @@ describe('accountUtils', () => { }, ] as unknown as InternalAccount[]; - const mockMessenger = { - call: jest.fn().mockReturnValue(mockAccounts) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); + const result = getSelectedEvmAccount(mockAccounts); expect(result).toBeUndefined(); }); - it('returns undefined when messenger returns empty accounts', () => { - const mockMessenger = { - call: jest.fn().mockReturnValue([]) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); + it('returns undefined when accounts array is empty', () => { + const result = getSelectedEvmAccount([]); expect(result).toBeUndefined(); }); diff --git a/app/components/UI/Perps/utils/rewardsUtils.test.ts b/app/components/UI/Perps/utils/rewardsUtils.test.ts index 0697848fca3e..d490a5b46b60 100644 --- a/app/components/UI/Perps/utils/rewardsUtils.test.ts +++ b/app/components/UI/Perps/utils/rewardsUtils.test.ts @@ -8,11 +8,9 @@ import { handleRewardsError, } from '@metamask/perps-controller'; import { toCaipAccountId, parseCaipChainId } from '@metamask/utils'; -import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; jest.mock('@metamask/utils'); -jest.mock('@metamask/bridge-controller'); jest.mock('@metamask/controller-utils'); const mockToCaipAccountId = toCaipAccountId as jest.MockedFunction< @@ -21,9 +19,6 @@ const mockToCaipAccountId = toCaipAccountId as jest.MockedFunction< const mockParseCaipChainId = parseCaipChainId as jest.MockedFunction< typeof parseCaipChainId >; -const mockFormatChainIdToCaip = formatChainIdToCaip as jest.MockedFunction< - typeof formatChainIdToCaip ->; const mockToChecksumHexAddress = toChecksumHexAddress as jest.MockedFunction< typeof toChecksumHexAddress >; @@ -41,7 +36,6 @@ describe('rewardsUtils', () => { 'eip155:42161:0x1234567890123456789012345678901234567890'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue(mockCaipChainId); mockParseCaipChainId.mockReturnValue({ namespace: 'eip155', reference: '42161', @@ -57,7 +51,6 @@ describe('rewardsUtils', () => { // Assert expect(result).toBe(mockCaipAccountId); - expect(mockFormatChainIdToCaip).toHaveBeenCalledWith(mockChainId); expect(mockParseCaipChainId).toHaveBeenCalledWith(mockCaipChainId); expect(mockToCaipAccountId).toHaveBeenCalledWith( 'eip155', @@ -66,13 +59,7 @@ describe('rewardsUtils', () => { ); }); - it('returns null when formatChainIdToCaip throws', () => { - // Arrange - const error = new Error('Invalid chain ID format'); - mockFormatChainIdToCaip.mockImplementation(() => { - throw error; - }); - + it('returns null when chain ID is invalid (NaN)', () => { // Act const result = formatAccountToCaipAccountId(mockAddress, 'invalid'); @@ -127,10 +114,8 @@ describe('rewardsUtils', () => { const checksummedAddress = '0x316BDE155acd07609872a56Bc32CcfB0B13201fA'; const mixedCaseAddress = '0x316BdE155AcD07609872a56bC32CcFb0b13201Fa'; const chainId = '1'; - const caipChainId = 'eip155:1'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue(caipChainId); mockParseCaipChainId.mockReturnValue({ namespace: 'eip155', reference: '1', @@ -238,9 +223,8 @@ describe('rewardsUtils', () => { const chainId = '1'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue( - 'bip122:000000000019d6689c085ae165831e93', - ); + // parseCaipChainId receives 'eip155:1' from inlined formatChainIdToCaip, + // but we mock it to return a non-eip155 namespace to test the branch mockParseCaipChainId.mockReturnValue({ namespace: 'bip122', reference: '000000000019d6689c085ae165831e93', diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts index de26b01602cc..937ad44eea31 100644 --- a/app/components/UI/Predict/constants/eventNames.ts +++ b/app/components/UI/Predict/constants/eventNames.ts @@ -22,6 +22,7 @@ export const PredictEventProperties = { // Trade specific MARKET_TYPE: 'market_type', OUTCOME: 'outcome', + ORDER_TYPE: 'order_type', // Sensitive properties AMOUNT_USD: 'amount_usd', diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 4996153bad32..630ce4b6ff79 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -5863,6 +5863,36 @@ describe('PredictController', () => { }); }); + it('includes orderType in analytics properties when provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + orderType: 'FAK', + }); + + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + order_type: 'FAK', + }), + }), + ); + }); + }); + + it('omits orderType from analytics properties when not provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + }); + + const eventArg = (analytics.trackEvent as jest.Mock).mock.calls[0][0]; + expect(eventArg.properties).not.toHaveProperty('order_type'); + }); + }); + it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { withController(({ controller }) => { controller.trackMarketDetailsOpened({ diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 58c75bef98f3..e11c74d78e11 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -79,6 +79,7 @@ import { PredictClaim, PredictClaimStatus, PredictMarket, + PredictOrderType, PredictPosition, PredictPositionStatus, PredictPriceHistoryPoint, @@ -1046,6 +1047,7 @@ export class PredictController extends BaseController< failureReason, sharePrice, pnl, + orderType, }: { status: PredictTradeStatusValue; amountUsd?: number; @@ -1054,6 +1056,7 @@ export class PredictController extends BaseController< failureReason?: string; sharePrice?: number; pnl?: number; + orderType?: PredictOrderType; }): Promise { if (!analyticsProperties) { return; @@ -1107,6 +1110,9 @@ export class PredictController extends BaseController< ...(analyticsProperties.gameClock && { [PredictEventProperties.GAME_CLOCK]: analyticsProperties.gameClock, }), + ...(orderType && { + [PredictEventProperties.ORDER_TYPE]: orderType, + }), }; // Build sensitive properties @@ -1436,6 +1442,7 @@ export class PredictController extends BaseController< amountUsd, analyticsProperties, sharePrice, + orderType: preview.orderType, }); // Invalidate query cache (to avoid nonce issues) @@ -1496,6 +1503,7 @@ export class PredictController extends BaseController< analyticsProperties, completionDuration, sharePrice: realSharePrice, + orderType: preview.orderType, }); traceData = { success: true, side: preview.side }; @@ -1515,6 +1523,7 @@ export class PredictController extends BaseController< sharePrice, completionDuration, failureReason: errorMessage, + orderType: preview.orderType, }); // Update error state for Sentry integration diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index c7074280b61d..9edb8fa2c44d 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -1363,21 +1363,52 @@ describe('PolymarketProvider', () => { }); describe('previewOrder', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createPreviewSigner = () => ({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }); + + const createPreviewOrderParams = () => ({ + marketId: 'market-123', + outcomeId: 'outcome-456', + outcomeTokenId: 'token-789', + side: Side.BUY, + size: 100, + signer: createPreviewSigner(), + }); + + const createPermit2PreviewProvider = (fakOrdersEnabled: boolean) => + createProvider({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled, + }); + + const mockPreviewOrderWithFees = () => { + mockPreviewOrder.mockResolvedValue({ + fees: { + totalFee: 1, + metamaskFee: 0.5, + providerFee: 0.5, + totalFeePercentage: 1, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + }, + }); + }; + it('calls previewOrder utility function with correct parameters', async () => { const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; const mockParams = { - marketId: 'market-123', - outcomeId: 'outcome-456', - outcomeTokenId: 'token-789', - side: Side.BUY, + ...createPreviewOrderParams(), amount: 100, - size: 100, - signer: mockSigner, }; await provider.previewOrder(mockParams); @@ -1387,6 +1418,62 @@ describe('PolymarketProvider', () => { feeCollection: DEFAULT_FEE_COLLECTION_FLAG, }); }); + it('returns FOK orderType by default', async () => { + const provider = createProvider(); + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FOK'); + }); + + it.each([ + { + permit2Ready: true, + fakOrdersEnabled: true, + expectedOrderType: 'FAK', + }, + { + permit2Ready: false, + fakOrdersEnabled: true, + expectedOrderType: 'FOK', + }, + { + permit2Ready: true, + fakOrdersEnabled: false, + expectedOrderType: 'FOK', + }, + ] as const)( + 'returns $expectedOrderType orderType when permit2Ready=$permit2Ready and fakOrdersEnabled=$fakOrdersEnabled', + async ({ permit2Ready, fakOrdersEnabled, expectedOrderType }) => { + mockPreviewOrderWithFees(); + mockHasPermit2Allowance.mockResolvedValue(permit2Ready); + const provider = createPermit2PreviewProvider(fakOrdersEnabled); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe(expectedOrderType); + }, + ); + + it('returns FOK orderType when permit2 allowance check throws', async () => { + mockPreviewOrderWithFees(); + mockHasPermit2Allowance.mockRejectedValue(new Error('RPC timeout')); + const provider = createPermit2PreviewProvider(true); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FOK'); + }); + + it('returns FAK orderType when fees are absent and FAK flags are enabled', async () => { + mockPreviewOrder.mockResolvedValue({}); + mockHasPermit2Allowance.mockResolvedValue(true); + const provider = createPermit2PreviewProvider(true); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FAK'); + expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); + }); }); describe('API key caching', () => { @@ -1867,6 +1954,57 @@ describe('PolymarketProvider', () => { ); }); }); + describe('placeOrder FAK order type for sell orders', () => { + it('submits FAK order type for sell order without fees when FAK is enabled', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: true, + }); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FAK' }), + }), + ); + expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); + }); + + it('submits FOK order type for sell order without fees when FAK is disabled', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: false, + }); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FOK' }), + }), + ); + }); + }); + describe('placeOrder edge cases', () => { it('places order without fee authorization when totalFee is zero', async () => { // Clear mock to ensure clean state for this test diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 3cfb5b8a38dd..3a88fbb1a8fc 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -187,6 +187,48 @@ export class PolymarketProvider implements PredictProvider { }; } + #hasPermit2Config(params: { + permit2Enabled?: boolean; + executors?: string[]; + }): boolean { + return ( + params.permit2Enabled === true && + Array.isArray(params.executors) && + params.executors.length > 0 + ); + } + + #shouldUseFakOrderType({ + permit2Enabled, + executors, + fakOrdersEnabled, + }: { + permit2Enabled?: boolean; + executors?: string[]; + fakOrdersEnabled: boolean; + }): boolean { + return ( + this.#hasPermit2Config({ permit2Enabled, executors }) && + fakOrdersEnabled === true + ); + } + + async #isPermit2AllowanceReady(ownerAddress: string): Promise { + const safeAddress = + this.#accountStateByAddress.get(ownerAddress)?.address ?? + computeProxyAddress(ownerAddress); + + try { + return await hasPermit2Allowance({ address: safeAddress }); + } catch (error) { + DevLogger.log('PolymarketProvider: Permit2 allowance check failed', { + error, + ownerAddress, + }); + return false; + } + } + public async getMarketDetails({ marketId, }: { @@ -977,19 +1019,49 @@ export class PolymarketProvider implements PredictProvider { signer: Signer; }, ): Promise { - const { feeCollection } = this.#getFeatureFlags(); + const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); const basePreview = await previewOrder({ ...params, feeCollection }); + // Determine intended order type from feature flags. + // FAK is used when Permit2 config is active and FAK orders are enabled. + // The Permit2 allowance check is only needed when fees must be collected. + let orderType = OrderType.FOK; + + const couldUseFak = this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }); + + if (couldUseFak) { + const hasFees = + basePreview.fees !== undefined && basePreview.fees.totalFee > 0; + if (hasFees) { + // TODO: remove this once placeOrder guarantees Permit2 allowance + // is set automatically before order submission. + const permit2Ready = await this.#isPermit2AllowanceReady( + params.signer.address, + ); + if (permit2Ready) { + orderType = OrderType.FAK; + } + } else { + // No fees to collect via Permit2 — FAK can be used directly. + orderType = OrderType.FAK; + } + } + if (params.signer) { if (this.isRateLimited(params.signer.address)) { return { ...basePreview, + orderType, rateLimited: true, }; } } - return basePreview; + return { ...basePreview, orderType }; } public async placeOrder( @@ -1145,18 +1217,19 @@ export class PolymarketProvider implements PredictProvider { // Determine fees, permit2, and order type BEFORE building clobOrder // so the HMAC signature covers the correct orderType. - const { fakOrdersEnabled } = this.#getFeatureFlags(); - const shouldUsePermit2 = - fees?.permit2Enabled === true && - Array.isArray(fees.executors) && - fees.executors.length > 0; + const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); + const shouldUsePermit2 = this.#hasPermit2Config({ + permit2Enabled: fees?.permit2Enabled, + executors: fees?.executors, + }); let feeAuthorization: | SafeFeeAuthorization | Permit2FeeAuthorization | undefined; let executor: string | undefined; - let shouldUseFakOrderType = false; + let orderType: OrderType = OrderType.FOK; + let permit2FeeReady = false; if (fees !== undefined && fees.totalFee > 0) { const safeAddress = computeProxyAddress(signer.address); @@ -1164,21 +1237,23 @@ export class PolymarketProvider implements PredictProvider { parseUnits(fees.totalFee.toString(), 6).toString(), ); - if (shouldUsePermit2 && fees.executors) { - const permit2Ready = await hasPermit2Allowance({ - address: safeAddress, - }); + if (shouldUsePermit2) { + const executors = fees.executors ?? []; + const permit2Ready = await this.#isPermit2AllowanceReady( + signer.address, + ); if (permit2Ready) { - executor = - fees.executors[Math.floor(Math.random() * fees.executors.length)]; + permit2FeeReady = true; + const randomIndex = new Uint32Array(1); + global.crypto.getRandomValues(randomIndex); + executor = executors[randomIndex[0] % executors.length]; feeAuthorization = await createPermit2FeeAuthorization({ safeAddress, signer, amount: feeAmountInUsdc, spender: executor, }); - shouldUseFakOrderType = fakOrdersEnabled === true; } else { feeAuthorization = await createSafeFeeAuthorization({ safeAddress, @@ -1197,10 +1272,26 @@ export class PolymarketProvider implements PredictProvider { } } + // Determine order type independently of fee authorization. + // FAK depends on feature flags only; the Permit2 allowance check + // is only needed when fees must be collected via Permit2. + if ( + this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }) + ) { + const hasFees = fees !== undefined && fees.totalFee > 0; + if (!hasFees || permit2FeeReady) { + orderType = OrderType.FAK; + } + } + const clobOrder = { order: { ...signedOrder, side, salt: parseInt(signedOrder.salt) }, owner: signerApiKey.apiKey, - orderType: shouldUseFakOrderType ? OrderType.FAK : OrderType.FOK, + orderType, }; const body = JSON.stringify(clobOrder); diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 672c524a22dd..51464d2ee186 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -7,6 +7,8 @@ export enum Side { SELL = 'SELL', } +export type PredictOrderType = 'FOK' | 'FAK'; + export enum PredictPriceHistoryInterval { ONE_HOUR = '1h', SIX_HOUR = '6h', @@ -470,6 +472,7 @@ export interface OrderPreview { // For sell orders, we can store the position ID // so we can perform optimistic updates positionId?: string; + orderType?: PredictOrderType; } export type OrderResult = Result<{ diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts new file mode 100644 index 000000000000..86b14cac06ea --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts @@ -0,0 +1,14 @@ +export type RampsOrderTypeSlug = 'buy' | 'sell' | 'deposit'; + +export const getOrderRowTestId = (type: RampsOrderTypeSlug, index: number) => + `orders-list-row-${type}-${index}`; + +export const getOrderRowCryptoAmountTestId = ( + type: RampsOrderTypeSlug, + index: number, +) => `orders-list-crypto-amount-${type}-${index}`; + +export const getOrderRowFiatAmountTestId = ( + type: RampsOrderTypeSlug, + index: number, +) => `orders-list-fiat-amount-${type}-${index}`; diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx index a4034f8d2a33..69d549095ba4 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx @@ -41,6 +41,12 @@ import ListItemColumn, { WidthType, } from '../../../../../../component-library/components/List/ListItemColumn'; import ListItemColumnEnd from '../../components/ListItemColumnEnd'; +import { + getOrderRowTestId, + getOrderRowCryptoAmountTestId, + getOrderRowFiatAmountTestId, + type RampsOrderTypeSlug, +} from './OrdersList.testIds'; type filterType = 'ALL' | 'PURCHASE' | 'SELL'; @@ -92,12 +98,25 @@ function getStatusColorAndText( return [statusColor, statusText]; } -function DisplayOrderListItem({ item }: { item: DisplayOrder }) { +function getOrderTypeSlug(orderType: string): RampsOrderTypeSlug { + if (orderType === 'DEPOSIT') return 'deposit'; + if (orderType === 'SELL') return 'sell'; + return 'buy'; +} + +function DisplayOrderListItem({ + item, + index, +}: { + item: DisplayOrder; + index: number; +}) { const isBuy = item.orderType === 'BUY' || item.orderType === 'DEPOSIT'; const [statusColor, statusText] = getStatusColorAndText( item.status, item.orderType, ); + const typeSlug = getOrderTypeSlug(item.orderType); const title = item.providerName ? `${item.providerName}: ${strings( @@ -128,10 +147,17 @@ function DisplayOrderListItem({ item }: { item: DisplayOrder }) { - + {item.cryptoAmount} {item.cryptoCurrencySymbol} - + {item.fiatAmount == null ? '...' : addCurrencySymbol( @@ -256,8 +282,15 @@ function OrdersList() { ], ); - const renderItem = ({ item }: { item: DisplayOrder }) => ( + const renderItem = ({ + item, + index, + }: { + item: DisplayOrder; + index: number; + }) => ( - + ); diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap index cb8638a72fc3..5091ad05fb18 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap @@ -476,6 +476,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-1" underlayColor="#f3f3f4" > 0.01231324 @@ -599,6 +601,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -623,6 +626,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-2" underlayColor="#f3f3f4" > 0.01231324 @@ -746,6 +751,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-2" > $34.23 @@ -770,6 +776,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-3" underlayColor="#f3f3f4" > 0.5 @@ -893,6 +901,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $1000 @@ -917,6 +926,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-4" underlayColor="#f3f3f4" > 100 @@ -1040,6 +1051,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-4" > $100 @@ -1064,6 +1076,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-5" underlayColor="#f3f3f4" > 20 @@ -1187,6 +1201,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-5" > $20 @@ -1211,6 +1226,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-6" underlayColor="#f3f3f4" > 0 @@ -1334,6 +1351,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-6" > ... @@ -1836,6 +1854,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-1" underlayColor="#f3f3f4" > 0.01231324 @@ -1959,6 +1979,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -1983,6 +2004,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-2" underlayColor="#f3f3f4" > 0.01231324 @@ -2106,6 +2129,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-2" > $34.23 @@ -2130,6 +2154,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-3" underlayColor="#f3f3f4" > 0.01231324 @@ -2253,6 +2279,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $34.23 @@ -2277,6 +2304,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-4" underlayColor="#f3f3f4" > 0.5 @@ -2400,6 +2429,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-4" > $1000 @@ -2424,6 +2454,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-5" underlayColor="#f3f3f4" > 100 @@ -2547,6 +2579,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-5" > $100 @@ -2571,6 +2604,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-6" underlayColor="#f3f3f4" > 20 @@ -2694,6 +2729,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-6" > $20 @@ -2718,6 +2754,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-7" underlayColor="#f3f3f4" > 0 @@ -2841,6 +2879,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-7" > ... @@ -4117,6 +4156,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-1" underlayColor="#f3f3f4" > 0.01231324 @@ -4240,6 +4281,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-1" > $34.23 @@ -4658,6 +4700,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-1" underlayColor="#f3f3f4" > 0.01231324 @@ -4781,6 +4825,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-1" > $34.23 @@ -5283,6 +5328,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-1" underlayColor="#f3f3f4" > 0.01231324 @@ -5406,6 +5453,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -5430,6 +5478,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-2" underlayColor="#f3f3f4" > 0.01231324 @@ -5553,6 +5603,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-2" > $34.23 @@ -5577,6 +5628,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-3" underlayColor="#f3f3f4" > 0.01231324 @@ -5700,6 +5753,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $34.23 @@ -5724,6 +5778,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-4" underlayColor="#f3f3f4" > 0.5 @@ -5847,6 +5903,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-4" > $1000 @@ -5871,6 +5928,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-5" underlayColor="#f3f3f4" > 100 @@ -5994,6 +6053,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-5" > $100 @@ -6018,6 +6078,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-6" underlayColor="#f3f3f4" > 20 @@ -6141,6 +6203,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-6" > $20 @@ -6165,6 +6228,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-7" underlayColor="#f3f3f4" > 0 @@ -6288,6 +6353,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-7" > ... diff --git a/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/PaymentMethodSelectorModal.test.tsx b/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/PaymentMethodSelectorModal.test.tsx index ba8f2a2a7086..41b55bb8b616 100644 --- a/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/PaymentMethodSelectorModal.test.tsx +++ b/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/PaymentMethodSelectorModal.test.tsx @@ -110,7 +110,34 @@ describe('PaymentMethodSelectorModal', () => { expect(toJSON()).toMatchSnapshot(); }); - it('tracks RAMPS_PAYMENT_METHOD_SELECTED event when payment method is selected', () => { + it('tracks OFFRAMP_PAYMENT_METHOD_SELECTED event when payment method is selected in sell flow', () => { + mockUseParams.mockReturnValue({ + ...defaultParams, + location: 'Amount to Sell Screen' as const, + }); + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + rampType: RampType.SELL, + isBuy: false, + }; + + const { getByText } = render(PaymentMethodSelectorModal); + + const paymentMethodElement = getByText('Bank Transfer'); + fireEvent.press(paymentMethodElement); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'OFFRAMP_PAYMENT_METHOD_SELECTED', + { + payment_method_id: 'payment-method-2', + available_payment_method_ids: ['payment-method-1', 'payment-method-2'], + region: 'US', + location: 'Amount to Sell Screen', + }, + ); + }); + + it('tracks ONRAMP_PAYMENT_METHOD_SELECTED event when payment method is selected in buy flow', () => { const { getByText } = render(PaymentMethodSelectorModal); const paymentMethodElement = getByText('Bank Transfer'); @@ -126,7 +153,7 @@ describe('PaymentMethodSelectorModal', () => { }, ); }); - it('does not track RAMPS_PAYMENT_METHOD_SELECTED event when the same payment method is selected', () => { + it('does not track ONRAMP_PAYMENT_METHOD_SELECTED event when the same payment method is selected', () => { const { getByText } = render(PaymentMethodSelectorModal); const paymentMethodElement = getByText('Credit Card'); diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.testIds.ts b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.testIds.ts new file mode 100644 index 000000000000..3be1da5e5547 --- /dev/null +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.testIds.ts @@ -0,0 +1,4 @@ +export const EnterEmailSelectorsIDs = { + EMAIL_INPUT: 'ramps-enter-email-input', + SEND_EMAIL_BUTTON: 'ramps-enter-email-send-button', +}; diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx index da96ff54c4a4..50fe451ed5f8 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx @@ -29,6 +29,7 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useTransakController } from '../../hooks/useTransakController'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { EnterEmailSelectorsIDs } from './EnterEmail.testIds'; export interface V2EnterEmailParams { amount?: string; @@ -149,6 +150,7 @@ const V2EnterEmail = () => { {