diff --git a/app/actions/settings/index.js b/app/actions/settings/index.js index fabfdd86edbe..87a4876cfb86 100644 --- a/app/actions/settings/index.js +++ b/app/actions/settings/index.js @@ -12,13 +12,6 @@ export function setShowHexData(showHexData) { }; } -export function setShowCustomNonce(showCustomNonce) { - return { - type: 'SET_SHOW_CUSTOM_NONCE', - showCustomNonce, - }; -} - export function setShowFiatOnTestnets(showFiatOnTestnets) { return { type: 'SET_SHOW_FIAT_ON_TESTNETS', diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index a3eaa6b86e2c..0bedfacee0b4 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -6,24 +6,20 @@ import { CHAIN_IDS } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; -export const MUSD_TOKEN_MAINNET = { - address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', +export const MUSD_TOKEN = { symbol: 'MUSD', name: 'MUSD', decimals: 6, - chainId: CHAIN_IDS.MAINNET, } as const; -export const MUSD_CURRENCY = 'MUSD'; - -// mUSD token address on Ethereum mainnet (6 decimals) -export const MUSD_ADDRESS_ETHEREUM = - '0xaca92e438df0b2401ff60da7e4337b687a2435da'; +export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { + [CHAIN_IDS.MAINNET]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', +}; -// Ethereum mainnet chain ID -export const ETHEREUM_MAINNET_CHAIN_ID = '0x1'; +export const MUSD_CURRENCY = 'MUSD'; -export const STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN: Record< +// All stablecoins that are supported in the mUSD conversion flow. +export const MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID: Record< Hex, Record > = { @@ -33,20 +29,20 @@ export const STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN: Record< DAI: '0x6b175474e89094c44da98b954eedeac495271d0f', }, // Temp: Uncomment once we support Linea -> Linea quotes - // [NETWORKS_CHAIN_ID.LINEA_MAINNET]: { - // USDC: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', - // USDT: '0xa219439258ca9da29e9cc4ce5596924745e12b93', - // }, - // [NETWORKS_CHAIN_ID.BSC]: { - // USDC: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', - // USDT: '0x55d398326f99059ff775485246999027b3197955', - // }, + [NETWORKS_CHAIN_ID.LINEA_MAINNET]: { + USDC: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + USDT: '0xa219439258ca9da29e9cc4ce5596924745e12b93', + }, + [NETWORKS_CHAIN_ID.BSC]: { + USDC: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + USDT: '0x55d398326f99059ff775485246999027b3197955', + }, }; export const CONVERTIBLE_STABLECOINS_BY_CHAIN: Record = (() => { const result: Record = {}; for (const [chainId, symbolMap] of Object.entries( - STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN, + MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID, )) { result[chainId as Hex] = Object.values(symbolMap); } diff --git a/app/components/UI/Earn/hooks/useMusdConversion.test.ts b/app/components/UI/Earn/hooks/useMusdConversion.test.ts index 552b4ba21ec5..e4b099d58d52 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.test.ts @@ -1,8 +1,5 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { - useMusdConversion, - areValidAllowedPaymentTokens, -} from './useMusdConversion'; +import { useMusdConversion } from './useMusdConversion'; import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; import { generateTransferData } from '../../../../util/transactions'; @@ -93,13 +90,7 @@ describe('useMusdConversion', () => { describe('initiateConversion', () => { const mockConfig = { - outputToken: { - address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA' as Hex, - chainId: '0x1' as Hex, - symbol: 'MUSD', - name: 'MUSD', - decimals: 6, - }, + outputChainId: '0x1' as Hex, preferredPaymentToken: { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, chainId: '0x1' as Hex, @@ -126,9 +117,11 @@ describe('useMusdConversion', () => { screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, params: { loader: ConfirmationLoader.CustomAmount, - preferredPaymentToken: mockConfig.preferredPaymentToken, - outputToken: mockConfig.outputToken, - allowedPaymentTokens: undefined, + preferredPaymentToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1', + }, + outputChainId: '0x1', }, }); }); @@ -151,11 +144,11 @@ describe('useMusdConversion', () => { expect(mockTransactionController.addTransaction).toHaveBeenCalledWith( { - to: mockConfig.outputToken.address, - from: mockSelectedAccount.address, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + from: '0x123456789abcdef', data: '0xmockedTransferData', value: '0x0', - chainId: mockConfig.outputToken.chainId, + chainId: '0x1', }, { networkClientId: 'mainnet', @@ -164,7 +157,7 @@ describe('useMusdConversion', () => { type: TransactionType.musdConversion, nestedTransactions: [ { - to: mockConfig.outputToken.address, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', data: '0xmockedTransferData', value: '0x0', }, @@ -197,7 +190,7 @@ describe('useMusdConversion', () => { expect(Array.isArray(options.nestedTransactions)).toBe(true); expect(options.nestedTransactions).toHaveLength(1); expect(options.nestedTransactions[0]).toEqual({ - to: mockConfig.outputToken.address, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', data: '0xmockedTransferData', value: '0x0', }); @@ -239,7 +232,7 @@ describe('useMusdConversion', () => { expect(Logger.error).toHaveBeenCalled(); }); - it('throws error when outputToken is missing', async () => { + it('throws error when outputChainId is missing', async () => { const mockSelectorFn = jest.fn(() => mockSelectedAccount); mockUseSelector.mockReturnValue(mockSelectorFn); mockSelectorFn.mockReturnValue(mockSelectedAccount); @@ -248,15 +241,15 @@ describe('useMusdConversion', () => { const invalidConfig = { ...mockConfig, - outputToken: undefined, + outputChainId: undefined, }; await act(async () => { await expect( - // @ts-expect-error - Intentionally testing invalid config with missing outputToken + // @ts-expect-error - Intentionally testing invalid config with missing outputChainId result.current.initiateConversion(invalidConfig), ).rejects.toThrow( - 'Output token and preferred payment token are required', + 'Output chain ID and preferred payment token are required', ); }); }); @@ -278,7 +271,7 @@ describe('useMusdConversion', () => { // @ts-expect-error - Intentionally testing invalid config with missing preferredPaymentToken result.current.initiateConversion(invalidConfig), ).rejects.toThrow( - 'Output token and preferred payment token are required', + 'Output chain ID and preferred payment token are required', ); }); }); @@ -335,39 +328,6 @@ describe('useMusdConversion', () => { ); }); - it('includes allowedPaymentTokens in navigation params when provided', async () => { - const mockSelectorFn = jest.fn(() => mockSelectedAccount); - mockUseSelector.mockReturnValue(mockSelectorFn); - mockSelectorFn.mockReturnValue(mockSelectedAccount); - - mockNetworkController.findNetworkClientIdByChainId.mockReturnValue( - 'mainnet', - ); - mockTransactionController.addTransaction.mockResolvedValue({ - transactionMeta: { id: 'tx-123' }, - }); - - const { result } = renderHook(() => useMusdConversion()); - - const allowedTokens: Record = { - '0x1': ['0xabc' as Hex], - }; - - const configWithAllowedTokens = { - ...mockConfig, - allowedPaymentTokens: allowedTokens, - }; - - await result.current.initiateConversion(configWithAllowedTokens); - - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.EARN.ROOT, { - screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, - params: expect.objectContaining({ - allowedPaymentTokens: allowedTokens, - }), - }); - }); - it('returns transaction ID on success', async () => { const mockSelectorFn = jest.fn(() => mockSelectedAccount); mockUseSelector.mockReturnValue(mockSelectorFn); @@ -410,14 +370,8 @@ describe('useMusdConversion', () => { const { result } = renderHook(() => useMusdConversion()); - const mockConfig = { - outputToken: { - address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA' as Hex, - chainId: '0x1' as Hex, - symbol: 'MUSD', - name: 'MUSD', - decimals: 6, - }, + const testConfig = { + outputChainId: '0x1' as Hex, preferredPaymentToken: { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, chainId: '0x1' as Hex, @@ -430,7 +384,7 @@ describe('useMusdConversion', () => { await act(async () => { await expect( - result.current.initiateConversion(mockConfig), + result.current.initiateConversion(testConfig), ).rejects.toThrow('Transaction failed'); }); @@ -441,87 +395,10 @@ describe('useMusdConversion', () => { }); await act(async () => { - await result.current.initiateConversion(mockConfig); + await result.current.initiateConversion(testConfig); }); expect(result.current.error).toBeNull(); }); }); }); - -describe('areValidAllowedPaymentTokens', () => { - it('returns true for valid Record', () => { - const validInput: Record = { - '0x1': ['0xabc' as Hex, '0xdef' as Hex], - '0x2': ['0x123' as Hex], - }; - - const result = areValidAllowedPaymentTokens(validInput); - - expect(result).toBe(true); - }); - - it('returns false for null', () => { - const result = areValidAllowedPaymentTokens(null); - - expect(result).toBe(false); - }); - - it('returns false for undefined', () => { - const result = areValidAllowedPaymentTokens(undefined); - - expect(result).toBe(false); - }); - - it('returns false for arrays', () => { - const result = areValidAllowedPaymentTokens(['0x1', '0x2']); - - expect(result).toBe(false); - }); - - it('returns false when keys are not hex strings', () => { - const invalidInput = { - notHex: ['0xabc' as Hex], - }; - - const result = areValidAllowedPaymentTokens(invalidInput); - - expect(result).toBe(false); - }); - - it('returns false when values are not arrays', () => { - const invalidInput = { - '0x1': '0xabc', - }; - - const result = areValidAllowedPaymentTokens(invalidInput); - - expect(result).toBe(false); - }); - - it('returns false when array elements are not hex strings', () => { - const invalidInput = { - '0x1': ['notHex'], - }; - - const result = areValidAllowedPaymentTokens(invalidInput); - - expect(result).toBe(false); - }); - - it('returns true for empty object', () => { - const result = areValidAllowedPaymentTokens({}); - - expect(result).toBe(true); - }); - - it('returns true for object with empty arrays', () => { - const validInput: Record = { - '0x1': [], - }; - - const result = areValidAllowedPaymentTokens(validInput); - - expect(result).toBe(true); - }); -}); diff --git a/app/components/UI/Earn/hooks/useMusdConversion.ts b/app/components/UI/Earn/hooks/useMusdConversion.ts index 183d621e434f..9da8816f1891 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.ts @@ -1,4 +1,4 @@ -import { Hex, isHexString } from '@metamask/utils'; +import { Hex } from '@metamask/utils'; import { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; @@ -11,44 +11,16 @@ import { ConfirmationLoader } from '../../../Views/confirmations/components/conf import { EVM_SCOPE } from '../constants/networks'; import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; import { TransactionType } from '@metamask/transaction-controller'; - -/** - * Type guard to validate allowedPaymentTokens structure. - * Checks if the value is a valid Record mapping. - * Validates that both keys (chain IDs) and values (token addresses) are hex strings. - * - * @param value - Value to validate - * @returns true if valid, false otherwise - */ -export const areValidAllowedPaymentTokens = ( - value: unknown, -): value is Record => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - - return Object.entries(value).every( - ([key, val]) => - isHexString(key) && - Array.isArray(val) && - val.every((addr) => isHexString(addr)), - ); -}; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; /** * Configuration for mUSD conversion */ export interface MusdConversionConfig { /** - * The mUSD token to convert to + * The chain ID of the mUSD token to convert to. */ - outputToken: { - address: Hex; - chainId: Hex; - symbol: string; - name: string; - decimals: number; - }; + outputChainId: Hex; /** * The payment token to prefill in the confirmation screen */ @@ -56,12 +28,6 @@ export interface MusdConversionConfig { address: Hex; chainId: Hex; }; - /** - * Optional allowlist of payment tokens that can be used to pay for the conversion, organized by chain ID. - * Maps chain IDs to arrays of allowed token addresses. - * If not provided, all tokens will be available for selection. - */ - allowedPaymentTokens?: Record; /** * Optional navigation stack to use (defaults to Routes.EARN.ROOT) */ @@ -73,7 +39,7 @@ export interface MusdConversionConfig { * * **EVM-Only**: This hook only supports EVM-compatible chains. It uses ERC-20 * transfer encoding and MetaMask Pay's Relay integration, which are specific to - * EVM networks. For non-EVM chains (Bitcoin, Solana, Tron), use alternative flows. + * EVM networks. * * This hook handles both transaction creation and navigation to the confirmation screen. * @@ -81,17 +47,12 @@ export interface MusdConversionConfig { * const { initiateConversion } = useMusdConversion(); * * await initiateConversion({ - * outputToken: { - * address: MUSD_ADDRESS_ETHEREUM, - * chainId: ETHEREUM_MAINNET_CHAIN_ID, - * symbol: 'mUSD', - * name: 'mUSD', - * decimals: 6, - * }, + * outputChainId: CHAIN_IDS.MAINNET, * preferredPaymentToken: { - * address: USDC_ADDRESS_ARBITRUM, - * chainId: NETWORKS_CHAIN_ID.ARBITRUM, + * address: USDC_ADDRESS_MAINNET, + * chainId: CHAIN_IDS.MAINNET, * }, + * navigationStack: Routes.EARN.ROOT, * }); */ export const useMusdConversion = () => { @@ -105,13 +66,13 @@ export const useMusdConversion = () => { const selectedAddress = selectedAccount?.address; /** - * Creates a placeholder transaction and navigating to confirmation. - * Navigation happens immediately, then transaction creation happens in background. + * Creates a placeholder transaction and navigates to confirmation. + * Navigation happens immediately. Transaction creation and gas estimation happen asynchronously. */ const initiateConversion = useCallback( async (config: MusdConversionConfig): Promise => { const { - outputToken, + outputChainId, preferredPaymentToken, navigationStack = Routes.EARN.ROOT, } = config; @@ -119,9 +80,9 @@ export const useMusdConversion = () => { try { setError(null); - if (!outputToken || !preferredPaymentToken) { + if (!outputChainId || !preferredPaymentToken) { throw new Error( - 'Output token and preferred payment token are required', + 'Output chain ID and preferred payment token are required', ); } @@ -130,13 +91,12 @@ export const useMusdConversion = () => { } const { NetworkController } = Engine.context; - const networkClientId = NetworkController.findNetworkClientIdByChainId( - outputToken.chainId, - ); + const networkClientId = + NetworkController.findNetworkClientIdByChainId(outputChainId); if (!networkClientId) { throw new Error( - `Network client not found for chain ID: ${outputToken.chainId}`, + `Network client not found for chain ID: ${outputChainId}`, ); } @@ -150,14 +110,7 @@ export const useMusdConversion = () => { params: { loader: ConfirmationLoader.CustomAmount, preferredPaymentToken, - outputToken: { - address: outputToken.address, - chainId: outputToken.chainId, - symbol: outputToken.symbol, - name: outputToken.name, - decimals: outputToken.decimals, - }, - allowedPaymentTokens: config.allowedPaymentTokens, + outputChainId, }, }); @@ -175,14 +128,22 @@ export const useMusdConversion = () => { const { TransactionController } = Engine.context; + const mUSDTokenAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[outputChainId]; + + if (!mUSDTokenAddress) { + throw new Error( + `mUSD token address not found for chain ID: ${outputChainId}`, + ); + } + const { transactionMeta } = await TransactionController.addTransaction( { - to: outputToken.address, + to: mUSDTokenAddress, from: selectedAddress, data: transferData, value: ZERO_HEX_VALUE, - chainId: outputToken.chainId, + chainId: outputChainId, }, { /** @@ -196,7 +157,7 @@ export const useMusdConversion = () => { // Important: Nested transaction is required for Relay to work. This will be fixed in a future iteration. nestedTransactions: [ { - to: outputToken.address, + to: mUSDTokenAddress, data: transferData as Hex, value: ZERO_HEX_VALUE, }, diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts new file mode 100644 index 000000000000..79cf48e50a0e --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts @@ -0,0 +1,436 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { Hex } from '@metamask/utils'; +import { useSelector } from 'react-redux'; +import { useMusdConversionTokens } from './useMusdConversionTokens'; +import { selectMusdConversionPaymentTokensAllowlist } from '../selectors/featureFlags'; +import { isMusdConversionPaymentToken } from '../utils/musd'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { useAccountTokens } from '../../../Views/confirmations/hooks/send/useAccountTokens'; + +jest.mock('react-redux'); +jest.mock('../selectors/featureFlags'); +jest.mock('../utils/musd'); +jest.mock('../../../Views/confirmations/hooks/send/useAccountTokens'); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockIsMusdConversionPaymentToken = + isMusdConversionPaymentToken as jest.MockedFunction< + typeof isMusdConversionPaymentToken + >; +const mockUseAccountTokens = useAccountTokens as jest.MockedFunction< + typeof useAccountTokens +>; + +describe('useMusdConversionTokens', () => { + const mockAllowlist: Record = { + '0x1': [ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC + '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT + ], + '0xe708': [ + '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC on Linea + ], + }; + + const mockUsdcMainnet: AssetType = { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + balance: '1000000', + logo: 'https://example.com/usdc.png', + isETH: false, + aggregators: [], + image: 'https://example.com/usdc.png', + }; + + const mockUsdtMainnet: AssetType = { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + chainId: '0x1', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + balance: '2000000', + logo: 'https://example.com/usdt.png', + isETH: false, + aggregators: [], + image: 'https://example.com/usdt.png', + }; + + const mockUsdcLinea: AssetType = { + address: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + chainId: '0xe708', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + balance: '500000', + logo: 'https://example.com/usdc.png', + isETH: false, + aggregators: [], + image: 'https://example.com/usdc.png', + }; + + const mockDaiMainnet: AssetType = { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: '0x1', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + balance: '3000000', + logo: 'https://example.com/dai.png', + isETH: false, + aggregators: [], + image: 'https://example.com/dai.png', + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectMusdConversionPaymentTokensAllowlist) { + return mockAllowlist; + } + return undefined; + }); + mockUseAccountTokens.mockReturnValue([]); + mockIsMusdConversionPaymentToken.mockReturnValue(false); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('hook structure', () => { + it('returns object with tokenFilter, isConversionToken, and tokens properties', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current).toHaveProperty('tokenFilter'); + expect(result.current).toHaveProperty('isConversionToken'); + expect(result.current).toHaveProperty('tokens'); + }); + + it('returns tokenFilter as a function', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(typeof result.current.tokenFilter).toBe('function'); + }); + + it('returns isConversionToken as a function', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(typeof result.current.isConversionToken).toBe('function'); + }); + + it('returns tokens as an array', () => { + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(Array.isArray(result.current.tokens)).toBe(true); + }); + }); + + describe('token filtering', () => { + it('filters tokens correctly based on allowlist', () => { + mockUseAccountTokens.mockReturnValue([ + mockUsdcMainnet, + mockUsdtMainnet, + mockDaiMainnet, + ]); + mockIsMusdConversionPaymentToken.mockImplementation( + (address, _allowlist, chainId) => { + if (chainId === '0x1') { + return ( + address.toLowerCase() === mockUsdcMainnet.address.toLowerCase() || + address.toLowerCase() === mockUsdtMainnet.address.toLowerCase() + ); + } + return false; + }, + ); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toHaveLength(2); + expect(result.current.tokens).toContainEqual(mockUsdcMainnet); + expect(result.current.tokens).toContainEqual(mockUsdtMainnet); + expect(result.current.tokens).not.toContainEqual(mockDaiMainnet); + }); + + it('returns empty array when no tokens match allowlist', () => { + mockUseAccountTokens.mockReturnValue([mockDaiMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(false); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toEqual([]); + }); + + it('filters tokens from multiple chains correctly', () => { + mockUseAccountTokens.mockReturnValue([ + mockUsdcMainnet, + mockUsdcLinea, + mockDaiMainnet, + ]); + mockIsMusdConversionPaymentToken.mockImplementation( + (address, _allowlist, chainId) => { + if (chainId === '0x1') { + return ( + address.toLowerCase() === mockUsdcMainnet.address.toLowerCase() + ); + } + if (chainId === '0xe708') { + return ( + address.toLowerCase() === mockUsdcLinea.address.toLowerCase() + ); + } + return false; + }, + ); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toHaveLength(2); + expect(result.current.tokens).toContainEqual(mockUsdcMainnet); + expect(result.current.tokens).toContainEqual(mockUsdcLinea); + expect(result.current.tokens).not.toContainEqual(mockDaiMainnet); + }); + + it('handles tokens with different case addresses', () => { + const uppercaseUsdcMainnet: AssetType = { + ...mockUsdcMainnet, + address: mockUsdcMainnet.address.toUpperCase() as Hex, + }; + mockUseAccountTokens.mockReturnValue([uppercaseUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toHaveLength(1); + expect(result.current.tokens[0]).toEqual(uppercaseUsdcMainnet); + }); + }); + + describe('isConversionToken', () => { + it('returns true for token in conversion tokens list with matching address and chainId', () => { + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + const { result } = renderHook(() => useMusdConversionTokens()); + const isConversion = result.current.isConversionToken(mockUsdcMainnet); + + expect(isConversion).toBe(true); + }); + + it('returns false for token not in conversion tokens list', () => { + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + const { result } = renderHook(() => useMusdConversionTokens()); + const isConversion = result.current.isConversionToken(mockDaiMainnet); + + expect(isConversion).toBe(false); + }); + + it('returns false when token is undefined', () => { + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + const { result } = renderHook(() => useMusdConversionTokens()); + const isConversion = result.current.isConversionToken(undefined); + + expect(isConversion).toBe(false); + }); + + it('returns false when token address matches but chainId differs', () => { + const usdcWithDifferentChain: AssetType = { + ...mockUsdcMainnet, + chainId: '0x89', // Polygon + }; + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + const { result } = renderHook(() => useMusdConversionTokens()); + const isConversion = result.current.isConversionToken( + usdcWithDifferentChain, + ); + + expect(isConversion).toBe(false); + }); + + it('performs case-insensitive address comparison', () => { + const uppercaseUsdcMainnet: AssetType = { + ...mockUsdcMainnet, + address: mockUsdcMainnet.address.toUpperCase() as Hex, + }; + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + const { result } = renderHook(() => useMusdConversionTokens()); + const isConversion = + result.current.isConversionToken(uppercaseUsdcMainnet); + + expect(isConversion).toBe(true); + }); + }); + + describe('tokenFilter callback', () => { + it('filters array of tokens correctly', () => { + mockUseAccountTokens.mockReturnValue([]); + mockIsMusdConversionPaymentToken.mockImplementation( + (address, _allowlist, chainId) => { + if (chainId === '0x1') { + return ( + address.toLowerCase() === mockUsdcMainnet.address.toLowerCase() + ); + } + return false; + }, + ); + + const { result } = renderHook(() => useMusdConversionTokens()); + const filtered = result.current.tokenFilter([ + mockUsdcMainnet, + mockDaiMainnet, + ]); + + expect(filtered).toHaveLength(1); + expect(filtered[0]).toEqual(mockUsdcMainnet); + }); + + it('returns empty array when given empty array', () => { + mockUseAccountTokens.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionTokens()); + const filtered = result.current.tokenFilter([]); + + expect(filtered).toEqual([]); + }); + + it('maintains referential equality across renders with same allowlist', () => { + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + + const { result, rerender } = renderHook(() => useMusdConversionTokens()); + const firstTokenFilter = result.current.tokenFilter; + + rerender(); + const secondTokenFilter = result.current.tokenFilter; + + expect(firstTokenFilter).toBe(secondTokenFilter); + }); + + it('creates new tokenFilter when allowlist changes', () => { + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + + const { result, rerender } = renderHook(() => useMusdConversionTokens()); + const firstTokenFilter = result.current.tokenFilter; + + const newAllowlist: Record = { + '0x1': ['0x6b175474e89094c44da98b954eedeac495271d0f'], + }; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectMusdConversionPaymentTokensAllowlist) { + return newAllowlist; + } + return undefined; + }); + + rerender(); + const secondTokenFilter = result.current.tokenFilter; + + expect(firstTokenFilter).not.toBe(secondTokenFilter); + }); + }); + + describe('integration with dependencies', () => { + it('uses allowlist from selector correctly', () => { + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + renderHook(() => useMusdConversionTokens()); + + expect(mockUseSelector).toHaveBeenCalled(); + }); + + it('uses tokens from useAccountTokens hook', () => { + const allTokens = [mockUsdcMainnet, mockUsdtMainnet]; + mockUseAccountTokens.mockReturnValue(allTokens); + + renderHook(() => useMusdConversionTokens()); + + expect(mockUseAccountTokens).toHaveBeenCalledWith({ + includeNoBalance: false, + }); + }); + + it('calls isMusdConversionPaymentToken utility with correct parameters', () => { + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(true); + + renderHook(() => useMusdConversionTokens()); + + expect(mockIsMusdConversionPaymentToken).toHaveBeenCalledWith( + mockUsdcMainnet.address, + mockAllowlist, + mockUsdcMainnet.chainId, + ); + }); + }); + + describe('edge cases', () => { + it('handles empty allowlist correctly', () => { + const emptyAllowlist: Record = {}; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectMusdConversionPaymentTokensAllowlist) { + return emptyAllowlist; + } + return undefined; + }); + mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); + mockIsMusdConversionPaymentToken.mockReturnValue(false); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toEqual([]); + }); + + it('handles empty token list from useAccountTokens', () => { + mockUseAccountTokens.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toEqual([]); + }); + + it('handles tokens without chainId property', () => { + const tokenWithoutChainId = { + ...mockUsdcMainnet, + chainId: undefined, + } as unknown as AssetType; + mockUseAccountTokens.mockReturnValue([tokenWithoutChainId]); + mockIsMusdConversionPaymentToken.mockImplementation( + (_address, _allowlist, chainId) => { + if (!chainId) { + return false; + } + return true; + }, + ); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toEqual([]); + }); + + it('handles tokens without address property gracefully', () => { + const tokenWithoutAddress = { + ...mockUsdcMainnet, + address: '', + } as AssetType; + mockUseAccountTokens.mockReturnValue([tokenWithoutAddress]); + mockIsMusdConversionPaymentToken.mockReturnValue(false); + + const { result } = renderHook(() => useMusdConversionTokens()); + + expect(result.current.tokens).toEqual([]); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts new file mode 100644 index 000000000000..f22922fa1239 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts @@ -0,0 +1,48 @@ +import { useSelector } from 'react-redux'; +import { selectMusdConversionPaymentTokensAllowlist } from '../selectors/featureFlags'; +import { isMusdConversionPaymentToken } from '../utils/musd'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { useAccountTokens } from '../../../Views/confirmations/hooks/send/useAccountTokens'; +import { useCallback, useMemo } from 'react'; +import { TokenI } from '../../Tokens/types'; + +export const useMusdConversionTokens = () => { + const musdConversionPaymentTokensAllowlist = useSelector( + selectMusdConversionPaymentTokensAllowlist, + ); + + const allTokens = useAccountTokens({ includeNoBalance: false }); + + const tokenFilter = useCallback( + (tokens: AssetType[]) => + tokens.filter((token) => + isMusdConversionPaymentToken( + token.address, + musdConversionPaymentTokensAllowlist, + token.chainId, + ), + ), + [musdConversionPaymentTokensAllowlist], + ); + + const conversionTokens = useMemo( + () => tokenFilter(allTokens), + [allTokens, tokenFilter], + ); + + const isConversionToken = (token?: AssetType | TokenI) => { + if (!token) return false; + + return conversionTokens.some( + (musdToken) => + token.address.toLowerCase() === musdToken.address.toLowerCase() && + token.chainId === musdToken.chainId, + ); + }; + + return { + tokenFilter, + isConversionToken, + tokens: conversionTokens, + }; +}; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index 48e4bd21192a..2d81630a8202 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -17,6 +17,9 @@ import { } from '../../../../../util/remoteFeatureFlag'; // eslint-disable-next-line import/no-namespace import * as remoteFeatureFlagModule from '../../../../../util/remoteFeatureFlag'; +// eslint-disable-next-line import/no-namespace +import * as musdUtils from '../../utils/musd'; +import { Hex } from '@metamask/utils'; jest.mock('react-native-device-info', () => ({ getVersion: jest.fn().mockReturnValue('1.0.0'), @@ -1013,5 +1016,131 @@ describe('Earn Feature Flag Selectors', () => { '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI ]); }); + + describe('validation of converted allowlists', () => { + let ConvertSymbolAllowlistToAddressesSpy: jest.MockedFunction< + typeof musdUtils.convertSymbolAllowlistToAddresses + >; + + beforeEach(() => { + ConvertSymbolAllowlistToAddressesSpy = jest.spyOn( + musdUtils, + 'convertSymbolAllowlistToAddresses', + ) as jest.MockedFunction< + typeof musdUtils.convertSymbolAllowlistToAddresses + >; + }); + + afterEach(() => { + ConvertSymbolAllowlistToAddressesSpy.mockRestore(); + delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; + }); + + it('uses remote allowlist over local when remote is valid', () => { + const localAllowlist = { '0x1': ['DAI'] }; + process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST = + JSON.stringify(localAllowlist); + const remoteAllowlist = { '0x1': ['USDC', 'USDT'] }; + const stateWithBoth = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensAllowlist: remoteAllowlist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + ConvertSymbolAllowlistToAddressesSpy.mockReturnValueOnce({ + // First call: LOCAL conversion (DAI) + '0x1': ['0x6b175474e89094c44da98b954eedeac495271d0f' as Hex], + }).mockReturnValueOnce({ + // Second call: REMOTE conversion (USDC, USDT) - takes priority + '0x1': [ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, + '0xdac17f958d2ee523a2206206994597c13d831ec7' as Hex, + ], + }); + + const result = + selectMusdConversionPaymentTokensAllowlist(stateWithBoth); + + expect(result['0x1']).toEqual([ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ]); + }); + + it('uses local allowlist when remote is invalid', () => { + const localAllowlist = { '0x1': ['DAI'] }; + process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST = + JSON.stringify(localAllowlist); + const remoteAllowlist = { '0x1': ['USDC'] }; + const stateWithBoth = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensAllowlist: remoteAllowlist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + ConvertSymbolAllowlistToAddressesSpy.mockReturnValueOnce({ + // First call: LOCAL conversion (DAI) - valid + '0x1': ['0x6b175474e89094c44da98b954eedeac495271d0f' as Hex], + }).mockReturnValueOnce({ + // Second call: REMOTE conversion (USDC) - invalid + '0x1': ['invalid-address' as Hex], + } as Record); + + const result = + selectMusdConversionPaymentTokensAllowlist(stateWithBoth); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Remote earnMusdConvertibleTokensAllowlist produced invalid structure', + ); + expect(result['0x1']).toEqual([ + '0x6b175474e89094c44da98b954eedeac495271d0f', + ]); + }); + + it('uses fallback allowlist when both remote and local are invalid', () => { + const localAllowlist = { '0x1': ['USDC'] }; + process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST = + JSON.stringify(localAllowlist); + const remoteAllowlist = { '0x1': ['USDT'] }; + const stateWithBoth = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensAllowlist: remoteAllowlist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + ConvertSymbolAllowlistToAddressesSpy.mockReturnValue({ + // Invalid for both + '0x1': ['invalid-local' as Hex], + } as Record); + const result = + selectMusdConversionPaymentTokensAllowlist(stateWithBoth); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Local MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST produced invalid structure', + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Remote earnMusdConvertibleTokensAllowlist produced invalid structure', + ); + expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN); + }); + }); }); }); diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 4db50da30931..1953e31b883e 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -6,7 +6,10 @@ import { } from '../../../../../util/remoteFeatureFlag'; import { Hex } from '@metamask/utils'; import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../../constants/musd'; -import { convertSymbolAllowlistToAddresses } from '../../utils/musd'; +import { + areValidAllowedPaymentTokens, + convertSymbolAllowlistToAddresses, +} from '../../utils/musd'; export const selectPooledStakingEnabledFlag = createSelector( selectRemoteFeatureFlags, @@ -86,7 +89,14 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( if (localEnvValue) { const parsed = JSON.parse(localEnvValue); - localAllowlist = convertSymbolAllowlistToAddresses(parsed); + const converted = convertSymbolAllowlistToAddresses(parsed); + if (areValidAllowedPaymentTokens(converted)) { + localAllowlist = converted; + } else { + console.warn( + 'Local MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST produced invalid structure', + ); + } } } catch (error) { console.warn( @@ -95,7 +105,6 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( ); } - // RemoteFeatureFlagController already parses the flag. const remoteAllowlist = remoteFeatureFlags?.earnMusdConvertibleTokensAllowlist; @@ -106,15 +115,20 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( ? JSON.parse(remoteAllowlist) : remoteAllowlist; - // Validate it's an object (not array) before passing to converter if ( parsedRemote && typeof parsedRemote === 'object' && !Array.isArray(parsedRemote) ) { - return convertSymbolAllowlistToAddresses( + const converted = convertSymbolAllowlistToAddresses( parsedRemote as Record, ); + if (areValidAllowedPaymentTokens(converted)) { + return converted; + } + console.warn( + 'Remote earnMusdConvertibleTokensAllowlist produced invalid structure', + ); } } catch (error) { console.warn( diff --git a/app/components/UI/Earn/utils/musd.test.ts b/app/components/UI/Earn/utils/musd.test.ts index 9e709f5c7c75..a8a4b9a8d2a6 100644 --- a/app/components/UI/Earn/utils/musd.test.ts +++ b/app/components/UI/Earn/utils/musd.test.ts @@ -1,9 +1,11 @@ import { Hex } from '@metamask/utils'; import { + areValidAllowedPaymentTokens, convertSymbolAllowlistToAddresses, isMusdConversionPaymentToken, } from './musd'; import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; +import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../constants/musd'; describe('convertSymbolAllowlistToAddresses', () => { let consoleWarnSpy: jest.SpyInstance; @@ -143,11 +145,89 @@ describe('convertSymbolAllowlistToAddresses', () => { }); }); +describe('areValidAllowedPaymentTokens', () => { + it('returns true for valid Record', () => { + const validInput: Record = { + '0x1': ['0xabc' as Hex, '0xdef' as Hex], + '0x2': ['0x123' as Hex], + }; + + const result = areValidAllowedPaymentTokens(validInput); + + expect(result).toBe(true); + }); + + it('returns false for null', () => { + const result = areValidAllowedPaymentTokens(null); + + expect(result).toBe(false); + }); + + it('returns false for undefined', () => { + const result = areValidAllowedPaymentTokens(undefined); + + expect(result).toBe(false); + }); + + it('returns false for arrays', () => { + const result = areValidAllowedPaymentTokens(['0x1', '0x2']); + + expect(result).toBe(false); + }); + + it('returns false when keys are not hex strings', () => { + const invalidInput = { + notHex: ['0xabc' as Hex], + }; + + const result = areValidAllowedPaymentTokens(invalidInput); + + expect(result).toBe(false); + }); + + it('returns false when values are not arrays', () => { + const invalidInput = { + '0x1': '0xabc', + }; + + const result = areValidAllowedPaymentTokens(invalidInput); + + expect(result).toBe(false); + }); + + it('returns false when array elements are not hex strings', () => { + const invalidInput = { + '0x1': ['notHex'], + }; + + const result = areValidAllowedPaymentTokens(invalidInput); + + expect(result).toBe(false); + }); + + it('returns true for empty object', () => { + const result = areValidAllowedPaymentTokens({}); + + expect(result).toBe(true); + }); + + it('returns true for object with empty arrays', () => { + const validInput: Record = { + '0x1': [], + }; + + const result = areValidAllowedPaymentTokens(validInput); + + expect(result).toBe(true); + }); +}); + describe('isMusdConversionPaymentToken', () => { describe('supported chains with valid tokens', () => { it('returns true for USDC on Mainnet', () => { const result = isMusdConversionPaymentToken( '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + CONVERTIBLE_STABLECOINS_BY_CHAIN, NETWORKS_CHAIN_ID.MAINNET, ); @@ -157,6 +237,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns true for DAI on Mainnet', () => { const result = isMusdConversionPaymentToken( '0x6b175474e89094c44da98b954eedeac495271d0f', + CONVERTIBLE_STABLECOINS_BY_CHAIN, NETWORKS_CHAIN_ID.MAINNET, ); @@ -168,6 +249,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns true for mixed case USDC address on Mainnet', () => { const result = isMusdConversionPaymentToken( '0xA0B86991c6218B36c1d19D4a2e9Eb0cE3606eB48', + CONVERTIBLE_STABLECOINS_BY_CHAIN, NETWORKS_CHAIN_ID.MAINNET, ); @@ -179,6 +261,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns false for valid token on unsupported chain', () => { const result = isMusdConversionPaymentToken( '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + CONVERTIBLE_STABLECOINS_BY_CHAIN, '0x999' as Hex, ); @@ -188,6 +271,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns false for Polygon chain', () => { const result = isMusdConversionPaymentToken( '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + CONVERTIBLE_STABLECOINS_BY_CHAIN, '0x89' as Hex, ); @@ -199,6 +283,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns false for random address on Mainnet', () => { const result = isMusdConversionPaymentToken( '0x1234567890123456789012345678901234567890', + CONVERTIBLE_STABLECOINS_BY_CHAIN, NETWORKS_CHAIN_ID.MAINNET, ); @@ -208,6 +293,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns false for WETH address on Mainnet', () => { const result = isMusdConversionPaymentToken( '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + CONVERTIBLE_STABLECOINS_BY_CHAIN, NETWORKS_CHAIN_ID.MAINNET, ); @@ -225,8 +311,8 @@ describe('isMusdConversionPaymentToken', () => { const result = isMusdConversionPaymentToken( '0x1234567890123456789012345678901234567890', - NETWORKS_CHAIN_ID.MAINNET, customAllowlist, + NETWORKS_CHAIN_ID.MAINNET, ); expect(result).toBe(true); @@ -241,8 +327,8 @@ describe('isMusdConversionPaymentToken', () => { const result = isMusdConversionPaymentToken( '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - NETWORKS_CHAIN_ID.MAINNET, customAllowlist, + NETWORKS_CHAIN_ID.MAINNET, ); expect(result).toBe(false); @@ -253,8 +339,8 @@ describe('isMusdConversionPaymentToken', () => { const result = isMusdConversionPaymentToken( '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - NETWORKS_CHAIN_ID.MAINNET, customAllowlist, + NETWORKS_CHAIN_ID.MAINNET, ); expect(result).toBe(false); @@ -265,6 +351,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns false for empty address', () => { const result = isMusdConversionPaymentToken( '', + CONVERTIBLE_STABLECOINS_BY_CHAIN, NETWORKS_CHAIN_ID.MAINNET, ); @@ -274,6 +361,7 @@ describe('isMusdConversionPaymentToken', () => { it('returns false for empty chain ID', () => { const result = isMusdConversionPaymentToken( '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + CONVERTIBLE_STABLECOINS_BY_CHAIN, '', ); diff --git a/app/components/UI/Earn/utils/musd.ts b/app/components/UI/Earn/utils/musd.ts index abd39e0b1644..583bb65be020 100644 --- a/app/components/UI/Earn/utils/musd.ts +++ b/app/components/UI/Earn/utils/musd.ts @@ -2,9 +2,9 @@ * mUSD Conversion Utility Functions for Earn namespace */ -import { Hex } from '@metamask/utils'; +import { Hex, isHexString } from '@metamask/utils'; import { - STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN, + MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID, CONVERTIBLE_STABLECOINS_BY_CHAIN, } from '../constants/musd'; @@ -26,11 +26,12 @@ export const convertSymbolAllowlistToAddresses = ( const result: Record = {}; for (const [chainId, symbols] of Object.entries(allowlistBySymbol)) { - const chainMapping = STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN[chainId as Hex]; + const chainMapping = + MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID[chainId as Hex]; if (!chainMapping) { console.warn( `[mUSD Allowlist] Unsupported chain ID "${chainId}" in allowlist. ` + - `Supported chains: ${Object.keys(STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN).join(', ')}`, + `Supported chains: ${Object.keys(MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID).join(', ')}`, ); continue; } @@ -62,6 +63,29 @@ export const convertSymbolAllowlistToAddresses = ( return result; }; +/** + * Type guard to validate allowedPaymentTokens structure. + * Checks if the value is a valid Record mapping. + * Validates that both keys (chain IDs) and values (token addresses) are hex strings. + * + * @param value - Value to validate + * @returns true if valid, false otherwise + */ +export const areValidAllowedPaymentTokens = ( + value: unknown, +): value is Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + return Object.entries(value).every( + ([key, val]) => + isHexString(key) && + Array.isArray(val) && + val.every((addr) => isHexString(addr)), + ); +}; + /** * Checks if a token is an allowed payment token for mUSD conversion based on its address and chain ID. * Centralizes the logic for determining which tokens on which chains can show the "Convert" CTA. @@ -73,9 +97,11 @@ export const convertSymbolAllowlistToAddresses = ( */ export const isMusdConversionPaymentToken = ( tokenAddress: string, - chainId: string, allowlist: Record = CONVERTIBLE_STABLECOINS_BY_CHAIN, + chainId?: string, ): boolean => { + if (!chainId) return false; + const convertibleTokens = allowlist[chainId as Hex]; if (!convertibleTokens) { return false; diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx index c544f4cc4949..e107099dd5ff 100644 --- a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx @@ -176,7 +176,7 @@ describe('PerpsAdjustMarginView', () => { }); mockUsePerpsMarkets.mockReturnValue({ - markets: [{ coin: 'ETH', maxLeverage: 50 }], + markets: [{ symbol: 'ETH', maxLeverage: '50x' }], }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index e2fb615ca429..ddfd394cea74 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -119,6 +119,8 @@ import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPref import PerpsSelectAdjustMarginActionView from '../PerpsSelectAdjustMarginActionView'; import PerpsSelectModifyActionView from '../PerpsSelectModifyActionView'; import { usePerpsTPSLUpdate } from '../../hooks/usePerpsTPSLUpdate'; +import PerpsStopLossPromptBanner from '../../components/PerpsStopLossPromptBanner'; +import { useStopLossPrompt } from '../../hooks/useStopLossPrompt'; interface MarketDetailsRouteParams { market: PerpsMarketData; @@ -172,6 +174,11 @@ const PerpsMarketDetailsView: React.FC = () => { const [selectedTooltip, setSelectedTooltip] = useState(null); + // Stop loss prompt banner state + const [isSettingStopLoss, setIsSettingStopLoss] = useState(false); + const [isStopLossSuccess, setIsStopLossSuccess] = useState(false); + const [hideBannerAfterSuccess, setHideBannerAfterSuccess] = useState(false); + const isEligible = useSelector(selectPerpsEligibility); // Check if current market is in watchlist @@ -365,6 +372,24 @@ const PerpsMarketDetailsView: React.FC = () => { return undefined; }, [existingPosition]); + // Stop loss prompt banner logic + const { + shouldShowBanner, + variant: bannerVariant, + liquidationDistance, + suggestedStopLossPrice, + suggestedStopLossPercent, + } = useStopLossPrompt({ + position: existingPosition, + currentPrice, + }); + + // Reset stop loss banner state when market or position changes + useEffect(() => { + setHideBannerAfterSuccess(false); + setIsStopLossSuccess(false); + }, [market?.symbol, existingPosition?.coin]); + // Track Perps asset screen load performance with simplified API usePerpsMeasurement({ traceName: TraceName.PerpsPositionDetailsView, @@ -645,6 +670,22 @@ const PerpsMarketDetailsView: React.FC = () => { setSelectedTooltip(null); }, []); + // Order book handler - navigates to order book view + const handleOrderBookPress = useCallback(() => { + if (!market?.symbol) return; + + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: market.symbol, + }); + + navigation.navigate(Routes.PERPS.ORDER_BOOK, { + symbol: market.symbol, + marketData: market, + }); + }, [market, navigation, track]); + // Close position handler const handleClosePosition = useCallback(() => { if (!existingPosition) return; @@ -657,6 +698,75 @@ const PerpsMarketDetailsView: React.FC = () => { openModifySheet(); }, [existingPosition, openModifySheet]); + // Handler for "Add Margin" from stop loss prompt banner + const handleAddMarginFromBanner = useCallback(() => { + if (!existingPosition) return; + + // Navigate directly to PerpsAdjustMarginView with mode='add' + navigation.navigate(Routes.PERPS.ADJUST_MARGIN, { + position: existingPosition, + mode: 'add', + }); + + // Track the interaction + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: existingPosition.coin, + [PerpsEventProperties.ACTION_TYPE]: 'add_margin_from_prompt', + }); + }, [existingPosition, navigation, track]); + + // Handler for "Set Stop Loss" from stop loss prompt banner + const handleSetStopLossFromBanner = useCallback(async () => { + if (!existingPosition || !suggestedStopLossPrice) return; + + setIsSettingStopLoss(true); + + try { + // Build tracking data + const trackingData: TPSLTrackingData = { + direction: parseFloat(existingPosition.size) >= 0 ? 'long' : 'short', + source: 'stop_loss_prompt_banner', + positionSize: Math.abs(parseFloat(existingPosition.size)), + }; + + // Set the stop loss using the suggested price (keep existing TP if any) + await handleUpdateTPSL( + existingPosition, + existingPosition.takeProfitPrice, // Keep existing TP + suggestedStopLossPrice, // Use suggested SL + trackingData, + ); + + // Trigger success state to start fade-out animation + setIsStopLossSuccess(true); + + // Track the interaction + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: existingPosition.coin, + [PerpsEventProperties.ACTION_TYPE]: 'set_stop_loss_from_prompt', + [PerpsEventProperties.STOP_LOSS_PRICE]: suggestedStopLossPrice, + }); + } catch (error) { + Logger.error(ensureError(error), { + feature: PERPS_CONSTANTS.FEATURE_NAME, + message: 'Failed to set stop loss from prompt banner', + }); + } finally { + setIsSettingStopLoss(false); + } + }, [existingPosition, suggestedStopLossPrice, handleUpdateTPSL, track]); + + // Handler for when banner fade-out animation completes + const handleBannerFadeOutComplete = useCallback(() => { + setHideBannerAfterSuccess(true); + // Reset success state for potential future displays + setIsStopLossSuccess(false); + }, []); + // Handler for order selection - navigates to order details const handleOrderSelect = useCallback( (order: (typeof nonTPSLOrders)[number]) => { @@ -831,6 +941,24 @@ const PerpsMarketDetailsView: React.FC = () => { /> )} + {/* Stop Loss Prompt Banner - Shows when position needs attention */} + {shouldShowBanner && bannerVariant && !hideBannerAfterSuccess && ( + + )} + {/* Position Section - Shows when user has an open position */} {existingPosition && ( @@ -870,6 +998,7 @@ const PerpsMarketDetailsView: React.FC = () => { nextFundingTime={market?.nextFundingTime} fundingIntervalHours={market?.fundingIntervalHours} dexName={market?.marketSource || undefined} + onOrderBookPress={handleOrderBookPress} /> diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.styles.ts b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.styles.ts new file mode 100644 index 000000000000..9ac49b64dfbb --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.styles.ts @@ -0,0 +1,136 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border.muted, + }, + headerBackButton: { + marginRight: 12, + }, + headerTitleContainer: { + flex: 1, + }, + // Header unit toggle (BTC/USD) + headerUnitToggle: { + flexDirection: 'row', + marginRight: 8, + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border.default, + overflow: 'hidden', + }, + headerUnitButton: { + paddingHorizontal: 10, + paddingVertical: 4, + }, + headerUnitButtonActive: { + backgroundColor: colors.primary.default, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 140, + }, + section: { + paddingHorizontal: 16, + marginBottom: 16, + }, + depthChartSection: { + paddingTop: 16, + }, + tableSection: { + // No flex or minHeight - let content determine size + }, + footer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 24, + backgroundColor: colors.background.default, + borderTopWidth: 1, + borderTopColor: colors.border.muted, + }, + spreadContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 12, + gap: 4, + }, + actionsContainer: { + flexDirection: 'row', + gap: 12, + }, + actionButtonWrapper: { + flex: 1, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + spreadInfoContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 12, + gap: 8, + }, + midPriceText: { + marginHorizontal: 8, + }, + // Depth band dropdown button + depthBandButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: colors.background.muted, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + depthBandButtonPressed: { + opacity: 0.7, + }, + // Bottom sheet content + depthBandSheetContent: { + paddingHorizontal: 16, + paddingBottom: 24, + }, + depthBandOption: { + paddingVertical: 16, + paddingHorizontal: 16, + borderRadius: 8, + marginBottom: 8, + }, + depthBandOptionSelected: { + backgroundColor: colors.primary.muted, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx new file mode 100644 index 000000000000..a4db55cfdf4d --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx @@ -0,0 +1,699 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import PerpsOrderBookView from './PerpsOrderBookView'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { PerpsOrderBookViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import type { OrderBookData } from '../../hooks/stream/usePerpsLiveOrderBook'; + +// Mock navigation +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockCanGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + canGoBack: mockCanGoBack, + setOptions: jest.fn(), + }), + useRoute: () => ({ + params: { + symbol: 'BTC', + }, + }), + }; +}); + +// Mock strings +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const translations: Record = { + 'perps.order_book.title': 'Order Book', + 'perps.order_book.error': 'Failed to load order book', + 'perps.market.long': 'Long', + 'perps.market.short': 'Short', + 'perps.order_book.depth_band.title': 'Depth Band', + }; + return translations[key] || key; + }), +})); + +// Mock useStyles +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: jest.fn(() => ({ + styles: { + container: { flex: 1 }, + header: { flexDirection: 'row', padding: 16 }, + headerBackButton: { marginRight: 12 }, + headerTitleContainer: { flex: 1 }, + headerUnitToggle: { flexDirection: 'row' }, + headerUnitButton: { padding: 8 }, + headerUnitButtonActive: { backgroundColor: '#000' }, + depthBandButton: { padding: 8 }, + depthBandButtonPressed: { opacity: 0.7 }, + scrollView: { flex: 1 }, + scrollContent: { padding: 16 }, + section: { marginBottom: 16 }, + depthChartSection: { paddingTop: 16 }, + tableSection: { flex: 1 }, + footer: { padding: 16 }, + actionsContainer: { flexDirection: 'row', gap: 12 }, + actionButtonWrapper: { flex: 1 }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + depthBandSheetContent: { padding: 16 }, + depthBandOption: { padding: 16 }, + depthBandOptionSelected: { backgroundColor: '#eee' }, + }, + })), +})); + +// Mock usePerpsLiveOrderBook +const mockOrderBook: OrderBookData = { + bids: [ + { + price: '50000', + size: '1.5', + total: '1.5', + notional: '75000', + totalNotional: '75000', + }, + ], + asks: [ + { + price: '50100', + size: '1.2', + total: '1.2', + notional: '60120', + totalNotional: '60120', + }, + ], + spread: '100', + spreadPercentage: '0.2', + midPrice: '50050', + lastUpdated: Date.now(), + maxTotal: '1.5', +}; + +const mockUsePerpsLiveOrderBook = jest.fn< + { orderBook: OrderBookData | null; isLoading: boolean; error: Error | null }, + [unknown] +>(() => ({ + orderBook: mockOrderBook, + isLoading: false, + error: null, +})); + +jest.mock('../../hooks/stream/usePerpsLiveOrderBook', () => ({ + usePerpsLiveOrderBook: (params: unknown) => mockUsePerpsLiveOrderBook(params), +})); + +// Mock usePerpsMeasurement +jest.mock('../../hooks/usePerpsMeasurement', () => ({ + usePerpsMeasurement: jest.fn(), +})); + +// Mock usePerpsNavigation +const mockNavigateToOrder = jest.fn(); + +jest.mock('../../hooks', () => ({ + usePerpsNavigation: jest.fn(() => ({ + navigateToOrder: mockNavigateToOrder, + })), +})); + +// Mock usePerpsEventTracking +const mockTrack = jest.fn(); + +jest.mock('../../hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn((params) => { + if (typeof params === 'object' && params !== null) { + return undefined; + } + return { track: mockTrack }; + }), +})); + +// Mock components +jest.mock('../../components/PerpsOrderBookTable', () => { + const { View } = jest.requireActual('react-native'); + return (props: { testID?: string }) => ( + + ); +}); + +jest.mock('../../components/PerpsOrderBookDepthChart', () => { + const { View } = jest.requireActual('react-native'); + return (props: { testID?: string }) => ( + + ); +}); + +// Mock BottomSheet components to avoid SafeAreaProvider requirement +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const { View } = jest.requireActual('react-native'); + const ReactMock = jest.requireActual('react'); + return { + __esModule: true, + default: ReactMock.forwardRef( + ( + props: { + children: React.ReactNode; + onClose?: () => void; + }, + _ref: unknown, + ) => {props.children}, + ), + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View } = jest.requireActual('react-native'); + return (props: { children: React.ReactNode; onClose?: () => void }) => ( + {props.children} + ); + }, +); + +describe('PerpsOrderBookView', () => { + const initialState = { + engine: { + backgroundState, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: mockOrderBook, + isLoading: false, + error: null, + }); + }); + + describe('rendering', () => { + it('renders successfully with order book data', () => { + const { getByTestId, getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect(getByText('Order Book')).toBeOnTheScreen(); + }); + + it('renders with custom testID', () => { + const customTestID = 'custom-order-book-view'; + + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByTestId(customTestID)).toBeOnTheScreen(); + }); + + it('renders back button', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.BACK_BUTTON), + ).toBeOnTheScreen(); + }); + + it('renders unit toggle buttons', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_BASE), + ).toBeOnTheScreen(); + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_USD), + ).toBeOnTheScreen(); + }); + + it('renders depth band button', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.DEPTH_BAND_BUTTON), + ).toBeOnTheScreen(); + }); + + it('renders depth chart', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.DEPTH_CHART), + ).toBeOnTheScreen(); + }); + + it('renders order book table', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.TABLE), + ).toBeOnTheScreen(); + }); + + it('renders Long button', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.LONG_BUTTON), + ).toBeOnTheScreen(); + }); + + it('renders Short button', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.SHORT_BUTTON), + ).toBeOnTheScreen(); + }); + + it('renders scroll view', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.SCROLL_VIEW), + ).toBeOnTheScreen(); + }); + }); + + describe('back button', () => { + it('calls goBack when back button is pressed', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const backButton = getByTestId( + PerpsOrderBookViewSelectorsIDs.BACK_BUTTON, + ); + + fireEvent.press(backButton); + + expect(mockGoBack).toHaveBeenCalled(); + }); + }); + + describe('unit toggle', () => { + it('displays BTC symbol on base unit toggle', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + expect(getByText('BTC')).toBeOnTheScreen(); + }); + + it('displays USD on USD unit toggle', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + expect(getByText('USD')).toBeOnTheScreen(); + }); + + it('switches to base unit when base toggle is pressed', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const baseToggle = getByTestId( + PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_BASE, + ); + + fireEvent.press(baseToggle); + + expect(mockTrack).toHaveBeenCalled(); + }); + + it('switches to USD unit when USD toggle is pressed', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const usdToggle = getByTestId( + PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_USD, + ); + + fireEvent.press(usdToggle); + + expect(mockTrack).toHaveBeenCalled(); + }); + + it('starts with USD as default unit', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_USD), + ).toBeOnTheScreen(); + }); + }); + + describe('price grouping selection', () => { + // For BTC at $50,050 mid price, grouping options are: 1, 2, 5, 10, 100, 1000 + // Default selection is the 4th option (index 3): 10 + it('displays default grouping label based on price', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + // For mid price $50,050, default is 10 (4th option in 1, 2, 5, 10, 100, 1000) + expect(getByText('10')).toBeOnTheScreen(); + }); + + it('opens grouping bottom sheet when button is pressed', async () => { + const { getByTestId, getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const depthBandButton = getByTestId( + PerpsOrderBookViewSelectorsIDs.DEPTH_BAND_BUTTON, + ); + + fireEvent.press(depthBandButton); + + await waitFor(() => { + expect(getByText('Depth Band')).toBeOnTheScreen(); + }); + }); + + it('displays dynamic grouping options in bottom sheet', async () => { + const { getByTestId, getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const depthBandButton = getByTestId( + PerpsOrderBookViewSelectorsIDs.DEPTH_BAND_BUTTON, + ); + + fireEvent.press(depthBandButton); + + // For BTC at $50,050, options are 1, 2, 5, 10, 100, 1000 + await waitFor(() => { + expect(getByText('1')).toBeOnTheScreen(); + expect(getByText('2')).toBeOnTheScreen(); + expect(getByText('5')).toBeOnTheScreen(); + expect(getByText('100')).toBeOnTheScreen(); + expect(getByText('1000')).toBeOnTheScreen(); + }); + }); + + it('selects grouping option when pressed', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const depthBandButton = getByTestId( + PerpsOrderBookViewSelectorsIDs.DEPTH_BAND_BUTTON, + ); + + fireEvent.press(depthBandButton); + + await waitFor(() => { + const option = getByTestId( + `${PerpsOrderBookViewSelectorsIDs.DEPTH_BAND_OPTION}-1`, + ); + expect(option).toBeOnTheScreen(); + }); + + const option = getByTestId( + `${PerpsOrderBookViewSelectorsIDs.DEPTH_BAND_OPTION}-1`, + ); + + fireEvent.press(option); + + expect(mockTrack).toHaveBeenCalled(); + }); + + it('always uses nSigFigs: 5 for API (client-side aggregation)', () => { + renderWithProvider(, { state: initialState }); + + // nSigFigs should always be 5 (finest granularity) regardless of grouping selection + expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith( + expect.objectContaining({ + nSigFigs: 5, + }), + ); + }); + }); + + describe('action buttons', () => { + it('navigates to long order when Long button is pressed', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const longButton = getByTestId( + PerpsOrderBookViewSelectorsIDs.LONG_BUTTON, + ); + + fireEvent.press(longButton); + + expect(mockNavigateToOrder).toHaveBeenCalledWith({ + direction: 'long', + asset: 'BTC', + }); + expect(mockTrack).toHaveBeenCalled(); + }); + + it('navigates to short order when Short button is pressed', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const shortButton = getByTestId( + PerpsOrderBookViewSelectorsIDs.SHORT_BUTTON, + ); + + fireEvent.press(shortButton); + + expect(mockNavigateToOrder).toHaveBeenCalledWith({ + direction: 'short', + asset: 'BTC', + }); + expect(mockTrack).toHaveBeenCalled(); + }); + }); + + describe('error state', () => { + it('displays error message when order book fails to load', () => { + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: null, + isLoading: false, + error: new Error('Failed to load'), + }); + + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + expect(getByText('Failed to load order book')).toBeOnTheScreen(); + }); + + it('renders back button in error state', () => { + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: null, + isLoading: false, + error: new Error('Failed to load'), + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.BACK_BUTTON), + ).toBeOnTheScreen(); + }); + + it('does not render order book table in error state', () => { + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: null, + isLoading: false, + error: new Error('Failed to load'), + }); + + const { queryByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + queryByTestId(PerpsOrderBookViewSelectorsIDs.TABLE), + ).not.toBeOnTheScreen(); + }); + + it('does not render action buttons in error state', () => { + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: null, + isLoading: false, + error: new Error('Failed to load'), + }); + + const { queryByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + queryByTestId(PerpsOrderBookViewSelectorsIDs.LONG_BUTTON), + ).not.toBeOnTheScreen(); + expect( + queryByTestId(PerpsOrderBookViewSelectorsIDs.SHORT_BUTTON), + ).not.toBeOnTheScreen(); + }); + + it('allows navigation back from error state', () => { + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: null, + isLoading: false, + error: new Error('Failed to load'), + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const backButton = getByTestId( + PerpsOrderBookViewSelectorsIDs.BACK_BUTTON, + ); + + fireEvent.press(backButton); + + expect(mockGoBack).toHaveBeenCalled(); + }); + }); + + describe('loading state', () => { + it('renders loading state correctly', () => { + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: null, + isLoading: true, + error: null, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + }); + + describe('hook subscriptions', () => { + it('subscribes to live order book with correct symbol', () => { + renderWithProvider(, { state: initialState }); + + expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'BTC', + }), + ); + }); + + it('subscribes to live order book with 50 levels for client-side aggregation', () => { + renderWithProvider(, { state: initialState }); + + expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith( + expect.objectContaining({ + levels: 50, + }), + ); + }); + + it('subscribes to live order book with correct throttleMs', () => { + renderWithProvider(, { state: initialState }); + + expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith( + expect.objectContaining({ + throttleMs: 100, + }), + ); + }); + + it('subscribes to live order book with finest nSigFigs (5) for aggregation', () => { + renderWithProvider(, { state: initialState }); + + expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith( + expect.objectContaining({ + nSigFigs: 5, + }), + ); + }); + }); + + describe('edge cases', () => { + it('handles missing symbol gracefully', () => { + jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useRoute: () => ({ + params: {}, + }), + }; + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders correctly when orderBook is null but no error', () => { + mockUsePerpsLiveOrderBook.mockReturnValue({ + orderBook: null, + isLoading: false, + error: null, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + expect( + getByTestId(PerpsOrderBookViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx new file mode 100644 index 000000000000..1d602207fdd7 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -0,0 +1,470 @@ +import React, { useCallback, useState, useRef, useMemo } from 'react'; +import { View, ScrollView, Pressable, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useStyles } from '../../../../../component-library/hooks'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import Button, { + ButtonVariants, + ButtonWidthTypes, + ButtonSize, +} from '../../../../../component-library/components/Buttons/Button'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../../../component-library/components/Buttons/ButtonIcon'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { strings } from '../../../../../../locales/i18n'; +import { usePerpsLiveOrderBook } from '../../hooks/stream/usePerpsLiveOrderBook'; +import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; +import { usePerpsNavigation } from '../../hooks'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; +import { TraceName } from '../../../../../util/trace'; +import PerpsOrderBookTable, { + type UnitDisplay, +} from '../../components/PerpsOrderBookTable'; +import PerpsOrderBookDepthChart from '../../components/PerpsOrderBookDepthChart'; +import styleSheet from './PerpsOrderBookView.styles'; +import type { + PerpsOrderBookViewProps, + OrderBookRouteParams, +} from './PerpsOrderBookView.types'; +import { PerpsOrderBookViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import { + calculateGroupingOptions, + formatGroupingLabel, + selectDefaultGrouping, + aggregateOrderBookLevels, +} from '../../utils/orderBookGrouping'; + +const PerpsOrderBookView: React.FC = ({ + testID = PerpsOrderBookViewSelectorsIDs.CONTAINER, +}) => { + const navigation = useNavigation(); + const route = + useRoute>(); + const { symbol } = route.params || {}; + const { styles } = useStyles(styleSheet, {}); + const { navigateToOrder } = usePerpsNavigation(); + const { track } = usePerpsEventTracking(); + + // Unit display state (base currency or USD) + const [unitDisplay, setUnitDisplay] = useState('usd'); + + // Price grouping state (actual price value, e.g., 10 for $10 grouping) + const [selectedGrouping, setSelectedGrouping] = useState(null); + const [isDepthBandSheetVisible, setIsDepthBandSheetVisible] = useState(false); + const depthBandSheetRef = useRef(null); + + // Subscribe to live order book data with finest granularity (nSigFigs: 5) + // We'll aggregate client-side based on selected grouping + const { + orderBook: rawOrderBook, + isLoading, + error, + } = usePerpsLiveOrderBook({ + symbol: symbol || '', + levels: 50, // Request more levels for aggregation + nSigFigs: 5, // Always use finest granularity + throttleMs: 100, + }); + + // Calculate mid price from order book + const midPrice = useMemo(() => { + if (!rawOrderBook?.bids?.length || !rawOrderBook?.asks?.length) { + return null; + } + const bestBid = parseFloat(rawOrderBook.bids[0].price); + const bestAsk = parseFloat(rawOrderBook.asks[0].price); + return (bestBid + bestAsk) / 2; + }, [rawOrderBook]); + + // Calculate dynamic grouping options based on mid price + const groupingOptions = useMemo(() => { + if (!midPrice) return []; + return calculateGroupingOptions(midPrice); + }, [midPrice]); + + // Current grouping value (use selected or auto-select default) + const currentGrouping = useMemo(() => { + if ( + selectedGrouping !== null && + groupingOptions.includes(selectedGrouping) + ) { + return selectedGrouping; + } + if (groupingOptions.length > 0) { + return selectDefaultGrouping(groupingOptions); + } + return null; + }, [selectedGrouping, groupingOptions]); + + // Maximum levels to display per side + const MAX_DISPLAY_LEVELS = 15; + + // Aggregate order book based on current grouping + const orderBook = useMemo(() => { + if (!rawOrderBook || !currentGrouping) { + return rawOrderBook; + } + + const aggregatedBids = aggregateOrderBookLevels( + rawOrderBook.bids, + currentGrouping, + 'bid', + ).slice(0, MAX_DISPLAY_LEVELS); + + const aggregatedAsks = aggregateOrderBookLevels( + rawOrderBook.asks, + currentGrouping, + 'ask', + ).slice(0, MAX_DISPLAY_LEVELS); + + // Calculate new max total for depth bars + const maxBidTotal = + aggregatedBids.length > 0 + ? parseFloat(aggregatedBids[aggregatedBids.length - 1].total) + : 0; + const maxAskTotal = + aggregatedAsks.length > 0 + ? parseFloat(aggregatedAsks[aggregatedAsks.length - 1].total) + : 0; + const maxTotal = Math.max(maxBidTotal, maxAskTotal).toString(); + + return { + ...rawOrderBook, + bids: aggregatedBids, + asks: aggregatedAsks, + maxTotal, + }; + }, [rawOrderBook, currentGrouping]); + + // Performance measurement + usePerpsMeasurement({ + traceName: TraceName.PerpsOrderBookView, + conditions: [!!symbol, !!orderBook], + }); + + // Track screen view + usePerpsEventTracking({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + conditions: [!!symbol, !!orderBook], + properties: { + [PerpsEventProperties.SCREEN_TYPE]: + PerpsEventValues.SCREEN_TYPE.ORDER_BOOK, + [PerpsEventProperties.ASSET]: symbol || '', + }, + }); + + // Handle back button press + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + // Get current grouping label for display + const currentGroupingLabel = useMemo(() => { + if (currentGrouping === null) return '—'; + return formatGroupingLabel(currentGrouping); + }, [currentGrouping]); + + // Handle grouping dropdown press + const handleDepthBandPress = useCallback(() => { + setIsDepthBandSheetVisible(true); + }, []); + + // Handle grouping selection + const handleGroupingSelect = useCallback( + (value: number) => { + setSelectedGrouping(value); + setIsDepthBandSheetVisible(false); + + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: symbol || '', + }); + }, + [symbol, track], + ); + + // Handle grouping sheet close + const handleDepthBandSheetClose = useCallback(() => { + setIsDepthBandSheetVisible(false); + }, []); + + // Handle unit toggle + const handleUnitChange = useCallback( + (unit: UnitDisplay) => { + setUnitDisplay(unit); + + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: symbol || '', + }); + }, + [symbol, track], + ); + + // Handle Long button press + const handleLongPress = useCallback(() => { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: symbol || '', + [PerpsEventProperties.DIRECTION]: PerpsEventValues.DIRECTION.LONG, + [PerpsEventProperties.SOURCE]: PerpsEventValues.SOURCE.PERP_ASSET_SCREEN, + }); + + navigateToOrder({ + direction: 'long', + asset: symbol || '', + }); + }, [symbol, navigateToOrder, track]); + + // Handle Short button press + const handleShortPress = useCallback(() => { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: symbol || '', + [PerpsEventProperties.DIRECTION]: PerpsEventValues.DIRECTION.SHORT, + [PerpsEventProperties.SOURCE]: PerpsEventValues.SOURCE.PERP_ASSET_SCREEN, + }); + + navigateToOrder({ + direction: 'short', + asset: symbol || '', + }); + }, [symbol, navigateToOrder, track]); + + // Error state + if (error) { + return ( + + + + + + {strings('perps.order_book.title')} + + + + + + {strings('perps.order_book.error')} + + + + ); + } + + return ( + + {/* Header */} + + + + + {strings('perps.order_book.title')} + + + {/* Unit Toggle (BTC/USD) */} + + handleUnitChange('base')} + testID={PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_BASE} + > + + {symbol} + + + handleUnitChange('usd')} + testID={PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_USD} + > + + USD + + + + {/* Price Grouping Dropdown */} + [ + styles.depthBandButton, + pressed && styles.depthBandButtonPressed, + ]} + onPress={handleDepthBandPress} + testID={PerpsOrderBookViewSelectorsIDs.DEPTH_BAND_BUTTON} + > + + {currentGroupingLabel} + + + + + + {/* Content */} + + {/* Depth Chart */} + + + + + {/* Order Book Table */} + + + + + + {/* Footer with Spread and Actions */} + + {/* Spread Row */} + {orderBook && ( + + + {strings('perps.order_book.spread')}: + + + ${parseFloat(orderBook.spread).toLocaleString()} + + + ({orderBook.spreadPercentage}%) + + + )} + + {/* Action Buttons */} + + +