From 62cd748708e5a57cb0cbfd6417554a182859d692 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Mon, 15 Dec 2025 19:04:02 -0500 Subject: [PATCH 01/40] chore: update cta to respect buy regions and route correctly per chain --- .../MusdConversionAssetListCta.styles.ts | 4 + .../MusdConversionAssetListCta.test.tsx | 160 ++++++ .../Musd/MusdConversionAssetListCta/index.tsx | 52 +- app/components/UI/Earn/constants/musd.ts | 10 + .../UI/Earn/hooks/useHasMusdBalance.test.ts | 230 ++++++++ .../UI/Earn/hooks/useHasMusdBalance.ts | 47 ++ .../Earn/hooks/useMusdCtaVisibility.test.ts | 515 ++++++++++++++++++ .../UI/Earn/hooks/useMusdCtaVisibility.ts | 131 +++++ 8 files changed, 1143 insertions(+), 6 deletions(-) create mode 100644 app/components/UI/Earn/hooks/useHasMusdBalance.test.ts create mode 100644 app/components/UI/Earn/hooks/useHasMusdBalance.ts create mode 100644 app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts create mode 100644 app/components/UI/Earn/hooks/useMusdCtaVisibility.ts diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts index 4ae908c1a922..817bafd6d877 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts @@ -11,11 +11,15 @@ const styleSheet = () => assetInfo: { flexDirection: 'row', gap: 20, + alignItems: 'center', }, button: { alignSelf: 'center', height: 32, }, + badge: { + alignSelf: 'center', + }, }); export default styleSheet; diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index 0991fbb268a9..acecd6029048 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -4,6 +4,7 @@ import { Hex } from '@metamask/utils'; jest.mock('../../../hooks/useMusdConversionTokens'); jest.mock('../../../hooks/useMusdConversion'); +jest.mock('../../../hooks/useMusdCtaVisibility'); jest.mock('../../../../Ramp/hooks/useRampNavigation'); jest.mock('../../../../../../util/Logger'); @@ -22,6 +23,7 @@ import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import MusdConversionAssetListCta from '.'; import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; +import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, @@ -30,6 +32,7 @@ import { import { EARN_TEST_IDS } from '../../../constants/testIds'; import initialRootState from '../../../../../../util/test/initial-root-state'; import Logger from '../../../../../../util/Logger'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; const mockToken = { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', @@ -68,6 +71,15 @@ describe('MusdConversionAssetListCta', () => { error: null, hasSeenConversionEducationScreen: true, }); + + // Default mock for visibility - show CTA without network icon + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + }); }); afterEach(() => { @@ -373,4 +385,152 @@ describe('MusdConversionAssetListCta', () => { }); }); }); + + describe('visibility behavior', () => { + beforeEach(() => { + ( + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + > + ).mockReturnValue({ + tokens: [], + tokenFilter: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), + }); + }); + + it('renders null when shouldShowCta is false', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }); + + const { queryByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeNull(); + }); + + it('renders component when shouldShowCta is true', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + }); + + describe('network badge', () => { + beforeEach(() => { + ( + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + > + ).mockReturnValue({ + tokens: [], + tokenFilter: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), + }); + }); + + it('renders without network badge when showNetworkIcon is false', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + // Badge wrapper is not rendered when showNetworkIcon is false + expect(queryByTestId('badge-wrapper')).toBeNull(); + }); + + it('renders with network badge when showNetworkIcon is true and mainnet selected', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId: CHAIN_IDS.MAINNET, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders with network badge when showNetworkIcon is true and Linea selected', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId: CHAIN_IDS.LINEA_MAINNET, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders with network badge when showNetworkIcon is true and BSC selected', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId: CHAIN_IDS.BSC, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx index bf371ad49947..0e0ccace5b0b 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx @@ -24,9 +24,17 @@ import Logger from '../../../../../../util/Logger'; import { useStyles } from '../../../../../hooks/useStyles'; import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; +import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; import { toChecksumAddress } from '../../../../../../util/address'; +import Badge, { + BadgeVariant, +} from '../../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../../component-library/components/Badges/BadgeWrapper'; +import { getNetworkImageSource } from '../../../../../../util/networks'; const MusdConversionAssetListCta = () => { const { styles } = useStyles(styleSheet, {}); @@ -37,6 +45,9 @@ const MusdConversionAssetListCta = () => { const { initiateConversion } = useMusdConversion(); + const { shouldShowCta, showNetworkIcon, selectedChainId } = + useMusdCtaVisibility(); + const canConvert = useMemo( () => Boolean(tokens.length > 0 && tokens?.[0]?.chainId !== undefined), [tokens], @@ -54,7 +65,10 @@ const MusdConversionAssetListCta = () => { // Redirect users to deposit flow if they don't have any stablecoins to convert. if (!canConvert) { const rampIntent: RampIntent = { - assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + assetId: + MUSD_TOKEN_ASSET_ID_BY_CHAIN[ + selectedChainId || MUSD_CONVERSION_DEFAULT_CHAIN_ID + ], }; goToBuy(rampIntent); return; @@ -84,17 +98,43 @@ const MusdConversionAssetListCta = () => { } }; + // Don't render if visibility conditions are not met + if (!shouldShowCta) { + return null; + } + + const renderTokenAvatar = () => ( + + ); + return ( - + {showNetworkIcon && selectedChainId ? ( + + } + > + {renderTokenAvatar()} + + ) : ( + renderTokenAvatar() + )} MetaMask USD diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index 2a6d342c8b95..ad897604c5b4 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -22,6 +22,16 @@ export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { [CHAIN_IDS.BSC]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', }; +/** + * Chains where mUSD CTA should show (buy routes available). + * BSC is excluded as buy routes are not yet available. + */ +export const MUSD_BUYABLE_CHAIN_IDS: Hex[] = [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.LINEA_MAINNET, + // CHAIN_IDS.BSC, // TODO: Uncomment once buy routes are available +]; + export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { [CHAIN_IDS.MAINNET]: 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', diff --git a/app/components/UI/Earn/hooks/useHasMusdBalance.test.ts b/app/components/UI/Earn/hooks/useHasMusdBalance.test.ts new file mode 100644 index 000000000000..26a9ebebe6f4 --- /dev/null +++ b/app/components/UI/Earn/hooks/useHasMusdBalance.test.ts @@ -0,0 +1,230 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useHasMusdBalance } from './useHasMusdBalance'; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; + +jest.mock('react-redux'); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('useHasMusdBalance', () => { + const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue({}); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('hook structure', () => { + it('returns object with hasMusdBalance and balancesByChain properties', () => { + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current).toHaveProperty('hasMusdBalance'); + expect(result.current).toHaveProperty('balancesByChain'); + }); + + it('returns hasMusdBalance as boolean', () => { + const { result } = renderHook(() => useHasMusdBalance()); + + expect(typeof result.current.hasMusdBalance).toBe('boolean'); + }); + + it('returns balancesByChain as object', () => { + const { result } = renderHook(() => useHasMusdBalance()); + + expect(typeof result.current.balancesByChain).toBe('object'); + }); + }); + + describe('balance detection', () => { + it('returns hasMusdBalance false when no balances exist', () => { + mockUseSelector.mockReturnValue({}); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('returns hasMusdBalance false when MUSD balance is 0x0', () => { + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: '0x0', + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('returns hasMusdBalance true when MUSD balance exists on mainnet', () => { + const balance = '0x1234'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.MAINNET]: balance, + }); + }); + + it('returns hasMusdBalance true when MUSD balance exists on Linea', () => { + const lineaMusdAddress = + MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]; + const balance = '0x5678'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.LINEA_MAINNET]: { + [lineaMusdAddress]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.LINEA_MAINNET]: balance, + }); + }); + + it('returns hasMusdBalance true when MUSD balance exists on BSC', () => { + const bscMusdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.BSC]; + const balance = '0x9abc'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.BSC]: { + [bscMusdAddress]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.BSC]: balance, + }); + }); + + it('returns balances from multiple chains', () => { + const mainnetBalance = '0x1111'; + const lineaBalance = '0x2222'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: mainnetBalance, + }, + [CHAIN_IDS.LINEA_MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: lineaBalance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.MAINNET]: mainnetBalance, + [CHAIN_IDS.LINEA_MAINNET]: lineaBalance, + }); + }); + }); + + describe('address case handling', () => { + it('handles lowercase token address in balances', () => { + const balance = '0x1234'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS.toLowerCase()]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + + it('handles checksummed token address in balances', () => { + const balance = '0x1234'; + // MUSD_ADDRESS is already lowercase in the constant + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + }); + + describe('edge cases', () => { + it('ignores non-MUSD tokens on supported chains', () => { + const otherTokenAddress = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [otherTokenAddress]: '0x9999', + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('ignores MUSD-like tokens on unsupported chains', () => { + const polygonChainId = '0x89' as Hex; + mockUseSelector.mockReturnValue({ + [polygonChainId]: { + [MUSD_ADDRESS]: '0x1234', + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('handles mixed zero and non-zero balances correctly', () => { + const balance = '0x1234'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: '0x0', + }, + [CHAIN_IDS.LINEA_MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.LINEA_MAINNET]: balance, + }); + }); + + it('handles undefined chain balances gracefully', () => { + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: undefined, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useHasMusdBalance.ts b/app/components/UI/Earn/hooks/useHasMusdBalance.ts new file mode 100644 index 000000000000..7746c79e1ae2 --- /dev/null +++ b/app/components/UI/Earn/hooks/useHasMusdBalance.ts @@ -0,0 +1,47 @@ +import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { Hex } from '@metamask/utils'; +import { selectContractBalancesPerChainId } from '../../../../selectors/tokenBalancesController'; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; +import { toChecksumAddress } from '../../../../util/address'; + +/** + * Hook to check if the user has any MUSD token balance across supported chains. + * @returns Object containing hasMusdBalance boolean and balancesByChain for detailed balance info + */ +export const useHasMusdBalance = () => { + const balancesPerChainId = useSelector(selectContractBalancesPerChainId); + const { hasMusdBalance, balancesByChain } = useMemo(() => { + const result: Record = {}; + let hasBalance = false; + + for (const [chainId, tokenAddress] of Object.entries( + MUSD_TOKEN_ADDRESS_BY_CHAIN, + )) { + const chainBalances = balancesPerChainId[chainId as Hex]; + + if (!chainBalances) continue; + + // MUSD token addresses are lowercase in the constant, but balances might be checksummed + const normalizedTokenAddress = toChecksumAddress(tokenAddress as Hex); + const balance = + chainBalances[normalizedTokenAddress] || + chainBalances[tokenAddress as Hex]; + + if (balance && balance !== '0x0') { + result[chainId as Hex] = balance; + hasBalance = true; + } + } + + return { + hasMusdBalance: hasBalance, + balancesByChain: result, + }; + }, [balancesPerChainId]); + + return { + hasMusdBalance, + balancesByChain, + }; +}; diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts new file mode 100644 index 000000000000..6e4e24ac8bc8 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -0,0 +1,515 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { Hex } from '@metamask/utils'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useMusdCtaVisibility } from './useMusdCtaVisibility'; +import { useHasMusdBalance } from './useHasMusdBalance'; +import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; +import { useNetworksByCustomNamespace } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; +import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; + +jest.mock('./useHasMusdBalance'); +jest.mock('../../../hooks/useCurrentNetworkInfo'); +jest.mock('../../../hooks/useNetworksByNamespace/useNetworksByNamespace'); +jest.mock('../../Ramp/hooks/useRampTokens'); + +const mockUseHasMusdBalance = useHasMusdBalance as jest.MockedFunction< + typeof useHasMusdBalance +>; +const mockUseCurrentNetworkInfo = useCurrentNetworkInfo as jest.MockedFunction< + typeof useCurrentNetworkInfo +>; +const mockUseNetworksByCustomNamespace = + useNetworksByCustomNamespace as jest.MockedFunction< + typeof useNetworksByCustomNamespace + >; +const mockUseRampTokens = useRampTokens as jest.MockedFunction< + typeof useRampTokens +>; + +describe('useMusdCtaVisibility', () => { + const defaultNetworkInfo = { + enabledNetworks: [], + getNetworkInfo: jest.fn(), + getNetworkInfoByChainId: jest.fn(), + isDisabled: false, + hasEnabledNetworks: false, + }; + + const defaultNetworksByNamespace = { + networks: [], + selectedNetworks: [], + areAllNetworksSelected: false, + areAnyNetworksSelected: false, + networkCount: 0, + selectedCount: 0, + totalEnabledNetworksCount: 0, + }; + + const createMusdRampToken = ( + chainId: Hex, + tokenSupported = true, + ): RampsToken => { + const assetId = MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId].toLowerCase(); + // Extract CAIP-2 chainId from CAIP-19 assetId (e.g., 'eip155:1' from 'eip155:1/erc20:0x...') + const caipChainId = assetId.split('/')[0] as `${string}:${string}`; + return { + assetId: assetId as `${string}:${string}/${string}:${string}`, + symbol: 'MUSD', + chainId: caipChainId, + tokenSupported, + name: 'MetaMask USD', + iconUrl: '', + decimals: 6, + }; + }; + + const defaultRampTokens = { + topTokens: null, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET), + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET), + ], + isLoading: false, + error: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + mockUseCurrentNetworkInfo.mockReturnValue(defaultNetworkInfo); + mockUseNetworksByCustomNamespace.mockReturnValue( + defaultNetworksByNamespace, + ); + mockUseRampTokens.mockReturnValue(defaultRampTokens); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('hook structure', () => { + it('returns object with shouldShowCta, showNetworkIcon, and selectedChainId properties', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current).toHaveProperty('shouldShowCta'); + expect(result.current).toHaveProperty('showNetworkIcon'); + expect(result.current).toHaveProperty('selectedChainId'); + }); + }); + + describe('all networks selected (popular networks)', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + { chainId: CHAIN_IDS.BSC, enabled: true }, + ], + }); + }); + + it('returns shouldShowCta true when user has no MUSD balance and MUSD is buyable', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when user has MUSD balance', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when MUSD is not buyable on any chain', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + + it('returns showNetworkIcon false for all networks', () => { + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.showNetworkIcon).toBe(false); + }); + }); + + describe('single supported network selected', () => { + describe('mainnet selected', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + }); + + it('returns shouldShowCta true when user has no MUSD on mainnet', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(true); + expect(result.current.selectedChainId).toBe(CHAIN_IDS.MAINNET); + }); + + it('returns shouldShowCta false when user has MUSD on mainnet', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + + it('returns shouldShowCta true when user has MUSD on different chain but not mainnet', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.LINEA_MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(true); + }); + + it('returns shouldShowCta false when MUSD not buyable in region for mainnet', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET, false), // tokenSupported = false + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET), + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when MUSD not buyable anywhere', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET, false), + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET, false), + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + + describe('linea selected', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + }); + + it('returns shouldShowCta true with network icon when no MUSD on Linea', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(true); + expect(result.current.selectedChainId).toBe(CHAIN_IDS.LINEA_MAINNET); + }); + + it('returns shouldShowCta false when MUSD not buyable in region for Linea', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET), + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET, false), // tokenSupported = false + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + }); + + describe('BSC selected', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.BSC, enabled: true }], + }); + }); + + it('returns shouldShowCta false when BSC selected', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when user has MUSD balance', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + }); + + describe('unsupported network selected', () => { + it('returns shouldShowCta false for Polygon', () => { + const polygonChainId = '0x89' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: polygonChainId, enabled: true }], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false for Arbitrum', () => { + const arbitrumChainId = '0xa4b1' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: arbitrumChainId, enabled: true }], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + }); + + it('returns shouldShowCta false for Optimism', () => { + const optimismChainId = '0xa' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: optimismChainId, enabled: true }], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + }); + + it('returns shouldShowCta false for unsupported network when user has MUSD balance', () => { + const polygonChainId = '0x89' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: polygonChainId, enabled: true }], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + + describe('multiple networks selected (not all)', () => { + it('returns shouldShowCta true without network icon when multiple networks selected and no MUSD balance', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when multiple networks selected and user has MUSD balance', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + + describe('geo restriction scenarios', () => { + it('returns shouldShowCta false when allTokens is null (loading)', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: null, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + + it('returns shouldShowCta true when MUSD buyable on at least one chain in all networks view', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET, false), // not buyable + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET, true), // buyable + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + }); + }); + + describe('edge cases', () => { + it('returns shouldShowCta false with empty enabledNetworks', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('handles undefined values gracefully', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [], + }); + + expect(() => renderHook(() => useMusdCtaVisibility())).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts new file mode 100644 index 000000000000..eab763b888d6 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -0,0 +1,131 @@ +import { useMemo } from 'react'; +import { Hex, KnownCaipNamespace } from '@metamask/utils'; +import { + MUSD_BUYABLE_CHAIN_IDS, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '../constants/musd'; +import { useHasMusdBalance } from './useHasMusdBalance'; +import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; +import { + NetworkType, + useNetworksByCustomNamespace, +} from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; + +/** + * Hook to determine visibility and network icon display for the MUSD CTA. + * + * @returns Object containing: + * - shouldShowCta: false if non-MUSD chain selected OR user has MUSD balance OR MUSD not buyable in region + * - showNetworkIcon: true only when a single MUSD-supported chain is selected + * - selectedChainId: the selected chain ID for the network badge (null if all networks) + */ +export const useMusdCtaVisibility = () => { + const { enabledNetworks } = useCurrentNetworkInfo(); + const { areAllNetworksSelected } = useNetworksByCustomNamespace({ + networkType: NetworkType.Popular, + namespace: KnownCaipNamespace.Eip155, + }); + const { hasMusdBalance, balancesByChain } = useHasMusdBalance(); + const { allTokens } = useRampTokens(); + + // 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) => + token.assetId === musdAssetId.toLowerCase() && + token.tokenSupported === true, + ); + + buyableByChain[chainId] = Boolean(musdToken); + }); + + return buyableByChain; + }, [allTokens]); + + // Check if mUSD is buyable on any chain (for "all networks" view) + const isMusdBuyableOnAnyChain = useMemo( + () => Object.values(isMusdBuyableOnChain).some(Boolean), + [isMusdBuyableOnChain], + ); + + const { shouldShowCta, showNetworkIcon, selectedChainId } = useMemo(() => { + // Get selected chains from enabled networks + const selectedChains = enabledNetworks + .filter((network) => network.enabled) + .map((network) => network.chainId as Hex); + + // If all networks are selected (popular networks filter) + if (areAllNetworksSelected || selectedChains.length > 1) { + // Show CTA without network icon if: + // - User doesn't have MUSD on any chain + // - AND mUSD is buyable on at least one chain in user's region + return { + shouldShowCta: !hasMusdBalance && isMusdBuyableOnAnyChain, + showNetworkIcon: false, + selectedChainId: null, + }; + } + + // If exactly one chain is selected + + const chainId = selectedChains[0]; + const isBuyableChain = MUSD_BUYABLE_CHAIN_IDS.includes(chainId); + + if (!isBuyableChain) { + // Chain doesn't have buy routes available (e.g., BSC) - hide CTA + return { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }; + } + + // Check if mUSD is buyable on this chain in user's region + const isMusdBuyableInRegion = isMusdBuyableOnChain[chainId] ?? false; + + if (!isMusdBuyableInRegion) { + // mUSD not buyable in user's region for this chain - hide CTA + return { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }; + } + + // Supported chain selected - check if user has MUSD on this specific chain + const hasMusdOnSelectedChain = Boolean(balancesByChain[chainId]); + + return { + shouldShowCta: !hasMusdOnSelectedChain, + showNetworkIcon: true, + selectedChainId: chainId, + }; + }, [ + areAllNetworksSelected, + enabledNetworks, + hasMusdBalance, + balancesByChain, + isMusdBuyableOnChain, + isMusdBuyableOnAnyChain, + ]); + + return { + shouldShowCta, + showNetworkIcon, + selectedChainId, + }; +}; From 6c3e5a5df2d9df4ce6128515360d9452596ccd97 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Mon, 15 Dec 2025 22:32:22 -0500 Subject: [PATCH 02/40] chore: add feature flag gating --- .../Earn/hooks/useMusdCtaVisibility.test.ts | 104 ++++++++++++++++++ .../UI/Earn/hooks/useMusdCtaVisibility.ts | 13 +++ .../UI/Earn/selectors/featureFlags/index.ts | 13 +++ 3 files changed, 130 insertions(+) diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index 6e4e24ac8bc8..c39d585f0a15 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -7,11 +7,19 @@ import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; import { useNetworksByCustomNamespace } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; +import { selectIsMusdCtaEnabledFlag } from '../selectors/featureFlags'; jest.mock('./useHasMusdBalance'); jest.mock('../../../hooks/useCurrentNetworkInfo'); jest.mock('../../../hooks/useNetworksByNamespace/useNetworksByNamespace'); jest.mock('../../Ramp/hooks/useRampTokens'); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('../selectors/featureFlags'); + +import { useSelector } from 'react-redux'; const mockUseHasMusdBalance = useHasMusdBalance as jest.MockedFunction< typeof useHasMusdBalance @@ -26,6 +34,7 @@ const mockUseNetworksByCustomNamespace = const mockUseRampTokens = useRampTokens as jest.MockedFunction< typeof useRampTokens >; +const mockUseSelector = useSelector as jest.MockedFunction; describe('useMusdCtaVisibility', () => { const defaultNetworkInfo = { @@ -74,8 +83,17 @@ describe('useMusdCtaVisibility', () => { error: null, }; + let mockIsMusdCtaEnabled = true; + beforeEach(() => { jest.clearAllMocks(); + mockIsMusdCtaEnabled = true; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); mockUseHasMusdBalance.mockReturnValue({ hasMusdBalance: false, balancesByChain: {}, @@ -106,6 +124,92 @@ describe('useMusdCtaVisibility', () => { }); }); + describe('feature flag', () => { + it('returns shouldShowCta false when feature flag is disabled', () => { + mockIsMusdCtaEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta true when feature flag is enabled and conditions are met', () => { + mockIsMusdCtaEnabled = true; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + }); + + it('returns shouldShowCta false when feature flag is disabled even on supported single chain', () => { + mockIsMusdCtaEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + }); + describe('all networks selected (popular networks)', () => { beforeEach(() => { mockUseNetworksByCustomNamespace.mockReturnValue({ diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts index eab763b888d6..c25c74a4a674 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { Hex, KnownCaipNamespace } from '@metamask/utils'; +import { useSelector } from 'react-redux'; import { MUSD_BUYABLE_CHAIN_IDS, MUSD_TOKEN_ASSET_ID_BY_CHAIN, @@ -11,6 +12,7 @@ import { useNetworksByCustomNamespace, } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; +import { selectIsMusdCtaEnabledFlag } from '../selectors/featureFlags'; /** * Hook to determine visibility and network icon display for the MUSD CTA. @@ -21,6 +23,7 @@ import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; * - selectedChainId: the selected chain ID for the network badge (null if all networks) */ export const useMusdCtaVisibility = () => { + const isMusdCtaEnabled = useSelector(selectIsMusdCtaEnabledFlag); const { enabledNetworks } = useCurrentNetworkInfo(); const { areAllNetworksSelected } = useNetworksByCustomNamespace({ networkType: NetworkType.Popular, @@ -63,6 +66,15 @@ export const useMusdCtaVisibility = () => { ); const { shouldShowCta, showNetworkIcon, selectedChainId } = useMemo(() => { + // If the mUSD CTA feature flag is disabled, don't show the CTA + if (!isMusdCtaEnabled) { + return { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }; + } + // Get selected chains from enabled networks const selectedChains = enabledNetworks .filter((network) => network.enabled) @@ -115,6 +127,7 @@ export const useMusdCtaVisibility = () => { selectedChainId: chainId, }; }, [ + isMusdCtaEnabled, areAllNetworksSelected, enabledNetworks, hasMusdBalance, diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 1953e31b883e..5f337de81be1 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -70,6 +70,19 @@ export const selectIsMusdConversionFlowEnabledFlag = createSelector( }, ); +export const selectIsMusdCtaEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + return true; + const localFlag = process.env.MM_MUSD_CTA_ENABLED === 'true'; + const remoteFlag = + remoteFeatureFlags?.earnMusdCtaEnabled as unknown as VersionGatedFeatureFlag; + + // Fallback to local flag if remote flag is not available + return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + }, +); + /** * Selects the allowed payment tokens for mUSD conversion from remote config or local fallback. * Returns a mapping of chain IDs to arrays of token addresses that users can pay with to convert to mUSD. From f7dbf9d6647503612e995096cc1be13ec7f640eb Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 16 Dec 2025 23:25:10 -0600 Subject: [PATCH 03/40] fix: bugbot comments --- app/components/UI/Earn/hooks/useMusdCtaVisibility.ts | 3 ++- app/components/UI/Earn/selectors/featureFlags/index.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts index c25c74a4a674..17eacbecb89e 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -13,6 +13,7 @@ import { } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; import { selectIsMusdCtaEnabledFlag } from '../selectors/featureFlags'; +import { toLowerCaseEquals } from '../../../../util/general'; /** * Hook to determine visibility and network icon display for the MUSD CTA. @@ -49,7 +50,7 @@ export const useMusdCtaVisibility = () => { const musdToken = allTokens.find( (token) => - token.assetId === musdAssetId.toLowerCase() && + toLowerCaseEquals(token.assetId, musdAssetId) && token.tokenSupported === true, ); diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 5f337de81be1..a4a4d36f4769 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -73,7 +73,6 @@ export const selectIsMusdConversionFlowEnabledFlag = createSelector( export const selectIsMusdCtaEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { - return true; const localFlag = process.env.MM_MUSD_CTA_ENABLED === 'true'; const remoteFlag = remoteFeatureFlags?.earnMusdCtaEnabled as unknown as VersionGatedFeatureFlag; From 043ac351d96a7d85601e01ee5f076c33a7550006 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 17 Dec 2025 00:10:38 -0600 Subject: [PATCH 04/40] fix: bugbot comments --- .../MusdConversionAssetListCta.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index acecd6029048..9fe2d1db1e71 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -33,6 +33,7 @@ import { EARN_TEST_IDS } from '../../../constants/testIds'; import initialRootState from '../../../../../../util/test/initial-root-state'; import Logger from '../../../../../../util/Logger'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { BADGE_WRAPPER_BADGE_TEST_ID } from '../../../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants'; const mockToken = { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', @@ -473,7 +474,7 @@ describe('MusdConversionAssetListCta', () => { getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), ).toBeOnTheScreen(); // Badge wrapper is not rendered when showNetworkIcon is false - expect(queryByTestId('badge-wrapper')).toBeNull(); + expect(queryByTestId(BADGE_WRAPPER_BADGE_TEST_ID)).toBeNull(); }); it('renders with network badge when showNetworkIcon is true and mainnet selected', () => { From 95a2480d1059562b800c77d5d97fb34dc8d64819 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Wed, 17 Dec 2025 17:45:29 -0500 Subject: [PATCH 05/40] feat: added mUSD conversion payment token blocklist with wildcard support --- .js.env.example | 8 + .../UI/Earn/hooks/useMusdConversionTokens.ts | 32 +- .../Earn/selectors/featureFlags/index.test.ts | 369 +++++++++++++++ .../UI/Earn/selectors/featureFlags/index.ts | 82 +++- app/components/UI/Earn/utils/musd.test.ts | 422 +++++++++++++++++- app/components/UI/Earn/utils/musd.ts | 95 +++- .../modals/pay-with-modal/pay-with-modal.tsx | 6 +- 7 files changed, 980 insertions(+), 34 deletions(-) diff --git a/.js.env.example b/.js.env.example index 58e0b190598b..d8c3544b4e22 100644 --- a/.js.env.example +++ b/.js.env.example @@ -111,6 +111,14 @@ export MM_MUSD_CONVERSION_FLOW_ENABLED="false" # IMPORTANT: Must use SINGLE QUOTES to preserve JSON format # Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}' export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' +# Blocklist of tokens that cannot be converted to mUSD (supports wildcards) +# Format: JSON string with chainId (or "*") mapping to token symbols (or ["*"]) +# Examples: +# Block USDC on all chains: '{"*":["USDC"]}' +# Block all tokens on mainnet: '{"0x1":["*"]}' +# Block specific tokens on chain: '{"0xa4b1":["USDT","DAI"]}' +# Combined rules (additive): '{"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT"]}' +export MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST='' # Activates remote feature flag override mode. # Remote feature flag values won't be updated, diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts index bf7306b00d52..7eaf19929580 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts @@ -1,6 +1,6 @@ import { useSelector } from 'react-redux'; -import { selectMusdConversionPaymentTokensAllowlist } from '../selectors/featureFlags'; -import { isMusdConversionPaymentToken } from '../utils/musd'; +import { selectMusdConversionPaymentTokensBlocklist } from '../selectors/featureFlags'; +import { isMusdConversionPaymentTokenBlocked } from '../utils/musd'; import { AssetType } from '../../../Views/confirmations/types/token'; import { useAccountTokens } from '../../../Views/confirmations/hooks/send/useAccountTokens'; import { useCallback, useMemo } from 'react'; @@ -13,27 +13,29 @@ import { toHex } from '@metamask/controller-utils'; import { Hex } from '@metamask/utils'; export const useMusdConversionTokens = () => { - const musdConversionPaymentTokensAllowlist = useSelector( - selectMusdConversionPaymentTokensAllowlist, + const musdConversionPaymentTokensBlocklist = useSelector( + selectMusdConversionPaymentTokensBlocklist, ); const allTokens = useAccountTokens({ includeNoBalance: false }); - const tokenFilter = useCallback( + // Remove tokens that are blocked from being used for mUSD conversion. + const filterBlockedTokens = useCallback( (tokens: AssetType[]) => - tokens.filter((token) => - isMusdConversionPaymentToken( - token.address, - musdConversionPaymentTokensAllowlist, - token.chainId, - ), + tokens.filter( + (token) => + !isMusdConversionPaymentTokenBlocked( + token.symbol, + musdConversionPaymentTokensBlocklist, + token.chainId, + ), ), - [musdConversionPaymentTokensAllowlist], + [musdConversionPaymentTokensBlocklist], ); const conversionTokens = useMemo( - () => tokenFilter(allTokens), - [allTokens, tokenFilter], + () => filterBlockedTokens(allTokens), + [allTokens, filterBlockedTokens], ); const isConversionToken = (token?: AssetType | TokenI) => { @@ -62,7 +64,7 @@ export const useMusdConversionTokens = () => { : MUSD_CONVERSION_DEFAULT_CHAIN_ID; return { - tokenFilter, + filterBlockedTokens, isConversionToken, isMusdSupportedOnChain, getMusdOutputChainId, diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index 2d81630a8202..429ef081199b 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -4,6 +4,7 @@ import { selectStablecoinLendingEnabledFlag, selectStablecoinLendingServiceInterruptionBannerEnabledFlag, selectMusdConversionPaymentTokensAllowlist, + selectMusdConversionPaymentTokensBlocklist, } from '.'; import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../../constants/musd'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; @@ -1143,4 +1144,372 @@ describe('Earn Feature Flag Selectors', () => { }); }); }); + + describe('selectMusdConversionPaymentTokensBlocklist', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST; + }); + + describe('remote flag precedence', () => { + it('returns parsed remote blocklist when valid', () => { + const remoteBlocklist = { + '*': ['USDC'], + '0x1': ['USDT'], + }; + + const stateWithRemoteBlocklist = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: remoteBlocklist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist( + stateWithRemoteBlocklist, + ); + + expect(result).toEqual({ + '*': ['USDC'], + '0x1': ['USDT'], + }); + }); + + it('returns remote blocklist over local when both are valid', () => { + const localBlocklist = { '0x1': ['DAI'] }; + process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST = + JSON.stringify(localBlocklist); + + const remoteBlocklist = { '*': ['USDC'], '0x1': ['*'] }; + + const stateWithBoth = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: remoteBlocklist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist(stateWithBoth); + + expect(result).toEqual(remoteBlocklist); + }); + + it('parses remote blocklist from JSON string', () => { + const remoteBlocklistString = '{"*":["USDC"],"0x1":["*"]}'; + + const stateWithStringRemote = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: remoteBlocklistString, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist( + stateWithStringRemote, + ); + + expect(result).toEqual({ + '*': ['USDC'], + '0x1': ['*'], + }); + }); + }); + + describe('local env fallback', () => { + it('returns local blocklist when remote is unavailable', () => { + const localBlocklist = { '0xa4b1': ['USDT', 'DAI'] }; + process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST = + JSON.stringify(localBlocklist); + + const stateWithoutRemote = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = + selectMusdConversionPaymentTokensBlocklist(stateWithoutRemote); + + expect(result).toEqual({ '0xa4b1': ['USDT', 'DAI'] }); + }); + + it('returns local blocklist when remote is invalid', () => { + const localBlocklist = { '0x1': ['USDC'] }; + process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST = + JSON.stringify(localBlocklist); + + const stateWithInvalidRemote = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: { '0x1': 'not-an-array' }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist( + stateWithInvalidRemote, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Remote earnMusdConvertibleTokensBlocklist produced invalid structure', + ), + ); + expect(result).toEqual({ '0x1': ['USDC'] }); + }); + }); + + describe('default empty blocklist', () => { + it('returns empty object when both remote and local are unavailable', () => { + delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST; + + const stateWithoutBlocklist = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist( + stateWithoutBlocklist, + ); + + expect(result).toEqual({}); + }); + + it('returns empty object when both remote and local are invalid', () => { + process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST = 'invalid json'; + + const stateWithInvalidRemote = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: 'also invalid json {{', + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist( + stateWithInvalidRemote, + ); + + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe('wildcard format validation', () => { + it('accepts blocklist with global wildcard key', () => { + const blocklist = { '*': ['USDC', 'USDT'] }; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: blocklist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist(state); + + expect(result).toEqual(blocklist); + }); + + it('accepts blocklist with chain wildcard symbol', () => { + const blocklist = { '0x1': ['*'] }; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: blocklist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist(state); + + expect(result).toEqual(blocklist); + }); + + it('accepts combined wildcard blocklist', () => { + const blocklist = { + '*': ['USDC'], + '0x1': ['*'], + '0xa4b1': ['USDT', 'DAI'], + }; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: blocklist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist(state); + + expect(result).toEqual(blocklist); + }); + }); + + describe('error handling', () => { + it('handles JSON parsing errors for local env gracefully', () => { + process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST = 'not valid json'; + + const stateWithoutRemote = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = + selectMusdConversionPaymentTokensBlocklist(stateWithoutRemote); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST', + ), + expect.anything(), + ); + expect(result).toEqual({}); + }); + + it('handles JSON parsing errors for remote flag gracefully', () => { + const stateWithInvalidRemote = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: '{ invalid json }', + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist( + stateWithInvalidRemote, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to parse remote earnMusdConvertibleTokensBlocklist', + ), + expect.anything(), + ); + expect(result).toEqual({}); + }); + + it('rejects blocklist when values are not arrays', () => { + const invalidBlocklist = { '0x1': 'USDC' }; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: invalidBlocklist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist(state); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('produced invalid structure'), + ); + expect(result).toEqual({}); + }); + + it('rejects blocklist when array contains non-strings', () => { + const invalidBlocklist = { '0x1': [123, 456] }; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensBlocklist: invalidBlocklist, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionPaymentTokensBlocklist(state); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('produced invalid structure'), + ); + expect(result).toEqual({}); + }); + }); + }); }); diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 1953e31b883e..32f975aa1e6b 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -7,8 +7,10 @@ import { import { Hex } from '@metamask/utils'; import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../../constants/musd'; import { - areValidAllowedPaymentTokens, + isValidPaymentTokenMap, convertSymbolAllowlistToAddresses, + isValidWildcardBlocklist, + WildcardBlocklist, } from '../../utils/musd'; export const selectPooledStakingEnabledFlag = createSelector( @@ -80,6 +82,9 @@ export const selectIsMusdConversionFlowEnabledFlag = createSelector( * * If both remote and local are unavailable, allows all supported payment tokens. */ +// TODO: Update: Should represent a list of tokens to have conversion CTAs (spotlight) +// TODO: Update: Break out duplicated parsing logic for allowlist and blocklist into helper. +// TODO: Delete if no longer needed. Will be replaced by CTA tokens list. export const selectMusdConversionPaymentTokensAllowlist = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags): Record => { @@ -90,7 +95,7 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( if (localEnvValue) { const parsed = JSON.parse(localEnvValue); const converted = convertSymbolAllowlistToAddresses(parsed); - if (areValidAllowedPaymentTokens(converted)) { + if (isValidPaymentTokenMap(converted)) { localAllowlist = converted; } else { console.warn( @@ -123,7 +128,7 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( const converted = convertSymbolAllowlistToAddresses( parsedRemote as Record, ); - if (areValidAllowedPaymentTokens(converted)) { + if (isValidPaymentTokenMap(converted)) { return converted; } console.warn( @@ -142,3 +147,74 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( return localAllowlist || CONVERTIBLE_STABLECOINS_BY_CHAIN; }, ); + +/** + * Selects the blocked payment tokens for mUSD conversion from remote config or local fallback. + * Returns a wildcard blocklist mapping chain IDs (or "*") to token symbols (or ["*"]). + * + * Supports wildcards: + * - "*" as chain key: applies to all chains + * - "*" in symbol array: blocks all tokens on that chain + * + * Examples: + * - { "*": ["USDC"] } - Block USDC on ALL chains + * - { "0x1": ["*"] } - Block ALL tokens on Ethereum mainnet + * - { "0xa4b1": ["USDT", "DAI"] } - Block specific tokens on specific chain + * + * Remote flag takes precedence over local env var. + * If both are unavailable, returns {} (no blocking). + */ +export const selectMusdConversionPaymentTokensBlocklist = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags): WildcardBlocklist => { + // Try remote flag first (takes precedence) + const remoteBlocklist = + remoteFeatureFlags?.earnMusdConvertibleTokensBlocklist; + + if (remoteBlocklist) { + try { + const parsedRemote = + typeof remoteBlocklist === 'string' + ? JSON.parse(remoteBlocklist) + : remoteBlocklist; + + if (isValidWildcardBlocklist(parsedRemote)) { + return parsedRemote; + } + console.warn( + 'Remote earnMusdConvertibleTokensBlocklist produced invalid structure. ' + + 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', + ); + } catch (error) { + console.warn( + 'Failed to parse remote earnMusdConvertibleTokensBlocklist:', + error, + ); + } + } + + // Fallback to local env var + try { + const localEnvValue = process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST; + + if (localEnvValue) { + const parsed = JSON.parse(localEnvValue); + if (isValidWildcardBlocklist(parsed)) { + return parsed; + } + console.warn( + 'Local MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST produced invalid structure. ' + + 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', + ); + } + } catch (error) { + console.warn( + 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST:', + error, + ); + } + + // Default: no blocking + return {}; + }, +); diff --git a/app/components/UI/Earn/utils/musd.test.ts b/app/components/UI/Earn/utils/musd.test.ts index a8a4b9a8d2a6..a37ceb103efa 100644 --- a/app/components/UI/Earn/utils/musd.test.ts +++ b/app/components/UI/Earn/utils/musd.test.ts @@ -1,8 +1,11 @@ import { Hex } from '@metamask/utils'; import { - areValidAllowedPaymentTokens, + isValidPaymentTokenMap, convertSymbolAllowlistToAddresses, isMusdConversionPaymentToken, + isValidWildcardBlocklist, + isMusdConversionPaymentTokenBlocked, + WildcardBlocklist, } from './musd'; import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../constants/musd'; @@ -145,32 +148,32 @@ describe('convertSymbolAllowlistToAddresses', () => { }); }); -describe('areValidAllowedPaymentTokens', () => { +describe('isValidPaymentTokenMap', () => { it('returns true for valid Record', () => { const validInput: Record = { '0x1': ['0xabc' as Hex, '0xdef' as Hex], '0x2': ['0x123' as Hex], }; - const result = areValidAllowedPaymentTokens(validInput); + const result = isValidPaymentTokenMap(validInput); expect(result).toBe(true); }); it('returns false for null', () => { - const result = areValidAllowedPaymentTokens(null); + const result = isValidPaymentTokenMap(null); expect(result).toBe(false); }); it('returns false for undefined', () => { - const result = areValidAllowedPaymentTokens(undefined); + const result = isValidPaymentTokenMap(undefined); expect(result).toBe(false); }); it('returns false for arrays', () => { - const result = areValidAllowedPaymentTokens(['0x1', '0x2']); + const result = isValidPaymentTokenMap(['0x1', '0x2']); expect(result).toBe(false); }); @@ -180,7 +183,7 @@ describe('areValidAllowedPaymentTokens', () => { notHex: ['0xabc' as Hex], }; - const result = areValidAllowedPaymentTokens(invalidInput); + const result = isValidPaymentTokenMap(invalidInput); expect(result).toBe(false); }); @@ -190,7 +193,7 @@ describe('areValidAllowedPaymentTokens', () => { '0x1': '0xabc', }; - const result = areValidAllowedPaymentTokens(invalidInput); + const result = isValidPaymentTokenMap(invalidInput); expect(result).toBe(false); }); @@ -200,13 +203,13 @@ describe('areValidAllowedPaymentTokens', () => { '0x1': ['notHex'], }; - const result = areValidAllowedPaymentTokens(invalidInput); + const result = isValidPaymentTokenMap(invalidInput); expect(result).toBe(false); }); it('returns true for empty object', () => { - const result = areValidAllowedPaymentTokens({}); + const result = isValidPaymentTokenMap({}); expect(result).toBe(true); }); @@ -216,7 +219,7 @@ describe('areValidAllowedPaymentTokens', () => { '0x1': [], }; - const result = areValidAllowedPaymentTokens(validInput); + const result = isValidPaymentTokenMap(validInput); expect(result).toBe(true); }); @@ -369,3 +372,400 @@ describe('isMusdConversionPaymentToken', () => { }); }); }); + +describe('isValidWildcardBlocklist', () => { + describe('valid blocklists', () => { + it('returns true for valid blocklist with chain-specific symbols', () => { + const blocklist: WildcardBlocklist = { + '0x1': ['USDC', 'USDT'], + '0xa4b1': ['DAI'], + }; + + const result = isValidWildcardBlocklist(blocklist); + + expect(result).toBe(true); + }); + + it('returns true for blocklist with global wildcard key', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + }; + + const result = isValidWildcardBlocklist(blocklist); + + expect(result).toBe(true); + }); + + it('returns true for blocklist with chain wildcard symbol', () => { + const blocklist: WildcardBlocklist = { + '0x1': ['*'], + }; + + const result = isValidWildcardBlocklist(blocklist); + + expect(result).toBe(true); + }); + + it('returns true for combined wildcard blocklist', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + '0x1': ['*'], + '0xa4b1': ['USDT', 'DAI'], + }; + + const result = isValidWildcardBlocklist(blocklist); + + expect(result).toBe(true); + }); + + it('returns true for empty object', () => { + const result = isValidWildcardBlocklist({}); + + expect(result).toBe(true); + }); + + it('returns true for object with empty arrays', () => { + const blocklist: WildcardBlocklist = { + '0x1': [], + }; + + const result = isValidWildcardBlocklist(blocklist); + + expect(result).toBe(true); + }); + }); + + describe('invalid blocklists', () => { + it('returns false for null', () => { + const result = isValidWildcardBlocklist(null); + + expect(result).toBe(false); + }); + + it('returns false for undefined', () => { + const result = isValidWildcardBlocklist(undefined); + + expect(result).toBe(false); + }); + + it('returns false for arrays', () => { + const result = isValidWildcardBlocklist(['0x1', 'USDC']); + + expect(result).toBe(false); + }); + + it('returns false when values are not arrays', () => { + const invalidInput = { + '0x1': 'USDC', + }; + + const result = isValidWildcardBlocklist(invalidInput); + + expect(result).toBe(false); + }); + + it('returns false when array elements are not strings', () => { + const invalidInput = { + '0x1': [123, 456], + }; + + const result = isValidWildcardBlocklist(invalidInput); + + expect(result).toBe(false); + }); + + it('returns false for primitive values', () => { + expect(isValidWildcardBlocklist('string')).toBe(false); + expect(isValidWildcardBlocklist(123)).toBe(false); + expect(isValidWildcardBlocklist(true)).toBe(false); + }); + }); +}); + +describe('isMusdConversionPaymentTokenBlocked', () => { + describe('global wildcard blocking', () => { + it('blocks USDC on any chain when global wildcard includes USDC', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDC', + blocklist, + '0x1', + ); + + expect(result).toBe(true); + }); + + it('blocks USDC on different chain when global wildcard includes USDC', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDC', + blocklist, + '0xa4b1', + ); + + expect(result).toBe(true); + }); + + it('does not block USDT when global wildcard only includes USDC', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDT', + blocklist, + '0x1', + ); + + expect(result).toBe(false); + }); + + it('blocks all tokens when global wildcard includes asterisk', () => { + const blocklist: WildcardBlocklist = { + '*': ['*'], + }; + + expect( + isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0x1'), + ).toBe(true); + expect( + isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0x1'), + ).toBe(true); + expect( + isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0xa4b1'), + ).toBe(true); + }); + }); + + describe('chain-specific wildcard blocking', () => { + it('blocks all tokens on specific chain when chain has asterisk', () => { + const blocklist: WildcardBlocklist = { + '0x1': ['*'], + }; + + expect( + isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0x1'), + ).toBe(true); + expect( + isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0x1'), + ).toBe(true); + expect(isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0x1')).toBe( + true, + ); + }); + + it('does not block tokens on other chains when only one chain has wildcard', () => { + const blocklist: WildcardBlocklist = { + '0x1': ['*'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDC', + blocklist, + '0xa4b1', + ); + + expect(result).toBe(false); + }); + }); + + describe('chain-specific symbol blocking', () => { + it('blocks specific symbol on specific chain', () => { + const blocklist: WildcardBlocklist = { + '0xa4b1': ['USDT', 'DAI'], + }; + + expect( + isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0xa4b1'), + ).toBe(true); + expect( + isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0xa4b1'), + ).toBe(true); + }); + + it('does not block unlisted symbol on that chain', () => { + const blocklist: WildcardBlocklist = { + '0xa4b1': ['USDT', 'DAI'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDC', + blocklist, + '0xa4b1', + ); + + expect(result).toBe(false); + }); + + it('does not block listed symbol on different chain', () => { + const blocklist: WildcardBlocklist = { + '0xa4b1': ['USDT', 'DAI'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDT', + blocklist, + '0x1', + ); + + expect(result).toBe(false); + }); + }); + + describe('combined rules (additive)', () => { + it('blocks USDC globally AND all tokens on mainnet', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + '0x1': ['*'], + }; + + // USDC blocked globally + expect( + isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0xa4b1'), + ).toBe(true); + + // All tokens blocked on mainnet + expect( + isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0x1'), + ).toBe(true); + expect(isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0x1')).toBe( + true, + ); + + // Non-USDC on non-mainnet is allowed + expect( + isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0xa4b1'), + ).toBe(false); + }); + + it('handles complex combined blocklist', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + '0x1': ['*'], + '0xa4b1': ['USDT'], + }; + + // Global USDC block + expect( + isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0x38'), + ).toBe(true); + + // Mainnet all blocked + expect(isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0x1')).toBe( + true, + ); + + // Arbitrum USDT blocked + expect( + isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0xa4b1'), + ).toBe(true); + + // Arbitrum DAI allowed + expect( + isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0xa4b1'), + ).toBe(false); + }); + }); + + describe('case-insensitive matching', () => { + it('matches lowercase symbol against uppercase blocklist', () => { + const blocklist: WildcardBlocklist = { + '0x1': ['USDC'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'usdc', + blocklist, + '0x1', + ); + + expect(result).toBe(true); + }); + + it('matches uppercase symbol against lowercase blocklist', () => { + const blocklist: WildcardBlocklist = { + '0x1': ['usdc'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDC', + blocklist, + '0x1', + ); + + expect(result).toBe(true); + }); + + it('matches mixed case symbol', () => { + const blocklist: WildcardBlocklist = { + '0x1': ['UsDc'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'uSdC', + blocklist, + '0x1', + ); + + expect(result).toBe(true); + }); + }); + + describe('edge cases', () => { + it('returns false for empty blocklist', () => { + const result = isMusdConversionPaymentTokenBlocked('USDC', {}, '0x1'); + + expect(result).toBe(false); + }); + + it('returns false when chainId is undefined', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + }; + + const result = isMusdConversionPaymentTokenBlocked( + 'USDC', + blocklist, + undefined, + ); + + expect(result).toBe(false); + }); + + it('returns false when chainId is empty string', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + }; + + const result = isMusdConversionPaymentTokenBlocked('USDC', blocklist, ''); + + expect(result).toBe(false); + }); + + it('returns false when tokenSymbol is empty string', () => { + const blocklist: WildcardBlocklist = { + '*': ['USDC'], + }; + + const result = isMusdConversionPaymentTokenBlocked('', blocklist, '0x1'); + + expect(result).toBe(false); + }); + + it('uses empty blocklist as default when blocklist is undefined', () => { + const result = isMusdConversionPaymentTokenBlocked( + 'USDC', + undefined, + '0x1', + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Earn/utils/musd.ts b/app/components/UI/Earn/utils/musd.ts index 583bb65be020..438fd46ceb64 100644 --- a/app/components/UI/Earn/utils/musd.ts +++ b/app/components/UI/Earn/utils/musd.ts @@ -64,14 +64,14 @@ export const convertSymbolAllowlistToAddresses = ( }; /** - * Type guard to validate allowedPaymentTokens structure. + * Type guard to validate paymentTokenMap 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 = ( +export const isValidPaymentTokenMap = ( value: unknown, ): value is Record => { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -95,6 +95,7 @@ export const areValidAllowedPaymentTokens = ( * @param allowlist - Optional allowlist to use instead of default CONVERTIBLE_STABLECOINS_BY_CHAIN * @returns true if the token is an allowed payment token for mUSD conversion, false otherwise */ +// TODO: Delete if no longer needed. export const isMusdConversionPaymentToken = ( tokenAddress: string, allowlist: Record = CONVERTIBLE_STABLECOINS_BY_CHAIN, @@ -111,3 +112,93 @@ export const isMusdConversionPaymentToken = ( .map((addr) => addr.toLowerCase()) .includes(tokenAddress.toLowerCase()); }; + +/** + * Wildcard blocklist type for mUSD conversion. + * Maps chain IDs (or "*" for all chains) to arrays of token symbols (or ["*"] for all tokens). + * + * @example + * { + * "*": ["USDC"], // Block USDC on ALL chains + * "0x1": ["*"], // Block ALL tokens on Ethereum mainnet + * "0xa4b1": ["USDT", "DAI"] // Block specific tokens on specific chain + * } + */ +// TODO: Rename to be more generic since we may use this wildcard list for other things. +export type WildcardBlocklist = Record; + +/** + * Type guard to validate WildcardBlocklist structure. + * Validates that the value is an object with string keys mapping to string arrays. + * + * @param value - Value to validate + * @returns true if valid WildcardBlocklist, false otherwise + */ +// TODO: Rename to be more generic since we may use this wildcard list for other things. +export const isValidWildcardBlocklist = ( + value: unknown, +): value is WildcardBlocklist => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + return Object.entries(value).every( + ([key, val]) => + typeof key === 'string' && + Array.isArray(val) && + val.every((symbol) => typeof symbol === 'string'), + ); +}; + +/** + * Checks if a token is blocked from being used for mUSD conversion. + * Supports wildcard matching: + * - "*" as chain key: applies to all chains + * - "*" in symbol array: blocks all tokens on that chain + * + * @param tokenSymbol - The token symbol (case-insensitive) + * @param blocklist - The wildcard blocklist to use + * @param chainId - The chain ID where the token exists + * @returns true if the token is blocked, false otherwise + */ +export const isMusdConversionPaymentTokenBlocked = ( + tokenSymbol: string, + blocklist: WildcardBlocklist = {}, + chainId?: string, +): boolean => { + if (!chainId || !tokenSymbol) return false; + + const normalizedSymbol = tokenSymbol.toUpperCase(); + + // Check global wildcard: blocklist["*"] includes this symbol + const globalBlockedSymbols = blocklist['*']; + if (globalBlockedSymbols) { + if ( + globalBlockedSymbols.includes('*') || + globalBlockedSymbols + .map((symbol) => symbol.toUpperCase()) + .includes(normalizedSymbol) + ) { + return true; + } + } + + // Check chain-specific rules + const chainBlockedSymbols = blocklist[chainId]; + if (chainBlockedSymbols) { + // Chain wildcard: block all tokens on this chain + if (chainBlockedSymbols.includes('*')) { + return true; + } + // Specific symbol check + if ( + chainBlockedSymbols + .map((symbol) => symbol.toUpperCase()) + .includes(normalizedSymbol) + ) { + return true; + } + } + + return false; +}; diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index c52a1a6bf8a1..98cfe0722e3f 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -20,7 +20,7 @@ export function PayWithModal() { const requiredTokens = useTransactionPayRequiredTokens(); const transactionMeta = useTransactionMetadataRequest(); const bottomSheetRef = useRef(null); - const { tokenFilter: musdTokenFilter } = useMusdConversionTokens(); + const { filterBlockedTokens } = useMusdConversionTokens(); const handleClose = useCallback(() => { bottomSheetRef.current?.onCloseBottomSheet(); @@ -49,12 +49,12 @@ export function PayWithModal() { if ( hasTransactionType(transactionMeta, [TransactionType.musdConversion]) ) { - return musdTokenFilter(availableTokens); + return filterBlockedTokens(availableTokens); } return availableTokens; }, - [musdTokenFilter, payToken, requiredTokens, transactionMeta], + [filterBlockedTokens, payToken, requiredTokens, transactionMeta], ); return ( From 25c390d3422d67eabac7f469f9cf889022736386 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Wed, 17 Dec 2025 19:47:25 -0500 Subject: [PATCH 06/40] feat: eod working --- .js.env.example | 4 +- .../components/EarnLendingBalance/index.tsx | 10 +- app/components/UI/Earn/constants/musd.ts | 32 - .../UI/Earn/hooks/useMusdConversionTokens.ts | 44 +- .../UI/Earn/selectors/featureFlags/index.ts | 119 ++- app/components/UI/Earn/utils/musd.test.ts | 771 ------------------ app/components/UI/Earn/utils/musd.ts | 204 ----- .../UI/Earn/utils/wildcardTokenList.test.ts | 326 ++++++++ .../UI/Earn/utils/wildcardTokenList.ts | 94 +++ .../UI/Stake/components/StakeButton/index.tsx | 12 +- .../TokenListItem/TokenListItemBip44.tsx | 12 +- .../Tokens/TokenList/TokenListItem/index.tsx | 13 +- 12 files changed, 539 insertions(+), 1102 deletions(-) delete mode 100644 app/components/UI/Earn/utils/musd.test.ts delete mode 100644 app/components/UI/Earn/utils/musd.ts create mode 100644 app/components/UI/Earn/utils/wildcardTokenList.test.ts create mode 100644 app/components/UI/Earn/utils/wildcardTokenList.ts diff --git a/.js.env.example b/.js.env.example index d8c3544b4e22..433a1453963e 100644 --- a/.js.env.example +++ b/.js.env.example @@ -109,8 +109,8 @@ export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true" export MM_MUSD_CONVERSION_FLOW_ENABLED="false" # Allowlist of convertible tokens by chain # IMPORTANT: Must use SINGLE QUOTES to preserve JSON format -# Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}' -export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' +# Example: MM_MUSD_CTA_TOKENS='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}' +export MM_MUSD_CTA_TOKENS='' # Blocklist of tokens that cannot be converted to mUSD (supports wildcards) # Format: JSON string with chainId (or "*") mapping to token symbols (or ["*"]) # Examples: diff --git a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx index 8154d0b6c62c..58245a694921 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx @@ -61,7 +61,7 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { selectIsMusdConversionFlowEnabledFlag, ); - const { isConversionToken } = useMusdConversionTokens(); + const { isTokenWithCta } = useMusdConversionTokens(); const { trackEvent, createEventBuilder } = useMetrics(); @@ -182,11 +182,11 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { ); - const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); + const hasMusdConversionCta = + isMusdConversionFlowEnabled && isTokenWithCta(asset); if (!isStablecoinLendingEnabled) { - if (isConvertibleStablecoin) { + if (hasMusdConversionCta) { return renderMusdConversionCta(); } return null; @@ -194,7 +194,7 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { const renderCta = () => { // Favour the mUSD Conversion CTA over the lending empty state CTA - if (isConvertibleStablecoin) { + if (hasMusdConversionCta) { return renderMusdConversionCta(); } diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index ad897604c5b4..b6f2bfa38346 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -4,7 +4,6 @@ import { CHAIN_IDS } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; -import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; import MusdIcon from '../../../../images/musd-icon-2x.png'; export const MUSD_TOKEN = { @@ -41,34 +40,3 @@ export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { }; export const MUSD_CURRENCY = 'MUSD'; - -// All stablecoins that are supported in the mUSD conversion flow. -export const MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID: Record< - Hex, - Record -> = { - [NETWORKS_CHAIN_ID.MAINNET]: { - USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - USDT: '0xdac17f958d2ee523a2206206994597c13d831ec7', - 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', - }, -}; - -export const CONVERTIBLE_STABLECOINS_BY_CHAIN: Record = (() => { - const result: Record = {}; - for (const [chainId, symbolMap] of Object.entries( - MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID, - )) { - result[chainId as Hex] = Object.values(symbolMap); - } - return result; -})(); diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts index 7eaf19929580..aee30b695447 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts @@ -1,6 +1,9 @@ import { useSelector } from 'react-redux'; -import { selectMusdConversionPaymentTokensBlocklist } from '../selectors/featureFlags'; -import { isMusdConversionPaymentTokenBlocked } from '../utils/musd'; +import { + selectMusdConversionCTATokens, + selectMusdConversionPaymentTokensBlocklist, +} from '../selectors/featureFlags'; +import { isTokenInWildcardList } from '../utils/wildcardTokenList'; import { AssetType } from '../../../Views/confirmations/types/token'; import { useAccountTokens } from '../../../Views/confirmations/hooks/send/useAccountTokens'; import { useCallback, useMemo } from 'react'; @@ -17,6 +20,8 @@ export const useMusdConversionTokens = () => { selectMusdConversionPaymentTokensBlocklist, ); + const musdConversionCTATokens = useSelector(selectMusdConversionCTATokens); + const allTokens = useAccountTokens({ includeNoBalance: false }); // Remove tokens that are blocked from being used for mUSD conversion. @@ -24,7 +29,7 @@ export const useMusdConversionTokens = () => { (tokens: AssetType[]) => tokens.filter( (token) => - !isMusdConversionPaymentTokenBlocked( + !isTokenInWildcardList( token.symbol, musdConversionPaymentTokensBlocklist, token.chainId, @@ -33,6 +38,7 @@ export const useMusdConversionTokens = () => { [musdConversionPaymentTokensBlocklist], ); + // Allowed tokens for conversion. const conversionTokens = useMemo( () => filterBlockedTokens(allTokens), [allTokens, filterBlockedTokens], @@ -48,6 +54,36 @@ export const useMusdConversionTokens = () => { ); }; + const getConversionTokensWithCtas = useCallback( + (tokens: AssetType[]) => + tokens.filter((token) => + // TODO: Rename isMusdConversionPaymentTokenBlocked + isTokenInWildcardList( + token.symbol, + musdConversionCTATokens, + token.chainId, + ), + ), + [musdConversionCTATokens], + ); + + // TODO: Temp - We'll likely want to consolidate this with the useMusdCtaVisibility hook once it's available. + const tokensWithCTAs = useMemo( + () => getConversionTokensWithCtas(conversionTokens), + [conversionTokens, getConversionTokensWithCtas], + ); + + // TODO: Temp - We'll likely want to consolidate this with the useMusdCtaVisibility hook once it's available. + const isTokenWithCta = (token?: AssetType | TokenI) => { + if (!token) return false; + + return tokensWithCTAs.some( + (musdToken) => + token.address.toLowerCase() === musdToken.address.toLowerCase() && + token.chainId === musdToken.chainId, + ); + }; + const isMusdSupportedOnChain = (chainId?: string) => chainId ? Object.keys(MUSD_TOKEN_ADDRESS_BY_CHAIN).includes(toHex(chainId)) @@ -66,8 +102,10 @@ export const useMusdConversionTokens = () => { return { filterBlockedTokens, isConversionToken, + isTokenWithCta, isMusdSupportedOnChain, getMusdOutputChainId, tokens: conversionTokens, + tokensWithCTAs, }; }; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index a4435943713e..12f477962f28 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -4,14 +4,10 @@ import { validatedVersionGatedFeatureFlag, VersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -import { Hex } from '@metamask/utils'; -import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../../constants/musd'; import { - isValidPaymentTokenMap, - convertSymbolAllowlistToAddresses, - isValidWildcardBlocklist, - WildcardBlocklist, -} from '../../utils/musd'; + isValidWildcardTokenList, + WildcardTokenList, +} from '../../utils/wildcardTokenList'; export const selectPooledStakingEnabledFlag = createSelector( selectRemoteFeatureFlags, @@ -85,78 +81,70 @@ export const selectIsMusdCtaEnabledFlag = createSelector( ); /** - * Selects the allowed payment tokens for mUSD conversion from remote config or local fallback. - * Returns a mapping of chain IDs to arrays of token addresses that users can pay with to convert to mUSD. + * Selects the tokens to have conversion CTAs (spotlight) from remote config or local fallback. + * Returns a wildcard list mapping chain IDs (or "*") to token symbols (or ["*"]). * - * The flag uses JSON format: { "hexChainId": ["tokenSymbol1", "tokenSymbol2"] } + * Supports wildcards: + * - "*" as chain key: applies to all chains + * - "*" in symbol array: blocks all tokens on that chain * - * Example: { "0x1": ["USDC", "USDT"], "0xa4b1": ["USDC", "DAI"] } + * Examples: + * - { "*": ["USDC"] } - Have conversion CTAs for USDC on ALL chains + * - { "0x1": ["*"] } - Have conversion CTAs for ALL tokens on Ethereum mainnet + * - { "0xa4b1": ["USDT", "DAI"] } - Have conversion CTAs for specific tokens on specific chain * - * If both remote and local are unavailable, allows all supported payment tokens. + * Remote flag takes precedence over local env var. + * If both are unavailable, returns {} (no conversion CTAs). */ -// TODO: Update: Should represent a list of tokens to have conversion CTAs (spotlight) -// TODO: Update: Break out duplicated parsing logic for allowlist and blocklist into helper. -// TODO: Delete if no longer needed. Will be replaced by CTA tokens list. -export const selectMusdConversionPaymentTokensAllowlist = createSelector( +// TODO: Break out duplicated parsing logic for cta tokens and blocklist into helper. +export const selectMusdConversionCTATokens = createSelector( selectRemoteFeatureFlags, - (remoteFeatureFlags): Record => { - let localAllowlist: Record | null = null; - try { - const localEnvValue = process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; + (remoteFeatureFlags): WildcardTokenList => { + // Try remote flag first (takes precedence) + const remoteCtaTokens = remoteFeatureFlags?.earnMusdConversionCtaTokens; - if (localEnvValue) { - const parsed = JSON.parse(localEnvValue); - const converted = convertSymbolAllowlistToAddresses(parsed); - if (isValidPaymentTokenMap(converted)) { - localAllowlist = converted; - } else { - console.warn( - 'Local MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST produced invalid structure', - ); + if (remoteCtaTokens) { + try { + const parsedRemote = + typeof remoteCtaTokens === 'string' + ? JSON.parse(remoteCtaTokens) + : remoteCtaTokens; + + if (isValidWildcardTokenList(parsedRemote)) { + return parsedRemote; } + console.warn( + 'Remote earnMusdConversionCtaTokens produced invalid structure. ' + + 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', + ); + } catch (error) { + console.warn( + 'Failed to parse remote earnMusdConversionCtaTokens:', + error, + ); } - } catch (error) { - console.warn( - 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST:', - error, - ); } - const remoteAllowlist = - remoteFeatureFlags?.earnMusdConvertibleTokensAllowlist; + // Fallback to local env var + try { + const localEnvValue = process.env.MM_MUSD_CTA_TOKENS; - if (remoteAllowlist) { - try { - const parsedRemote = - typeof remoteAllowlist === 'string' - ? JSON.parse(remoteAllowlist) - : remoteAllowlist; - - if ( - parsedRemote && - typeof parsedRemote === 'object' && - !Array.isArray(parsedRemote) - ) { - const converted = convertSymbolAllowlistToAddresses( - parsedRemote as Record, - ); - if (isValidPaymentTokenMap(converted)) { - return converted; - } - console.warn( - 'Remote earnMusdConvertibleTokensAllowlist produced invalid structure', - ); + if (localEnvValue) { + const parsed = JSON.parse(localEnvValue); + if (isValidWildcardTokenList(parsed)) { + return parsed; } - } catch (error) { console.warn( - 'Failed to parse remote earnMusdConvertibleTokensAllowlist. ' + - 'Expected JSON string format: {"0x1":["USDC","USDT"]}', - error, + 'Local MM_MUSD_CTA_TOKENS produced invalid structure. ' + + 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', ); } + } catch (error) { + console.warn('Failed to parse MM_MUSD_CTA_TOKENS:', error); } - return localAllowlist || CONVERTIBLE_STABLECOINS_BY_CHAIN; + // Default: no tokens to have conversion CTAs + return {}; }, ); @@ -178,7 +166,7 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( */ export const selectMusdConversionPaymentTokensBlocklist = createSelector( selectRemoteFeatureFlags, - (remoteFeatureFlags): WildcardBlocklist => { + (remoteFeatureFlags): WildcardTokenList => { // Try remote flag first (takes precedence) const remoteBlocklist = remoteFeatureFlags?.earnMusdConvertibleTokensBlocklist; @@ -190,7 +178,7 @@ export const selectMusdConversionPaymentTokensBlocklist = createSelector( ? JSON.parse(remoteBlocklist) : remoteBlocklist; - if (isValidWildcardBlocklist(parsedRemote)) { + if (isValidWildcardTokenList(parsedRemote)) { return parsedRemote; } console.warn( @@ -207,11 +195,12 @@ export const selectMusdConversionPaymentTokensBlocklist = createSelector( // Fallback to local env var try { + // TODO: Smoke test using local vars. Do after to avoid slowing down dev to restart server. const localEnvValue = process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST; if (localEnvValue) { const parsed = JSON.parse(localEnvValue); - if (isValidWildcardBlocklist(parsed)) { + if (isValidWildcardTokenList(parsed)) { return parsed; } console.warn( diff --git a/app/components/UI/Earn/utils/musd.test.ts b/app/components/UI/Earn/utils/musd.test.ts deleted file mode 100644 index a37ceb103efa..000000000000 --- a/app/components/UI/Earn/utils/musd.test.ts +++ /dev/null @@ -1,771 +0,0 @@ -import { Hex } from '@metamask/utils'; -import { - isValidPaymentTokenMap, - convertSymbolAllowlistToAddresses, - isMusdConversionPaymentToken, - isValidWildcardBlocklist, - isMusdConversionPaymentTokenBlocked, - WildcardBlocklist, -} from './musd'; -import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; -import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../constants/musd'; - -describe('convertSymbolAllowlistToAddresses', () => { - let consoleWarnSpy: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - }); - - afterEach(() => { - jest.resetAllMocks(); - consoleWarnSpy.mockRestore(); - }); - - describe('valid conversions', () => { - it('converts symbols to addresses for Mainnet', () => { - const input = { - [NETWORKS_CHAIN_ID.MAINNET]: ['USDC', 'USDT', 'DAI'], - }; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toHaveLength(3); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - ); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain( - '0xdac17f958d2ee523a2206206994597c13d831ec7', - ); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain( - '0x6b175474e89094c44da98b954eedeac495271d0f', - ); - }); - }); - - describe('invalid chain IDs', () => { - it('warns and skips unsupported chain ID', () => { - const input = { - '0x999': ['USDC'], - }; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Unsupported chain ID "0x999"'), - ); - expect(Object.keys(result)).toHaveLength(0); - }); - - it('processes valid chains and warns about invalid chains', () => { - const input = { - [NETWORKS_CHAIN_ID.MAINNET]: ['USDC'], - '0x999': ['USDT'], - }; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toBeDefined(); - expect(result['0x999' as Hex]).toBeUndefined(); - }); - }); - - describe('invalid token symbols', () => { - it('warns about invalid token symbols and excludes them', () => { - const input = { - [NETWORKS_CHAIN_ID.MAINNET]: ['USDC', 'INVALID_TOKEN'], - }; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid token symbols'), - ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('INVALID_TOKEN'), - ); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toHaveLength(1); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - ); - }); - - it('returns empty result when all symbols are invalid', () => { - const input = { - [NETWORKS_CHAIN_ID.MAINNET]: ['INVALID1', 'INVALID2'], - }; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(consoleWarnSpy).toHaveBeenCalled(); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toBeUndefined(); - }); - }); - - describe('mixed valid and invalid symbols', () => { - it('includes valid symbols and warns about invalid ones', () => { - const input = { - [NETWORKS_CHAIN_ID.MAINNET]: ['USDC', 'INVALID', 'USDT'], - }; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid token symbols'), - ); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toHaveLength(2); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - ); - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain( - '0xdac17f958d2ee523a2206206994597c13d831ec7', - ); - }); - }); - - describe('edge cases', () => { - it('returns empty object for empty input', () => { - const input = {}; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(result).toEqual({}); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - - it('handles empty symbol array', () => { - const input = { - [NETWORKS_CHAIN_ID.MAINNET]: [], - }; - - const result = convertSymbolAllowlistToAddresses(input); - - expect(result[NETWORKS_CHAIN_ID.MAINNET]).toBeUndefined(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - }); -}); - -describe('isValidPaymentTokenMap', () => { - it('returns true for valid Record', () => { - const validInput: Record = { - '0x1': ['0xabc' as Hex, '0xdef' as Hex], - '0x2': ['0x123' as Hex], - }; - - const result = isValidPaymentTokenMap(validInput); - - expect(result).toBe(true); - }); - - it('returns false for null', () => { - const result = isValidPaymentTokenMap(null); - - expect(result).toBe(false); - }); - - it('returns false for undefined', () => { - const result = isValidPaymentTokenMap(undefined); - - expect(result).toBe(false); - }); - - it('returns false for arrays', () => { - const result = isValidPaymentTokenMap(['0x1', '0x2']); - - expect(result).toBe(false); - }); - - it('returns false when keys are not hex strings', () => { - const invalidInput = { - notHex: ['0xabc' as Hex], - }; - - const result = isValidPaymentTokenMap(invalidInput); - - expect(result).toBe(false); - }); - - it('returns false when values are not arrays', () => { - const invalidInput = { - '0x1': '0xabc', - }; - - const result = isValidPaymentTokenMap(invalidInput); - - expect(result).toBe(false); - }); - - it('returns false when array elements are not hex strings', () => { - const invalidInput = { - '0x1': ['notHex'], - }; - - const result = isValidPaymentTokenMap(invalidInput); - - expect(result).toBe(false); - }); - - it('returns true for empty object', () => { - const result = isValidPaymentTokenMap({}); - - expect(result).toBe(true); - }); - - it('returns true for object with empty arrays', () => { - const validInput: Record = { - '0x1': [], - }; - - const result = isValidPaymentTokenMap(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, - ); - - expect(result).toBe(true); - }); - - it('returns true for DAI on Mainnet', () => { - const result = isMusdConversionPaymentToken( - '0x6b175474e89094c44da98b954eedeac495271d0f', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(true); - }); - }); - - describe('case-insensitive address matching', () => { - it('returns true for mixed case USDC address on Mainnet', () => { - const result = isMusdConversionPaymentToken( - '0xA0B86991c6218B36c1d19D4a2e9Eb0cE3606eB48', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(true); - }); - }); - - describe('unsupported chains', () => { - it('returns false for valid token on unsupported chain', () => { - const result = isMusdConversionPaymentToken( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - '0x999' as Hex, - ); - - expect(result).toBe(false); - }); - - it('returns false for Polygon chain', () => { - const result = isMusdConversionPaymentToken( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - '0x89' as Hex, - ); - - expect(result).toBe(false); - }); - }); - - describe('non-convertible tokens', () => { - it('returns false for random address on Mainnet', () => { - const result = isMusdConversionPaymentToken( - '0x1234567890123456789012345678901234567890', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(false); - }); - - it('returns false for WETH address on Mainnet', () => { - const result = isMusdConversionPaymentToken( - '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(false); - }); - }); - - describe('custom allowlist', () => { - it('uses custom allowlist when provided', () => { - const customAllowlist: Record = { - [NETWORKS_CHAIN_ID.MAINNET]: [ - '0x1234567890123456789012345678901234567890' as Hex, - ], - }; - - const result = isMusdConversionPaymentToken( - '0x1234567890123456789012345678901234567890', - customAllowlist, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(true); - }); - - it('returns false for default convertible token when custom allowlist excludes it', () => { - const customAllowlist: Record = { - [NETWORKS_CHAIN_ID.MAINNET]: [ - '0x1234567890123456789012345678901234567890' as Hex, - ], - }; - - const result = isMusdConversionPaymentToken( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - customAllowlist, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(false); - }); - - it('works with empty custom allowlist', () => { - const customAllowlist: Record = {}; - - const result = isMusdConversionPaymentToken( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - customAllowlist, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(false); - }); - }); - - describe('edge cases', () => { - it('returns false for empty address', () => { - const result = isMusdConversionPaymentToken( - '', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - NETWORKS_CHAIN_ID.MAINNET, - ); - - expect(result).toBe(false); - }); - - it('returns false for empty chain ID', () => { - const result = isMusdConversionPaymentToken( - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - CONVERTIBLE_STABLECOINS_BY_CHAIN, - '', - ); - - expect(result).toBe(false); - }); - }); -}); - -describe('isValidWildcardBlocklist', () => { - describe('valid blocklists', () => { - it('returns true for valid blocklist with chain-specific symbols', () => { - const blocklist: WildcardBlocklist = { - '0x1': ['USDC', 'USDT'], - '0xa4b1': ['DAI'], - }; - - const result = isValidWildcardBlocklist(blocklist); - - expect(result).toBe(true); - }); - - it('returns true for blocklist with global wildcard key', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - }; - - const result = isValidWildcardBlocklist(blocklist); - - expect(result).toBe(true); - }); - - it('returns true for blocklist with chain wildcard symbol', () => { - const blocklist: WildcardBlocklist = { - '0x1': ['*'], - }; - - const result = isValidWildcardBlocklist(blocklist); - - expect(result).toBe(true); - }); - - it('returns true for combined wildcard blocklist', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - '0x1': ['*'], - '0xa4b1': ['USDT', 'DAI'], - }; - - const result = isValidWildcardBlocklist(blocklist); - - expect(result).toBe(true); - }); - - it('returns true for empty object', () => { - const result = isValidWildcardBlocklist({}); - - expect(result).toBe(true); - }); - - it('returns true for object with empty arrays', () => { - const blocklist: WildcardBlocklist = { - '0x1': [], - }; - - const result = isValidWildcardBlocklist(blocklist); - - expect(result).toBe(true); - }); - }); - - describe('invalid blocklists', () => { - it('returns false for null', () => { - const result = isValidWildcardBlocklist(null); - - expect(result).toBe(false); - }); - - it('returns false for undefined', () => { - const result = isValidWildcardBlocklist(undefined); - - expect(result).toBe(false); - }); - - it('returns false for arrays', () => { - const result = isValidWildcardBlocklist(['0x1', 'USDC']); - - expect(result).toBe(false); - }); - - it('returns false when values are not arrays', () => { - const invalidInput = { - '0x1': 'USDC', - }; - - const result = isValidWildcardBlocklist(invalidInput); - - expect(result).toBe(false); - }); - - it('returns false when array elements are not strings', () => { - const invalidInput = { - '0x1': [123, 456], - }; - - const result = isValidWildcardBlocklist(invalidInput); - - expect(result).toBe(false); - }); - - it('returns false for primitive values', () => { - expect(isValidWildcardBlocklist('string')).toBe(false); - expect(isValidWildcardBlocklist(123)).toBe(false); - expect(isValidWildcardBlocklist(true)).toBe(false); - }); - }); -}); - -describe('isMusdConversionPaymentTokenBlocked', () => { - describe('global wildcard blocking', () => { - it('blocks USDC on any chain when global wildcard includes USDC', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDC', - blocklist, - '0x1', - ); - - expect(result).toBe(true); - }); - - it('blocks USDC on different chain when global wildcard includes USDC', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDC', - blocklist, - '0xa4b1', - ); - - expect(result).toBe(true); - }); - - it('does not block USDT when global wildcard only includes USDC', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDT', - blocklist, - '0x1', - ); - - expect(result).toBe(false); - }); - - it('blocks all tokens when global wildcard includes asterisk', () => { - const blocklist: WildcardBlocklist = { - '*': ['*'], - }; - - expect( - isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0x1'), - ).toBe(true); - expect( - isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0x1'), - ).toBe(true); - expect( - isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0xa4b1'), - ).toBe(true); - }); - }); - - describe('chain-specific wildcard blocking', () => { - it('blocks all tokens on specific chain when chain has asterisk', () => { - const blocklist: WildcardBlocklist = { - '0x1': ['*'], - }; - - expect( - isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0x1'), - ).toBe(true); - expect( - isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0x1'), - ).toBe(true); - expect(isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0x1')).toBe( - true, - ); - }); - - it('does not block tokens on other chains when only one chain has wildcard', () => { - const blocklist: WildcardBlocklist = { - '0x1': ['*'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDC', - blocklist, - '0xa4b1', - ); - - expect(result).toBe(false); - }); - }); - - describe('chain-specific symbol blocking', () => { - it('blocks specific symbol on specific chain', () => { - const blocklist: WildcardBlocklist = { - '0xa4b1': ['USDT', 'DAI'], - }; - - expect( - isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0xa4b1'), - ).toBe(true); - expect( - isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0xa4b1'), - ).toBe(true); - }); - - it('does not block unlisted symbol on that chain', () => { - const blocklist: WildcardBlocklist = { - '0xa4b1': ['USDT', 'DAI'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDC', - blocklist, - '0xa4b1', - ); - - expect(result).toBe(false); - }); - - it('does not block listed symbol on different chain', () => { - const blocklist: WildcardBlocklist = { - '0xa4b1': ['USDT', 'DAI'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDT', - blocklist, - '0x1', - ); - - expect(result).toBe(false); - }); - }); - - describe('combined rules (additive)', () => { - it('blocks USDC globally AND all tokens on mainnet', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - '0x1': ['*'], - }; - - // USDC blocked globally - expect( - isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0xa4b1'), - ).toBe(true); - - // All tokens blocked on mainnet - expect( - isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0x1'), - ).toBe(true); - expect(isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0x1')).toBe( - true, - ); - - // Non-USDC on non-mainnet is allowed - expect( - isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0xa4b1'), - ).toBe(false); - }); - - it('handles complex combined blocklist', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - '0x1': ['*'], - '0xa4b1': ['USDT'], - }; - - // Global USDC block - expect( - isMusdConversionPaymentTokenBlocked('USDC', blocklist, '0x38'), - ).toBe(true); - - // Mainnet all blocked - expect(isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0x1')).toBe( - true, - ); - - // Arbitrum USDT blocked - expect( - isMusdConversionPaymentTokenBlocked('USDT', blocklist, '0xa4b1'), - ).toBe(true); - - // Arbitrum DAI allowed - expect( - isMusdConversionPaymentTokenBlocked('DAI', blocklist, '0xa4b1'), - ).toBe(false); - }); - }); - - describe('case-insensitive matching', () => { - it('matches lowercase symbol against uppercase blocklist', () => { - const blocklist: WildcardBlocklist = { - '0x1': ['USDC'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'usdc', - blocklist, - '0x1', - ); - - expect(result).toBe(true); - }); - - it('matches uppercase symbol against lowercase blocklist', () => { - const blocklist: WildcardBlocklist = { - '0x1': ['usdc'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDC', - blocklist, - '0x1', - ); - - expect(result).toBe(true); - }); - - it('matches mixed case symbol', () => { - const blocklist: WildcardBlocklist = { - '0x1': ['UsDc'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'uSdC', - blocklist, - '0x1', - ); - - expect(result).toBe(true); - }); - }); - - describe('edge cases', () => { - it('returns false for empty blocklist', () => { - const result = isMusdConversionPaymentTokenBlocked('USDC', {}, '0x1'); - - expect(result).toBe(false); - }); - - it('returns false when chainId is undefined', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - }; - - const result = isMusdConversionPaymentTokenBlocked( - 'USDC', - blocklist, - undefined, - ); - - expect(result).toBe(false); - }); - - it('returns false when chainId is empty string', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - }; - - const result = isMusdConversionPaymentTokenBlocked('USDC', blocklist, ''); - - expect(result).toBe(false); - }); - - it('returns false when tokenSymbol is empty string', () => { - const blocklist: WildcardBlocklist = { - '*': ['USDC'], - }; - - const result = isMusdConversionPaymentTokenBlocked('', blocklist, '0x1'); - - expect(result).toBe(false); - }); - - it('uses empty blocklist as default when blocklist is undefined', () => { - const result = isMusdConversionPaymentTokenBlocked( - 'USDC', - undefined, - '0x1', - ); - - expect(result).toBe(false); - }); - }); -}); diff --git a/app/components/UI/Earn/utils/musd.ts b/app/components/UI/Earn/utils/musd.ts deleted file mode 100644 index 438fd46ceb64..000000000000 --- a/app/components/UI/Earn/utils/musd.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * mUSD Conversion Utility Functions for Earn namespace - */ - -import { Hex, isHexString } from '@metamask/utils'; -import { - MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID, - CONVERTIBLE_STABLECOINS_BY_CHAIN, -} from '../constants/musd'; - -/** - * Converts a chain-to-symbol allowlist to a chain-to-address mapping. - * Used to translate the feature flag format to the format used by isMusdConversionPaymentToken. - * - * @param allowlistBySymbol - Object mapping chain IDs to arrays of token symbols - * @returns Object mapping chain IDs to arrays of token addresses - * @example - * convertSymbolAllowlistToAddresses({ - * '0x1': ['USDC', 'USDT', 'DAI'], - * '0xa4b1': ['USDC', 'USDT'], - * }); - */ -export const convertSymbolAllowlistToAddresses = ( - allowlistBySymbol: Record, -): Record => { - const result: Record = {}; - - for (const [chainId, symbols] of Object.entries(allowlistBySymbol)) { - 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(MUSD_CONVERSION_STABLECOINS_BY_CHAIN_ID).join(', ')}`, - ); - continue; - } - - const addresses: Hex[] = []; - const invalidSymbols: string[] = []; - - for (const symbol of symbols) { - const address = chainMapping[symbol]; - if (address) { - addresses.push(address); - continue; - } - invalidSymbols.push(symbol); - } - - if (invalidSymbols.length > 0) { - console.warn( - `[mUSD Allowlist] Invalid token symbols for chain ${chainId}: ${invalidSymbols.join(', ')}. ` + - `Supported tokens: ${Object.keys(chainMapping).join(', ')}`, - ); - } - - if (addresses.length > 0) { - result[chainId as Hex] = addresses; - } - } - - return result; -}; - -/** - * Type guard to validate paymentTokenMap 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 isValidPaymentTokenMap = ( - 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. - * - * @param tokenAddress - The token contract address (case-insensitive) - * @param chainId - The chain ID where the token exists - * @param allowlist - Optional allowlist to use instead of default CONVERTIBLE_STABLECOINS_BY_CHAIN - * @returns true if the token is an allowed payment token for mUSD conversion, false otherwise - */ -// TODO: Delete if no longer needed. -export const isMusdConversionPaymentToken = ( - tokenAddress: string, - allowlist: Record = CONVERTIBLE_STABLECOINS_BY_CHAIN, - chainId?: string, -): boolean => { - if (!chainId) return false; - - const convertibleTokens = allowlist[chainId as Hex]; - if (!convertibleTokens) { - return false; - } - - return convertibleTokens - .map((addr) => addr.toLowerCase()) - .includes(tokenAddress.toLowerCase()); -}; - -/** - * Wildcard blocklist type for mUSD conversion. - * Maps chain IDs (or "*" for all chains) to arrays of token symbols (or ["*"] for all tokens). - * - * @example - * { - * "*": ["USDC"], // Block USDC on ALL chains - * "0x1": ["*"], // Block ALL tokens on Ethereum mainnet - * "0xa4b1": ["USDT", "DAI"] // Block specific tokens on specific chain - * } - */ -// TODO: Rename to be more generic since we may use this wildcard list for other things. -export type WildcardBlocklist = Record; - -/** - * Type guard to validate WildcardBlocklist structure. - * Validates that the value is an object with string keys mapping to string arrays. - * - * @param value - Value to validate - * @returns true if valid WildcardBlocklist, false otherwise - */ -// TODO: Rename to be more generic since we may use this wildcard list for other things. -export const isValidWildcardBlocklist = ( - value: unknown, -): value is WildcardBlocklist => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - - return Object.entries(value).every( - ([key, val]) => - typeof key === 'string' && - Array.isArray(val) && - val.every((symbol) => typeof symbol === 'string'), - ); -}; - -/** - * Checks if a token is blocked from being used for mUSD conversion. - * Supports wildcard matching: - * - "*" as chain key: applies to all chains - * - "*" in symbol array: blocks all tokens on that chain - * - * @param tokenSymbol - The token symbol (case-insensitive) - * @param blocklist - The wildcard blocklist to use - * @param chainId - The chain ID where the token exists - * @returns true if the token is blocked, false otherwise - */ -export const isMusdConversionPaymentTokenBlocked = ( - tokenSymbol: string, - blocklist: WildcardBlocklist = {}, - chainId?: string, -): boolean => { - if (!chainId || !tokenSymbol) return false; - - const normalizedSymbol = tokenSymbol.toUpperCase(); - - // Check global wildcard: blocklist["*"] includes this symbol - const globalBlockedSymbols = blocklist['*']; - if (globalBlockedSymbols) { - if ( - globalBlockedSymbols.includes('*') || - globalBlockedSymbols - .map((symbol) => symbol.toUpperCase()) - .includes(normalizedSymbol) - ) { - return true; - } - } - - // Check chain-specific rules - const chainBlockedSymbols = blocklist[chainId]; - if (chainBlockedSymbols) { - // Chain wildcard: block all tokens on this chain - if (chainBlockedSymbols.includes('*')) { - return true; - } - // Specific symbol check - if ( - chainBlockedSymbols - .map((symbol) => symbol.toUpperCase()) - .includes(normalizedSymbol) - ) { - return true; - } - } - - return false; -}; diff --git a/app/components/UI/Earn/utils/wildcardTokenList.test.ts b/app/components/UI/Earn/utils/wildcardTokenList.test.ts new file mode 100644 index 000000000000..c80b8add6ab5 --- /dev/null +++ b/app/components/UI/Earn/utils/wildcardTokenList.test.ts @@ -0,0 +1,326 @@ +import { + isValidWildcardTokenList, + isTokenInWildcardList, + WildcardTokenList, +} from './wildcardTokenList'; + +describe('isValidWildcardTokenList', () => { + describe('valid lists', () => { + it('returns true for valid list with chain-specific symbols', () => { + const tokenList: WildcardTokenList = { + '0x1': ['USDC', 'USDT'], + '0xa4b1': ['DAI'], + }; + + const result = isValidWildcardTokenList(tokenList); + + expect(result).toBe(true); + }); + + it('returns true for list with global wildcard key', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + }; + + const result = isValidWildcardTokenList(tokenList); + + expect(result).toBe(true); + }); + + it('returns true for list with chain wildcard symbol', () => { + const tokenList: WildcardTokenList = { + '0x1': ['*'], + }; + + const result = isValidWildcardTokenList(tokenList); + + expect(result).toBe(true); + }); + + it('returns true for combined wildcard list', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + '0x1': ['*'], + '0xa4b1': ['USDT', 'DAI'], + }; + + const result = isValidWildcardTokenList(tokenList); + + expect(result).toBe(true); + }); + + it('returns true for empty object', () => { + const result = isValidWildcardTokenList({}); + + expect(result).toBe(true); + }); + + it('returns true for object with empty arrays', () => { + const tokenList: WildcardTokenList = { + '0x1': [], + }; + + const result = isValidWildcardTokenList(tokenList); + + expect(result).toBe(true); + }); + }); + + describe('invalid lists', () => { + it('returns false for null', () => { + const result = isValidWildcardTokenList(null); + + expect(result).toBe(false); + }); + + it('returns false for undefined', () => { + const result = isValidWildcardTokenList(undefined); + + expect(result).toBe(false); + }); + + it('returns false for arrays', () => { + const result = isValidWildcardTokenList(['0x1', 'USDC']); + + expect(result).toBe(false); + }); + + it('returns false when values are not arrays', () => { + const invalidInput = { + '0x1': 'USDC', + }; + + const result = isValidWildcardTokenList(invalidInput); + + expect(result).toBe(false); + }); + + it('returns false when array elements are not strings', () => { + const invalidInput = { + '0x1': [123, 456], + }; + + const result = isValidWildcardTokenList(invalidInput); + + expect(result).toBe(false); + }); + + it('returns false for primitive values', () => { + expect(isValidWildcardTokenList('string')).toBe(false); + expect(isValidWildcardTokenList(123)).toBe(false); + expect(isValidWildcardTokenList(true)).toBe(false); + }); + }); +}); + +describe('isTokenInWildcardList', () => { + describe('global wildcard matching', () => { + it('matches USDC on any chain when global wildcard includes USDC', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + }; + + const result = isTokenInWildcardList('USDC', tokenList, '0x1'); + + expect(result).toBe(true); + }); + + it('matches USDC on different chain when global wildcard includes USDC', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + }; + + const result = isTokenInWildcardList('USDC', tokenList, '0xa4b1'); + + expect(result).toBe(true); + }); + + it('does not match USDT when global wildcard only includes USDC', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + }; + + const result = isTokenInWildcardList('USDT', tokenList, '0x1'); + + expect(result).toBe(false); + }); + + it('matches all tokens when global wildcard includes asterisk', () => { + const tokenList: WildcardTokenList = { + '*': ['*'], + }; + + expect(isTokenInWildcardList('USDC', tokenList, '0x1')).toBe(true); + expect(isTokenInWildcardList('USDT', tokenList, '0x1')).toBe(true); + expect(isTokenInWildcardList('DAI', tokenList, '0xa4b1')).toBe(true); + }); + }); + + describe('chain-specific wildcard matching', () => { + it('matches all tokens on specific chain when chain has asterisk', () => { + const tokenList: WildcardTokenList = { + '0x1': ['*'], + }; + + expect(isTokenInWildcardList('USDC', tokenList, '0x1')).toBe(true); + expect(isTokenInWildcardList('USDT', tokenList, '0x1')).toBe(true); + expect(isTokenInWildcardList('DAI', tokenList, '0x1')).toBe(true); + }); + + it('does not match tokens on other chains when only one chain has wildcard', () => { + const tokenList: WildcardTokenList = { + '0x1': ['*'], + }; + + const result = isTokenInWildcardList('USDC', tokenList, '0xa4b1'); + + expect(result).toBe(false); + }); + }); + + describe('chain-specific symbol matching', () => { + it('matches specific symbol on specific chain', () => { + const tokenList: WildcardTokenList = { + '0xa4b1': ['USDT', 'DAI'], + }; + + expect(isTokenInWildcardList('USDT', tokenList, '0xa4b1')).toBe(true); + expect(isTokenInWildcardList('DAI', tokenList, '0xa4b1')).toBe(true); + }); + + it('does not match unlisted symbol on that chain', () => { + const tokenList: WildcardTokenList = { + '0xa4b1': ['USDT', 'DAI'], + }; + + const result = isTokenInWildcardList('USDC', tokenList, '0xa4b1'); + + expect(result).toBe(false); + }); + + it('does not match listed symbol on different chain', () => { + const tokenList: WildcardTokenList = { + '0xa4b1': ['USDT', 'DAI'], + }; + + const result = isTokenInWildcardList('USDT', tokenList, '0x1'); + + expect(result).toBe(false); + }); + }); + + describe('combined rules (additive)', () => { + it('matches USDC globally AND all tokens on mainnet', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + '0x1': ['*'], + }; + + // USDC matched globally + expect(isTokenInWildcardList('USDC', tokenList, '0xa4b1')).toBe(true); + + // All tokens matched on mainnet + expect(isTokenInWildcardList('USDT', tokenList, '0x1')).toBe(true); + expect(isTokenInWildcardList('DAI', tokenList, '0x1')).toBe(true); + + // Non-USDC on non-mainnet is not matched + expect(isTokenInWildcardList('USDT', tokenList, '0xa4b1')).toBe(false); + }); + + it('handles complex combined list', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + '0x1': ['*'], + '0xa4b1': ['USDT'], + }; + + // Global USDC match + expect(isTokenInWildcardList('USDC', tokenList, '0x38')).toBe(true); + + // Mainnet all matched + expect(isTokenInWildcardList('DAI', tokenList, '0x1')).toBe(true); + + // Arbitrum USDT matched + expect(isTokenInWildcardList('USDT', tokenList, '0xa4b1')).toBe(true); + + // Arbitrum DAI not matched + expect(isTokenInWildcardList('DAI', tokenList, '0xa4b1')).toBe(false); + }); + }); + + describe('case-insensitive matching', () => { + it('matches lowercase symbol against uppercase list', () => { + const tokenList: WildcardTokenList = { + '0x1': ['USDC'], + }; + + const result = isTokenInWildcardList('usdc', tokenList, '0x1'); + + expect(result).toBe(true); + }); + + it('matches uppercase symbol against lowercase list', () => { + const tokenList: WildcardTokenList = { + '0x1': ['usdc'], + }; + + const result = isTokenInWildcardList('USDC', tokenList, '0x1'); + + expect(result).toBe(true); + }); + + it('matches mixed case symbol', () => { + const tokenList: WildcardTokenList = { + '0x1': ['UsDc'], + }; + + const result = isTokenInWildcardList('uSdC', tokenList, '0x1'); + + expect(result).toBe(true); + }); + }); + + describe('edge cases', () => { + it('returns false for empty list', () => { + const result = isTokenInWildcardList('USDC', {}, '0x1'); + + expect(result).toBe(false); + }); + + it('returns false when chainId is undefined', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + }; + + const result = isTokenInWildcardList('USDC', tokenList, undefined); + + expect(result).toBe(false); + }); + + it('returns false when chainId is empty string', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + }; + + const result = isTokenInWildcardList('USDC', tokenList, ''); + + expect(result).toBe(false); + }); + + it('returns false when tokenSymbol is empty string', () => { + const tokenList: WildcardTokenList = { + '*': ['USDC'], + }; + + const result = isTokenInWildcardList('', tokenList, '0x1'); + + expect(result).toBe(false); + }); + + it('uses empty list as default when list is undefined', () => { + const result = isTokenInWildcardList('USDC', undefined, '0x1'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Earn/utils/wildcardTokenList.ts b/app/components/UI/Earn/utils/wildcardTokenList.ts new file mode 100644 index 000000000000..99d8df2a967f --- /dev/null +++ b/app/components/UI/Earn/utils/wildcardTokenList.ts @@ -0,0 +1,94 @@ +/** + * Wildcard Token List Utility Functions + * + * Generic utilities for working with chain-to-token mappings that support wildcards. + * Used for mUSD conversion filtering, CTA visibility, and other token filtering needs. + */ + +/** + * Wildcard token list type for mUSD conversion. + * Maps chain IDs (or "*" for all chains) to arrays of token symbols (or ["*"] for all tokens). + * + * @example + * { + * "*": ["USDC"], // Include USDC on all chains + * "0x1": ["*"], // Include all tokens on Ethereum mainnet + * "0xa4b1": ["USDT", "DAI"] // Include USDT and DAI on Arbitrum + * } + */ +export type WildcardTokenList = Record; + +/** + * Type guard to validate WildcardTokenList structure. + * Validates that the value is an object with string keys mapping to string arrays. + * + * @param value - Value to validate + * @returns true if valid WildcardTokenList, false otherwise + */ +export const isValidWildcardTokenList = ( + value: unknown, +): value is WildcardTokenList => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + return Object.entries(value).every( + ([key, val]) => + typeof key === 'string' && + Array.isArray(val) && + val.every((symbol) => typeof symbol === 'string'), + ); +}; + +/** + * Checks if a token is in a wildcard token list. + * Supports wildcard matching: + * - "*" as chain key: applies to all chains + * - "*" in symbol array: applies to all tokens on that chain + * + * @param tokenSymbol - The token symbol (case-insensitive) + * @param wildcardTokenList - The wildcard token list to use + * @param chainId - The chain ID where the token exists + * @returns true if the token is in the wildcard token list, false otherwise + */ +export const isTokenInWildcardList = ( + tokenSymbol: string, + wildcardTokenList: WildcardTokenList = {}, + chainId?: string, +): boolean => { + if (!chainId || !tokenSymbol) return false; + + const normalizedSymbol = tokenSymbol.toUpperCase(); + + // Check global wildcard: wildcardTokenList["*"] includes this symbol + const globalTokenSymbols = wildcardTokenList['*']; + if (globalTokenSymbols) { + if ( + globalTokenSymbols.includes('*') || + globalTokenSymbols + .map((symbol) => symbol.toUpperCase()) + .includes(normalizedSymbol) + ) { + return true; + } + } + + // Check chain-specific rules + const chainTokenSymbols = wildcardTokenList[chainId]; + if (chainTokenSymbols) { + // Chain wildcard: include all tokens on this chain + if (chainTokenSymbols.includes('*')) { + return true; + } + // Specific symbol check + if ( + chainTokenSymbols + .map((symbol) => symbol.toUpperCase()) + .includes(normalizedSymbol) + ) { + return true; + } + } + + return false; +}; diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index 4add77989e06..6880089bea07 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -88,10 +88,10 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { ); const { initiateConversion } = useMusdConversion(); - const { isConversionToken, getMusdOutputChainId } = useMusdConversionTokens(); + const { isTokenWithCta, getMusdOutputChainId } = useMusdConversionTokens(); - const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); + const hasMusdConversionCta = + isMusdConversionFlowEnabled && isTokenWithCta(asset); const areEarnExperiencesDisabled = !isPooledStakingEnabled && !isStablecoinLendingEnabled; @@ -247,7 +247,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { }, [asset.address, asset.chainId, initiateConversion, getMusdOutputChainId]); const onEarnButtonPress = async () => { - if (isConvertibleStablecoin) { + if (hasMusdConversionCta) { return handleConvertToMUSD(); } @@ -262,7 +262,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { if ( areEarnExperiencesDisabled || - (!isConvertibleStablecoin && // Show for convertible stablecoins even with 0 balance + (!hasMusdConversionCta && // Show for convertible tokens even with 0 balance primaryExperienceType !== EARN_EXPERIENCES.STABLECOIN_LENDING && !earnToken?.isETH && earnToken?.balanceMinimalUnit === '0') || @@ -271,7 +271,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { return <>; const renderEarnButtonText = () => { - if (isConvertibleStablecoin) { + if (hasMusdConversionCta) { return strings('asset_overview.convert_to_musd'); } diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx index 84491692db4e..7206b0333e37 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx @@ -88,10 +88,10 @@ export const TokenListItemBip44 = React.memo( selectIsMusdConversionFlowEnabledFlag, ); - const { isConversionToken } = useMusdConversionTokens(); + const { isTokenWithCta } = useMusdConversionTokens(); - const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); + const hasMusdConversionCta = + isMusdConversionFlowEnabled && isTokenWithCta(asset); const pricePercentChange1d = useTokenPricePercentageChange(asset); @@ -159,12 +159,10 @@ export const TokenListItemBip44 = React.memo( const shouldShowStablecoinLendingCta = earnToken && isStablecoinLendingEnabled; - const shouldShowMusdConvertCta = isConvertibleStablecoin; - if ( shouldShowStakeCta || shouldShowStablecoinLendingCta || - shouldShowMusdConvertCta + hasMusdConversionCta ) { // TODO: Rename to EarnCta return ; @@ -172,7 +170,7 @@ export const TokenListItemBip44 = React.memo( }, [ asset, earnToken, - isConvertibleStablecoin, + hasMusdConversionCta, isStablecoinLendingEnabled, isStakeable, ]); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx index 0f998e874c8e..e7472644f477 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx @@ -283,9 +283,10 @@ export const TokenListItem = React.memo( selectIsMusdConversionFlowEnabledFlag, ); - const { isConversionToken } = useMusdConversionTokens(); - const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); + const { isTokenWithCta } = useMusdConversionTokens(); + + const hasMusdConversionCta = + isMusdConversionFlowEnabled && isTokenWithCta(asset); const networkBadgeSource = useCallback( (currentChainId: Hex) => { @@ -400,12 +401,10 @@ export const TokenListItem = React.memo( const shouldShowStablecoinLendingCta = earnToken && isStablecoinLendingEnabled; - const shouldShowMusdConvertCta = isConvertibleStablecoin; - if ( shouldShowStakeCta || shouldShowStablecoinLendingCta || - shouldShowMusdConvertCta + hasMusdConversionCta ) { // TODO: Rename to EarnCta return ; @@ -413,7 +412,7 @@ export const TokenListItem = React.memo( }, [ asset, earnToken, - isConvertibleStablecoin, + hasMusdConversionCta, isStablecoinLendingEnabled, isStakeable, ]); From 8a0e5e9ab9ed63305924698c868ddaedf4b6297c Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 13:12:48 -0500 Subject: [PATCH 07/40] feat: refactored useNavbar to allow render overrides --- .../Earn/Navbars/musdNavbarOptions.test.tsx | 108 ----------- .../UI/Earn/Navbars/musdNavbarOptions.tsx | 104 ---------- .../hooks/useMusdConversionNavbar.test.tsx | 127 +++++++++++++ .../UI/Earn/hooks/useMusdConversionNavbar.tsx | 94 +++++++++ app/components/UI/Earn/routes/index.tsx | 17 +- .../components/UI/navbar/navbar.test.tsx | 178 +++++++++++++----- .../components/UI/navbar/navbar.tsx | 75 +++++--- .../musd-conversion-info.tsx | 3 + .../confirmations/hooks/ui/useNavbar.test.ts | 41 ++++ .../Views/confirmations/hooks/ui/useNavbar.ts | 9 +- 10 files changed, 461 insertions(+), 295 deletions(-) delete mode 100644 app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx delete mode 100644 app/components/UI/Earn/Navbars/musdNavbarOptions.tsx create mode 100644 app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx create mode 100644 app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx diff --git a/app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx b/app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx deleted file mode 100644 index ab3ee537a601..000000000000 --- a/app/components/UI/Earn/Navbars/musdNavbarOptions.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { getMusdConversionNavbarOptions } from './musdNavbarOptions'; -import { mockTheme } from '../../../../util/theme'; -import { strings } from '../../../../../locales/i18n'; - -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => key), -})); - -const mockStrings = strings as jest.MockedFunction; - -describe('getMusdConversionNavbarOptions', () => { - const mockGoBack = jest.fn(); - const mockCanGoBack = jest.fn(); - - const createMockNavigation = () => ({ - goBack: mockGoBack, - canGoBack: mockCanGoBack, - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns navbar options with expected structure', () => { - const navigation = createMockNavigation(); - const chainId = CHAIN_IDS.MAINNET; - - const options = getMusdConversionNavbarOptions( - navigation, - mockTheme, - chainId, - ); - - expect(options.headerTitleAlign).toBe('center'); - expect(typeof options.headerTitle).toBe('function'); - expect(typeof options.headerLeft).toBe('function'); - expect(options.headerStyle.backgroundColor).toBe( - mockTheme.colors.background.alternative, - ); - }); - - it('renders headerTitle with mUSD icon, network badge, and localized text', () => { - const navigation = createMockNavigation(); - const chainId = CHAIN_IDS.MAINNET; - - const options = getMusdConversionNavbarOptions( - navigation, - mockTheme, - chainId, - ); - - const HeaderTitle = options.headerTitle as React.FC; - const { getByTestId, getByText } = render(); - - expect(getByTestId('musd-token-icon')).toBeOnTheScreen(); - expect(getByTestId('badge-wrapper-badge')).toBeOnTheScreen(); - expect(getByTestId('badgenetwork')).toBeOnTheScreen(); - expect(mockStrings).toHaveBeenCalledWith( - 'earn.musd_conversion.convert_to_musd', - ); - expect(getByText('earn.musd_conversion.convert_to_musd')).toBeOnTheScreen(); - }); - - it('calls goBack when back button pressed and canGoBack returns true', () => { - const navigation = createMockNavigation(); - mockCanGoBack.mockReturnValue(true); - const chainId = CHAIN_IDS.MAINNET; - - const options = getMusdConversionNavbarOptions( - navigation, - mockTheme, - chainId, - ); - - const HeaderLeft = options.headerLeft as React.FC; - const { getByTestId } = render(); - - const backButton = getByTestId('button-icon'); - fireEvent.press(backButton); - - expect(mockCanGoBack).toHaveBeenCalledTimes(1); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('does not call goBack when canGoBack returns false', () => { - const navigation = createMockNavigation(); - mockCanGoBack.mockReturnValue(false); - const chainId = CHAIN_IDS.MAINNET; - - const options = getMusdConversionNavbarOptions( - navigation, - mockTheme, - chainId, - ); - - const HeaderLeft = options.headerLeft as React.FC; - const { getByTestId } = render(); - - const backButton = getByTestId('button-icon'); - fireEvent.press(backButton); - - expect(mockCanGoBack).toHaveBeenCalledTimes(1); - expect(mockGoBack).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Earn/Navbars/musdNavbarOptions.tsx b/app/components/UI/Earn/Navbars/musdNavbarOptions.tsx deleted file mode 100644 index a121705aee5a..000000000000 --- a/app/components/UI/Earn/Navbars/musdNavbarOptions.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; -import { Theme } from '../../../../util/theme/models'; -import { View, StyleSheet, Image } from 'react-native'; -import Text, { - TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../component-library/components/Badges/BadgeWrapper'; -import Badge, { - BadgeVariant, -} from '../../../../component-library/components/Badges/Badge'; -import { getNetworkImageSource } from '../../../../util/networks'; -import { MUSD_TOKEN } from '../constants/musd'; -import { strings } from '../../../../../locales/i18n'; -import { - ButtonIcon, - ButtonIconSize, - IconColor, - IconName, -} from '@metamask/design-system-react-native'; - -/** - * Function that returns the navigation options for the mUSD conversion screen - * - * @param {Object} navigation - Navigation object required to push new views - * @param {Theme} theme - Theme object required to style the navbar - * @param {string} chainId - Chain ID for the network badge - * @returns {Object} - Corresponding navbar options - */ - -export const getMusdConversionNavbarOptions = ( - navigation: { goBack: () => void; canGoBack: () => boolean }, - theme: Theme, - chainId: string, -) => { - const innerStyles = StyleSheet.create({ - tokenIcon: { - width: 16, - height: 16, - }, - badgeWrapper: { - alignSelf: 'center', - }, - headerLeft: { - marginHorizontal: 8, - }, - headerTitle: { - flexDirection: 'row', - gap: 8, - }, - headerStyle: { - backgroundColor: theme.colors.background.alternative, - }, - }); - - const networkImageSource = getNetworkImageSource({ - chainId, - }); - - const handleBackPress = () => { - if (navigation.canGoBack()) { - navigation.goBack(); - } - }; - - return { - headerTitleAlign: 'center', - headerTitle: () => ( - - - } - > - - - - {strings('earn.musd_conversion.convert_to_musd')} - - - ), - headerLeft: () => ( - - - - ), - headerStyle: innerStyles.headerStyle, - } as const; -}; diff --git a/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx b/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx new file mode 100644 index 000000000000..46a0fa05279e --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { renderHook } from '@testing-library/react-hooks'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useMusdConversionNavbar } from './useMusdConversionNavbar'; +import useNavbar from '../../../Views/confirmations/hooks/ui/useNavbar'; +import { strings } from '../../../../../locales/i18n'; +import { NavbarOverrides } from '../../../Views/confirmations/components/UI/navbar/navbar'; +import { getNetworkImageSource } from '../../../../util/networks'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../../Views/confirmations/hooks/ui/useNavbar'); + +jest.mock('../../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(), +})); + +const mockUseNavbar = useNavbar as jest.MockedFunction; +const mockStrings = strings as jest.MockedFunction; +const mockGetNetworkImageSource = getNetworkImageSource as jest.MockedFunction< + typeof getNetworkImageSource +>; + +describe('useMusdConversionNavbar', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls useNavbar with correct title and addBackButton parameters', () => { + renderHook(() => useMusdConversionNavbar(CHAIN_IDS.MAINNET)); + + expect(mockUseNavbar).toHaveBeenCalledTimes(1); + expect(mockStrings).toHaveBeenCalledWith( + 'earn.musd_conversion.convert_to_musd', + ); + expect(mockUseNavbar).toHaveBeenCalledWith( + 'earn.musd_conversion.convert_to_musd', + true, + expect.objectContaining({ + headerTitle: expect.any(Function), + headerLeft: expect.any(Function), + }), + ); + }); + + it('provides headerTitle override that renders mUSD icon with network badge', () => { + let capturedOverrides: NavbarOverrides | undefined; + mockUseNavbar.mockImplementation((_title, _addBackButton, overrides) => { + capturedOverrides = overrides; + }); + + renderHook(() => useMusdConversionNavbar(CHAIN_IDS.MAINNET)); + + expect(capturedOverrides?.headerTitle).toBeDefined(); + + const HeaderTitle = capturedOverrides?.headerTitle as React.FC; + const { getByTestId, getByText } = render(); + + expect(getByTestId('musd-token-icon')).toBeOnTheScreen(); + expect(getByTestId('badge-wrapper-badge')).toBeOnTheScreen(); + expect(getByTestId('badgenetwork')).toBeOnTheScreen(); + expect(getByText('earn.musd_conversion.convert_to_musd')).toBeOnTheScreen(); + }); + + it('provides headerLeft override that renders back button', () => { + let capturedOverrides: NavbarOverrides | undefined; + mockUseNavbar.mockImplementation((_title, _addBackButton, overrides) => { + capturedOverrides = overrides; + }); + + renderHook(() => useMusdConversionNavbar(CHAIN_IDS.MAINNET)); + + expect(capturedOverrides?.headerLeft).toBeDefined(); + + const mockOnBackPress = jest.fn(); + const headerLeftFn = capturedOverrides?.headerLeft as ( + onBackPress: () => void, + ) => React.ReactNode; + const HeaderLeft = () => <>{headerLeftFn(mockOnBackPress)}; + + const { getByTestId } = render(); + + const backButton = getByTestId('button-icon'); + expect(backButton).toBeOnTheScreen(); + }); + + it('calls provided onBackPress when back button is pressed', () => { + let capturedOverrides: NavbarOverrides | undefined; + mockUseNavbar.mockImplementation((_title, _addBackButton, overrides) => { + capturedOverrides = overrides; + }); + + renderHook(() => useMusdConversionNavbar(CHAIN_IDS.MAINNET)); + + const mockOnBackPress = jest.fn(); + const headerLeftFn = capturedOverrides?.headerLeft as ( + onBackPress: () => void, + ) => React.ReactNode; + const HeaderLeft = () => <>{headerLeftFn(mockOnBackPress)}; + + const { getByTestId } = render(); + + const backButton = getByTestId('button-icon'); + fireEvent.press(backButton); + + expect(mockOnBackPress).toHaveBeenCalledTimes(1); + }); + + it('passes Linea chainId to getNetworkImageSource', () => { + renderHook(() => useMusdConversionNavbar(CHAIN_IDS.LINEA_MAINNET)); + + expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ + chainId: CHAIN_IDS.LINEA_MAINNET, + }); + }); + + it('passes Mainnet chainId to getNetworkImageSource', () => { + renderHook(() => useMusdConversionNavbar(CHAIN_IDS.MAINNET)); + + expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ + chainId: CHAIN_IDS.MAINNET, + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx new file mode 100644 index 000000000000..7d49745ce247 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx @@ -0,0 +1,94 @@ +import React, { useCallback } from 'react'; +import { View, StyleSheet, Image } from 'react-native'; +import Text, { + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../../component-library/components/Badges/Badge'; +import { getNetworkImageSource } from '../../../../util/networks'; +import { MUSD_TOKEN } from '../constants/musd'; +import { strings } from '../../../../../locales/i18n'; +import { + ButtonIcon, + ButtonIconSize, + IconColor, + IconName, +} from '@metamask/design-system-react-native'; +import useNavbar from '../../../Views/confirmations/hooks/ui/useNavbar'; + +const styles = StyleSheet.create({ + headerTitle: { + flexDirection: 'row', + gap: 8, + }, + tokenIcon: { + width: 16, + height: 16, + }, + badgeWrapper: { + alignSelf: 'center', + }, + headerLeft: { + marginHorizontal: 8, + }, +}); + +/** + * Hook that sets up the mUSD conversion navbar with custom styling. + * Uses the centralized rejection logic from useNavbar. + * + * @param chainId - Chain ID for the network badge + */ +export function useMusdConversionNavbar(chainId: string) { + const networkImageSource = getNetworkImageSource({ chainId }); + + const renderHeaderTitle = useCallback( + () => ( + + + } + > + + + + {strings('earn.musd_conversion.convert_to_musd')} + + + ), + [networkImageSource], + ); + + const renderHeaderLeft = useCallback( + (onBackPress: () => void) => ( + + + + ), + [], + ); + + useNavbar(strings('earn.musd_conversion.convert_to_musd'), true, { + headerTitle: renderHeaderTitle, + headerLeft: renderHeaderLeft, + }); +} diff --git a/app/components/UI/Earn/routes/index.tsx b/app/components/UI/Earn/routes/index.tsx index d4d62b3dfeff..cf3f8f5142f2 100644 --- a/app/components/UI/Earn/routes/index.tsx +++ b/app/components/UI/Earn/routes/index.tsx @@ -7,9 +7,7 @@ import EarnMusdConversionEducationView from '../Views/EarnMusdConversionEducatio import EarnLendingMaxWithdrawalModal from '../modals/LendingMaxWithdrawalModal'; import LendingLearnMoreModal from '../LendingLearnMoreModal'; import { Confirm } from '../../../Views/confirmations/components/confirm'; -import { getMusdConversionNavbarOptions } from '../Navbars/musdNavbarOptions'; -import { useTheme } from '../../../../util/theme'; -import { MusdConversionConfig } from '../hooks/useMusdConversion'; +import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; const Stack = createStackNavigator(); const ModalStack = createStackNavigator(); @@ -23,7 +21,7 @@ const clearStackNavigatorOptions = { }; const EarnScreenStack = () => { - const theme = useTheme(); + const emptyNavHeaderOptions = useEmptyNavHeaderForConfirmations(); return ( @@ -38,16 +36,7 @@ const EarnScreenStack = () => { { - const params = route.params as Partial; - - return getMusdConversionNavbarOptions( - navigation, - theme, - params.outputChainId ?? '', - ); - }} + options={emptyNavHeaderOptions} /> { - it('renders the header title correctly', () => { - const title = 'Test Title'; - const { getByText } = render( - <> - {getNavbar({ - onReject: jest.fn(), - theme: { - colors: { - background: { - alternative: 'red', - }, - }, - } as Theme, - title, - }).headerTitle()} - , - ); - - expect(getByText(title)).toBeTruthy(); +import { mockTheme } from '../../../../../../util/theme'; + +describe('getNavbar', () => { + const mockOnReject = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); }); - it('calls onReject when the back button is pressed', () => { - const onRejectMock = jest.fn(); - const { getByTestId } = render( - <> - {getNavbar({ - onReject: onRejectMock, - theme: { - colors: { - background: { - alternative: 'red', - }, - }, - } as Theme, - title: 'Test Title', - }).headerLeft()} - , - ); - - const backButton = getByTestId('Test Title-navbar-back-button'); - backButton.props.onPress(); - - expect(onRejectMock).toHaveBeenCalled(); + describe('default behavior', () => { + it('renders the header title correctly', () => { + const title = 'Test Title'; + + const { getByText } = render( + <> + {getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title, + }).headerTitle()} + , + ); + + expect(getByText(title)).toBeOnTheScreen(); + }); + + it('calls onReject when the back button is pressed', () => { + const { getByTestId } = render( + <> + {getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title: 'Test Title', + }).headerLeft()} + , + ); + + const backButton = getByTestId('Test Title-navbar-back-button'); + fireEvent.press(backButton); + + expect(mockOnReject).toHaveBeenCalledTimes(1); + }); + + it('returns center-aligned header by default', () => { + const result = getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title: 'Test Title', + }); + + expect(result.headerTitleAlign).toBe('center'); + }); + + it('applies theme background color to header style', () => { + const result = getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title: 'Test Title', + }); + + expect(result.headerStyle.backgroundColor).toBe( + mockTheme.colors.background.alternative, + ); + }); + }); + + describe('overrides', () => { + it('uses custom headerTitle when provided in overrides', () => { + const customHeaderTitle = () => ( + Custom Title + ); + + const { getByTestId } = render( + <> + {getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title: 'Test Title', + overrides: { headerTitle: customHeaderTitle }, + }).headerTitle()} + , + ); + + expect(getByTestId('custom-header-title')).toBeOnTheScreen(); + }); + + it('uses custom headerLeft when provided and passes onBackPress', () => { + const customHeaderLeft = (onBackPress: () => void) => ( + + ); + + const { getByTestId } = render( + <> + {getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title: 'Test Title', + overrides: { headerLeft: customHeaderLeft }, + }).headerLeft()} + , + ); + + const customLeft = getByTestId('custom-header-left'); + expect(customLeft).toBeOnTheScreen(); + + fireEvent(customLeft, 'touchEnd'); + expect(mockOnReject).toHaveBeenCalledTimes(1); + }); + + it('applies custom headerTitleAlign from overrides', () => { + const result = getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title: 'Test Title', + overrides: { headerTitleAlign: 'left' }, + }); + + expect(result.headerTitleAlign).toBe('left'); + }); + + it('merges custom headerStyle with default styles', () => { + const result = getNavbar({ + onReject: mockOnReject, + theme: mockTheme, + title: 'Test Title', + overrides: { headerStyle: { borderBottomWidth: 2 } }, + }); + + expect(result.headerStyle.backgroundColor).toBe( + mockTheme.colors.background.alternative, + ); + expect(result.headerStyle.borderBottomWidth).toBe(2); + }); }); }); diff --git a/app/components/Views/confirmations/components/UI/navbar/navbar.tsx b/app/components/Views/confirmations/components/UI/navbar/navbar.tsx index 99ea57ad4d11..4a33d28ab116 100644 --- a/app/components/Views/confirmations/components/UI/navbar/navbar.tsx +++ b/app/components/Views/confirmations/components/UI/navbar/navbar.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import React, { ReactNode } from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; import { colors as importedColors } from '../../../../../../styles/common'; import ButtonIcon, { ButtonIconSizes, @@ -12,17 +12,34 @@ import { import Device from '../../../../../../util/device'; import { Theme } from '../../../../../../util/theme/models'; +/** + * Optional overrides for navbar customization. + * Each property mirrors the return value of getNavbar. + */ +export interface NavbarOverrides { + headerTitle?: () => ReactNode; + /** Custom header left component. Receives onBackPress for rejection handling. */ + headerLeft?: (onBackPress: () => void) => ReactNode; + /** Additional styles to merge with header */ + headerStyle?: ViewStyle; + headerTitleAlign?: 'left' | 'center'; +} + +export interface NavbarOptions { + title: string; + onReject?: () => void; + addBackButton?: boolean; + theme: Theme; + overrides?: NavbarOverrides; +} + export function getNavbar({ title, onReject, addBackButton = true, theme, -}: { - title: string; - onReject?: () => void; - addBackButton?: boolean; - theme: Theme; -}) { + overrides, +}: NavbarOptions) { const innerStyles = StyleSheet.create({ headerLeft: { marginHorizontal: 16, @@ -45,22 +62,34 @@ export function getNavbar({ } } + const defaultHeaderTitle = () => ( + + {title} + + ); + + const defaultHeaderLeft = () => ( + + ); + + const customHeaderLeft = overrides?.headerLeft; + return { - headerTitle: () => ( - - {title} - - ), - headerLeft: () => ( - - ), - headerStyle: innerStyles.headerStyle, + headerTitleAlign: overrides?.headerTitleAlign ?? ('center' as const), + headerTitle: overrides?.headerTitle ?? defaultHeaderTitle, + headerLeft: customHeaderLeft + ? () => customHeaderLeft(handleBackPress) + : defaultHeaderLeft, + headerStyle: { + ...innerStyles.headerStyle, + ...overrides?.headerStyle, + }, }; } diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx index b799d1976d65..0ea2a720329b 100644 --- a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx +++ b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx @@ -11,6 +11,7 @@ import { useAddToken } from '../../../hooks/tokens/useAddToken'; import { PayWithRow } from '../../rows/pay-with-row'; import { CustomAmountInfo } from '../custom-amount-info'; import { useTransactionPayAvailableTokens } from '../../../hooks/pay/useTransactionPayAvailableTokens'; +import { useMusdConversionNavbar } from '../../../../../UI/Earn/hooks/useMusdConversionNavbar'; interface MusdOverrideContentProps { amountHuman: string; @@ -53,6 +54,8 @@ export const MusdConversionInfo = () => { ); } + useMusdConversionNavbar(outputChainId); + useAddToken({ chainId: outputChainId, decimals, diff --git a/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts b/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts index cfb999f5ba59..ca01edcf92be 100644 --- a/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts +++ b/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts @@ -144,4 +144,45 @@ describe('useNavbar', () => { theme: expect.any(Object), }); }); + + describe('overrides parameter', () => { + it('passes overrides to getNavbar when provided', () => { + (useFullScreenConfirmation as jest.Mock).mockReturnValue({ + isFullScreenConfirmation: true, + }); + + const mockHeaderTitle = jest.fn(); + const mockHeaderLeft = jest.fn(); + const overrides = { + headerTitle: mockHeaderTitle, + headerLeft: mockHeaderLeft, + }; + + renderHook(() => useNavbar(mockTitle, true, overrides)); + + expect(getNavbar).toHaveBeenCalledWith({ + title: mockTitle, + onReject: mockOnReject, + addBackButton: true, + theme: expect.any(Object), + overrides, + }); + }); + + it('passes undefined overrides when not provided', () => { + (useFullScreenConfirmation as jest.Mock).mockReturnValue({ + isFullScreenConfirmation: true, + }); + + renderHook(() => useNavbar(mockTitle, false)); + + expect(getNavbar).toHaveBeenCalledWith({ + title: mockTitle, + onReject: mockOnReject, + addBackButton: false, + theme: expect.any(Object), + overrides: undefined, + }); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/ui/useNavbar.ts b/app/components/Views/confirmations/hooks/ui/useNavbar.ts index 7b4ea590debf..9ad12c8fad52 100644 --- a/app/components/Views/confirmations/hooks/ui/useNavbar.ts +++ b/app/components/Views/confirmations/hooks/ui/useNavbar.ts @@ -6,11 +6,16 @@ import { StakeNavigationParamsList } from '../../../../UI/Stake/types'; import { getModalNavigationOptions, getNavbar, + NavbarOverrides, } from '../../components/UI/navbar/navbar'; import { useConfirmActions } from '../useConfirmActions'; import { useFullScreenConfirmation } from './useFullScreenConfirmation'; -const useNavbar = (title: string, addBackButton = true) => { +const useNavbar = ( + title: string, + addBackButton = true, + overrides?: NavbarOverrides, +) => { const navigation = useNavigation>(); const { onReject } = useConfirmActions(); @@ -25,6 +30,7 @@ const useNavbar = (title: string, addBackButton = true) => { onReject, addBackButton, theme, + overrides, }), ); } @@ -33,6 +39,7 @@ const useNavbar = (title: string, addBackButton = true) => { isFullScreenConfirmation, navigation, onReject, + overrides, theme, title, ]); From 1c464ea011b04a7a8a8972f044239e35972152a6 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 13:30:38 -0500 Subject: [PATCH 08/40] fix: fix breaking tests --- .../hooks/ui/useEmptyNavHeaderForConfirmations.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations.test.tsx b/app/components/Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations.test.tsx index 480569b17099..3c5b57682d2b 100644 --- a/app/components/Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations.test.tsx +++ b/app/components/Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations.test.tsx @@ -21,6 +21,7 @@ describe('useEmptyNavHeaderForConfirmations', () => { const mockNavbarOptions = { headerTitle: () => <>, headerLeft: () => <>, + headerTitleAlign: 'center' as const, headerStyle: { backgroundColor: '#ffffff', shadowColor: 'transparent', From 35f847476f4dc12f57059f8709f4d0d422d4c3b0 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 15:58:03 -0500 Subject: [PATCH 09/40] fix: fix breaking tests --- .../musd-conversion-info.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx index acbd9ce02f32..3af0f103f599 100644 --- a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx +++ b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx @@ -7,10 +7,12 @@ import { useRoute } from '@react-navigation/native'; import { CustomAmountInfo } from '../custom-amount-info'; import { useCustomAmount } from '../../../hooks/earn/useCustomAmount'; import { useTransactionPayAvailableTokens } from '../../../hooks/pay/useTransactionPayAvailableTokens'; +import { useMusdConversionNavbar } from '../../../../../UI/Earn/hooks/useMusdConversionNavbar'; jest.mock('../../../hooks/tokens/useAddToken'); jest.mock('../../../hooks/earn/useCustomAmount'); jest.mock('../../../hooks/pay/useTransactionPayAvailableTokens'); +jest.mock('../../../../../UI/Earn/hooks/useMusdConversionNavbar'); jest.mock('../custom-amount-info', () => ({ CustomAmountInfo: jest.fn(() => null), @@ -41,9 +43,11 @@ describe('MusdConversionInfo', () => { const mockUseTransactionPayAvailableTokens = jest.mocked( useTransactionPayAvailableTokens, ); + const mockUseMusdConversionNavbar = jest.mocked(useMusdConversionNavbar); beforeEach(() => { jest.clearAllMocks(); + mockUseMusdConversionNavbar.mockReturnValue(undefined); mockUseCustomAmount.mockReturnValue({ shouldShowOutputAmountTag: false, outputAmount: null, @@ -172,4 +176,20 @@ describe('MusdConversionInfo', () => { expect(mockUseTransactionPayAvailableTokens).toHaveBeenCalled(); }); }); + + describe('useMusdConversionNavbar', () => { + it('calls useMusdConversionNavbar with outputChainId', () => { + mockRoute.params = { + outputChainId: '0xe708' as Hex, + }; + + mockUseRoute.mockReturnValue(mockRoute); + + renderWithProvider(, { + state: {}, + }); + + expect(mockUseMusdConversionNavbar).toHaveBeenCalledWith('0xe708'); + }); + }); }); From 57d8f74f781c128479268e639abf67aefc664cb7 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 16:24:15 -0500 Subject: [PATCH 10/40] fix: memoize overrides to address cursor comment --- .../UI/Earn/hooks/useMusdConversionNavbar.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx index 7d49745ce247..4feca7cbc82d 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx +++ b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { View, StyleSheet, Image } from 'react-native'; import Text, { TextVariant, @@ -87,8 +87,13 @@ export function useMusdConversionNavbar(chainId: string) { [], ); - useNavbar(strings('earn.musd_conversion.convert_to_musd'), true, { - headerTitle: renderHeaderTitle, - headerLeft: renderHeaderLeft, - }); + const overrides = useMemo( + () => ({ + headerTitle: renderHeaderTitle, + headerLeft: renderHeaderLeft, + }), + [renderHeaderTitle, renderHeaderLeft], + ); + + useNavbar(strings('earn.musd_conversion.convert_to_musd'), true, overrides); } From 65b3e36399c6a91d00656494fd1dbd496097c816 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 16:39:09 -0500 Subject: [PATCH 11/40] feat: added allowlist for dual filtering using allow and blocklist override --- .js.env.example | 1 + .../EarnLendingBalance.test.tsx | 24 +- .../MusdConversionAssetListCta.test.tsx | 48 +++- .../MusdConversionAssetOverviewCta.test.tsx | 4 +- .../UI/Earn/docs/wildcard-token-list.md | 224 +++++++++++++++++ .../hooks/useMusdConversionTokens.test.ts | 140 ++++++----- .../UI/Earn/hooks/useMusdConversionTokens.ts | 41 ++-- .../Earn/selectors/featureFlags/index.test.ts | 199 ++++----------- .../UI/Earn/selectors/featureFlags/index.ts | 72 ++++++ .../UI/Earn/utils/wildcardTokenList.test.ts | 227 ++++++++++++++++++ .../UI/Earn/utils/wildcardTokenList.ts | 62 +++++ .../StakeButton/StakeButton.test.tsx | 44 +++- .../TokenListItem/TokenListItemBip44.test.tsx | 4 +- .../TokenList/TokenListItem/index.test.tsx | 4 +- .../modals/pay-with-modal/pay-with-modal.tsx | 6 +- 15 files changed, 848 insertions(+), 252 deletions(-) create mode 100644 app/components/UI/Earn/docs/wildcard-token-list.md diff --git a/.js.env.example b/.js.env.example index 433a1453963e..54c4c19995da 100644 --- a/.js.env.example +++ b/.js.env.example @@ -119,6 +119,7 @@ export MM_MUSD_CTA_TOKENS='' # Block specific tokens on chain: '{"0xa4b1":["USDT","DAI"]}' # Combined rules (additive): '{"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT"]}' export MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST='' +export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' # Activates remote feature flag override mode. # Remote feature flag values won't be updated, diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index ba49e6a21821..61cbb702b844 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -128,6 +128,10 @@ jest.mock('../../hooks/useMusdConversionTokens', () => ({ __esModule: true, useMusdConversionTokens: jest.fn().mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn().mockReturnValue([]), + tokens: [], + tokensWithCTAs: [], }), })); @@ -332,10 +336,12 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn().mockReturnValue([]), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(false), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), tokens: [], + tokensWithCTAs: [], }); const { toJSON } = renderWithProvider( @@ -490,10 +496,12 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), - tokenFilter: jest.fn().mockReturnValue([]), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), tokens: [], + tokensWithCTAs: [], }); const { queryByTestId } = renderWithProvider( @@ -519,10 +527,12 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn().mockReturnValue([]), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), tokens: [], + tokensWithCTAs: [], }); const { queryByTestId } = renderWithProvider( @@ -554,10 +564,12 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), - tokenFilter: jest.fn().mockReturnValue([]), + isTokenWithCta: jest.fn().mockReturnValue(true), + filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), tokens: [], + tokensWithCTAs: [], }); const { getByTestId } = renderWithProvider( @@ -590,10 +602,12 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), - tokenFilter: jest.fn().mockReturnValue([]), + isTokenWithCta: jest.fn().mockReturnValue(true), + filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), tokens: [], + tokensWithCTAs: [], }); ( diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index 9fe2d1db1e71..3bd736372b9b 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -95,8 +95,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -120,8 +122,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -140,8 +144,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -162,8 +168,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -182,8 +190,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [mockToken], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -204,8 +214,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -242,8 +254,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [mockToken], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -279,8 +293,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [firstToken, secondToken], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -311,8 +327,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [mockToken], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -339,8 +357,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [mockToken], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -395,8 +415,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); @@ -449,8 +471,10 @@ describe('MusdConversionAssetListCta', () => { > ).mockReturnValue({ tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx index 8a5ca94cf25a..4dfe80c2054c 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.test.tsx @@ -45,8 +45,10 @@ describe('MusdConversionAssetOverviewCta', () => { jest.mocked(useMusdConversionTokens).mockReturnValue({ isMusdSupportedOnChain: jest.fn().mockReturnValue(true), isConversionToken: jest.fn().mockReturnValue(false), + isTokenWithCta: jest.fn().mockReturnValue(false), tokens: [], - tokenFilter: jest.fn(), + tokensWithCTAs: [], + filterAllowedTokens: jest.fn(), getMusdOutputChainId: jest .fn() .mockImplementation((chainId) => chainId || CHAIN_IDS.MAINNET), diff --git a/app/components/UI/Earn/docs/wildcard-token-list.md b/app/components/UI/Earn/docs/wildcard-token-list.md new file mode 100644 index 000000000000..bdaf80a12420 --- /dev/null +++ b/app/components/UI/Earn/docs/wildcard-token-list.md @@ -0,0 +1,224 @@ +# Wildcard Token List Architecture + +This document describes the architecture for filtering tokens using allowlists and blocklists with wildcard support. + +## Overview + +The Wildcard Token List system provides a flexible way to control which tokens are permitted for specific operations (e.g., mUSD conversion). It supports: + +- **Allowlists**: Explicitly permit specific tokens +- **Blocklists**: Explicitly forbid specific tokens (override) +- **Wildcards**: Apply rules across all chains or all tokens + +## Core Types + +### `WildcardTokenList` + +A mapping of chain IDs (or `"*"`) to arrays of token symbols (or `["*"]`): + +```typescript +type WildcardTokenList = Record; +``` + +## Wildcard Patterns + +### Chain-Level Wildcards + +| Pattern | Meaning | +| ----------------------------- | -------------------------------------------------- | +| `{ "*": ["USDC"] }` | USDC is matched on **all chains** | +| `{ "0x1": ["USDC", "USDT"] }` | USDC and USDT are matched only on Ethereum mainnet | + +### Token-Level Wildcards + +| Pattern | Meaning | +| ------------------ | ---------------------------------------------- | +| `{ "0x1": ["*"] }` | **All tokens** are matched on Ethereum mainnet | +| `{ "*": ["*"] }` | **All tokens** on **all chains** are matched | + +### Combined Examples + +```typescript +// Allow USDC on all chains, plus all tokens on Linea +const allowlist: WildcardTokenList = { + '*': ['USDC'], + '0xe708': ['*'], +}; + +// Block tokens globally or on specific chains +const blocklist: WildcardTokenList = { + '*': ['SCAM_TOKEN'], // Blocked on all chains + '0x1': ['DEPRECATED_TOKEN'], // Blocked only on Ethereum mainnet +}; +``` + +## Filtering Logic + +The `isTokenAllowed` function implements a two-step filtering process: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ isTokenAllowed() │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: Allowlist Check (if non-empty) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Token must be IN the allowlist to proceed │ │ +│ │ If not in allowlist → BLOCKED │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ Step 2: Blocklist Check (if non-empty) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Token must NOT be in the blocklist │ │ +│ │ If in blocklist → BLOCKED │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ Result: Token is ALLOWED │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Behaviors + +| Allowlist | Blocklist | Token In Allowlist | Token In Blocklist | Result | +| ----------- | ----------- | ------------------ | ------------------ | ---------- | +| Empty `{}` | Empty `{}` | N/A | N/A | ✅ Allowed | +| Has entries | Empty `{}` | ✅ Yes | N/A | ✅ Allowed | +| Has entries | Empty `{}` | ❌ No | N/A | ❌ Blocked | +| Empty `{}` | Has entries | N/A | ❌ No | ✅ Allowed | +| Empty `{}` | Has entries | N/A | ✅ Yes | ❌ Blocked | +| Has entries | Has entries | ✅ Yes | ❌ No | ✅ Allowed | +| Has entries | Has entries | ✅ Yes | ✅ Yes | ❌ Blocked | +| Has entries | Has entries | ❌ No | N/A | ❌ Blocked | + +## Configuration Sources + +Configuration is loaded with the following priority: + +1. **Remote Feature Flags** (highest priority) +2. **Local Environment Variables** (fallback) +3. **Empty object `{}`** (default - allows all if no blocklist) + +### Remote Feature Flags + +```typescript +// Allowlist +remoteFeatureFlags.earnMusdConvertibleTokensAllowlist; + +// Blocklist +remoteFeatureFlags.earnMusdConvertibleTokensBlocklist; +``` + +### Environment Variables + +```bash +# Allowlist +MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT","DAI"],"0xe708":["USDC","USDT"]}' + +# Blocklist +MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST='{"*":["BLOCKED_TOKEN"]}' +``` + +## Use Cases + +### 1. Allow Only Specific Tokens + +```typescript +// Only allow USDC, USDT, DAI on Ethereum and USDC, USDT on Linea +const allowlist = { + '0x1': ['USDC', 'USDT', 'DAI'], + '0xe708': ['USDC', 'USDT'], +}; +const blocklist = {}; // Empty - no overrides +``` + +### 2. Allow All Tokens Except Specific Ones + +```typescript +const allowlist = {}; // Empty - allows all by default +const blocklist = { + '*': ['SCAM_TOKEN', 'DEPRECATED_TOKEN'], +}; +``` + +### 3. Allow All Tokens on Specific Chains + +```typescript +const allowlist = { + '0x1': ['*'], // All tokens on Ethereum + '0xe708': ['*'], // All tokens on Linea +}; +const blocklist = {}; +``` + +### 4. Emergency Kill Switch + +The blocklist can serve as an emergency override to quickly disable specific tokens without changing the allowlist: + +```typescript +// Existing allowlist +const allowlist = { + '0x1': ['USDC', 'USDT', 'DAI'], +}; + +// Emergency: block USDT due to an issue +const blocklist = { + '*': ['USDT'], +}; +// Result: Only USDC and DAI are allowed +``` + +## API Reference + +### `isTokenInWildcardList` + +Checks if a token matches any entry in a wildcard token list. + +```typescript +function isTokenInWildcardList( + tokenSymbol: string, + tokenList: WildcardTokenList, + chainId?: string, +): boolean; +``` + +### `isTokenAllowed` + +Combines allowlist and blocklist logic to determine if a token is permitted. + +```typescript +function isTokenAllowed( + tokenSymbol: string, + allowlist?: WildcardTokenList, + blocklist?: WildcardTokenList, + chainId?: string, +): boolean; +``` + +### `isValidWildcardTokenList` + +Validates that an object conforms to the `WildcardTokenList` structure. + +```typescript +function isValidWildcardTokenList(obj: unknown): obj is WildcardTokenList; +``` + +## File Locations + +| File | Description | +| --------------------------------------------------------- | ---------------------------------- | +| `app/components/UI/Earn/utils/wildcardTokenList.ts` | Core utility functions | +| `app/components/UI/Earn/selectors/featureFlags/index.ts` | Redux selectors for fetching lists | +| `app/components/UI/Earn/hooks/useMusdConversionTokens.ts` | Hook using the filtering logic | + +## Symbol Matching + +- Token symbols are matched **case-insensitively** +- Example: `"usdc"`, `"USDC"`, and `"Usdc"` all match + +## Notes + +- An **empty allowlist** (`{}`) means "allow all tokens" (no restrictions) +- An **empty blocklist** (`{}`) means "block nothing" (no overrides) +- The **blocklist always takes precedence** over the allowlist +- Chain IDs should be in hex format (e.g., `"0x1"` for Ethereum mainnet) diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts index b3bb4aa8b72e..bd0feb0e14c0 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.test.ts @@ -3,36 +3,35 @@ import { Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useMusdConversionTokens } from './useMusdConversionTokens'; -import { selectMusdConversionPaymentTokensAllowlist } from '../selectors/featureFlags'; -import { isMusdConversionPaymentToken } from '../utils/musd'; +import { + selectMusdConversionPaymentTokensAllowlist, + selectMusdConversionPaymentTokensBlocklist, +} from '../selectors/featureFlags'; +import { isTokenAllowed, WildcardTokenList } from '../utils/wildcardTokenList'; 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('../utils/wildcardTokenList'); jest.mock('../../../Views/confirmations/hooks/send/useAccountTokens'); const mockUseSelector = useSelector as jest.MockedFunction; -const mockIsMusdConversionPaymentToken = - isMusdConversionPaymentToken as jest.MockedFunction< - typeof isMusdConversionPaymentToken - >; +const mockIsTokenAllowed = isTokenAllowed as jest.MockedFunction< + typeof isTokenAllowed +>; const mockUseAccountTokens = useAccountTokens as jest.MockedFunction< typeof useAccountTokens >; describe('useMusdConversionTokens', () => { - const mockAllowlist: Record = { - '0x1': [ - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC - '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - ], - '0xe708': [ - '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC on Linea - ], + const mockAllowlist: WildcardTokenList = { + '0x1': ['USDC', 'USDT'], + '0xe708': ['USDC'], }; + const mockBlocklist: WildcardTokenList = {}; + const mockUsdcMainnet: AssetType = { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', chainId: '0x1', @@ -91,10 +90,13 @@ describe('useMusdConversionTokens', () => { if (selector === selectMusdConversionPaymentTokensAllowlist) { return mockAllowlist; } + if (selector === selectMusdConversionPaymentTokensBlocklist) { + return mockBlocklist; + } return undefined; }); mockUseAccountTokens.mockReturnValue([]); - mockIsMusdConversionPaymentToken.mockReturnValue(false); + mockIsTokenAllowed.mockReturnValue(false); }); afterEach(() => { @@ -102,19 +104,19 @@ describe('useMusdConversionTokens', () => { }); describe('hook structure', () => { - it('returns object with tokenFilter, isConversionToken, isMusdSupportedOnChain, and tokens properties', () => { + it('returns object with filterAllowedTokens, isConversionToken, isMusdSupportedOnChain, and tokens properties', () => { const { result } = renderHook(() => useMusdConversionTokens()); - expect(result.current).toHaveProperty('tokenFilter'); + expect(result.current).toHaveProperty('filterAllowedTokens'); expect(result.current).toHaveProperty('isConversionToken'); expect(result.current).toHaveProperty('isMusdSupportedOnChain'); expect(result.current).toHaveProperty('tokens'); }); - it('returns tokenFilter as a function', () => { + it('returns filterAllowedTokens as a function', () => { const { result } = renderHook(() => useMusdConversionTokens()); - expect(typeof result.current.tokenFilter).toBe('function'); + expect(typeof result.current.filterAllowedTokens).toBe('function'); }); it('returns isConversionToken as a function', () => { @@ -143,13 +145,15 @@ describe('useMusdConversionTokens', () => { mockUsdtMainnet, mockDaiMainnet, ]); - mockIsMusdConversionPaymentToken.mockImplementation( - (address, _allowlist, chainId) => { + mockIsTokenAllowed.mockImplementation( + ( + symbol: string, + _allowlist?: WildcardTokenList, + _blocklist?: WildcardTokenList, + chainId?: string, + ) => { if (chainId === '0x1') { - return ( - address.toLowerCase() === mockUsdcMainnet.address.toLowerCase() || - address.toLowerCase() === mockUsdtMainnet.address.toLowerCase() - ); + return symbol === 'USDC' || symbol === 'USDT'; } return false; }, @@ -165,7 +169,7 @@ describe('useMusdConversionTokens', () => { it('returns empty array when no tokens match allowlist', () => { mockUseAccountTokens.mockReturnValue([mockDaiMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(false); + mockIsTokenAllowed.mockReturnValue(false); const { result } = renderHook(() => useMusdConversionTokens()); @@ -178,17 +182,18 @@ describe('useMusdConversionTokens', () => { mockUsdcLinea, mockDaiMainnet, ]); - mockIsMusdConversionPaymentToken.mockImplementation( - (address, _allowlist, chainId) => { + mockIsTokenAllowed.mockImplementation( + ( + symbol: string, + _allowlist?: WildcardTokenList, + _blocklist?: WildcardTokenList, + chainId?: string, + ) => { if (chainId === '0x1') { - return ( - address.toLowerCase() === mockUsdcMainnet.address.toLowerCase() - ); + return symbol === 'USDC'; } if (chainId === '0xe708') { - return ( - address.toLowerCase() === mockUsdcLinea.address.toLowerCase() - ); + return symbol === 'USDC'; } return false; }, @@ -208,7 +213,7 @@ describe('useMusdConversionTokens', () => { address: mockUsdcMainnet.address.toUpperCase() as Hex, }; mockUseAccountTokens.mockReturnValue([uppercaseUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); const { result } = renderHook(() => useMusdConversionTokens()); @@ -220,7 +225,7 @@ describe('useMusdConversionTokens', () => { describe('isConversionToken', () => { it('returns true for token in conversion tokens list with matching address and chainId', () => { mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); const { result } = renderHook(() => useMusdConversionTokens()); const isConversion = result.current.isConversionToken(mockUsdcMainnet); @@ -230,7 +235,7 @@ describe('useMusdConversionTokens', () => { it('returns false for token not in conversion tokens list', () => { mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); const { result } = renderHook(() => useMusdConversionTokens()); const isConversion = result.current.isConversionToken(mockDaiMainnet); @@ -240,7 +245,7 @@ describe('useMusdConversionTokens', () => { it('returns false when token is undefined', () => { mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); const { result } = renderHook(() => useMusdConversionTokens()); const isConversion = result.current.isConversionToken(undefined); @@ -254,7 +259,7 @@ describe('useMusdConversionTokens', () => { chainId: '0x89', // Polygon }; mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); const { result } = renderHook(() => useMusdConversionTokens()); const isConversion = result.current.isConversionToken( @@ -270,7 +275,7 @@ describe('useMusdConversionTokens', () => { address: mockUsdcMainnet.address.toUpperCase() as Hex, }; mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); const { result } = renderHook(() => useMusdConversionTokens()); const isConversion = @@ -326,22 +331,25 @@ describe('useMusdConversionTokens', () => { }); }); - describe('tokenFilter callback', () => { + describe('filterAllowedTokens callback', () => { it('filters array of tokens correctly', () => { mockUseAccountTokens.mockReturnValue([]); - mockIsMusdConversionPaymentToken.mockImplementation( - (address, _allowlist, chainId) => { + mockIsTokenAllowed.mockImplementation( + ( + symbol: string, + _allowlist?: WildcardTokenList, + _blocklist?: WildcardTokenList, + chainId?: string, + ) => { if (chainId === '0x1') { - return ( - address.toLowerCase() === mockUsdcMainnet.address.toLowerCase() - ); + return symbol === 'USDC'; } return false; }, ); const { result } = renderHook(() => useMusdConversionTokens()); - const filtered = result.current.tokenFilter([ + const filtered = result.current.filterAllowedTokens([ mockUsdcMainnet, mockDaiMainnet, ]); @@ -354,7 +362,7 @@ describe('useMusdConversionTokens', () => { mockUseAccountTokens.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionTokens()); - const filtered = result.current.tokenFilter([]); + const filtered = result.current.filterAllowedTokens([]); expect(filtered).toEqual([]); }); @@ -363,19 +371,19 @@ describe('useMusdConversionTokens', () => { mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); const { result, rerender } = renderHook(() => useMusdConversionTokens()); - const firstTokenFilter = result.current.tokenFilter; + const firstTokenFilter = result.current.filterAllowedTokens; rerender(); - const secondTokenFilter = result.current.tokenFilter; + const secondTokenFilter = result.current.filterAllowedTokens; expect(firstTokenFilter).toBe(secondTokenFilter); }); - it('creates new tokenFilter when allowlist changes', () => { + it('creates new filterAllowedTokens when allowlist changes', () => { mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); const { result, rerender } = renderHook(() => useMusdConversionTokens()); - const firstTokenFilter = result.current.tokenFilter; + const firstTokenFilter = result.current.filterAllowedTokens; const newAllowlist: Record = { '0x1': ['0x6b175474e89094c44da98b954eedeac495271d0f'], @@ -388,7 +396,7 @@ describe('useMusdConversionTokens', () => { }); rerender(); - const secondTokenFilter = result.current.tokenFilter; + const secondTokenFilter = result.current.filterAllowedTokens; expect(firstTokenFilter).not.toBe(secondTokenFilter); }); @@ -397,7 +405,7 @@ describe('useMusdConversionTokens', () => { describe('integration with dependencies', () => { it('uses allowlist from selector correctly', () => { mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); renderHook(() => useMusdConversionTokens()); @@ -415,15 +423,16 @@ describe('useMusdConversionTokens', () => { }); }); - it('calls isMusdConversionPaymentToken utility with correct parameters', () => { + it('calls isTokenAllowed utility with correct parameters', () => { mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(true); + mockIsTokenAllowed.mockReturnValue(true); renderHook(() => useMusdConversionTokens()); - expect(mockIsMusdConversionPaymentToken).toHaveBeenCalledWith( - mockUsdcMainnet.address, + expect(mockIsTokenAllowed).toHaveBeenCalledWith( + mockUsdcMainnet.symbol, mockAllowlist, + mockBlocklist, mockUsdcMainnet.chainId, ); }); @@ -439,7 +448,7 @@ describe('useMusdConversionTokens', () => { return undefined; }); mockUseAccountTokens.mockReturnValue([mockUsdcMainnet]); - mockIsMusdConversionPaymentToken.mockReturnValue(false); + mockIsTokenAllowed.mockReturnValue(false); const { result } = renderHook(() => useMusdConversionTokens()); @@ -460,8 +469,13 @@ describe('useMusdConversionTokens', () => { chainId: undefined, } as unknown as AssetType; mockUseAccountTokens.mockReturnValue([tokenWithoutChainId]); - mockIsMusdConversionPaymentToken.mockImplementation( - (_address, _allowlist, chainId) => { + mockIsTokenAllowed.mockImplementation( + ( + _symbol: string, + _allowlist?: WildcardTokenList, + _blocklist?: WildcardTokenList, + chainId?: string, + ) => { if (!chainId) { return false; } @@ -480,7 +494,7 @@ describe('useMusdConversionTokens', () => { address: '', } as AssetType; mockUseAccountTokens.mockReturnValue([tokenWithoutAddress]); - mockIsMusdConversionPaymentToken.mockReturnValue(false); + mockIsTokenAllowed.mockReturnValue(false); const { result } = renderHook(() => useMusdConversionTokens()); diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts index aee30b695447..abbdb0b5d010 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts @@ -1,9 +1,13 @@ import { useSelector } from 'react-redux'; import { selectMusdConversionCTATokens, + selectMusdConversionPaymentTokensAllowlist, selectMusdConversionPaymentTokensBlocklist, } from '../selectors/featureFlags'; -import { isTokenInWildcardList } from '../utils/wildcardTokenList'; +import { + isTokenAllowed, + isTokenInWildcardList, +} from '../utils/wildcardTokenList'; import { AssetType } from '../../../Views/confirmations/types/token'; import { useAccountTokens } from '../../../Views/confirmations/hooks/send/useAccountTokens'; import { useCallback, useMemo } from 'react'; @@ -16,6 +20,10 @@ import { toHex } from '@metamask/controller-utils'; import { Hex } from '@metamask/utils'; export const useMusdConversionTokens = () => { + const musdConversionPaymentTokensAllowlist = useSelector( + selectMusdConversionPaymentTokensAllowlist, + ); + const musdConversionPaymentTokensBlocklist = useSelector( selectMusdConversionPaymentTokensBlocklist, ); @@ -24,24 +32,29 @@ export const useMusdConversionTokens = () => { const allTokens = useAccountTokens({ includeNoBalance: false }); - // Remove tokens that are blocked from being used for mUSD conversion. - const filterBlockedTokens = useCallback( + // Filter tokens based on allowlist and blocklist rules. + // If allowlist is non-empty, token must be in it. + // If blocklist is non-empty, token must NOT be in it. + const filterAllowedTokens = useCallback( (tokens: AssetType[]) => - tokens.filter( - (token) => - !isTokenInWildcardList( - token.symbol, - musdConversionPaymentTokensBlocklist, - token.chainId, - ), + tokens.filter((token) => + isTokenAllowed( + token.symbol, + musdConversionPaymentTokensAllowlist, + musdConversionPaymentTokensBlocklist, + token.chainId, + ), ), - [musdConversionPaymentTokensBlocklist], + [ + musdConversionPaymentTokensAllowlist, + musdConversionPaymentTokensBlocklist, + ], ); // Allowed tokens for conversion. const conversionTokens = useMemo( - () => filterBlockedTokens(allTokens), - [allTokens, filterBlockedTokens], + () => filterAllowedTokens(allTokens), + [allTokens, filterAllowedTokens], ); const isConversionToken = (token?: AssetType | TokenI) => { @@ -100,7 +113,7 @@ export const useMusdConversionTokens = () => { : MUSD_CONVERSION_DEFAULT_CHAIN_ID; return { - filterBlockedTokens, + filterAllowedTokens, isConversionToken, isTokenWithCta, isMusdSupportedOnChain, diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index 429ef081199b..62f4a482bbdd 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -6,7 +6,6 @@ import { selectMusdConversionPaymentTokensAllowlist, selectMusdConversionPaymentTokensBlocklist, } from '.'; -import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../../constants/musd'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; import { mockedState, @@ -18,9 +17,6 @@ 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'), @@ -830,6 +826,7 @@ describe('Earn Feature Flag Selectors', () => { afterEach(() => { consoleWarnSpy.mockRestore(); + delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; }); it('returns parsed remote allowlist when available', () => { @@ -855,10 +852,7 @@ describe('Earn Feature Flag Selectors', () => { stateWithRemoteAllowlist, ); - expect(result['0x1']).toEqual([ - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC on Mainnet - '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT on Mainnet - ]); + expect(result).toEqual(remoteAllowlist); }); it('falls back to local env variable when remote unavailable', () => { @@ -882,15 +876,10 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionPaymentTokensAllowlist(stateWithoutRemote); - expect(result['0x1']).toEqual([ - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC - '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI - ]); - - delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; + expect(result).toEqual(localAllowlist); }); - it('falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN when both unavailable', () => { + it('returns empty object when both remote and local are unavailable', () => { delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; const stateWithoutRemote = { @@ -907,7 +896,7 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionPaymentTokensAllowlist(stateWithoutRemote); - expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN); + expect(result).toEqual({}); }); it('handles JSON parsing errors for local env gracefully', () => { @@ -933,10 +922,7 @@ describe('Earn Feature Flag Selectors', () => { ), expect.anything(), ); - // Falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN - expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN); - - delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; + expect(result).toEqual({}); }); it('handles JSON parsing errors for remote flag gracefully', () => { @@ -964,11 +950,10 @@ describe('Earn Feature Flag Selectors', () => { ), expect.anything(), ); - // Falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN - expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN); + expect(result).toEqual({}); }); - it('falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN when remote flag is not formatted correctly a object keyed by chain IDs with array of token symbols as values', () => { + it('returns empty object when remote flag is not formatted correctly as object keyed by chain IDs with array of token symbols as values', () => { const stateWithArrayRemote = { engine: { backgroundState: { @@ -985,13 +970,14 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionPaymentTokensAllowlist(stateWithArrayRemote); - // Falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN since array is invalid - expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN); + expect(result).toEqual({}); }); - it('converts symbol allowlist to address mapping', () => { + it('returns allowlist with wildcards', () => { const remoteAllowlist = { - '0x1': ['USDC', 'USDT', 'DAI'], + '*': ['USDC'], + '0x1': ['*'], + '0xa4b1': ['USDT', 'DAI'], }; const stateWithRemoteAllowlist = { @@ -1011,137 +997,57 @@ describe('Earn Feature Flag Selectors', () => { stateWithRemoteAllowlist, ); - expect(result['0x1']).toEqual([ - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC - '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI - ]); + expect(result).toEqual(remoteAllowlist); }); - 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, + it('uses remote allowlist over local when both are available', () => { + 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); + const result = selectMusdConversionPaymentTokensAllowlist(stateWithBoth); - expect(result['0x1']).toEqual([ - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - '0xdac17f958d2ee523a2206206994597c13d831ec7', - ]); - }); + expect(result).toEqual(remoteAllowlist); + }); - 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, + it('uses local allowlist when remote is invalid structure', () => { + const localAllowlist = { '0x1': ['DAI'] }; + process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST = + JSON.stringify(localAllowlist); + const stateWithInvalidRemote = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConvertibleTokensAllowlist: { '0x1': 'not-an-array' }, }, + 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); + const result = selectMusdConversionPaymentTokensAllowlist( + stateWithInvalidRemote, + ); - 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); - }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('produced invalid structure'), + ); + expect(result).toEqual(localAllowlist); }); }); @@ -1207,7 +1113,8 @@ describe('Earn Feature Flag Selectors', () => { }, }; - const result = selectMusdConversionPaymentTokensBlocklist(stateWithBoth); + const result = + selectMusdConversionPaymentTokensBlocklist(stateWithBoth); expect(result).toEqual(remoteBlocklist); }); diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 12f477962f28..afe8044641f0 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -148,6 +148,78 @@ export const selectMusdConversionCTATokens = createSelector( }, ); +/** + * Selects the allowed payment tokens for mUSD conversion from remote config or local fallback. + * Returns a wildcard allowlist mapping chain IDs (or "*") to token symbols (or ["*"]). + * + * Supports wildcards: + * - "*" as chain key: applies to all chains + * - "*" in symbol array: allows all tokens on that chain + * + * Examples: + * - { "*": ["USDC"] } - Allow USDC on ALL chains + * - { "0x1": ["*"] } - Allow ALL tokens on Ethereum mainnet + * - { "0x1": ["USDC", "USDT", "DAI"], "0xe708": ["USDC", "USDT"] } - Allow specific tokens on specific chains + * + * Remote flag takes precedence over local env var. + * If both are unavailable, returns {} (empty allowlist = allow all tokens). + */ +// TODO: Consider consolidating duplicate logic for allowlist and blocklist into helper. +export const selectMusdConversionPaymentTokensAllowlist = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags): WildcardTokenList => { + // Try remote flag first (takes precedence) + const remoteAllowlist = + remoteFeatureFlags?.earnMusdConvertibleTokensAllowlist; + + if (remoteAllowlist) { + try { + const parsedRemote = + typeof remoteAllowlist === 'string' + ? JSON.parse(remoteAllowlist) + : remoteAllowlist; + + if (isValidWildcardTokenList(parsedRemote)) { + return parsedRemote; + } + console.warn( + 'Remote earnMusdConvertibleTokensAllowlist produced invalid structure. ' + + 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', + ); + } catch (error) { + console.warn( + 'Failed to parse remote earnMusdConvertibleTokensAllowlist:', + error, + ); + } + } + + // Fallback to local env var + try { + const localEnvValue = process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; + + if (localEnvValue) { + const parsed = JSON.parse(localEnvValue); + if (isValidWildcardTokenList(parsed)) { + return parsed; + } + console.warn( + 'Local MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST produced invalid structure. ' + + 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', + ); + } + } catch (error) { + console.warn( + 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST:', + error, + ); + } + + // Default: empty allowlist = allow all tokens + return {}; + }, +); + /** * Selects the blocked payment tokens for mUSD conversion from remote config or local fallback. * Returns a wildcard blocklist mapping chain IDs (or "*") to token symbols (or ["*"]). diff --git a/app/components/UI/Earn/utils/wildcardTokenList.test.ts b/app/components/UI/Earn/utils/wildcardTokenList.test.ts index c80b8add6ab5..85915cd0ef0a 100644 --- a/app/components/UI/Earn/utils/wildcardTokenList.test.ts +++ b/app/components/UI/Earn/utils/wildcardTokenList.test.ts @@ -1,6 +1,7 @@ import { isValidWildcardTokenList, isTokenInWildcardList, + isTokenAllowed, WildcardTokenList, } from './wildcardTokenList'; @@ -324,3 +325,229 @@ describe('isTokenInWildcardList', () => { }); }); }); + +describe('isTokenAllowed', () => { + describe('allowlist-only scenarios', () => { + it('allows token when in allowlist', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC', 'USDT', 'DAI'], + }; + + const result = isTokenAllowed('USDC', allowlist, {}, '0x1'); + + expect(result).toBe(true); + }); + + it('rejects token when not in allowlist', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC', 'USDT', 'DAI'], + }; + + const result = isTokenAllowed('WBTC', allowlist, {}, '0x1'); + + expect(result).toBe(false); + }); + + it('allows all tokens when allowlist is empty', () => { + const result = isTokenAllowed('WBTC', {}, {}, '0x1'); + + expect(result).toBe(true); + }); + + it('allows token when allowlist uses chain wildcard', () => { + const allowlist: WildcardTokenList = { + '0x1': ['*'], + }; + + expect(isTokenAllowed('USDC', allowlist, {}, '0x1')).toBe(true); + expect(isTokenAllowed('WBTC', allowlist, {}, '0x1')).toBe(true); + }); + + it('allows token when allowlist uses global wildcard', () => { + const allowlist: WildcardTokenList = { + '*': ['*'], + }; + + expect(isTokenAllowed('USDC', allowlist, {}, '0x1')).toBe(true); + expect(isTokenAllowed('WBTC', allowlist, {}, '0xa4b1')).toBe(true); + }); + + it('rejects token on wrong chain even when in allowlist for other chain', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC'], + }; + + const result = isTokenAllowed('USDC', allowlist, {}, '0xa4b1'); + + expect(result).toBe(false); + }); + }); + + describe('blocklist-only scenarios', () => { + it('rejects token when in blocklist', () => { + const blocklist: WildcardTokenList = { + '*': ['TUSD'], + }; + + const result = isTokenAllowed('TUSD', {}, blocklist, '0x1'); + + expect(result).toBe(false); + }); + + it('allows token when not in blocklist', () => { + const blocklist: WildcardTokenList = { + '*': ['TUSD'], + }; + + const result = isTokenAllowed('USDC', {}, blocklist, '0x1'); + + expect(result).toBe(true); + }); + + it('allows all tokens when blocklist is empty', () => { + const result = isTokenAllowed('TUSD', {}, {}, '0x1'); + + expect(result).toBe(true); + }); + + it('rejects all tokens on chain when blocklist uses chain wildcard', () => { + const blocklist: WildcardTokenList = { + '0x1': ['*'], + }; + + expect(isTokenAllowed('USDC', {}, blocklist, '0x1')).toBe(false); + expect(isTokenAllowed('WBTC', {}, blocklist, '0x1')).toBe(false); + }); + + it('allows tokens on other chains when only one chain is blocklisted', () => { + const blocklist: WildcardTokenList = { + '0x1': ['*'], + }; + + const result = isTokenAllowed('USDC', {}, blocklist, '0xa4b1'); + + expect(result).toBe(true); + }); + }); + + describe('combined allowlist + blocklist scenarios', () => { + it('rejects token in allowlist when also in blocklist', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC', 'USDT', 'DAI'], + }; + const blocklist: WildcardTokenList = { + '*': ['USDT'], + }; + + expect(isTokenAllowed('USDC', allowlist, blocklist, '0x1')).toBe(true); + expect(isTokenAllowed('USDT', allowlist, blocklist, '0x1')).toBe(false); + expect(isTokenAllowed('DAI', allowlist, blocklist, '0x1')).toBe(true); + }); + + it('rejects token not in allowlist even if not in blocklist', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC', 'USDT'], + }; + const blocklist: WildcardTokenList = { + '*': ['TUSD'], + }; + + const result = isTokenAllowed('WBTC', allowlist, blocklist, '0x1'); + + expect(result).toBe(false); + }); + + it('allows all tokens except blocklisted when allowlist allows all', () => { + const allowlist: WildcardTokenList = { + '*': ['*'], + }; + const blocklist: WildcardTokenList = { + '*': ['TUSD'], + }; + + expect(isTokenAllowed('USDC', allowlist, blocklist, '0x1')).toBe(true); + expect(isTokenAllowed('TUSD', allowlist, blocklist, '0x1')).toBe(false); + expect(isTokenAllowed('WBTC', allowlist, blocklist, '0xa4b1')).toBe(true); + }); + + it('handles chain-specific allowlist with global blocklist', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC', 'USDT', 'DAI'], + '0xe708': ['USDC', 'USDT'], + }; + const blocklist: WildcardTokenList = { + '*': ['USDT'], + }; + + // Mainnet: USDC allowed, USDT blocked, DAI allowed + expect(isTokenAllowed('USDC', allowlist, blocklist, '0x1')).toBe(true); + expect(isTokenAllowed('USDT', allowlist, blocklist, '0x1')).toBe(false); + expect(isTokenAllowed('DAI', allowlist, blocklist, '0x1')).toBe(true); + + // Linea: USDC allowed, USDT blocked + expect(isTokenAllowed('USDC', allowlist, blocklist, '0xe708')).toBe(true); + expect(isTokenAllowed('USDT', allowlist, blocklist, '0xe708')).toBe( + false, + ); + + // Other chains: nothing allowed (not in allowlist) + expect(isTokenAllowed('USDC', allowlist, blocklist, '0xa4b1')).toBe( + false, + ); + }); + + it('handles PM requirement: specific tokens on specific chains', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC', 'USDT', 'DAI'], + '0xe708': ['USDC', 'USDT'], + }; + const blocklist: WildcardTokenList = {}; + + // Mainnet: all three allowed + expect(isTokenAllowed('USDC', allowlist, blocklist, '0x1')).toBe(true); + expect(isTokenAllowed('USDT', allowlist, blocklist, '0x1')).toBe(true); + expect(isTokenAllowed('DAI', allowlist, blocklist, '0x1')).toBe(true); + expect(isTokenAllowed('WBTC', allowlist, blocklist, '0x1')).toBe(false); + + // Linea: only USDC and USDT + expect(isTokenAllowed('USDC', allowlist, blocklist, '0xe708')).toBe(true); + expect(isTokenAllowed('USDT', allowlist, blocklist, '0xe708')).toBe(true); + expect(isTokenAllowed('DAI', allowlist, blocklist, '0xe708')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false when chainId is undefined', () => { + const result = isTokenAllowed('USDC', {}, {}, undefined); + + expect(result).toBe(false); + }); + + it('returns false when chainId is empty string', () => { + const result = isTokenAllowed('USDC', {}, {}, ''); + + expect(result).toBe(false); + }); + + it('returns false when tokenSymbol is empty string', () => { + const result = isTokenAllowed('', {}, {}, '0x1'); + + expect(result).toBe(false); + }); + + it('uses empty lists as defaults when undefined', () => { + const result = isTokenAllowed('USDC', undefined, undefined, '0x1'); + + expect(result).toBe(true); + }); + + it('performs case-insensitive matching', () => { + const allowlist: WildcardTokenList = { + '0x1': ['USDC'], + }; + + expect(isTokenAllowed('usdc', allowlist, {}, '0x1')).toBe(true); + expect(isTokenAllowed('Usdc', allowlist, {}, '0x1')).toBe(true); + }); + }); +}); diff --git a/app/components/UI/Earn/utils/wildcardTokenList.ts b/app/components/UI/Earn/utils/wildcardTokenList.ts index 99d8df2a967f..58b6dac0b99c 100644 --- a/app/components/UI/Earn/utils/wildcardTokenList.ts +++ b/app/components/UI/Earn/utils/wildcardTokenList.ts @@ -92,3 +92,65 @@ export const isTokenInWildcardList = ( return false; }; + +/** + * Checks if a token is allowed based on combined allowlist and blocklist rules. + * + * Logic: + * 1. If allowlist is non-empty, token MUST be in allowlist + * 2. If blocklist is non-empty, token must NOT be in blocklist + * 3. Both conditions must pass for the token to be allowed + * + * @param tokenSymbol - The token symbol (case-insensitive) + * @param allowlist - Tokens to allow (empty = allow all) + * @param blocklist - Tokens to block (empty = block none) + * @param chainId - The chain ID where the token exists + * @returns true if the token is allowed, false otherwise + * + * @example Allowlist only (specific tokens) + * isTokenAllowed("USDC", { "0x1": ["USDC", "USDT"] }, {}, "0x1") // → true + * isTokenAllowed("DAI", { "0x1": ["USDC", "USDT"] }, {}, "0x1") // → false + * + * @example Blocklist only (all except certain tokens) + * isTokenAllowed("USDC", {}, { "*": ["TUSD"] }, "0x1") // → true + * isTokenAllowed("TUSD", {}, { "*": ["TUSD"] }, "0x1") // → false + * + * @example Combined (allowlist + blocklist override) + * isTokenAllowed("USDT", { "0x1": ["USDC", "USDT"] }, { "*": ["USDT"] }, "0x1") // → false + */ +export const isTokenAllowed = ( + tokenSymbol: string, + allowlist: WildcardTokenList = {}, + blocklist: WildcardTokenList = {}, + chainId?: string, +): boolean => { + if (!chainId || !tokenSymbol) return false; + + // Step 1: If allowlist is non-empty, token must be in it + const hasAllowlist = Object.keys(allowlist).length > 0; + if (hasAllowlist) { + const isInAllowlist = isTokenInWildcardList( + tokenSymbol, + allowlist, + chainId, + ); + if (!isInAllowlist) { + return false; + } + } + + // Step 2: If blocklist is non-empty, token must NOT be in it + const hasBlocklist = Object.keys(blocklist).length > 0; + if (hasBlocklist) { + const isInBlocklist = isTokenInWildcardList( + tokenSymbol, + blocklist, + chainId, + ); + if (isInBlocklist) { + return false; + } + } + + return true; +}; diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx index 294f934fff0c..60ae74c91bf9 100644 --- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx +++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx @@ -136,10 +136,12 @@ const mockUseMusdConversionTokens = >; mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn(), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), tokens: [], + tokensWithCTAs: [], }); jest.mock('../../../../../selectors/earnController/earn', () => ({ @@ -494,10 +496,12 @@ describe('StakeButton', () => { }); mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn(), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), tokens: [], + tokensWithCTAs: [], }); }); @@ -514,10 +518,17 @@ describe('StakeButton', () => { MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn( + (asset) => + asset?.address?.toLowerCase() === + MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && + asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, + ), + filterAllowedTokens: jest.fn(), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), tokens: [], + tokensWithCTAs: [], }); const { getByText } = renderWithProvider( @@ -547,10 +558,17 @@ describe('StakeButton', () => { MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn( + (asset) => + asset?.address?.toLowerCase() === + MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && + asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, + ), + filterAllowedTokens: jest.fn(), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), tokens: [], + tokensWithCTAs: [], }); const { getByTestId } = renderWithProvider( @@ -591,10 +609,17 @@ describe('StakeButton', () => { MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn( + (asset) => + asset?.address?.toLowerCase() === + MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && + asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, + ), + filterAllowedTokens: jest.fn(), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), tokens: [], + tokensWithCTAs: [], }); const { getByTestId } = renderWithProvider( @@ -630,10 +655,17 @@ describe('StakeButton', () => { MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, ), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn( + (asset) => + asset?.address?.toLowerCase() === + MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && + asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, + ), + filterAllowedTokens: jest.fn(), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), tokens: [], + tokensWithCTAs: [], }); const zeroBalanceAsset = { diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx index cc139cdec24f..cc088a722be4 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx @@ -54,8 +54,10 @@ jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ useMusdConversionTokens: jest.fn(() => ({ isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn(), tokens: [], + tokensWithCTAs: [], })), })); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx index 6b28b021feac..48c9c89c826f 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx @@ -49,8 +49,10 @@ jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ useMusdConversionTokens: jest.fn(() => ({ isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(false), + filterAllowedTokens: jest.fn(), tokens: [], + tokensWithCTAs: [], })), })); diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index 98cfe0722e3f..eec85d370b0c 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -20,7 +20,7 @@ export function PayWithModal() { const requiredTokens = useTransactionPayRequiredTokens(); const transactionMeta = useTransactionMetadataRequest(); const bottomSheetRef = useRef(null); - const { filterBlockedTokens } = useMusdConversionTokens(); + const { filterAllowedTokens } = useMusdConversionTokens(); const handleClose = useCallback(() => { bottomSheetRef.current?.onCloseBottomSheet(); @@ -49,12 +49,12 @@ export function PayWithModal() { if ( hasTransactionType(transactionMeta, [TransactionType.musdConversion]) ) { - return filterBlockedTokens(availableTokens); + return filterAllowedTokens(availableTokens); } return availableTokens; }, - [filterBlockedTokens, payToken, requiredTokens, transactionMeta], + [filterAllowedTokens, payToken, requiredTokens, transactionMeta], ); return ( From 9eca8af20e8b9067e981cf5201adbcb14fd70a1d Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 16:44:52 -0500 Subject: [PATCH 12/40] feat: check for explicit undefined overrides in useNavbar tests --- app/components/Views/confirmations/hooks/ui/useNavbar.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts b/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts index ca01edcf92be..95293101b53e 100644 --- a/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts +++ b/app/components/Views/confirmations/hooks/ui/useNavbar.test.ts @@ -65,6 +65,7 @@ describe('useNavbar', () => { onReject: mockOnReject, addBackButton: true, theme: expect.any(Object), + overrides: undefined, }); expect(mockSetOptions).toHaveBeenCalledWith( getNavbar({ @@ -123,6 +124,7 @@ describe('useNavbar', () => { onReject: mockOnReject, addBackButton: true, theme: expect.any(Object), + overrides: undefined, }); }); @@ -142,6 +144,7 @@ describe('useNavbar', () => { onReject: newOnReject, addBackButton: true, theme: expect.any(Object), + overrides: undefined, }); }); From c99d1a5b012190ee4e07825a292ff8c622123942 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 16:53:24 -0500 Subject: [PATCH 13/40] feat: broke out duplicate wildcard token list parsing into helper --- .../UI/Earn/selectors/featureFlags/index.ts | 175 +++--------------- .../UI/Earn/utils/wildcardTokenList.ts | 79 ++++++++ 2 files changed, 101 insertions(+), 153 deletions(-) diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index afe8044641f0..7e6f4b21dc21 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -5,7 +5,7 @@ import { VersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; import { - isValidWildcardTokenList, + getWildcardTokenListFromConfig, WildcardTokenList, } from '../../utils/wildcardTokenList'; @@ -96,56 +96,15 @@ export const selectIsMusdCtaEnabledFlag = createSelector( * Remote flag takes precedence over local env var. * If both are unavailable, returns {} (no conversion CTAs). */ -// TODO: Break out duplicated parsing logic for cta tokens and blocklist into helper. export const selectMusdConversionCTATokens = createSelector( selectRemoteFeatureFlags, - (remoteFeatureFlags): WildcardTokenList => { - // Try remote flag first (takes precedence) - const remoteCtaTokens = remoteFeatureFlags?.earnMusdConversionCtaTokens; - - if (remoteCtaTokens) { - try { - const parsedRemote = - typeof remoteCtaTokens === 'string' - ? JSON.parse(remoteCtaTokens) - : remoteCtaTokens; - - if (isValidWildcardTokenList(parsedRemote)) { - return parsedRemote; - } - console.warn( - 'Remote earnMusdConversionCtaTokens produced invalid structure. ' + - 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', - ); - } catch (error) { - console.warn( - 'Failed to parse remote earnMusdConversionCtaTokens:', - error, - ); - } - } - - // Fallback to local env var - try { - const localEnvValue = process.env.MM_MUSD_CTA_TOKENS; - - if (localEnvValue) { - const parsed = JSON.parse(localEnvValue); - if (isValidWildcardTokenList(parsed)) { - return parsed; - } - console.warn( - 'Local MM_MUSD_CTA_TOKENS produced invalid structure. ' + - 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', - ); - } - } catch (error) { - console.warn('Failed to parse MM_MUSD_CTA_TOKENS:', error); - } - - // Default: no tokens to have conversion CTAs - return {}; - }, + (remoteFeatureFlags): WildcardTokenList => + getWildcardTokenListFromConfig( + remoteFeatureFlags?.earnMusdConversionCtaTokens, + 'earnMusdConversionCtaTokens', + process.env.MM_MUSD_CTA_TOKENS, + 'MM_MUSD_CTA_TOKENS', + ), ); /** @@ -164,60 +123,15 @@ export const selectMusdConversionCTATokens = createSelector( * Remote flag takes precedence over local env var. * If both are unavailable, returns {} (empty allowlist = allow all tokens). */ -// TODO: Consider consolidating duplicate logic for allowlist and blocklist into helper. export const selectMusdConversionPaymentTokensAllowlist = createSelector( selectRemoteFeatureFlags, - (remoteFeatureFlags): WildcardTokenList => { - // Try remote flag first (takes precedence) - const remoteAllowlist = - remoteFeatureFlags?.earnMusdConvertibleTokensAllowlist; - - if (remoteAllowlist) { - try { - const parsedRemote = - typeof remoteAllowlist === 'string' - ? JSON.parse(remoteAllowlist) - : remoteAllowlist; - - if (isValidWildcardTokenList(parsedRemote)) { - return parsedRemote; - } - console.warn( - 'Remote earnMusdConvertibleTokensAllowlist produced invalid structure. ' + - 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', - ); - } catch (error) { - console.warn( - 'Failed to parse remote earnMusdConvertibleTokensAllowlist:', - error, - ); - } - } - - // Fallback to local env var - try { - const localEnvValue = process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST; - - if (localEnvValue) { - const parsed = JSON.parse(localEnvValue); - if (isValidWildcardTokenList(parsed)) { - return parsed; - } - console.warn( - 'Local MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST produced invalid structure. ' + - 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', - ); - } - } catch (error) { - console.warn( - 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST:', - error, - ); - } - - // Default: empty allowlist = allow all tokens - return {}; - }, + (remoteFeatureFlags): WildcardTokenList => + getWildcardTokenListFromConfig( + remoteFeatureFlags?.earnMusdConvertibleTokensAllowlist, + 'earnMusdConvertibleTokensAllowlist', + process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST, + 'MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST', + ), ); /** @@ -238,56 +152,11 @@ export const selectMusdConversionPaymentTokensAllowlist = createSelector( */ export const selectMusdConversionPaymentTokensBlocklist = createSelector( selectRemoteFeatureFlags, - (remoteFeatureFlags): WildcardTokenList => { - // Try remote flag first (takes precedence) - const remoteBlocklist = - remoteFeatureFlags?.earnMusdConvertibleTokensBlocklist; - - if (remoteBlocklist) { - try { - const parsedRemote = - typeof remoteBlocklist === 'string' - ? JSON.parse(remoteBlocklist) - : remoteBlocklist; - - if (isValidWildcardTokenList(parsedRemote)) { - return parsedRemote; - } - console.warn( - 'Remote earnMusdConvertibleTokensBlocklist produced invalid structure. ' + - 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', - ); - } catch (error) { - console.warn( - 'Failed to parse remote earnMusdConvertibleTokensBlocklist:', - error, - ); - } - } - - // Fallback to local env var - try { - // TODO: Smoke test using local vars. Do after to avoid slowing down dev to restart server. - const localEnvValue = process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST; - - if (localEnvValue) { - const parsed = JSON.parse(localEnvValue); - if (isValidWildcardTokenList(parsed)) { - return parsed; - } - console.warn( - 'Local MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST produced invalid structure. ' + - 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}', - ); - } - } catch (error) { - console.warn( - 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST:', - error, - ); - } - - // Default: no blocking - return {}; - }, + (remoteFeatureFlags): WildcardTokenList => + getWildcardTokenListFromConfig( + remoteFeatureFlags?.earnMusdConvertibleTokensBlocklist, + 'earnMusdConvertibleTokensBlocklist', + process.env.MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST, + 'MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST', + ), ); diff --git a/app/components/UI/Earn/utils/wildcardTokenList.ts b/app/components/UI/Earn/utils/wildcardTokenList.ts index 58b6dac0b99c..a67665b4ebc9 100644 --- a/app/components/UI/Earn/utils/wildcardTokenList.ts +++ b/app/components/UI/Earn/utils/wildcardTokenList.ts @@ -93,6 +93,85 @@ export const isTokenInWildcardList = ( return false; }; +/** + * Checks if a token is allowed based on combined allowlist and blocklist rules. + * + * Logic: + * 1. If allowlist is non-empty, token MUST be in allowlist + * 2. If blocklist is non-empty, token must NOT be in blocklist + * 3. Both conditions must pass for the token to be allowed + * + * @param tokenSymbol - The token symbol (case-insensitive) + * @param allowlist - Tokens to allow (empty = allow all) + * @param blocklist - Tokens to block (empty = block none) + * @param chainId - The chain ID where the token exists + * @returns true if the token is allowed, false otherwise + * + * @example Allowlist only (specific tokens) + * isTokenAllowed("USDC", { "0x1": ["USDC", "USDT"] }, {}, "0x1") // → true + * isTokenAllowed("DAI", { "0x1": ["USDC", "USDT"] }, {}, "0x1") // → false + * + * @example Blocklist only (all except certain tokens) + * isTokenAllowed("USDC", {}, { "*": ["TUSD"] }, "0x1") // → true + * isTokenAllowed("TUSD", {}, { "*": ["TUSD"] }, "0x1") // → false + * + * @example Combined (allowlist + blocklist override) + * isTokenAllowed("USDT", { "0x1": ["USDC", "USDT"] }, { "*": ["USDT"] }, "0x1") // → false + */ +/** + * Gets a WildcardTokenList from remote feature flag or local env var configuration. + * Remote value takes precedence. Returns empty object if both are invalid/unavailable. + * + * @param remoteValue - The remote feature flag value (string or object) + * @param remoteFlagName - Name of the remote flag (for error messages) + * @param localEnvValue - The local environment variable value + * @param localEnvName - Name of the local env var (for error messages) + * @returns WildcardTokenList from config or empty object + */ +export const getWildcardTokenListFromConfig = ( + remoteValue: unknown, + remoteFlagName: string, + localEnvValue: string | undefined, + localEnvName: string, +): WildcardTokenList => { + const expectedFormat = + 'Expected format: {"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT","DAI"]}'; + + // Try remote value first (takes precedence) + if (remoteValue) { + try { + const parsed = + typeof remoteValue === 'string' ? JSON.parse(remoteValue) : remoteValue; + + if (isValidWildcardTokenList(parsed)) { + return parsed; + } + console.warn( + `Remote ${remoteFlagName} produced invalid structure. ${expectedFormat}`, + ); + } catch (error) { + console.warn(`Failed to parse remote ${remoteFlagName}:`, error); + } + } + + // Fallback to local env var + if (localEnvValue) { + try { + const parsed = JSON.parse(localEnvValue); + if (isValidWildcardTokenList(parsed)) { + return parsed; + } + console.warn( + `Local ${localEnvName} produced invalid structure. ${expectedFormat}`, + ); + } catch (error) { + console.warn(`Failed to parse ${localEnvName}:`, error); + } + } + + return {}; +}; + /** * Checks if a token is allowed based on combined allowlist and blocklist rules. * From 5f62275004fdd3d3f6a2854bf0e59a89a2b84400 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 17:05:04 -0500 Subject: [PATCH 14/40] feat: linked to wildcard-token-list doc in .js.example --- .js.env.example | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.js.env.example b/.js.env.example index 54c4c19995da..d494aea0e4ea 100644 --- a/.js.env.example +++ b/.js.env.example @@ -111,13 +111,7 @@ export MM_MUSD_CONVERSION_FLOW_ENABLED="false" # IMPORTANT: Must use SINGLE QUOTES to preserve JSON format # Example: MM_MUSD_CTA_TOKENS='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}' export MM_MUSD_CTA_TOKENS='' -# Blocklist of tokens that cannot be converted to mUSD (supports wildcards) -# Format: JSON string with chainId (or "*") mapping to token symbols (or ["*"]) -# Examples: -# Block USDC on all chains: '{"*":["USDC"]}' -# Block all tokens on mainnet: '{"0x1":["*"]}' -# Block specific tokens on chain: '{"0xa4b1":["USDT","DAI"]}' -# Combined rules (additive): '{"*":["USDC"],"0x1":["*"],"0xa4b1":["USDT"]}' +# See app/components/UI/Earn/docs/wildcard-token-list.md for more information. export MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST='' export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' From 60b8485560b477b306859c8750064484cb486ef8 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 18 Dec 2025 18:10:41 -0500 Subject: [PATCH 15/40] feat: removed completed TODO --- app/components/UI/Earn/hooks/useMusdConversionTokens.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts index abbdb0b5d010..0b1ab54d65b0 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts @@ -70,7 +70,6 @@ export const useMusdConversionTokens = () => { const getConversionTokensWithCtas = useCallback( (tokens: AssetType[]) => tokens.filter((token) => - // TODO: Rename isMusdConversionPaymentTokenBlocked isTokenInWildcardList( token.symbol, musdConversionCTATokens, From b50f05d949d52dd4f099b10ac7e889b32af5af32 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Mon, 15 Dec 2025 19:04:02 -0500 Subject: [PATCH 16/40] chore: update cta to respect buy regions and route correctly per chain --- .../MusdConversionAssetListCta.styles.ts | 4 + .../MusdConversionAssetListCta.test.tsx | 160 ++++++ .../Musd/MusdConversionAssetListCta/index.tsx | 52 +- app/components/UI/Earn/constants/musd.ts | 10 + .../UI/Earn/hooks/useHasMusdBalance.test.ts | 230 ++++++++ .../UI/Earn/hooks/useHasMusdBalance.ts | 47 ++ .../Earn/hooks/useMusdCtaVisibility.test.ts | 515 ++++++++++++++++++ .../UI/Earn/hooks/useMusdCtaVisibility.ts | 131 +++++ 8 files changed, 1143 insertions(+), 6 deletions(-) create mode 100644 app/components/UI/Earn/hooks/useHasMusdBalance.test.ts create mode 100644 app/components/UI/Earn/hooks/useHasMusdBalance.ts create mode 100644 app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts create mode 100644 app/components/UI/Earn/hooks/useMusdCtaVisibility.ts diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts index 4ae908c1a922..817bafd6d877 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts @@ -11,11 +11,15 @@ const styleSheet = () => assetInfo: { flexDirection: 'row', gap: 20, + alignItems: 'center', }, button: { alignSelf: 'center', height: 32, }, + badge: { + alignSelf: 'center', + }, }); export default styleSheet; diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index 0991fbb268a9..acecd6029048 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -4,6 +4,7 @@ import { Hex } from '@metamask/utils'; jest.mock('../../../hooks/useMusdConversionTokens'); jest.mock('../../../hooks/useMusdConversion'); +jest.mock('../../../hooks/useMusdCtaVisibility'); jest.mock('../../../../Ramp/hooks/useRampNavigation'); jest.mock('../../../../../../util/Logger'); @@ -22,6 +23,7 @@ import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import MusdConversionAssetListCta from '.'; import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; +import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, @@ -30,6 +32,7 @@ import { import { EARN_TEST_IDS } from '../../../constants/testIds'; import initialRootState from '../../../../../../util/test/initial-root-state'; import Logger from '../../../../../../util/Logger'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; const mockToken = { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', @@ -68,6 +71,15 @@ describe('MusdConversionAssetListCta', () => { error: null, hasSeenConversionEducationScreen: true, }); + + // Default mock for visibility - show CTA without network icon + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + }); }); afterEach(() => { @@ -373,4 +385,152 @@ describe('MusdConversionAssetListCta', () => { }); }); }); + + describe('visibility behavior', () => { + beforeEach(() => { + ( + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + > + ).mockReturnValue({ + tokens: [], + tokenFilter: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), + }); + }); + + it('renders null when shouldShowCta is false', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }); + + const { queryByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeNull(); + }); + + it('renders component when shouldShowCta is true', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + }); + + describe('network badge', () => { + beforeEach(() => { + ( + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + > + ).mockReturnValue({ + tokens: [], + tokenFilter: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), + }); + }); + + it('renders without network badge when showNetworkIcon is false', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + // Badge wrapper is not rendered when showNetworkIcon is false + expect(queryByTestId('badge-wrapper')).toBeNull(); + }); + + it('renders with network badge when showNetworkIcon is true and mainnet selected', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId: CHAIN_IDS.MAINNET, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders with network badge when showNetworkIcon is true and Linea selected', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId: CHAIN_IDS.LINEA_MAINNET, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders with network badge when showNetworkIcon is true and BSC selected', () => { + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId: CHAIN_IDS.BSC, + }); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx index bf371ad49947..0e0ccace5b0b 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx @@ -24,9 +24,17 @@ import Logger from '../../../../../../util/Logger'; import { useStyles } from '../../../../../hooks/useStyles'; import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; +import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; import { toChecksumAddress } from '../../../../../../util/address'; +import Badge, { + BadgeVariant, +} from '../../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../../component-library/components/Badges/BadgeWrapper'; +import { getNetworkImageSource } from '../../../../../../util/networks'; const MusdConversionAssetListCta = () => { const { styles } = useStyles(styleSheet, {}); @@ -37,6 +45,9 @@ const MusdConversionAssetListCta = () => { const { initiateConversion } = useMusdConversion(); + const { shouldShowCta, showNetworkIcon, selectedChainId } = + useMusdCtaVisibility(); + const canConvert = useMemo( () => Boolean(tokens.length > 0 && tokens?.[0]?.chainId !== undefined), [tokens], @@ -54,7 +65,10 @@ const MusdConversionAssetListCta = () => { // Redirect users to deposit flow if they don't have any stablecoins to convert. if (!canConvert) { const rampIntent: RampIntent = { - assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + assetId: + MUSD_TOKEN_ASSET_ID_BY_CHAIN[ + selectedChainId || MUSD_CONVERSION_DEFAULT_CHAIN_ID + ], }; goToBuy(rampIntent); return; @@ -84,17 +98,43 @@ const MusdConversionAssetListCta = () => { } }; + // Don't render if visibility conditions are not met + if (!shouldShowCta) { + return null; + } + + const renderTokenAvatar = () => ( + + ); + return ( - + {showNetworkIcon && selectedChainId ? ( + + } + > + {renderTokenAvatar()} + + ) : ( + renderTokenAvatar() + )} MetaMask USD diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index 2a6d342c8b95..ad897604c5b4 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -22,6 +22,16 @@ export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { [CHAIN_IDS.BSC]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', }; +/** + * Chains where mUSD CTA should show (buy routes available). + * BSC is excluded as buy routes are not yet available. + */ +export const MUSD_BUYABLE_CHAIN_IDS: Hex[] = [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.LINEA_MAINNET, + // CHAIN_IDS.BSC, // TODO: Uncomment once buy routes are available +]; + export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { [CHAIN_IDS.MAINNET]: 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', diff --git a/app/components/UI/Earn/hooks/useHasMusdBalance.test.ts b/app/components/UI/Earn/hooks/useHasMusdBalance.test.ts new file mode 100644 index 000000000000..26a9ebebe6f4 --- /dev/null +++ b/app/components/UI/Earn/hooks/useHasMusdBalance.test.ts @@ -0,0 +1,230 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useHasMusdBalance } from './useHasMusdBalance'; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; + +jest.mock('react-redux'); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('useHasMusdBalance', () => { + const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue({}); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('hook structure', () => { + it('returns object with hasMusdBalance and balancesByChain properties', () => { + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current).toHaveProperty('hasMusdBalance'); + expect(result.current).toHaveProperty('balancesByChain'); + }); + + it('returns hasMusdBalance as boolean', () => { + const { result } = renderHook(() => useHasMusdBalance()); + + expect(typeof result.current.hasMusdBalance).toBe('boolean'); + }); + + it('returns balancesByChain as object', () => { + const { result } = renderHook(() => useHasMusdBalance()); + + expect(typeof result.current.balancesByChain).toBe('object'); + }); + }); + + describe('balance detection', () => { + it('returns hasMusdBalance false when no balances exist', () => { + mockUseSelector.mockReturnValue({}); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('returns hasMusdBalance false when MUSD balance is 0x0', () => { + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: '0x0', + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('returns hasMusdBalance true when MUSD balance exists on mainnet', () => { + const balance = '0x1234'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.MAINNET]: balance, + }); + }); + + it('returns hasMusdBalance true when MUSD balance exists on Linea', () => { + const lineaMusdAddress = + MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]; + const balance = '0x5678'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.LINEA_MAINNET]: { + [lineaMusdAddress]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.LINEA_MAINNET]: balance, + }); + }); + + it('returns hasMusdBalance true when MUSD balance exists on BSC', () => { + const bscMusdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.BSC]; + const balance = '0x9abc'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.BSC]: { + [bscMusdAddress]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.BSC]: balance, + }); + }); + + it('returns balances from multiple chains', () => { + const mainnetBalance = '0x1111'; + const lineaBalance = '0x2222'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: mainnetBalance, + }, + [CHAIN_IDS.LINEA_MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: lineaBalance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.MAINNET]: mainnetBalance, + [CHAIN_IDS.LINEA_MAINNET]: lineaBalance, + }); + }); + }); + + describe('address case handling', () => { + it('handles lowercase token address in balances', () => { + const balance = '0x1234'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS.toLowerCase()]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + + it('handles checksummed token address in balances', () => { + const balance = '0x1234'; + // MUSD_ADDRESS is already lowercase in the constant + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + }); + + describe('edge cases', () => { + it('ignores non-MUSD tokens on supported chains', () => { + const otherTokenAddress = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [otherTokenAddress]: '0x9999', + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('ignores MUSD-like tokens on unsupported chains', () => { + const polygonChainId = '0x89' as Hex; + mockUseSelector.mockReturnValue({ + [polygonChainId]: { + [MUSD_ADDRESS]: '0x1234', + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + + it('handles mixed zero and non-zero balances correctly', () => { + const balance = '0x1234'; + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: '0x0', + }, + [CHAIN_IDS.LINEA_MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: balance, + }, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(true); + expect(result.current.balancesByChain).toEqual({ + [CHAIN_IDS.LINEA_MAINNET]: balance, + }); + }); + + it('handles undefined chain balances gracefully', () => { + mockUseSelector.mockReturnValue({ + [CHAIN_IDS.MAINNET]: undefined, + }); + + const { result } = renderHook(() => useHasMusdBalance()); + + expect(result.current.hasMusdBalance).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useHasMusdBalance.ts b/app/components/UI/Earn/hooks/useHasMusdBalance.ts new file mode 100644 index 000000000000..7746c79e1ae2 --- /dev/null +++ b/app/components/UI/Earn/hooks/useHasMusdBalance.ts @@ -0,0 +1,47 @@ +import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { Hex } from '@metamask/utils'; +import { selectContractBalancesPerChainId } from '../../../../selectors/tokenBalancesController'; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; +import { toChecksumAddress } from '../../../../util/address'; + +/** + * Hook to check if the user has any MUSD token balance across supported chains. + * @returns Object containing hasMusdBalance boolean and balancesByChain for detailed balance info + */ +export const useHasMusdBalance = () => { + const balancesPerChainId = useSelector(selectContractBalancesPerChainId); + const { hasMusdBalance, balancesByChain } = useMemo(() => { + const result: Record = {}; + let hasBalance = false; + + for (const [chainId, tokenAddress] of Object.entries( + MUSD_TOKEN_ADDRESS_BY_CHAIN, + )) { + const chainBalances = balancesPerChainId[chainId as Hex]; + + if (!chainBalances) continue; + + // MUSD token addresses are lowercase in the constant, but balances might be checksummed + const normalizedTokenAddress = toChecksumAddress(tokenAddress as Hex); + const balance = + chainBalances[normalizedTokenAddress] || + chainBalances[tokenAddress as Hex]; + + if (balance && balance !== '0x0') { + result[chainId as Hex] = balance; + hasBalance = true; + } + } + + return { + hasMusdBalance: hasBalance, + balancesByChain: result, + }; + }, [balancesPerChainId]); + + return { + hasMusdBalance, + balancesByChain, + }; +}; diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts new file mode 100644 index 000000000000..6e4e24ac8bc8 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -0,0 +1,515 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { Hex } from '@metamask/utils'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useMusdCtaVisibility } from './useMusdCtaVisibility'; +import { useHasMusdBalance } from './useHasMusdBalance'; +import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; +import { useNetworksByCustomNamespace } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; +import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; + +jest.mock('./useHasMusdBalance'); +jest.mock('../../../hooks/useCurrentNetworkInfo'); +jest.mock('../../../hooks/useNetworksByNamespace/useNetworksByNamespace'); +jest.mock('../../Ramp/hooks/useRampTokens'); + +const mockUseHasMusdBalance = useHasMusdBalance as jest.MockedFunction< + typeof useHasMusdBalance +>; +const mockUseCurrentNetworkInfo = useCurrentNetworkInfo as jest.MockedFunction< + typeof useCurrentNetworkInfo +>; +const mockUseNetworksByCustomNamespace = + useNetworksByCustomNamespace as jest.MockedFunction< + typeof useNetworksByCustomNamespace + >; +const mockUseRampTokens = useRampTokens as jest.MockedFunction< + typeof useRampTokens +>; + +describe('useMusdCtaVisibility', () => { + const defaultNetworkInfo = { + enabledNetworks: [], + getNetworkInfo: jest.fn(), + getNetworkInfoByChainId: jest.fn(), + isDisabled: false, + hasEnabledNetworks: false, + }; + + const defaultNetworksByNamespace = { + networks: [], + selectedNetworks: [], + areAllNetworksSelected: false, + areAnyNetworksSelected: false, + networkCount: 0, + selectedCount: 0, + totalEnabledNetworksCount: 0, + }; + + const createMusdRampToken = ( + chainId: Hex, + tokenSupported = true, + ): RampsToken => { + const assetId = MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId].toLowerCase(); + // Extract CAIP-2 chainId from CAIP-19 assetId (e.g., 'eip155:1' from 'eip155:1/erc20:0x...') + const caipChainId = assetId.split('/')[0] as `${string}:${string}`; + return { + assetId: assetId as `${string}:${string}/${string}:${string}`, + symbol: 'MUSD', + chainId: caipChainId, + tokenSupported, + name: 'MetaMask USD', + iconUrl: '', + decimals: 6, + }; + }; + + const defaultRampTokens = { + topTokens: null, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET), + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET), + ], + isLoading: false, + error: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + mockUseCurrentNetworkInfo.mockReturnValue(defaultNetworkInfo); + mockUseNetworksByCustomNamespace.mockReturnValue( + defaultNetworksByNamespace, + ); + mockUseRampTokens.mockReturnValue(defaultRampTokens); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('hook structure', () => { + it('returns object with shouldShowCta, showNetworkIcon, and selectedChainId properties', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current).toHaveProperty('shouldShowCta'); + expect(result.current).toHaveProperty('showNetworkIcon'); + expect(result.current).toHaveProperty('selectedChainId'); + }); + }); + + describe('all networks selected (popular networks)', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + { chainId: CHAIN_IDS.BSC, enabled: true }, + ], + }); + }); + + it('returns shouldShowCta true when user has no MUSD balance and MUSD is buyable', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when user has MUSD balance', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when MUSD is not buyable on any chain', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + + it('returns showNetworkIcon false for all networks', () => { + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.showNetworkIcon).toBe(false); + }); + }); + + describe('single supported network selected', () => { + describe('mainnet selected', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + }); + + it('returns shouldShowCta true when user has no MUSD on mainnet', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(true); + expect(result.current.selectedChainId).toBe(CHAIN_IDS.MAINNET); + }); + + it('returns shouldShowCta false when user has MUSD on mainnet', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + + it('returns shouldShowCta true when user has MUSD on different chain but not mainnet', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.LINEA_MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(true); + }); + + it('returns shouldShowCta false when MUSD not buyable in region for mainnet', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET, false), // tokenSupported = false + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET), + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when MUSD not buyable anywhere', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET, false), + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET, false), + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + + describe('linea selected', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + }); + + it('returns shouldShowCta true with network icon when no MUSD on Linea', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(true); + expect(result.current.selectedChainId).toBe(CHAIN_IDS.LINEA_MAINNET); + }); + + it('returns shouldShowCta false when MUSD not buyable in region for Linea', () => { + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET), + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET, false), // tokenSupported = false + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + }); + + describe('BSC selected', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.BSC, enabled: true }], + }); + }); + + it('returns shouldShowCta false when BSC selected', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when user has MUSD balance', () => { + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + }); + + describe('unsupported network selected', () => { + it('returns shouldShowCta false for Polygon', () => { + const polygonChainId = '0x89' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: polygonChainId, enabled: true }], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false for Arbitrum', () => { + const arbitrumChainId = '0xa4b1' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: arbitrumChainId, enabled: true }], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + }); + + it('returns shouldShowCta false for Optimism', () => { + const optimismChainId = '0xa' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: optimismChainId, enabled: true }], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + }); + + it('returns shouldShowCta false for unsupported network when user has MUSD balance', () => { + const polygonChainId = '0x89' as Hex; + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: polygonChainId, enabled: true }], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + + describe('multiple networks selected (not all)', () => { + it('returns shouldShowCta true without network icon when multiple networks selected and no MUSD balance', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when multiple networks selected and user has MUSD balance', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + }); + + describe('geo restriction scenarios', () => { + it('returns shouldShowCta false when allTokens is null (loading)', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: null, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + }); + + it('returns shouldShowCta true when MUSD buyable on at least one chain in all networks view', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET, false), // not buyable + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET, true), // buyable + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + }); + }); + + describe('edge cases', () => { + it('returns shouldShowCta false with empty enabledNetworks', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('handles undefined values gracefully', () => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [], + }); + + expect(() => renderHook(() => useMusdCtaVisibility())).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts new file mode 100644 index 000000000000..eab763b888d6 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -0,0 +1,131 @@ +import { useMemo } from 'react'; +import { Hex, KnownCaipNamespace } from '@metamask/utils'; +import { + MUSD_BUYABLE_CHAIN_IDS, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '../constants/musd'; +import { useHasMusdBalance } from './useHasMusdBalance'; +import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; +import { + NetworkType, + useNetworksByCustomNamespace, +} from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; + +/** + * Hook to determine visibility and network icon display for the MUSD CTA. + * + * @returns Object containing: + * - shouldShowCta: false if non-MUSD chain selected OR user has MUSD balance OR MUSD not buyable in region + * - showNetworkIcon: true only when a single MUSD-supported chain is selected + * - selectedChainId: the selected chain ID for the network badge (null if all networks) + */ +export const useMusdCtaVisibility = () => { + const { enabledNetworks } = useCurrentNetworkInfo(); + const { areAllNetworksSelected } = useNetworksByCustomNamespace({ + networkType: NetworkType.Popular, + namespace: KnownCaipNamespace.Eip155, + }); + const { hasMusdBalance, balancesByChain } = useHasMusdBalance(); + const { allTokens } = useRampTokens(); + + // 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) => + token.assetId === musdAssetId.toLowerCase() && + token.tokenSupported === true, + ); + + buyableByChain[chainId] = Boolean(musdToken); + }); + + return buyableByChain; + }, [allTokens]); + + // Check if mUSD is buyable on any chain (for "all networks" view) + const isMusdBuyableOnAnyChain = useMemo( + () => Object.values(isMusdBuyableOnChain).some(Boolean), + [isMusdBuyableOnChain], + ); + + const { shouldShowCta, showNetworkIcon, selectedChainId } = useMemo(() => { + // Get selected chains from enabled networks + const selectedChains = enabledNetworks + .filter((network) => network.enabled) + .map((network) => network.chainId as Hex); + + // If all networks are selected (popular networks filter) + if (areAllNetworksSelected || selectedChains.length > 1) { + // Show CTA without network icon if: + // - User doesn't have MUSD on any chain + // - AND mUSD is buyable on at least one chain in user's region + return { + shouldShowCta: !hasMusdBalance && isMusdBuyableOnAnyChain, + showNetworkIcon: false, + selectedChainId: null, + }; + } + + // If exactly one chain is selected + + const chainId = selectedChains[0]; + const isBuyableChain = MUSD_BUYABLE_CHAIN_IDS.includes(chainId); + + if (!isBuyableChain) { + // Chain doesn't have buy routes available (e.g., BSC) - hide CTA + return { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }; + } + + // Check if mUSD is buyable on this chain in user's region + const isMusdBuyableInRegion = isMusdBuyableOnChain[chainId] ?? false; + + if (!isMusdBuyableInRegion) { + // mUSD not buyable in user's region for this chain - hide CTA + return { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }; + } + + // Supported chain selected - check if user has MUSD on this specific chain + const hasMusdOnSelectedChain = Boolean(balancesByChain[chainId]); + + return { + shouldShowCta: !hasMusdOnSelectedChain, + showNetworkIcon: true, + selectedChainId: chainId, + }; + }, [ + areAllNetworksSelected, + enabledNetworks, + hasMusdBalance, + balancesByChain, + isMusdBuyableOnChain, + isMusdBuyableOnAnyChain, + ]); + + return { + shouldShowCta, + showNetworkIcon, + selectedChainId, + }; +}; From 2ba73e6ddadd5a5c9041d90d708eeea83396db7c Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Mon, 15 Dec 2025 22:32:22 -0500 Subject: [PATCH 17/40] chore: add feature flag gating --- .../Earn/hooks/useMusdCtaVisibility.test.ts | 104 ++++++++++++++++++ .../UI/Earn/hooks/useMusdCtaVisibility.ts | 13 +++ .../UI/Earn/selectors/featureFlags/index.ts | 13 +++ 3 files changed, 130 insertions(+) diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index 6e4e24ac8bc8..c39d585f0a15 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -7,11 +7,19 @@ import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; import { useNetworksByCustomNamespace } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; +import { selectIsMusdCtaEnabledFlag } from '../selectors/featureFlags'; jest.mock('./useHasMusdBalance'); jest.mock('../../../hooks/useCurrentNetworkInfo'); jest.mock('../../../hooks/useNetworksByNamespace/useNetworksByNamespace'); jest.mock('../../Ramp/hooks/useRampTokens'); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('../selectors/featureFlags'); + +import { useSelector } from 'react-redux'; const mockUseHasMusdBalance = useHasMusdBalance as jest.MockedFunction< typeof useHasMusdBalance @@ -26,6 +34,7 @@ const mockUseNetworksByCustomNamespace = const mockUseRampTokens = useRampTokens as jest.MockedFunction< typeof useRampTokens >; +const mockUseSelector = useSelector as jest.MockedFunction; describe('useMusdCtaVisibility', () => { const defaultNetworkInfo = { @@ -74,8 +83,17 @@ describe('useMusdCtaVisibility', () => { error: null, }; + let mockIsMusdCtaEnabled = true; + beforeEach(() => { jest.clearAllMocks(); + mockIsMusdCtaEnabled = true; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); mockUseHasMusdBalance.mockReturnValue({ hasMusdBalance: false, balancesByChain: {}, @@ -106,6 +124,92 @@ describe('useMusdCtaVisibility', () => { }); }); + describe('feature flag', () => { + it('returns shouldShowCta false when feature flag is disabled', () => { + mockIsMusdCtaEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta true when feature flag is enabled and conditions are met', () => { + mockIsMusdCtaEnabled = true; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(true); + }); + + it('returns shouldShowCta false when feature flag is disabled even on supported single chain', () => { + mockIsMusdCtaEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsMusdCtaEnabledFlag) { + return mockIsMusdCtaEnabled; + } + return undefined; + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + mockUseHasMusdBalance.mockReturnValue({ + hasMusdBalance: false, + balancesByChain: {}, + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + expect(result.current.shouldShowCta).toBe(false); + expect(result.current.showNetworkIcon).toBe(false); + expect(result.current.selectedChainId).toBeNull(); + }); + }); + describe('all networks selected (popular networks)', () => { beforeEach(() => { mockUseNetworksByCustomNamespace.mockReturnValue({ diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts index eab763b888d6..c25c74a4a674 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { Hex, KnownCaipNamespace } from '@metamask/utils'; +import { useSelector } from 'react-redux'; import { MUSD_BUYABLE_CHAIN_IDS, MUSD_TOKEN_ASSET_ID_BY_CHAIN, @@ -11,6 +12,7 @@ import { useNetworksByCustomNamespace, } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; +import { selectIsMusdCtaEnabledFlag } from '../selectors/featureFlags'; /** * Hook to determine visibility and network icon display for the MUSD CTA. @@ -21,6 +23,7 @@ import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; * - selectedChainId: the selected chain ID for the network badge (null if all networks) */ export const useMusdCtaVisibility = () => { + const isMusdCtaEnabled = useSelector(selectIsMusdCtaEnabledFlag); const { enabledNetworks } = useCurrentNetworkInfo(); const { areAllNetworksSelected } = useNetworksByCustomNamespace({ networkType: NetworkType.Popular, @@ -63,6 +66,15 @@ export const useMusdCtaVisibility = () => { ); const { shouldShowCta, showNetworkIcon, selectedChainId } = useMemo(() => { + // If the mUSD CTA feature flag is disabled, don't show the CTA + if (!isMusdCtaEnabled) { + return { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }; + } + // Get selected chains from enabled networks const selectedChains = enabledNetworks .filter((network) => network.enabled) @@ -115,6 +127,7 @@ export const useMusdCtaVisibility = () => { selectedChainId: chainId, }; }, [ + isMusdCtaEnabled, areAllNetworksSelected, enabledNetworks, hasMusdBalance, diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 1953e31b883e..5f337de81be1 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -70,6 +70,19 @@ export const selectIsMusdConversionFlowEnabledFlag = createSelector( }, ); +export const selectIsMusdCtaEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + return true; + const localFlag = process.env.MM_MUSD_CTA_ENABLED === 'true'; + const remoteFlag = + remoteFeatureFlags?.earnMusdCtaEnabled as unknown as VersionGatedFeatureFlag; + + // Fallback to local flag if remote flag is not available + return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + }, +); + /** * Selects the allowed payment tokens for mUSD conversion from remote config or local fallback. * Returns a mapping of chain IDs to arrays of token addresses that users can pay with to convert to mUSD. From 329adc514f54affe14fb221819048df14622e41f Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 16 Dec 2025 23:25:10 -0600 Subject: [PATCH 18/40] fix: bugbot comments --- app/components/UI/Earn/hooks/useMusdCtaVisibility.ts | 3 ++- app/components/UI/Earn/selectors/featureFlags/index.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts index c25c74a4a674..17eacbecb89e 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -13,6 +13,7 @@ import { } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; import { selectIsMusdCtaEnabledFlag } from '../selectors/featureFlags'; +import { toLowerCaseEquals } from '../../../../util/general'; /** * Hook to determine visibility and network icon display for the MUSD CTA. @@ -49,7 +50,7 @@ export const useMusdCtaVisibility = () => { const musdToken = allTokens.find( (token) => - token.assetId === musdAssetId.toLowerCase() && + toLowerCaseEquals(token.assetId, musdAssetId) && token.tokenSupported === true, ); diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 5f337de81be1..a4a4d36f4769 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -73,7 +73,6 @@ export const selectIsMusdConversionFlowEnabledFlag = createSelector( export const selectIsMusdCtaEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { - return true; const localFlag = process.env.MM_MUSD_CTA_ENABLED === 'true'; const remoteFlag = remoteFeatureFlags?.earnMusdCtaEnabled as unknown as VersionGatedFeatureFlag; From 972b0532aa94c521133a8eca7be001d62d812642 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 17 Dec 2025 00:10:38 -0600 Subject: [PATCH 19/40] fix: bugbot comments --- .../MusdConversionAssetListCta.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index acecd6029048..9fe2d1db1e71 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -33,6 +33,7 @@ import { EARN_TEST_IDS } from '../../../constants/testIds'; import initialRootState from '../../../../../../util/test/initial-root-state'; import Logger from '../../../../../../util/Logger'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { BADGE_WRAPPER_BADGE_TEST_ID } from '../../../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants'; const mockToken = { address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', @@ -473,7 +474,7 @@ describe('MusdConversionAssetListCta', () => { getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), ).toBeOnTheScreen(); // Badge wrapper is not rendered when showNetworkIcon is false - expect(queryByTestId('badge-wrapper')).toBeNull(); + expect(queryByTestId(BADGE_WRAPPER_BADGE_TEST_ID)).toBeNull(); }); it('renders with network badge when showNetworkIcon is true and mainnet selected', () => { From cd047458374616a8ee56d1aa200ab91f94d2b641 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 18 Dec 2025 20:51:49 -0600 Subject: [PATCH 20/40] chore: add MM_MUSD_CTA_ENABLED var to eaxmple --- .js.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.js.env.example b/.js.env.example index 58e0b190598b..ae9913eb600a 100644 --- a/.js.env.example +++ b/.js.env.example @@ -111,7 +111,7 @@ export MM_MUSD_CONVERSION_FLOW_ENABLED="false" # IMPORTANT: Must use SINGLE QUOTES to preserve JSON format # Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}' export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' - +export MM_MUSD_CTA_ENABLED="false" # Activates remote feature flag override mode. # Remote feature flag values won't be updated, # and selectors should return their fallback values From d8d439a5e1601785cb3f6cf94a881004906e59d7 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 18 Dec 2025 21:49:44 -0600 Subject: [PATCH 21/40] chore: refactor earn flow to add atokens in EarnTransactionMonitor --- .husky/pre-commit | 2 +- .../index.tsx | 42 +- .../EarnTransactionMonitor.test.tsx | 11 + .../components/EarnTransactionMonitor.tsx | 4 + .../useEarnLendingTransactionStatus.test.ts | 456 ++++++++++++ .../hooks/useEarnLendingTransactionStatus.ts | 690 ++++++++++++++++++ app/components/UI/Earn/hooks/useEarnToken.ts | 7 +- app/components/UI/Earn/utils/index.ts | 2 + .../UI/Earn/utils/lending-transaction.ts | 253 +++++++ app/components/UI/Earn/utils/tempLending.ts | 6 +- .../UI/Earn/utils/token-snapshot.ts | 166 +++++ 11 files changed, 1588 insertions(+), 51 deletions(-) create mode 100644 app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.test.ts create mode 100644 app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.ts create mode 100644 app/components/UI/Earn/utils/lending-transaction.ts create mode 100644 app/components/UI/Earn/utils/token-snapshot.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index 372362317175..1a1f1f060e89 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -yarn lint-staged +yarn lint-staged \ No newline at end of file diff --git a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx index b4e7d5dd8893..4f8e1d219c9c 100644 --- a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx @@ -84,8 +84,7 @@ const EarnLendingWithdrawalConfirmationView = () => { const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); - const { earnTokenPair, getTokenSnapshot, tokenSnapshot } = - useEarnToken(token); + const { earnTokenPair } = useEarnToken(token); const earnToken = earnTokenPair?.earnToken; const outputToken = earnTokenPair?.outputToken; @@ -181,15 +180,6 @@ const EarnLendingWithdrawalConfirmationView = () => { // return parseFloat(healthFactor).toFixed(2); // }; - useEffect(() => { - if (!earnToken) { - getTokenSnapshot( - outputToken?.chainId as Hex, - outputToken?.experience.market?.underlying?.address as Hex, - ); - } - }, [outputToken, getTokenSnapshot, earnToken]); - // Needed to get token's network name const networkConfig = Engine.context.NetworkController.getNetworkConfigurationByChainId( @@ -339,36 +329,8 @@ const EarnLendingWithdrawalConfirmationView = () => { }, (transactionMeta) => transactionMeta.id === transactionId, ); - Engine.controllerMessenger.subscribeOnceIf( - 'TransactionController:transactionConfirmed', - () => { - if (!earnToken) { - try { - const tokenNetworkClientId = - Engine.context.NetworkController.findNetworkClientIdByChainId( - tokenSnapshot?.chainId as Hex, - ); - Engine.context.TokensController.addToken({ - decimals: tokenSnapshot?.token?.decimals || 0, - symbol: tokenSnapshot?.token?.symbol || '', - address: tokenSnapshot?.token?.address || '', - name: tokenSnapshot?.token?.name || '', - networkClientId: tokenNetworkClientId, - }).catch(console.error); - } catch (error) { - console.error( - error, - `error adding counter-token for ${ - outputToken?.symbol || outputToken?.ticker || '' - } on confirmation`, - ); - } - } - }, - (transactionMeta) => transactionMeta.id === transactionId, - ); }, - [emitTxMetaMetric, tokenSnapshot, earnToken, outputToken, navigation], + [emitTxMetaMetric, navigation, outputToken?.chainId], ); // Guards diff --git a/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx b/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx index 6a95cf819ed3..d083c4bacb9c 100644 --- a/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx +++ b/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx @@ -1,12 +1,17 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import EarnTransactionMonitor from './EarnTransactionMonitor'; +import { useEarnLendingTransactionStatus } from '../hooks/useEarnLendingTransactionStatus'; import { useMusdConversionStatus } from '../hooks/useMusdConversionStatus'; jest.mock('../hooks/useMusdConversionStatus'); +jest.mock('../hooks/useEarnLendingTransactionStatus'); describe('EarnTransactionMonitor', () => { const mockUseMusdConversionStatus = jest.mocked(useMusdConversionStatus); + const mockUseEarnLendingTransactionStatus = jest.mocked( + useEarnLendingTransactionStatus, + ); beforeEach(() => { jest.clearAllMocks(); @@ -28,6 +33,12 @@ describe('EarnTransactionMonitor', () => { expect(mockUseMusdConversionStatus).toHaveBeenCalledTimes(1); }); + it('calls useEarnLendingTransactionStatus hook', () => { + render(); + + expect(mockUseEarnLendingTransactionStatus).toHaveBeenCalledTimes(1); + }); + it('returns null', () => { const { toJSON } = render(); diff --git a/app/components/UI/Earn/components/EarnTransactionMonitor.tsx b/app/components/UI/Earn/components/EarnTransactionMonitor.tsx index 3fbaa375b90d..bca7945032e7 100644 --- a/app/components/UI/Earn/components/EarnTransactionMonitor.tsx +++ b/app/components/UI/Earn/components/EarnTransactionMonitor.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useEarnLendingTransactionStatus } from '../hooks/useEarnLendingTransactionStatus'; import { useMusdConversionStatus } from '../hooks/useMusdConversionStatus'; /** @@ -11,6 +12,9 @@ const EarnTransactionMonitor: React.FC = () => { // Enable mUSD conversion status monitoring and toasts useMusdConversionStatus(); + // Enable lending transaction token addition (no toasts) + useEarnLendingTransactionStatus(); + // This component doesn't render anything return null; }; diff --git a/app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.test.ts b/app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.test.ts new file mode 100644 index 000000000000..a41c24edae86 --- /dev/null +++ b/app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.test.ts @@ -0,0 +1,456 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { renderHook } from '@testing-library/react-hooks'; +import { ethers } from 'ethers'; +import { useEarnLendingTransactionStatus } from './useEarnLendingTransactionStatus'; + +// Mock dependencies +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ name: 'test-event' }), +})); + +jest.mock('../../../hooks/useMetrics', () => ({ + MetaMetricsEvents: { + EARN_TRANSACTION_SUBMITTED: { name: 'Earn Transaction Submitted' }, + EARN_TRANSACTION_CONFIRMED: { name: 'Earn Transaction Confirmed' }, + EARN_TRANSACTION_REJECTED: { name: 'Earn Transaction Rejected' }, + EARN_TRANSACTION_DROPPED: { name: 'Earn Transaction Dropped' }, + EARN_TRANSACTION_FAILED: { name: 'Earn Transaction Failed' }, + }, + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock('../../../../util/Logger', () => ({ + log: jest.fn(), +})); + +const mockSubscribe = jest.fn(); +const mockUnsubscribe = jest.fn(); +const mockAddToken = jest.fn().mockResolvedValue(undefined); +const mockFindNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); +const mockGetNetworkConfigurationByChainId = jest.fn().mockReturnValue({ + name: 'Ethereum Mainnet', + chainId: '0x1', +}); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { + subscribe: (...args: unknown[]) => mockSubscribe(...args), + unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args), + }, + context: { + TokensController: { + addToken: (...args: unknown[]) => mockAddToken(...args), + }, + NetworkController: { + findNetworkClientIdByChainId: (...args: unknown[]) => + mockFindNetworkClientIdByChainId(...args), + getNetworkConfigurationByChainId: (...args: unknown[]) => + mockGetNetworkConfigurationByChainId(...args), + }, + }, +})); + +jest.mock('../../../Views/confirmations/utils/transaction', () => ({ + hasTransactionType: ( + transactionMeta: TransactionMeta | undefined, + types: TransactionType[], + ) => { + const { nestedTransactions, type } = transactionMeta ?? {}; + if (types.includes(type as TransactionType)) { + return true; + } + return ( + nestedTransactions?.some((tx: { type?: TransactionType }) => + types.includes(tx.type as TransactionType), + ) ?? false + ); + }, +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('./useEarnTokens', () => () => ({ + getPairedEarnTokens: jest.fn(() => ({ + earnToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + outputToken: { + address: '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c', + decimals: 6, + symbol: 'aEthUSDC', + name: 'Aave Ethereum USDC', + }, + })), +})); + +import { MetaMetricsEvents } from '../../../hooks/useMetrics'; + +type TransactionHandler = ( + event: TransactionMeta | { transactionMeta: TransactionMeta }, +) => void; + +describe('useEarnLendingTransactionStatus', () => { + const UNDERLYING_TOKEN_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const OUTPUT_TOKEN_ADDRESS = '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c'; + const CHAIN_ID = '0x1'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createLendingDepositData = (): string => { + const supplyAbi = [ + 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)', + ]; + const contractInterface = new ethers.utils.Interface(supplyAbi); + return contractInterface.encodeFunctionData('supply', [ + UNDERLYING_TOKEN_ADDRESS, + '1000000', + '0x1230000000000000000000000000000000000456', + 0, + ]); + }; + + const createLendingWithdrawalData = (): string => { + const withdrawAbi = [ + 'function withdraw(address asset, uint256 amount, address to)', + ]; + const contractInterface = new ethers.utils.Interface(withdrawAbi); + return contractInterface.encodeFunctionData('withdraw', [ + UNDERLYING_TOKEN_ADDRESS, + '1000000', + '0x1230000000000000000000000000000000000456', + ]); + }; + + const createTransactionMeta = ( + type: TransactionType, + transactionId = 'test-transaction-1', + data?: string, + nestedTransactions?: Array<{ type: TransactionType; data?: string }>, + ): TransactionMeta => + ({ + id: transactionId, + type, + chainId: CHAIN_ID, + networkClientId: 'mainnet', + time: Date.now(), + txParams: { + from: '0x1230000000000000000000000000000000000456', + to: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + data, + }, + nestedTransactions, + }) as TransactionMeta; + + const getHandler = (eventName: string): TransactionHandler => { + const subscribeCalls = mockSubscribe.mock.calls; + const call = subscribeCalls.find( + ([name]: [string]) => name === eventName, + ) as [string, TransactionHandler] | undefined; + if (!call) { + throw new Error(`No subscription found for ${eventName}`); + } + return call[1]; + }; + + describe('subscription lifecycle', () => { + it('subscribes to all transaction lifecycle events on mount', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + expect(mockSubscribe).toHaveBeenCalledTimes(5); + expect(mockSubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionSubmitted', + expect.any(Function), + ); + expect(mockSubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionConfirmed', + expect.any(Function), + ); + expect(mockSubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionRejected', + expect.any(Function), + ); + expect(mockSubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionDropped', + expect.any(Function), + ); + expect(mockSubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionFailed', + expect.any(Function), + ); + }); + + it('unsubscribes from all events on unmount', () => { + const { unmount } = renderHook(() => useEarnLendingTransactionStatus()); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(5); + }); + }); + + describe('direct deposit transactions', () => { + it('adds receipt token when deposit transaction is confirmed', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const depositData = createLendingDepositData(); + const transactionMeta = createTransactionMeta( + TransactionType.lendingDeposit, + 'test-deposit-1', + depositData, + ); + + handler(transactionMeta); + + expect(mockAddToken).toHaveBeenCalledTimes(1); + expect(mockAddToken).toHaveBeenCalledWith({ + decimals: 6, + symbol: 'aEthUSDC', + address: OUTPUT_TOKEN_ADDRESS, + name: 'Aave Ethereum USDC', + networkClientId: 'mainnet', + }); + }); + + it('tracks analytics event when deposit is confirmed', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const depositData = createLendingDepositData(); + const transactionMeta = createTransactionMeta( + TransactionType.lendingDeposit, + 'test-deposit-analytics', + depositData, + ); + + handler(transactionMeta); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.EARN_TRANSACTION_CONFIRMED, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('prevents duplicate events for same transaction', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const depositData = createLendingDepositData(); + const transactionMeta = createTransactionMeta( + TransactionType.lendingDeposit, + 'test-deposit-duplicate', + depositData, + ); + + handler(transactionMeta); + handler(transactionMeta); + handler(transactionMeta); + + expect(mockAddToken).toHaveBeenCalledTimes(1); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + }); + + describe('direct withdrawal transactions', () => { + it('adds underlying token when withdrawal transaction is confirmed', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const withdrawalData = createLendingWithdrawalData(); + const transactionMeta = createTransactionMeta( + TransactionType.lendingWithdraw, + 'test-withdrawal-1', + withdrawalData, + ); + + handler(transactionMeta); + + expect(mockAddToken).toHaveBeenCalledTimes(1); + expect(mockAddToken).toHaveBeenCalledWith({ + decimals: 6, + symbol: 'USDC', + address: UNDERLYING_TOKEN_ADDRESS, + name: 'USD Coin', + networkClientId: 'mainnet', + }); + }); + }); + + describe('batch transactions (1-click flow)', () => { + it('extracts lending deposit from nested transactions and adds token', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const depositData = createLendingDepositData(); + + const batchMeta = createTransactionMeta( + TransactionType.batch, + 'batch-deposit-1', + undefined, + [ + { type: TransactionType.tokenMethodApprove, data: '0xapprovedata' }, + { type: TransactionType.lendingDeposit, data: depositData }, + ], + ); + + handler(batchMeta); + + expect(mockAddToken).toHaveBeenCalledTimes(1); + expect(mockAddToken).toHaveBeenCalledWith({ + decimals: 6, + symbol: 'aEthUSDC', + address: OUTPUT_TOKEN_ADDRESS, + name: 'Aave Ethereum USDC', + networkClientId: 'mainnet', + }); + }); + + it('extracts lending withdrawal from nested transactions and adds token', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const withdrawalData = createLendingWithdrawalData(); + + const batchMeta = createTransactionMeta( + TransactionType.batch, + 'batch-withdrawal-1', + undefined, + [{ type: TransactionType.lendingWithdraw, data: withdrawalData }], + ); + + handler(batchMeta); + + expect(mockAddToken).toHaveBeenCalledTimes(1); + expect(mockAddToken).toHaveBeenCalledWith({ + decimals: 6, + symbol: 'USDC', + address: UNDERLYING_TOKEN_ADDRESS, + name: 'USD Coin', + networkClientId: 'mainnet', + }); + }); + + it('ignores batch transactions without lending nested transactions', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + + const batchMeta = createTransactionMeta( + TransactionType.batch, + 'batch-no-lending', + undefined, + [ + { type: TransactionType.tokenMethodApprove, data: '0xapprovedata' }, + { type: TransactionType.swap, data: '0xswapdata' }, + ], + ); + + handler(batchMeta); + + expect(mockAddToken).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + describe('transaction events tracking', () => { + it('tracks EARN_TRANSACTION_SUBMITTED on transaction submitted', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionSubmitted'); + const depositData = createLendingDepositData(); + const transactionMeta = createTransactionMeta( + TransactionType.lendingDeposit, + 'test-submitted', + depositData, + ); + + handler({ transactionMeta }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.EARN_TRANSACTION_SUBMITTED, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockAddToken).not.toHaveBeenCalled(); + }); + + it('tracks EARN_TRANSACTION_REJECTED on transaction rejected', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionRejected'); + const depositData = createLendingDepositData(); + const transactionMeta = createTransactionMeta( + TransactionType.lendingDeposit, + 'test-rejected', + depositData, + ); + + handler({ transactionMeta }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.EARN_TRANSACTION_REJECTED, + ); + }); + }); + + describe('non-lending transactions', () => { + it('ignores transactions with non-lending type', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const transactionMeta = createTransactionMeta( + TransactionType.swap, + 'test-swap', + ); + + handler(transactionMeta); + + expect(mockAddToken).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('handles missing transaction data gracefully', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const transactionMeta = createTransactionMeta( + TransactionType.lendingDeposit, + 'test-no-data', + undefined, + ); + + expect(() => handler(transactionMeta)).not.toThrow(); + expect(mockAddToken).not.toHaveBeenCalled(); + }); + + it('handles invalid transaction data gracefully', () => { + renderHook(() => useEarnLendingTransactionStatus()); + + const handler = getHandler('TransactionController:transactionConfirmed'); + const transactionMeta = createTransactionMeta( + TransactionType.lendingDeposit, + 'test-invalid-data', + '0xinvaliddata', + ); + + expect(() => handler(transactionMeta)).not.toThrow(); + expect(mockAddToken).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.ts b/app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.ts new file mode 100644 index 000000000000..5c1de3004b51 --- /dev/null +++ b/app/components/UI/Earn/hooks/useEarnLendingTransactionStatus.ts @@ -0,0 +1,690 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { useEffect, useRef } from 'react'; +import Engine from '../../../../core/Engine'; +import Logger from '../../../../util/Logger'; +import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; +import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { EarnTokenDetails } from '../types/lending.types'; +import { + LENDING_TYPES, + TransactionEventType, + LendingTransactionInfo, + DecodedLendingData, + getLendingTransactionInfo, + decodeLendingTransactionData, + getTrackEventProperties, + getMetricsEvent, +} from '../utils/lending-transaction'; +import { + fetchTokenSnapshot, + getTokenSnapshotFromState, + getEarnTokenPairAddressesFromState, +} from '../utils/token-snapshot'; +import useEarnTokens from './useEarnTokens'; +import { TokenI } from '../../Tokens/types'; + +const LOG_TAG = '[EarnLendingTxStatus]'; + +// Max number of transaction event keys to track for deduplication +const MAX_PROCESSED_TRANSACTIONS = 100; + +/** + * Hook to monitor lending transaction lifecycle and add receipt/underlying tokens. + * Subscribes to TransactionController lifecycle events and tracks analytics. + * Supports both 1-click batch flow and legacy direct transactions. + * On confirmation: adds receipt token (deposits) or underlying token (withdrawals). + */ +export const useEarnLendingTransactionStatus = () => { + const { getEarnToken, getOutputToken } = useEarnTokens(); + const { trackEvent, createEventBuilder } = useMetrics(); + const processedTransactionsRef = useRef>(new Set()); + + // Use refs to avoid stale closures in event handlers + const trackEventRef = useRef(trackEvent); + const createEventBuilderRef = useRef(createEventBuilder); + + trackEventRef.current = trackEvent; + createEventBuilderRef.current = createEventBuilder; + + useEffect(() => { + Logger.log(LOG_TAG, '=== HOOK MOUNTED ==='); + Logger.log( + LOG_TAG, + 'Subscribing to 5 transaction events: submitted, confirmed, rejected, dropped, failed', + ); + + /** + * Track analytics event for lending transaction + */ + const trackLendingEvent = ( + transactionMeta: TransactionMeta, + eventType: TransactionEventType, + lendingInfo: LendingTransactionInfo, + earnToken: EarnTokenDetails | undefined, + decodedData: DecodedLendingData | null, + networkName: string | undefined, + ) => { + const actionType = + lendingInfo.type === TransactionType.lendingDeposit + ? 'deposit' + : 'withdrawal'; + const metricsEvent = getMetricsEvent(eventType); + const properties = getTrackEventProperties( + transactionMeta, + actionType, + earnToken, + decodedData?.amountMinimalUnit, + networkName, + ); + + Logger.log( + LOG_TAG, + `[trackLendingEvent] 📊 Tracking EARN_TRANSACTION_${eventType.toUpperCase()}`, + properties, + ); + + trackEventRef.current( + createEventBuilderRef + .current(metricsEvent) + .addProperties(properties) + .build(), + ); + }; + + /** + * Pre-fetch token data on submitted event. + * For deposits: pre-fetch outputToken (receipt token) if not known. + * For withdrawals: pre-fetch earnToken (underlying token) if not known. + * This gives time for the data to be available by the time confirmed fires. + */ + const prefetchTokenOnSubmitted = ( + transactionMeta: TransactionMeta, + lendingInfo: LendingTransactionInfo, + earnToken: EarnTokenDetails | undefined, + outputToken: EarnTokenDetails | undefined, + ) => { + const chainId = transactionMeta.chainId as Hex; + const isDeposit = lendingInfo.type === TransactionType.lendingDeposit; + + if (isDeposit) { + // For deposits: pre-fetch outputToken if not known + if (outputToken) { + return; + } + + const outputTokenAddress = + earnToken?.experience?.market?.outputToken?.address; + if (!outputTokenAddress) { + Logger.log( + LOG_TAG, + '[prefetchToken] ⚠️ Deposit: No output token address in earnToken market data', + ); + return; + } + + Logger.log( + LOG_TAG, + '[prefetchToken] 🔄 Deposit: Pre-fetching output token data', + { chainId, outputTokenAddress }, + ); + + fetchTokenSnapshot(chainId, outputTokenAddress as Hex).catch( + (error) => { + Logger.log( + LOG_TAG, + '[prefetchToken] ❌ Deposit: Pre-fetch failed', + { error }, + ); + }, + ); + } else { + // For withdrawals: pre-fetch earnToken (underlying) if not known + if (earnToken) { + return; + } + + const underlyingTokenAddress = + outputToken?.experience?.market?.underlying?.address; + if (!underlyingTokenAddress) { + Logger.log( + LOG_TAG, + '[prefetchToken] ⚠️ Withdrawal: No underlying token address in outputToken market data', + ); + return; + } + + Logger.log( + LOG_TAG, + '[prefetchToken] 🔄 Withdrawal: Pre-fetching underlying token data', + { chainId, underlyingTokenAddress }, + ); + + fetchTokenSnapshot(chainId, underlyingTokenAddress as Hex).catch( + (error) => { + Logger.log( + LOG_TAG, + '[prefetchToken] ❌ Withdrawal: Pre-fetch failed', + { error }, + ); + }, + ); + } + }; + + /** + * Add token to wallet on confirmation + */ + const addTokenOnConfirmation = ( + transactionMeta: TransactionMeta, + lendingInfo: LendingTransactionInfo, + earnToken: EarnTokenDetails | undefined, + outputToken: EarnTokenDetails | undefined, + ) => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] Starting token addition...', + ); + + const chainId = transactionMeta.chainId as Hex; + const isDeposit = lendingInfo.type === TransactionType.lendingDeposit; + + try { + const networkClientId = + Engine.context.NetworkController.findNetworkClientIdByChainId( + chainId, + ); + + Logger.log(LOG_TAG, '[addTokenOnConfirmation] Network client found', { + networkClientId, + }); + + if (isDeposit) { + // For deposits, add the receipt/output token + if (outputToken) { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] 💰 Adding RECEIPT token (from earnTokens)', + { + symbol: outputToken.symbol, + address: outputToken.address, + decimals: outputToken.decimals, + }, + ); + Engine.context.TokensController.addToken({ + decimals: outputToken.decimals || 0, + symbol: outputToken.symbol || '', + address: outputToken.address || '', + name: outputToken.name || outputToken.symbol || '', + networkClientId, + }) + .then(() => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ✓ Receipt token added successfully', + ); + }) + .catch((error) => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ❌ Failed to add receipt token', + { error }, + ); + }); + } else { + // Fallback: try to get from pre-fetched token snapshot + const outputTokenAddress = + earnToken?.experience?.market?.outputToken?.address; + if (outputTokenAddress) { + const snapshot = getTokenSnapshotFromState( + chainId, + outputTokenAddress as Hex, + ); + if (snapshot?.token) { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] 💰 Adding RECEIPT token (from snapshot)', + { + symbol: snapshot.token.symbol, + address: snapshot.address, + decimals: snapshot.token.decimals, + }, + ); + Engine.context.TokensController.addToken({ + decimals: snapshot.token.decimals || 0, + symbol: snapshot.token.symbol || '', + address: snapshot.address || '', + name: snapshot.token.name || snapshot.token.symbol || '', + networkClientId, + }) + .then(() => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ✓ Receipt token added from snapshot', + ); + }) + .catch((error) => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ❌ Failed to add receipt token from snapshot', + { error }, + ); + }); + } else { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ⚠️ No output token or snapshot available', + ); + } + } else { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ⚠️ No output token address available', + ); + } + } + } else if (!isDeposit) { + // For withdrawals, add the underlying/earn token + if (earnToken) { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] 💰 Adding UNDERLYING token (from earnTokens)', + { + symbol: earnToken.symbol, + address: earnToken.address, + decimals: earnToken.decimals, + }, + ); + Engine.context.TokensController.addToken({ + decimals: earnToken.decimals || 0, + symbol: earnToken.symbol || '', + address: earnToken.address || '', + name: earnToken.name || earnToken.symbol || '', + networkClientId, + }) + .then(() => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ✓ Underlying token added successfully', + ); + }) + .catch((error) => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ❌ Failed to add underlying token', + { error }, + ); + }); + } else { + // Fallback: try to get from pre-fetched token snapshot + const underlyingTokenAddress = + outputToken?.experience?.market?.underlying?.address; + if (underlyingTokenAddress) { + const snapshot = getTokenSnapshotFromState( + chainId, + underlyingTokenAddress as Hex, + ); + if (snapshot?.token) { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] 💰 Adding UNDERLYING token (from snapshot)', + { + symbol: snapshot.token.symbol, + address: snapshot.address, + decimals: snapshot.token.decimals, + }, + ); + Engine.context.TokensController.addToken({ + decimals: snapshot.token.decimals || 0, + symbol: snapshot.token.symbol || '', + address: snapshot.address || '', + name: snapshot.token.name || snapshot.token.symbol || '', + networkClientId, + }) + .then(() => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ✓ Underlying token added from snapshot', + ); + }) + .catch((error) => { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ❌ Failed to add underlying token from snapshot', + { error }, + ); + }); + } else { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ⚠️ No earnToken or snapshot available for withdrawal', + ); + } + } else { + Logger.log( + LOG_TAG, + '[addTokenOnConfirmation] ⚠️ No underlying token address available for withdrawal', + ); + } + } + } + } catch (error) { + Logger.log(LOG_TAG, '[addTokenOnConfirmation] ❌ Error', { error }); + } + }; + + /** + * Handle transaction events + */ + const handleTransactionEvent = ( + transactionMeta: TransactionMeta, + eventType: TransactionEventType, + ) => { + Logger.log( + LOG_TAG, + `\n========== TX EVENT: ${eventType.toUpperCase()} ==========`, + ); + Logger.log(LOG_TAG, 'Transaction metadata:', { + id: transactionMeta.id, + type: transactionMeta.type, + chainId: transactionMeta.chainId, + hasNestedTx: Boolean(transactionMeta.nestedTransactions?.length), + nestedCount: transactionMeta.nestedTransactions?.length ?? 0, + }); + + // Check if this is a lending transaction (direct or batch) + const isLending = hasTransactionType(transactionMeta, LENDING_TYPES); + Logger.log( + LOG_TAG, + `[Step 1] Is lending transaction? ${isLending ? '✓ YES' : '✗ NO'}`, + ); + + if (!isLending) { + Logger.log(LOG_TAG, '--- Ignoring non-lending transaction ---\n'); + return; + } + + // Deduplicate by transaction ID + event type + const eventKey = `${transactionMeta.id}-${eventType}`; + const isDuplicate = processedTransactionsRef.current.has(eventKey); + Logger.log( + LOG_TAG, + `[Step 2] Duplicate check: ${isDuplicate ? '⚠️ DUPLICATE - skipping' : '✓ New event'}`, + ); + + if (isDuplicate) { + Logger.log(LOG_TAG, '--- Skipping duplicate event ---\n'); + return; + } + + // Evict oldest entry if at capacity (Set maintains insertion order) + const processedSet = processedTransactionsRef.current; + if (processedSet.size >= MAX_PROCESSED_TRANSACTIONS) { + const oldest = processedSet.values().next().value; + if (oldest) processedSet.delete(oldest); + } + processedSet.add(eventKey); + + // Extract lending transaction info + Logger.log(LOG_TAG, '[Step 3] Extracting lending transaction info...'); + const lendingInfo = getLendingTransactionInfo(transactionMeta); + if (!lendingInfo) { + Logger.log(LOG_TAG, '❌ Could not extract lending info - aborting\n'); + return; + } + Logger.log( + LOG_TAG, + `[Step 3] ✓ Lending info extracted: ${lendingInfo.type}`, + ); + + // Decode transaction data to get token address and amount + Logger.log(LOG_TAG, '[Step 4] Decoding lending transaction data...'); + const decodedData = decodeLendingTransactionData(lendingInfo); + let earnToken: EarnTokenDetails | undefined; + let outputToken: EarnTokenDetails | undefined; + + const chainId = transactionMeta.chainId as Hex; + + // Look up network name for analytics + const networkConfig = + Engine.context.NetworkController.getNetworkConfigurationByChainId( + chainId, + ); + const networkName = networkConfig?.name; + + if (decodedData?.tokenAddress) { + // Read directly from Engine.state to get latest data (bypasses React render cycle) + const tokenPair = getEarnTokenPairAddressesFromState( + chainId, + decodedData.tokenAddress, + ); + earnToken = getEarnToken({ + chainId, + address: tokenPair.earnToken as Hex, + } as TokenI); + outputToken = getOutputToken({ + chainId, + address: tokenPair.outputToken as Hex, + } as TokenI); + Logger.log(LOG_TAG, '[Step 4] ✓ Token lookup complete', { + earnTokenSymbol: earnToken?.symbol ?? 'not found', + outputTokenSymbol: outputToken?.symbol ?? 'not found', + amountMinimalUnit: decodedData.amountMinimalUnit, + networkName, + }); + } else { + Logger.log( + LOG_TAG, + '[Step 4] ⚠️ No decoded data - tracking without token info', + ); + } + + // Track analytics + Logger.log(LOG_TAG, '[Step 5] Tracking analytics event...'); + trackLendingEvent( + transactionMeta, + eventType, + lendingInfo, + earnToken, + decodedData, + networkName, + ); + + // Handle submitted-specific actions + if (eventType === 'submitted') { + // Track EARN_TRANSACTION_INITIATED (this was previously tracked when tx was first created) + Logger.log( + LOG_TAG, + '[Step 6a] Event is SUBMITTED - tracking EARN_TRANSACTION_INITIATED...', + ); + const actionType = + lendingInfo.type === TransactionType.lendingDeposit + ? 'deposit' + : 'withdrawal'; + const initiatedProperties = getTrackEventProperties( + transactionMeta, + actionType, + earnToken, + decodedData?.amountMinimalUnit, + networkName, + ); + trackEventRef.current( + createEventBuilderRef + .current(MetaMetricsEvents.EARN_TRANSACTION_INITIATED) + .addProperties(initiatedProperties) + .build(), + ); + + // Track allowance transaction if present in batch + const hasAllowanceTx = transactionMeta.nestedTransactions?.some( + (tx) => + tx.type === TransactionType.tokenMethodApprove || + tx.type === TransactionType.tokenMethodIncreaseAllowance, + ); + if (hasAllowanceTx) { + Logger.log( + LOG_TAG, + '[Step 6b] Batch contains allowance tx - tracking allowance submitted...', + ); + trackEventRef.current( + createEventBuilderRef + .current(MetaMetricsEvents.EARN_TRANSACTION_SUBMITTED) + .addProperties({ + ...initiatedProperties, + transaction_type: TransactionType.tokenMethodIncreaseAllowance, + is_allowance: true, + }) + .build(), + ); + } + + // Pre-fetch token (output for deposits, underlying for withdrawals) + Logger.log(LOG_TAG, '[Step 6c] Pre-fetching token if needed...'); + prefetchTokenOnSubmitted( + transactionMeta, + lendingInfo, + earnToken, + outputToken, + ); + } else if (eventType === 'confirmed') { + // Track allowance confirmed if present in batch + const hasAllowanceTx = transactionMeta.nestedTransactions?.some( + (tx) => + tx.type === TransactionType.tokenMethodApprove || + tx.type === TransactionType.tokenMethodIncreaseAllowance, + ); + if (hasAllowanceTx) { + const actionType = + lendingInfo.type === TransactionType.lendingDeposit + ? 'deposit' + : 'withdrawal'; + const allowanceProperties = getTrackEventProperties( + transactionMeta, + actionType, + earnToken, + decodedData?.amountMinimalUnit, + networkName, + ); + Logger.log( + LOG_TAG, + '[Step 6a] Batch contains allowance tx - tracking allowance confirmed...', + ); + trackEventRef.current( + createEventBuilderRef + .current(MetaMetricsEvents.EARN_TRANSACTION_CONFIRMED) + .addProperties({ + ...allowanceProperties, + transaction_type: TransactionType.tokenMethodIncreaseAllowance, + is_allowance: true, + }) + .build(), + ); + } + + // Add token on confirmation + Logger.log( + LOG_TAG, + '[Step 6b] Event is CONFIRMED - adding token to wallet...', + ); + addTokenOnConfirmation( + transactionMeta, + lendingInfo, + earnToken, + outputToken, + ); + } else { + Logger.log( + LOG_TAG, + `[Step 6] Event is ${eventType.toUpperCase()} - no action needed`, + ); + } + + Logger.log( + LOG_TAG, + `========== END ${eventType.toUpperCase()} ==========\n`, + ); + }; + + // Event handlers + const handleSubmitted = ({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + }) => handleTransactionEvent(transactionMeta, 'submitted'); + + const handleConfirmed = (transactionMeta: TransactionMeta) => + handleTransactionEvent(transactionMeta, 'confirmed'); + + const handleRejected = ({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + }) => handleTransactionEvent(transactionMeta, 'rejected'); + + const handleDropped = ({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + }) => handleTransactionEvent(transactionMeta, 'dropped'); + + const handleFailed = ({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + }) => handleTransactionEvent(transactionMeta, 'failed'); + + // Subscribe to all transaction lifecycle events + Engine.controllerMessenger.subscribe( + 'TransactionController:transactionSubmitted', + handleSubmitted, + ); + Engine.controllerMessenger.subscribe( + 'TransactionController:transactionConfirmed', + handleConfirmed, + ); + Engine.controllerMessenger.subscribe( + 'TransactionController:transactionRejected', + handleRejected, + ); + Engine.controllerMessenger.subscribe( + 'TransactionController:transactionDropped', + handleDropped, + ); + Engine.controllerMessenger.subscribe( + 'TransactionController:transactionFailed', + handleFailed, + ); + + Logger.log(LOG_TAG, '=== SUBSCRIPTIONS COMPLETE ===\n'); + + return () => { + Logger.log(LOG_TAG, '=== HOOK UNMOUNTING ==='); + Logger.log(LOG_TAG, 'Unsubscribing from all events...'); + Engine.controllerMessenger.unsubscribe( + 'TransactionController:transactionSubmitted', + handleSubmitted, + ); + Engine.controllerMessenger.unsubscribe( + 'TransactionController:transactionConfirmed', + handleConfirmed, + ); + Engine.controllerMessenger.unsubscribe( + 'TransactionController:transactionRejected', + handleRejected, + ); + Engine.controllerMessenger.unsubscribe( + 'TransactionController:transactionDropped', + handleDropped, + ); + Engine.controllerMessenger.unsubscribe( + 'TransactionController:transactionFailed', + handleFailed, + ); + Logger.log(LOG_TAG, '=== UNSUBSCRIBED ===\n'); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; diff --git a/app/components/UI/Earn/hooks/useEarnToken.ts b/app/components/UI/Earn/hooks/useEarnToken.ts index bcb464c85fa0..1475861101fc 100644 --- a/app/components/UI/Earn/hooks/useEarnToken.ts +++ b/app/components/UI/Earn/hooks/useEarnToken.ts @@ -2,7 +2,6 @@ import { Token } from '@metamask/assets-controllers'; import { Hex } from '@metamask/utils'; import { useCallback, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; import { RootState } from '../../../../reducers'; import { selectCurrentCurrency } from '../../../../selectors/currencyRateController'; import { earnSelectors } from '../../../../selectors/earnController/earn'; @@ -10,6 +9,7 @@ import { selectTokenDisplayData } from '../../../../selectors/tokenSearchDiscove import { TokenI } from '../../Tokens/types'; import { EarnTokenDetails } from '../types/lending.types'; import { getEstimatedAnnualRewards } from '../utils/token'; +import { fetchTokenSnapshot } from '../utils/token-snapshot'; /** * Represents the price of a token in a currency. Picked from @metamask/assets-controllers @@ -98,10 +98,7 @@ const useEarnToken = (asset: TokenI | EarnTokenDetails) => { const getTokenSnapshot = useCallback(async (chainId: Hex, address: Hex) => { try { setIsLoadingTokenSnapshot(true); - await Engine.context.TokenSearchDiscoveryDataController.fetchTokenDisplayData( - chainId, - address, - ); + await fetchTokenSnapshot(chainId, address); setIsLoadingTokenSnapshot(false); setTokenDisplayDataParams({ chainId, address }); } catch (error) { diff --git a/app/components/UI/Earn/utils/index.ts b/app/components/UI/Earn/utils/index.ts index 46dede3615ac..72906994111a 100644 --- a/app/components/UI/Earn/utils/index.ts +++ b/app/components/UI/Earn/utils/index.ts @@ -1,3 +1,5 @@ export * from './token'; export * from './number'; export * from './analytics'; +export * from './lending-transaction'; +export * from './token-snapshot'; diff --git a/app/components/UI/Earn/utils/lending-transaction.ts b/app/components/UI/Earn/utils/lending-transaction.ts new file mode 100644 index 000000000000..90f97c08b203 --- /dev/null +++ b/app/components/UI/Earn/utils/lending-transaction.ts @@ -0,0 +1,253 @@ +import { + NestedTransactionMetadata, + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { ethers } from 'ethers'; +import Logger from '../../../../util/Logger'; +import { renderFromTokenMinimalUnit } from '../../../../util/number'; +import { MetaMetricsEvents } from '../../../hooks/useMetrics'; +import { EARN_EXPERIENCES } from '../constants/experiences'; +import { EarnTokenDetails } from '../types/lending.types'; + +const LOG_TAG = '[EarnLendingTxUtils]'; + +// Aave V3 ABIs for decoding transaction data +export const AAVE_V3_SUPPLY_ABI = [ + 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)', +]; + +export const AAVE_V3_WITHDRAW_ABI = [ + 'function withdraw(address asset, uint256 amount, address to)', +]; + +// Lending transaction types to filter for +export const LENDING_TYPES = [ + TransactionType.lendingDeposit, + TransactionType.lendingWithdraw, +]; + +/** + * Transaction event types for lifecycle tracking + */ +export type TransactionEventType = + | 'submitted' + | 'confirmed' + | 'rejected' + | 'dropped' + | 'failed'; + +/** + * Extracted lending transaction info containing type and encoded data + */ +export interface LendingTransactionInfo { + type: TransactionType.lendingDeposit | TransactionType.lendingWithdraw; + data: string; +} + +/** + * Decoded lending transaction data containing token address and amount + */ +export interface DecodedLendingData { + tokenAddress: string; + amountMinimalUnit: string; +} + +/** + * Get the lending transaction info (type and data) from the transaction. + * For batch transactions, extracts from nestedTransactions. + * For direct transactions, uses the transaction itself. + * + * @param transactionMeta - The transaction metadata + * @returns LendingTransactionInfo or null if not found/invalid + */ +export const getLendingTransactionInfo = ( + transactionMeta: TransactionMeta, +): LendingTransactionInfo | null => { + Logger.log(LOG_TAG, '[getLendingTransactionInfo] Extracting lending info', { + txType: transactionMeta.type, + isBatch: Boolean(transactionMeta.nestedTransactions?.length), + }); + + // Direct lending transaction + if ( + transactionMeta.type === TransactionType.lendingDeposit || + transactionMeta.type === TransactionType.lendingWithdraw + ) { + const data = transactionMeta.txParams?.data; + if (!data) { + Logger.log( + LOG_TAG, + '[getLendingTransactionInfo] ❌ Direct lending tx has no data', + ); + return null; + } + Logger.log( + LOG_TAG, + '[getLendingTransactionInfo] ✓ Direct lending tx found', + { type: transactionMeta.type }, + ); + return { + type: transactionMeta.type, + data: data as string, + }; + } + + // Batch: find nested lending transaction + Logger.log( + LOG_TAG, + '[getLendingTransactionInfo] Searching nestedTransactions', + { + nestedCount: transactionMeta.nestedTransactions?.length ?? 0, + nestedTypes: transactionMeta.nestedTransactions?.map((tx) => tx.type), + }, + ); + + const nestedLendingTx = transactionMeta.nestedTransactions?.find( + (tx: NestedTransactionMetadata) => + tx.type === TransactionType.lendingDeposit || + tx.type === TransactionType.lendingWithdraw, + ); + + if (!nestedLendingTx) { + Logger.log( + LOG_TAG, + '[getLendingTransactionInfo] ❌ No nested lending tx found', + ); + return null; + } + + if (!nestedLendingTx.data) { + Logger.log( + LOG_TAG, + '[getLendingTransactionInfo] ❌ Nested lending tx has no data', + ); + return null; + } + + Logger.log( + LOG_TAG, + '[getLendingTransactionInfo] ✓ Found nested lending tx', + { type: nestedLendingTx.type }, + ); + + return { + type: nestedLendingTx.type as + | TransactionType.lendingDeposit + | TransactionType.lendingWithdraw, + data: nestedLendingTx.data as string, + }; +}; + +/** + * Decode lending transaction data to extract token address and amount. + * + * @param lendingInfo - The lending transaction info with type and encoded data + * @returns DecodedLendingData with tokenAddress and amountMinimalUnit, or null if decoding fails + */ +export const decodeLendingTransactionData = ( + lendingInfo: LendingTransactionInfo, +): DecodedLendingData | null => { + const isDeposit = lendingInfo.type === TransactionType.lendingDeposit; + Logger.log(LOG_TAG, '[decodeLendingTransactionData] Decoding tx data', { + type: isDeposit ? 'deposit (supply)' : 'withdrawal (withdraw)', + }); + + try { + const abi = isDeposit ? AAVE_V3_SUPPLY_ABI : AAVE_V3_WITHDRAW_ABI; + const functionName = isDeposit ? 'supply' : 'withdraw'; + + const contractInterface = new ethers.utils.Interface(abi); + const decoded = contractInterface.decodeFunctionData( + functionName, + lendingInfo.data, + ); + + const tokenAddress = decoded[0] as string; + const amountMinimalUnit = decoded[1].toString(); + + Logger.log(LOG_TAG, '[decodeLendingTransactionData] ✓ Decoded tx data', { + tokenAddress, + amountMinimalUnit, + }); + + return { tokenAddress, amountMinimalUnit }; + } catch (error) { + Logger.log( + LOG_TAG, + '[decodeLendingTransactionData] ❌ Failed to decode', + { error }, + ); + return null; + } +}; + +/** + * @deprecated Use decodeLendingTransactionData instead + * Kept for backward compatibility - extracts only the token address + */ +export const extractUnderlyingTokenAddress = ( + lendingInfo: LendingTransactionInfo, +): string | null => { + const decoded = decodeLendingTransactionData(lendingInfo); + return decoded?.tokenAddress ?? null; +}; + +/** + * Build analytics properties for tracking lending transaction events. + * Matches the properties tracked by EarnLendingDepositConfirmationView and + * EarnLendingWithdrawalConfirmationView for parity across flows. + * + * @param transactionMeta - The transaction metadata + * @param actionType - Whether this is a deposit or withdrawal + * @param earnToken - The earn token details (optional) + * @param amountMinimalUnit - The transaction amount in minimal units (optional) + * @param networkName - The network name (optional) + * @returns Analytics properties object + */ +export const getTrackEventProperties = ( + transactionMeta: TransactionMeta, + actionType: 'deposit' | 'withdrawal', + earnToken: EarnTokenDetails | undefined, + amountMinimalUnit?: string, + networkName?: string, +) => { + // Format transaction value if we have amount and token decimals + let transactionValue: string | undefined; + if (amountMinimalUnit && earnToken?.decimals !== undefined) { + const formattedAmount = renderFromTokenMinimalUnit( + amountMinimalUnit, + earnToken.decimals, + ); + transactionValue = `${formattedAmount} ${earnToken.symbol ?? ''}`.trim(); + } + + return { + action_type: actionType, + token: earnToken?.symbol, + network: networkName, + user_token_balance: earnToken?.balanceFormatted, + transaction_value: transactionValue, + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + transaction_id: transactionMeta.id, + transaction_type: transactionMeta.type, + }; +}; + +/** + * Get the MetaMetrics event for a given transaction event type. + * + * @param eventType - The transaction lifecycle event type + * @returns The corresponding MetaMetrics event + */ +export const getMetricsEvent = (eventType: TransactionEventType) => { + const eventMap = { + submitted: MetaMetricsEvents.EARN_TRANSACTION_SUBMITTED, + confirmed: MetaMetricsEvents.EARN_TRANSACTION_CONFIRMED, + rejected: MetaMetricsEvents.EARN_TRANSACTION_REJECTED, + dropped: MetaMetricsEvents.EARN_TRANSACTION_DROPPED, + failed: MetaMetricsEvents.EARN_TRANSACTION_FAILED, + }; + return eventMap[eventType]; +}; + diff --git a/app/components/UI/Earn/utils/tempLending.ts b/app/components/UI/Earn/utils/tempLending.ts index 7b6e8420d7bd..623cf1dc302b 100644 --- a/app/components/UI/Earn/utils/tempLending.ts +++ b/app/components/UI/Earn/utils/tempLending.ts @@ -477,11 +477,7 @@ export const generateLendingWithdrawalTransaction = ( encodedWithdrawalTransactionData, ); - // TODO: Remove type assertion once "lendingDeposit" is available from @metamask/transaction-controller - const txOptions = getTxOptions( - chainId, - 'lendingWithdrawal' as TransactionType, - ); + const txOptions = getTxOptions(chainId, TransactionType.lendingWithdraw); return { txParams, diff --git a/app/components/UI/Earn/utils/token-snapshot.ts b/app/components/UI/Earn/utils/token-snapshot.ts new file mode 100644 index 000000000000..92c5ed1c98e9 --- /dev/null +++ b/app/components/UI/Earn/utils/token-snapshot.ts @@ -0,0 +1,166 @@ +import { Token } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; +import Engine from '../../../../core/Engine'; +import Logger from '../../../../util/Logger'; +import { getDecimalChainId } from '../../../../util/networks'; +import { store } from '../../../../store'; +import { earnSelectors } from '../../../../selectors/earnController/earn'; +import { EarnTokenDetails } from '../types/lending.types'; + +const LOG_TAG = '[TokenSnapshot]'; + +export interface TokenSnapshotResult { + chainId: Hex; + address: string; + token: Token; +} + +/** + * Triggers fetch of token display data from TokenSearchDiscoveryDataController. + * Data becomes available in Engine.state.TokenSearchDiscoveryDataController.tokenDisplayData + * after the promise resolves. + * + * @param chainId - The chain ID (hex) + * @param address - The token address (hex) + */ +export async function fetchTokenSnapshot( + chainId: Hex, + address: Hex, +): Promise { + Logger.log(LOG_TAG, 'Fetching token snapshot', { chainId, address }); + try { + await Engine.context.TokenSearchDiscoveryDataController.fetchTokenDisplayData( + chainId, + address, + ); + Logger.log(LOG_TAG, '✓ Token snapshot fetch complete'); + } catch (error) { + Logger.log(LOG_TAG, '❌ Token snapshot fetch failed', { error }); + throw error; + } +} + +/** + * Reads token display data from controller state. + * Call this after fetchTokenSnapshot has resolved. + * + * @param chainId - The chain ID (hex) + * @param address - The token address (hex) + * @returns Token snapshot result or null if not found + */ +export function getTokenSnapshotFromState( + chainId: Hex, + address: Hex, +): TokenSnapshotResult | null { + const tokenDisplayData = + Engine.state.TokenSearchDiscoveryDataController.tokenDisplayData; + + const entry = tokenDisplayData.find( + (d) => + d.chainId === chainId && + d.address.toLowerCase() === address.toLowerCase() && + d.found === true, + ); + + if (!entry || !entry.found || !entry.token) { + Logger.log(LOG_TAG, 'Token snapshot not found in state', { + chainId, + address, + }); + return null; + } + + Logger.log(LOG_TAG, '✓ Token snapshot found', { + symbol: entry.token.symbol, + address: entry.address, + }); + + return { + chainId: entry.chainId as Hex, + address: entry.address, + token: entry.token, + }; +} + +export interface EarnTokenPairResult { + earnToken?: string; + outputToken?: string; +} + +/** + * Reads earn token pair from Redux store using the selector. + * Bypasses React render cycle to get the absolute latest derived data. + * + * @param chainId - The chain ID (hex) + * @param tokenAddress - The token address to look up + * @returns Earn token pair result with earnToken and/or outputToken + */ +export function getEarnTokenPairAddressesFromState( + chainId: Hex, + tokenAddress: string, +): EarnTokenPairResult { + const state = store.getState(); + + Logger.log( + LOG_TAG, + '[getEarnTokenPairAddressesFromState] Looking up markets', + chainId, + tokenAddress, + { + markets: JSON.stringify( + state.engine.backgroundState.EarnController.lending.markets.map( + (m) => ({ + chainId: m.chainId, + outputToken: m.outputToken.address, + underlying: m.underlying.address, + }), + ), + null, + 2, + ), + }, + ); + + const market = + state.engine.backgroundState.EarnController.lending.markets.find( + (m) => + m.chainId === Number(getDecimalChainId(chainId)) && + (m.outputToken.address.toLowerCase() === tokenAddress.toLowerCase() || + m.underlying.address.toLowerCase() === tokenAddress.toLowerCase()), + ); + + Logger.log(LOG_TAG, '[getEarnTokenPairFromState] Looking up token', { + chainId, + market, + markets: state.engine.backgroundState.EarnController.lending.markets, + }); + + // Try to find as earn token (underlying) + const earnToken = market?.underlying.address; + // Try to find as output token (receipt token like aToken) + const outputToken = market?.outputToken.address; + + if (earnToken) { + Logger.log( + LOG_TAG, + '[getEarnTokenPairFromState] ✓ Found as earn token', + earnToken, + ); + } else if (outputToken) { + Logger.log( + LOG_TAG, + '[getEarnTokenPairFromState] ✓ Found as output token', + outputToken, + ); + } else { + Logger.log( + LOG_TAG, + '[getEarnTokenPairFromState] ⚠️ Token not found in earn state', + ); + } + + return { + earnToken, + outputToken, + }; +} From 8557d04b5aeb10472b6f561b198f2d99e3ced1a4 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 18 Dec 2025 21:53:33 -0600 Subject: [PATCH 22/40] chore: mocking for build --- .../UI/Earn/utils/lending-transaction.test.ts | 393 ++++++++++++++++++ .../UI/Earn/utils/token-snapshot.test.ts | 117 ++++++ .../UI/Stake/sdk/stakeSdkProvider.tsx | 2 + .../hooks/useTokenHistoricalPrices.ts | 39 +- app/constants/tokens.ts | 34 ++ .../controllers/earn-controller-init.ts | 2 + app/selectors/assets/assets-list.ts | 80 +++- app/selectors/tokenListController.ts | 93 ++++- app/selectors/tokenRatesController.ts | 122 +++++- app/selectors/tokensController.ts | 44 +- 10 files changed, 888 insertions(+), 38 deletions(-) create mode 100644 app/components/UI/Earn/utils/lending-transaction.test.ts create mode 100644 app/components/UI/Earn/utils/token-snapshot.test.ts create mode 100644 app/constants/tokens.ts diff --git a/app/components/UI/Earn/utils/lending-transaction.test.ts b/app/components/UI/Earn/utils/lending-transaction.test.ts new file mode 100644 index 000000000000..3f2ec5b053fb --- /dev/null +++ b/app/components/UI/Earn/utils/lending-transaction.test.ts @@ -0,0 +1,393 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { ethers } from 'ethers'; +import { + AAVE_V3_SUPPLY_ABI, + AAVE_V3_WITHDRAW_ABI, + LENDING_TYPES, + getLendingTransactionInfo, + decodeLendingTransactionData, + extractUnderlyingTokenAddress, + getTrackEventProperties, + getMetricsEvent, + LendingTransactionInfo, +} from './lending-transaction'; +import { MetaMetricsEvents } from '../../../hooks/useMetrics'; +import { EARN_EXPERIENCES } from '../constants/experiences'; + +jest.mock('../../../../util/Logger', () => ({ + log: jest.fn(), +})); + +describe('lending-transaction utils', () => { + const UNDERLYING_TOKEN_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const CHAIN_ID = '0x1'; + + const createSupplyData = (): string => { + const contractInterface = new ethers.utils.Interface(AAVE_V3_SUPPLY_ABI); + return contractInterface.encodeFunctionData('supply', [ + UNDERLYING_TOKEN_ADDRESS, + '1000000', + '0x1230000000000000000000000000000000000456', + 0, + ]); + }; + + const createWithdrawData = (): string => { + const contractInterface = new ethers.utils.Interface(AAVE_V3_WITHDRAW_ABI); + return contractInterface.encodeFunctionData('withdraw', [ + UNDERLYING_TOKEN_ADDRESS, + '1000000', + '0x1230000000000000000000000000000000000456', + ]); + }; + + const createTransactionMeta = ( + type: TransactionType, + data?: string, + nestedTransactions?: { type: TransactionType; data?: string }[], + ): TransactionMeta => + ({ + id: 'test-tx-1', + type, + chainId: CHAIN_ID, + networkClientId: 'mainnet', + time: Date.now(), + txParams: { + from: '0x1230000000000000000000000000000000000456', + to: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + data, + }, + nestedTransactions, + }) as TransactionMeta; + + describe('LENDING_TYPES', () => { + it('contains lendingDeposit and lendingWithdraw types', () => { + expect(LENDING_TYPES).toContain(TransactionType.lendingDeposit); + expect(LENDING_TYPES).toContain(TransactionType.lendingWithdraw); + expect(LENDING_TYPES).toHaveLength(2); + }); + }); + + describe('getLendingTransactionInfo', () => { + describe('direct transactions', () => { + it('returns info for direct lendingDeposit transaction', () => { + const data = createSupplyData(); + const txMeta = createTransactionMeta( + TransactionType.lendingDeposit, + data, + ); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toEqual({ + type: TransactionType.lendingDeposit, + data, + }); + }); + + it('returns info for direct lendingWithdraw transaction', () => { + const data = createWithdrawData(); + const txMeta = createTransactionMeta( + TransactionType.lendingWithdraw, + data, + ); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toEqual({ + type: TransactionType.lendingWithdraw, + data, + }); + }); + + it('returns null for direct lending tx without data', () => { + const txMeta = createTransactionMeta(TransactionType.lendingDeposit); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toBeNull(); + }); + }); + + describe('batch transactions', () => { + it('extracts lendingDeposit from nested transactions', () => { + const depositData = createSupplyData(); + const txMeta = createTransactionMeta(TransactionType.batch, undefined, [ + { type: TransactionType.tokenMethodApprove, data: '0xapprove' }, + { type: TransactionType.lendingDeposit, data: depositData }, + ]); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toEqual({ + type: TransactionType.lendingDeposit, + data: depositData, + }); + }); + + it('extracts lendingWithdraw from nested transactions', () => { + const withdrawData = createWithdrawData(); + const txMeta = createTransactionMeta(TransactionType.batch, undefined, [ + { type: TransactionType.lendingWithdraw, data: withdrawData }, + ]); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toEqual({ + type: TransactionType.lendingWithdraw, + data: withdrawData, + }); + }); + + it('returns null for batch without lending nested tx', () => { + const txMeta = createTransactionMeta(TransactionType.batch, undefined, [ + { type: TransactionType.tokenMethodApprove, data: '0xapprove' }, + { type: TransactionType.swap, data: '0xswap' }, + ]); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toBeNull(); + }); + + it('returns null for batch with nested lending tx but no data', () => { + const txMeta = createTransactionMeta(TransactionType.batch, undefined, [ + { type: TransactionType.lendingDeposit }, + ]); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toBeNull(); + }); + }); + + describe('non-lending transactions', () => { + it('returns null for swap transaction', () => { + const txMeta = createTransactionMeta(TransactionType.swap, '0xdata'); + + const result = getLendingTransactionInfo(txMeta); + + expect(result).toBeNull(); + }); + }); + }); + + describe('decodeLendingTransactionData', () => { + it('decodes token address and amount from deposit (supply) data', () => { + const data = createSupplyData(); + const lendingInfo: LendingTransactionInfo = { + type: TransactionType.lendingDeposit, + data, + }; + + const result = decodeLendingTransactionData(lendingInfo); + + expect(result).not.toBeNull(); + expect(result?.tokenAddress.toLowerCase()).toBe( + UNDERLYING_TOKEN_ADDRESS.toLowerCase(), + ); + expect(result?.amountMinimalUnit).toBe('1000000'); + }); + + it('decodes token address and amount from withdraw data', () => { + const data = createWithdrawData(); + const lendingInfo: LendingTransactionInfo = { + type: TransactionType.lendingWithdraw, + data, + }; + + const result = decodeLendingTransactionData(lendingInfo); + + expect(result).not.toBeNull(); + expect(result?.tokenAddress.toLowerCase()).toBe( + UNDERLYING_TOKEN_ADDRESS.toLowerCase(), + ); + expect(result?.amountMinimalUnit).toBe('1000000'); + }); + + it('returns null for invalid data', () => { + const lendingInfo: LendingTransactionInfo = { + type: TransactionType.lendingDeposit, + data: '0xinvaliddata', + }; + + const result = decodeLendingTransactionData(lendingInfo); + + expect(result).toBeNull(); + }); + }); + + describe('extractUnderlyingTokenAddress (deprecated)', () => { + it('decodes token address from deposit (supply) data', () => { + const data = createSupplyData(); + const lendingInfo: LendingTransactionInfo = { + type: TransactionType.lendingDeposit, + data, + }; + + const result = extractUnderlyingTokenAddress(lendingInfo); + + expect(result?.toLowerCase()).toBe( + UNDERLYING_TOKEN_ADDRESS.toLowerCase(), + ); + }); + + it('decodes token address from withdraw data', () => { + const data = createWithdrawData(); + const lendingInfo: LendingTransactionInfo = { + type: TransactionType.lendingWithdraw, + data, + }; + + const result = extractUnderlyingTokenAddress(lendingInfo); + + expect(result?.toLowerCase()).toBe( + UNDERLYING_TOKEN_ADDRESS.toLowerCase(), + ); + }); + + it('returns null for invalid data', () => { + const lendingInfo: LendingTransactionInfo = { + type: TransactionType.lendingDeposit, + data: '0xinvaliddata', + }; + + const result = extractUnderlyingTokenAddress(lendingInfo); + + expect(result).toBeNull(); + }); + }); + + describe('getTrackEventProperties', () => { + it('builds correct properties for deposit with all data', () => { + const txMeta = createTransactionMeta( + TransactionType.lendingDeposit, + '0xdata', + ); + const earnToken = { + symbol: 'USDC', + address: UNDERLYING_TOKEN_ADDRESS, + decimals: 6, + balanceFormatted: '100.00 USDC', + }; + + const result = getTrackEventProperties( + txMeta, + 'deposit', + earnToken, + '1000000', + 'Ethereum Mainnet', + ); + + expect(result).toEqual({ + action_type: 'deposit', + token: 'USDC', + network: 'Ethereum Mainnet', + user_token_balance: '100.00 USDC', + transaction_value: '1 USDC', + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + transaction_id: 'test-tx-1', + transaction_type: TransactionType.lendingDeposit, + }); + }); + + it('builds correct properties for withdrawal with all data', () => { + const txMeta = createTransactionMeta( + TransactionType.lendingWithdraw, + '0xdata', + ); + const earnToken = { + symbol: 'USDC', + address: UNDERLYING_TOKEN_ADDRESS, + decimals: 6, + balanceFormatted: '50.00 USDC', + }; + + const result = getTrackEventProperties( + txMeta, + 'withdrawal', + earnToken, + '500000', + 'Arbitrum One', + ); + + expect(result).toEqual({ + action_type: 'withdrawal', + token: 'USDC', + network: 'Arbitrum One', + user_token_balance: '50.00 USDC', + transaction_value: '0.5 USDC', + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + transaction_id: 'test-tx-1', + transaction_type: TransactionType.lendingWithdraw, + }); + }); + + it('handles undefined earnToken', () => { + const txMeta = createTransactionMeta( + TransactionType.lendingDeposit, + '0xdata', + ); + + const result = getTrackEventProperties(txMeta, 'deposit', undefined); + + expect(result.token).toBeUndefined(); + expect(result.user_token_balance).toBeUndefined(); + expect(result.transaction_value).toBeUndefined(); + }); + + it('handles missing amount and network', () => { + const txMeta = createTransactionMeta( + TransactionType.lendingDeposit, + '0xdata', + ); + const earnToken = { + symbol: 'USDC', + address: UNDERLYING_TOKEN_ADDRESS, + decimals: 6, + balanceFormatted: '100.00 USDC', + }; + + const result = getTrackEventProperties(txMeta, 'deposit', earnToken); + + expect(result.network).toBeUndefined(); + expect(result.transaction_value).toBeUndefined(); + expect(result.user_token_balance).toBe('100.00 USDC'); + }); + }); + + describe('getMetricsEvent', () => { + it('returns correct event for submitted', () => { + expect(getMetricsEvent('submitted')).toBe( + MetaMetricsEvents.EARN_TRANSACTION_SUBMITTED, + ); + }); + + it('returns correct event for confirmed', () => { + expect(getMetricsEvent('confirmed')).toBe( + MetaMetricsEvents.EARN_TRANSACTION_CONFIRMED, + ); + }); + + it('returns correct event for rejected', () => { + expect(getMetricsEvent('rejected')).toBe( + MetaMetricsEvents.EARN_TRANSACTION_REJECTED, + ); + }); + + it('returns correct event for dropped', () => { + expect(getMetricsEvent('dropped')).toBe( + MetaMetricsEvents.EARN_TRANSACTION_DROPPED, + ); + }); + + it('returns correct event for failed', () => { + expect(getMetricsEvent('failed')).toBe( + MetaMetricsEvents.EARN_TRANSACTION_FAILED, + ); + }); + }); +}); diff --git a/app/components/UI/Earn/utils/token-snapshot.test.ts b/app/components/UI/Earn/utils/token-snapshot.test.ts new file mode 100644 index 000000000000..58ab91920971 --- /dev/null +++ b/app/components/UI/Earn/utils/token-snapshot.test.ts @@ -0,0 +1,117 @@ +import { Hex } from '@metamask/utils'; +import { + fetchTokenSnapshot, + getTokenSnapshotFromState, +} from './token-snapshot'; + +const mockFetchTokenDisplayData = jest.fn(); + +jest.mock('../../../../core/Engine', () => ({ + context: { + TokenSearchDiscoveryDataController: { + fetchTokenDisplayData: (...args: unknown[]) => + mockFetchTokenDisplayData(...args), + }, + }, + state: { + TokenSearchDiscoveryDataController: { + tokenDisplayData: [ + { + found: true, + chainId: '0x1' as Hex, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + currency: 'USD', + token: { + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + }, + { + found: false, + chainId: '0x1' as Hex, + address: '0x0000000000000000000000000000000000000000', + currency: 'USD', + }, + ], + }, + }, +})); + +jest.mock('../../../../util/Logger', () => ({ + log: jest.fn(), +})); + +describe('token-snapshot utils', () => { + const CHAIN_ID = '0x1' as Hex; + const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Hex; + const UNKNOWN_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchTokenSnapshot', () => { + it('calls TokenSearchDiscoveryDataController.fetchTokenDisplayData', async () => { + mockFetchTokenDisplayData.mockResolvedValueOnce(undefined); + + await fetchTokenSnapshot(CHAIN_ID, USDC_ADDRESS); + + expect(mockFetchTokenDisplayData).toHaveBeenCalledWith( + CHAIN_ID, + USDC_ADDRESS, + ); + }); + + it('throws error if fetch fails', async () => { + const error = new Error('Fetch failed'); + mockFetchTokenDisplayData.mockRejectedValueOnce(error); + + await expect(fetchTokenSnapshot(CHAIN_ID, USDC_ADDRESS)).rejects.toThrow( + 'Fetch failed', + ); + }); + }); + + describe('getTokenSnapshotFromState', () => { + it('returns token snapshot when found in state', () => { + const result = getTokenSnapshotFromState(CHAIN_ID, USDC_ADDRESS); + + expect(result).toEqual({ + chainId: CHAIN_ID, + address: USDC_ADDRESS, + token: { + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + address: USDC_ADDRESS, + }, + }); + }); + + it('returns null when token not found in state', () => { + const result = getTokenSnapshotFromState(CHAIN_ID, UNKNOWN_ADDRESS); + + expect(result).toBeNull(); + }); + + it('handles case-insensitive address matching', () => { + const lowercaseAddress = + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex; + + const result = getTokenSnapshotFromState(CHAIN_ID, lowercaseAddress); + + expect(result).not.toBeNull(); + expect(result?.token.symbol).toBe('USDC'); + }); + + it('returns null when entry has found: false', () => { + const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; + + const result = getTokenSnapshotFromState(CHAIN_ID, zeroAddress); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx index 911922d5e4ce..ef151b871ba8 100644 --- a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx +++ b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx @@ -1,5 +1,6 @@ import { EarnApiService, + EarnEnvironments, EarnSdk, LendingProvider, PooledStakingContract, @@ -63,6 +64,7 @@ export const StakeSDKProvider: React.FC< try { const sdk = await EarnSdk.create(provider, { + env: EarnEnvironments.DEV, chainId: getDecimalChainId(chainId), }); setSdkService(sdk); diff --git a/app/components/hooks/useTokenHistoricalPrices.ts b/app/components/hooks/useTokenHistoricalPrices.ts index 94cc17880040..a7795bb1dba1 100644 --- a/app/components/hooks/useTokenHistoricalPrices.ts +++ b/app/components/hooks/useTokenHistoricalPrices.ts @@ -11,6 +11,35 @@ import { TokenI } from '../UI/Tokens/types'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { selectLastSelectedSolanaAccount } from '../../selectors/accountsController'; +/** + * Temporary: aToken to underlying token address mapping for price API fallback. + * Used when aToken historical prices aren't available in price API. + * TODO: Remove once price API supports aToken prices. + */ +const ATOKEN_TO_UNDERLYING_FALLBACK: Record> = { + '1': { + // amUSD -> mUSD (Mainnet) + '0xaa0200d169ff3ba9385c12e073c5d1d30434ae7b': + '0xaca92e438df0b2401ff60da7e4337b687a2435da', + }, + '59144': { + // amUSD -> mUSD (Linea) + '0x61b19879f4033c2b5682a969cccc9141e022823c': + '0xaca92e438df0b2401ff60da7e4337b687a2435da', + }, +}; + +/** + * Gets the underlying token address for an aToken, or returns the original address. + */ +const getUnderlyingAddressForHistoricalPrices = ( + chainId: string, + address: string, +): string => { + const lowerAddress = address.toLowerCase(); + return ATOKEN_TO_UNDERLYING_FALLBACK[chainId]?.[lowerAddress] ?? address; +}; + export type TimePeriod = '1d' | '1w' | '7d' | '1m' | '3m' | '1y' | '3y' | 'all'; export type TokenPrice = [string, number]; @@ -120,10 +149,14 @@ const useTokenHistoricalPrices = ({ setPrices(transformedResult); } else { const baseUri = 'https://price.api.cx.metamask.io/v1'; + const decimalChainId = getDecimalChainId(chainId); + // Use underlying token address for aTokens (temporary until price API supports aTokens) + const priceAddress = getUnderlyingAddressForHistoricalPrices( + decimalChainId, + address, + ); const uri = new URL( - `${baseUri}/chains/${getDecimalChainId( - chainId, - )}/historical-prices/${address}`, + `${baseUri}/chains/${decimalChainId}/historical-prices/${priceAddress}`, ); uri.searchParams.set( 'timePeriod', diff --git a/app/constants/tokens.ts b/app/constants/tokens.ts new file mode 100644 index 000000000000..fef8548609c0 --- /dev/null +++ b/app/constants/tokens.ts @@ -0,0 +1,34 @@ +import { Hex } from '@metamask/utils'; + +/** + * Fallback metadata for aTokens not yet in the tokens API. + * Temporary until the API includes these tokens. + */ +export const ATOKEN_METADATA_FALLBACK: Record< + Hex, + Record +> = { + // Mainnet (chainId: 0x1) + '0x1': { + '0xaa0200d169ff3ba9385c12e073c5d1d30434ae7b': { + name: 'Aave v3 MUSD', + symbol: 'AMUSD', + decimals: 6, + }, + }, + // Linea Mainnet (chainId: 0xe708) + '0xe708': { + '0x61b19879f4033c2b5682a969cccc9141e022823c': { + name: 'Aave v3 MUSD', + symbol: 'AMUSD', + decimals: 6, + }, + }, +}; + +/** + * Check if a string looks like an address or is missing. + * Used to determine if token metadata needs fallback values. + */ +export const isAddressLikeOrMissing = (str: string | undefined): boolean => + !str || str.startsWith('0x') || str.length === 0; diff --git a/app/core/Engine/controllers/earn-controller-init.ts b/app/core/Engine/controllers/earn-controller-init.ts index e6d1efa60c41..135ed4ef7d8b 100644 --- a/app/core/Engine/controllers/earn-controller-init.ts +++ b/app/core/Engine/controllers/earn-controller-init.ts @@ -4,6 +4,7 @@ import { EarnControllerMessenger, } from '@metamask/earn-controller'; import { EarnControllerInitMessenger } from '../messengers/earn-controller-messenger'; +import { EarnEnvironments } from '@metamask/stake-sdk'; /** * Initialize the earn controller. @@ -21,6 +22,7 @@ export const earnControllerInit: ControllerInitFunction< const transactionController = getController('TransactionController'); const controller = new EarnController({ + env: EarnEnvironments.DEV, messenger: controllerMessenger, addTransactionFn: transactionController.addTransaction.bind( transactionController, diff --git a/app/selectors/assets/assets-list.ts b/app/selectors/assets/assets-list.ts index 1cc7e8909fa3..3109ede191fd 100644 --- a/app/selectors/assets/assets-list.ts +++ b/app/selectors/assets/assets-list.ts @@ -27,6 +27,12 @@ import { TronResourceSymbol, } from '../../core/Multichain/constants'; import { sortAssetsWithPriority } from '../../components/UI/Tokens/util/sortAssetsWithPriority'; +import { selectTokenMarketData } from '../tokenRatesController'; +import { selectERC20TokensByChain } from '../tokenListController'; +import { + ATOKEN_METADATA_FALLBACK, + isAddressLikeOrMissing, +} from '../../constants/tokens'; const getStateForAssetSelector = (state: RootState) => { const { @@ -67,6 +73,8 @@ const getStateForAssetSelector = (state: RootState) => { ...TokensController, ...TokenBalancesController, ...TokenRatesController, + // Override marketData with enriched version that includes aToken price fallbacks + marketData: selectTokenMarketData(state), ...multichainState, ...CurrencyRateController, ...NetworkController, @@ -84,11 +92,49 @@ const getStateForAssetSelector = (state: RootState) => { }; }; -export const selectAssetsBySelectedAccountGroup = createDeepEqualSelector( +const selectAssetsBySelectedAccountGroupRaw = createDeepEqualSelector( getStateForAssetSelector, (assetsState) => _selectAssetsBySelectedAccountGroup(assetsState), ); +/** + * Enriched assets selector that adds fallback metadata for aTokens. + * Patches name/symbol for aTokens not yet in the tokens API. + */ +export const selectAssetsBySelectedAccountGroup = createDeepEqualSelector( + selectAssetsBySelectedAccountGroupRaw, + (assets): Record => { + const enriched: Record = {}; + + for (const [chainId, chainAssets] of Object.entries(assets)) { + const fallbacksForChain = ATOKEN_METADATA_FALLBACK[chainId as Hex]; + + if (!fallbacksForChain) { + enriched[chainId] = chainAssets; + continue; + } + + enriched[chainId] = chainAssets.map((asset) => { + const addressKey = + 'address' in asset ? asset.address?.toLowerCase() : undefined; + const fallback = addressKey ? fallbacksForChain[addressKey] : undefined; + + if (fallback) { + return { + ...asset, + name: fallback.name, + symbol: fallback.symbol, + decimals: asset.decimals ?? fallback.decimals, + }; + } + return asset; + }); + } + + return enriched; + }, +); + // BIP44 MAINTENANCE: Add these items at controller level, but have them being optional on selectAssetsBySelectedAccountGroup to avoid breaking changes const selectStakedAssets = createDeepEqualSelector( [ @@ -258,8 +304,8 @@ export const selectAsset = createSelector( [ selectAssetsBySelectedAccountGroup, selectStakedAssets, - (state: RootState) => - state.engine.backgroundState.TokenListController.tokensChainsCache, + // Use enriched selector for aToken metadata fallback + selectERC20TokensByChain, ( _state: RootState, params: { address: string; chainId: string; isStaked?: boolean }, @@ -298,16 +344,28 @@ function assetToToken( asset: Asset & { isStaked?: boolean }, tokensChainsCache: TokenListState['tokensChainsCache'], ): TokenI { + // Get fallback metadata from token list cache as additional safety net + const cachedTokenData = + 'address' in asset + ? tokensChainsCache[asset.chainId]?.data[asset.address.toLowerCase()] + : undefined; + + // Asset should already be enriched via selectAssetsBySelectedAccountGroup, + // but fall back to cache if still missing + const finalName = !isAddressLikeOrMissing(asset.name) + ? asset.name + : cachedTokenData?.name || ''; + const finalSymbol = !isAddressLikeOrMissing(asset.symbol) + ? asset.symbol + : cachedTokenData?.symbol || ''; + return { address: asset.assetId, - aggregators: - ('address' in asset && - tokensChainsCache[asset.chainId]?.data[asset.address]?.aggregators) || - [], - decimals: asset.decimals, + aggregators: cachedTokenData?.aggregators || [], + decimals: asset.decimals ?? cachedTokenData?.decimals ?? 18, image: asset.image, - name: asset.name, - symbol: asset.symbol, + name: finalName, + symbol: finalSymbol, balance: formatWithThreshold( parseFloat(asset.balance), oneHundredThousandths, @@ -335,7 +393,7 @@ function assetToToken( isStaked: asset.isStaked || false, chainId: asset.chainId, isNative: asset.isNative, - ticker: asset.symbol, + ticker: finalSymbol, accountType: asset.accountType, }; } diff --git a/app/selectors/tokenListController.ts b/app/selectors/tokenListController.ts index 787de3fdd356..0609b1acac43 100644 --- a/app/selectors/tokenListController.ts +++ b/app/selectors/tokenListController.ts @@ -1,22 +1,101 @@ import { createSelector } from 'reselect'; -import { TokenListState } from '@metamask/assets-controllers'; +import { TokenListState, TokenListToken } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; import { RootState } from '../reducers'; import { tokenListToArray } from '../util/tokens'; import { createDeepEqualSelector } from '../selectors/util'; import { selectEvmChainId } from './networkController'; +/** + * Fallback metadata for aTokens not yet in the tokens API. + * Temporary until the API includes these tokens. + */ + +const ATOKEN_METADATA_FALLBACK: Record< + Hex, + Record> +> = { + // Mainnet (chainId: 0x1) + '0x1': { + '0xaa0200d169ff3ba9385c12e073c5d1d30434ae7b': { + name: 'Aave v3 MUSD', + symbol: 'AMUSD', + decimals: 6, + }, + }, + // Linea Mainnet (chainId: 0xe708) + '0xe708': { + '0x61b19879f4033c2b5682a969cccc9141e022823c': { + name: 'Aave v3 MUSD', + symbol: 'AMUSD', + decimals: 6, + }, + }, +}; + const selectTokenListConstrollerState = (state: RootState) => state.engine.backgroundState.TokenListController; /** - * Return token list from TokenListController. + * Enriches tokensChainsCache with fallback metadata for aTokens. + * Adds missing token metadata using ATOKEN_METADATA_FALLBACK. + */ +const selectTokensChainsCacheEnriched = createSelector( + selectTokenListConstrollerState, + (tokenListControllerState): TokenListState['tokensChainsCache'] => { + const rawCache = tokenListControllerState?.tokensChainsCache; + if (!rawCache) return rawCache; + + const enrichedCache = { ...rawCache }; + + for (const [chainId, fallbackTokens] of Object.entries( + ATOKEN_METADATA_FALLBACK, + )) { + const hexChainId = chainId as Hex; + + // Create chain entry if it doesn't exist + if (!enrichedCache[hexChainId]) { + enrichedCache[hexChainId] = { + timestamp: Date.now(), + data: {}, + }; + } else if (!enrichedCache[hexChainId].data) { + enrichedCache[hexChainId] = { + ...enrichedCache[hexChainId], + data: {}, + }; + } else { + // Clone chain data to avoid mutation + enrichedCache[hexChainId] = { + ...enrichedCache[hexChainId], + data: { ...enrichedCache[hexChainId].data }, + }; + } + + for (const [address, metadata] of Object.entries(fallbackTokens)) { + const lowercaseAddress = address.toLowerCase(); + // Only add if not already present + if (!enrichedCache[hexChainId].data[lowercaseAddress]) { + enrichedCache[hexChainId].data[lowercaseAddress] = { + address: lowercaseAddress, + ...metadata, + } as TokenListToken; + } + } + } + + return enrichedCache; + }, +); + +/** + * Return token list from TokenListController with fallback metadata. * Can pass directly into useSelector. */ export const selectTokenList = createSelector( - selectTokenListConstrollerState, + selectTokensChainsCacheEnriched, selectEvmChainId, - (tokenListControllerState: TokenListState, chainId) => - tokenListControllerState?.tokensChainsCache?.[chainId]?.data || [], + (tokensChainsCache, chainId) => tokensChainsCache?.[chainId]?.data || [], ); /** @@ -29,8 +108,8 @@ export const selectTokenListArray = createDeepEqualSelector( ); const selectERC20TokensByChainInternal = createDeepEqualSelector( - selectTokenListConstrollerState, - (tokenListControllerState) => tokenListControllerState?.tokensChainsCache, + selectTokensChainsCacheEnriched, + (tokensChainsCache) => tokensChainsCache, ); export const selectERC20TokensByChain = createDeepEqualSelector( diff --git a/app/selectors/tokenRatesController.ts b/app/selectors/tokenRatesController.ts index 803f5ef56a86..4c594b44b5ab 100644 --- a/app/selectors/tokenRatesController.ts +++ b/app/selectors/tokenRatesController.ts @@ -5,6 +5,29 @@ import { RootState } from '../reducers'; import { selectEvmChainId } from './networkController'; import { Hex } from '@metamask/utils'; import { createDeepEqualSelector } from './util'; +import { toChecksumAddress } from '../util/address'; + +/** + * Mapping of aToken addresses to their underlying token addresses. + * Used as price fallback when aToken price data is unavailable from the API. + * aTokens maintain 1:1 value with underlying assets. + * + * This is a temporary solution until the price API includes aToken prices. + */ +const ATOKEN_TO_UNDERLYING_MAP: Record> = { + // Mainnet (chainId: 0x1) + '0x1': { + // amUSD -> mUSD + '0xaa0200d169ff3ba9385c12e073c5d1d30434ae7b': + '0xaca92e438df0b2401ff60da7e4337b687a2435da', + }, + // Linea Mainnet (chainId: 0xe708) + '0xe708': { + // amUSD -> mUSD + '0x61b19879f4033c2b5682a969cccc9141e022823c': + '0xaca92e438df0b2401ff60da7e4337b687a2435da', + }, +}; /** * utility similar to lodash.mapValues. @@ -24,6 +47,72 @@ const mapValues = ( const selectTokenRatesControllerState = (state: RootState) => state.engine.backgroundState.TokenRatesController; +/** + * Finds token price data using case-insensitive address lookup. + * Raw market data may have addresses in various cases, so we normalize for comparison. + */ +const getTokenPriceData = ( + chainMarketData: Record | undefined, + address: string, +) => { + if (!chainMarketData) return undefined; + const lowercaseAddress = address.toLowerCase(); + // Find the key that matches case-insensitively + const matchingKey = Object.keys(chainMarketData).find( + (key) => key.toLowerCase() === lowercaseAddress, + ); + return matchingKey + ? (chainMarketData[matchingKey] as { price?: number } | undefined) + : undefined; +}; + +/** + * Enriched market data selector that adds aToken price fallbacks. + * For aTokens without direct price data, uses the underlying token's price. + */ +export const selectTokenMarketData = createSelector( + selectTokenRatesControllerState, + (tokenRatesControllerState: TokenRatesControllerState) => { + const rawMarketData = tokenRatesControllerState.marketData; + const enrichedMarketData = { ...rawMarketData }; + + for (const [chainId, aTokenMappings] of Object.entries( + ATOKEN_TO_UNDERLYING_MAP, + )) { + const hexChainId = chainId as Hex; + const chainMarketData = enrichedMarketData[hexChainId]; + if (!chainMarketData) continue; + + enrichedMarketData[hexChainId] = { ...chainMarketData }; + + for (const [aTokenAddress, underlyingAddress] of Object.entries( + aTokenMappings, + )) { + // Skip if aToken already has price data + const existingATokenData = getTokenPriceData( + enrichedMarketData[hexChainId], + aTokenAddress, + ); + if (existingATokenData?.price !== undefined) continue; + + // Copy underlying token's price data to aToken + const underlyingTokenData = getTokenPriceData( + enrichedMarketData[hexChainId], + underlyingAddress, + ); + if (underlyingTokenData?.price !== undefined) { + const checksummedAToken = toChecksumAddress(aTokenAddress) as Hex; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (enrichedMarketData[hexChainId] as any)[checksummedAToken] = + underlyingTokenData; + } + } + } + + return enrichedMarketData; + }, +); + export const selectContractExchangeRates = createSelector( selectEvmChainId, selectTokenRatesControllerState, @@ -32,16 +121,8 @@ export const selectContractExchangeRates = createSelector( ); export const selectContractExchangeRatesByChainId = createSelector( - selectTokenRatesControllerState, - (_state: RootState, chainId: Hex) => chainId, - (tokenRatesControllerState: TokenRatesControllerState, chainId: Hex) => - tokenRatesControllerState.marketData[chainId], -); - -export const selectTokenMarketData = createSelector( - selectTokenRatesControllerState, - (tokenRatesControllerState: TokenRatesControllerState) => - tokenRatesControllerState.marketData, + [selectTokenMarketData, (_state: RootState, chainId: Hex) => chainId], + (marketData, chainId) => marketData?.[chainId], ); export function selectPricePercentChange1d( @@ -50,22 +131,31 @@ export function selectPricePercentChange1d( tokenAddress: Hex, ) { const marketData = selectTokenMarketData(state); - const pricePercentage1d: number | undefined = - marketData?.[chainId]?.[tokenAddress]?.pricePercentChange1d; - return pricePercentage1d; + const chainData = marketData?.[chainId]; + // Use case-insensitive lookup for consistent behavior + const tokenData = getTokenPriceData(chainData, tokenAddress) as + | { pricePercentChange1d?: number } + | undefined; + return tokenData?.pricePercentChange1d; } export const selectSingleTokenPriceMarketData = createSelector( [ (state: RootState, chainId: Hex, tokenAddress: Hex) => { const marketData = selectTokenMarketData(state); - const price = marketData?.[chainId]?.[tokenAddress]?.price; - return price; + const chainData = marketData?.[chainId]; + // Use case-insensitive lookup since enriched data uses checksummed keys + const tokenData = getTokenPriceData(chainData, tokenAddress); + return tokenData?.price; }, (_state: RootState, chainId: Hex) => chainId, (_state: RootState, _chainId: Hex, tokenAddress: Hex) => tokenAddress, ], - (price, _chainId, address) => (price ? { [address]: { price } } : {}), + (price, _chainId, address) => { + // Return with checksummed key to match lookup in deriveBalanceFromAssetMarketDetails + const checksummedAddress = toChecksumAddress(address) as Hex; + return price ? { [checksummedAddress]: { price } } : {}; + }, { memoize: weakMapMemoize, argsMemoize: weakMapMemoize, diff --git a/app/selectors/tokensController.ts b/app/selectors/tokensController.ts index e86eb0035851..9d7ce09b9d3e 100644 --- a/app/selectors/tokensController.ts +++ b/app/selectors/tokensController.ts @@ -12,6 +12,7 @@ import { } from './networkController'; import { PopularList } from '../util/networks/customNetworks'; import { ChainId } from '@metamask/controller-utils'; +import { ATOKEN_METADATA_FALLBACK } from '../constants/tokens'; const selectTokensControllerState = (state: RootState) => state?.engine?.backgroundState?.TokensController; @@ -88,12 +89,53 @@ export const selectDetectedTokens = createSelector( ], ); -export const selectAllTokens = createDeepEqualSelector( +const selectAllTokensRaw = createDeepEqualSelector( selectTokensControllerState, (tokensControllerState: TokensControllerState) => tokensControllerState?.allTokens, ); +/** + * Enriched selectAllTokens that applies fallback metadata for aTokens. + * This ensures aTokens have correct name/symbol even when API data is missing. + */ +export const selectAllTokens = createDeepEqualSelector( + selectAllTokensRaw, + (allTokens): TokensControllerState['allTokens'] => { + if (!allTokens) return allTokens; + + const enriched: TokensControllerState['allTokens'] = {}; + + for (const [chainId, addressTokens] of Object.entries(allTokens)) { + const fallbacksForChain = ATOKEN_METADATA_FALLBACK[chainId as Hex]; + + if (!fallbacksForChain) { + enriched[chainId as Hex] = addressTokens; + continue; + } + + // Enrich tokens for each address on this chain + enriched[chainId as Hex] = {}; + for (const [address, tokens] of Object.entries(addressTokens)) { + enriched[chainId as Hex][address as Hex] = tokens.map((token) => { + const fallback = fallbacksForChain[token.address?.toLowerCase()]; + if (fallback) { + return { + ...token, + name: fallback.name, + symbol: fallback.symbol, + decimals: token.decimals ?? fallback.decimals, + }; + } + return token; + }); + } + } + + return enriched; + }, +); + export const getChainIdsToPoll = createDeepEqualSelector( selectEvmNetworkConfigurationsByChainId, selectEvmChainId, From ae9ba90da1b7474769f3a613829f51b1366f904f Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Fri, 19 Dec 2025 05:25:19 -0600 Subject: [PATCH 23/40] chore: updates the deposit confirmation view to match old flows view --- .../Views/EarnInputView/EarnInputView.tsx | 18 +- .../components/info-root/info-root.tsx | 20 ++ .../info/lending-deposit-info/index.ts | 5 + .../lending-deposit-info.tsx | 124 ++++++++++ .../lending-details.styles.ts | 19 ++ .../lending-deposit-info/lending-details.tsx | 160 +++++++++++++ .../lending-hero.styles.ts | 33 +++ .../lending-deposit-info/lending-hero.tsx | 110 +++++++++ .../lending-receive-section.styles.ts | 31 +++ .../lending-receive-section.tsx | 87 +++++++ .../useLendingDepositDetails.ts | 224 ++++++++++++++++++ .../Views/confirmations/constants/info-ids.ts | 1 + 12 files changed, 826 insertions(+), 6 deletions(-) create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/index.ts create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/lending-deposit-info.tsx create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.styles.ts create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.tsx create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.styles.ts create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx create mode 100644 app/components/Views/confirmations/components/info/lending-deposit-info/useLendingDepositDetails.ts diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index 1a9ffe86908c..6a3a30f008a7 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -32,7 +32,7 @@ import { selectNetworkConfigurationByChainId, selectDefaultEndpointByChainId, } from '../../../../../selectors/networkController'; -import { selectContractExchangeRatesByChainId } from '../../../../../selectors/tokenRatesController'; +import { selectTokenMarketDataByChainId } from '../../../../../selectors/tokenRatesController'; import { getDecimalChainId } from '../../../../../util/networks'; import { addTransactionBatch } from '../../../../../util/transaction-controller'; import Keypad from '../../../../Base/Keypad'; @@ -74,6 +74,8 @@ import { trace, TraceName } from '../../../../../util/trace'; import { useEndTraceOnMount } from '../../../../hooks/useEndTraceOnMount'; import { EVM_SCOPE } from '../../constants/networks'; import { selectConfirmationRedesignFlags } from '../../../../../selectors/featureFlagController/confirmations'; +import { ConfirmationLoader } from '../../../../Views/confirmations/components/confirm/confirm-component'; +import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useConfirmNavigation'; ///: BEGIN:ONLY_INCLUDE_IF(tron) import useTronStake from '../../hooks/useTronStake'; import useTronStakeApy from '../../hooks/useTronStakeApy'; @@ -86,6 +88,7 @@ const EarnInputView = () => { // navigation hooks const navigation = useNavigation(); const route = useRoute(); + const { navigateToConfirmation } = useConfirmNavigation(); const { token } = route.params; // We want to keep track of the last quick amount pressed before navigating to review. @@ -102,7 +105,7 @@ const EarnInputView = () => { ); const conversionRate = useSelector(selectConversionRate) ?? 1; const contractExchangeRates = useSelector((state: RootState) => - selectContractExchangeRatesByChainId(state, token?.chainId as Hex), + selectTokenMarketDataByChainId(state, token?.chainId as Hex), ); const network = useSelector((state: RootState) => selectNetworkConfigurationByChainId(state, token?.chainId as Hex), @@ -434,6 +437,12 @@ const EarnInputView = () => { type: TransactionType.lendingDeposit, }; + // Navigate first so the loader shows while the transaction batch is being added + navigateToConfirmation({ + loader: ConfirmationLoader.Default, + stack: 'StakeScreens', + }); + addTransactionBatch({ from: (selectedAccount?.address as Hex) || '0x', networkClientId, @@ -441,10 +450,6 @@ const EarnInputView = () => { transactions: [approveTx, lendingDepositTx], requireApproval: true, }); - - navigation.navigate('StakeScreens', { - screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, - }); }; const createLegacyLendingDepositConfirmation = ( @@ -491,6 +496,7 @@ const EarnInputView = () => { amountTokenMinimalUnit, networkClientId, navigation, + navigateToConfirmation, token, amountFiatNumber, annualRewardsToken, diff --git a/app/components/Views/confirmations/components/info-root/info-root.tsx b/app/components/Views/confirmations/components/info-root/info-root.tsx index 85cd6e2d29f7..e87e2d20c2d6 100644 --- a/app/components/Views/confirmations/components/info-root/info-root.tsx +++ b/app/components/Views/confirmations/components/info-root/info-root.tsx @@ -8,6 +8,7 @@ import StakingDeposit from '../../external/staking/info/staking-deposit'; import StakingWithdrawal from '../../external/staking/info/staking-withdrawal'; import { use7702TransactionType } from '../../hooks/7702/use7702TransactionType'; import { useTransactionMetadataRequest } from '../../hooks/transactions/useTransactionMetadataRequest'; +import { useTransactionBatchesMetadata } from '../../hooks/transactions/useTransactionBatchesMetadata'; import useApprovalRequest from '../../hooks/useApprovalRequest'; import ContractInteraction from '../info/contract-interaction'; import PersonalSign from '../info/personal-sign'; @@ -25,6 +26,7 @@ import { hasTransactionType } from '../../utils/transaction'; import { PredictClaimInfo } from '../info/predict-claim-info'; import { PredictWithdrawInfo } from '../info/predict-withdraw-info'; import { MusdConversionInfo } from '../info/musd-conversion-info'; +import { LendingDepositInfo } from '../info/lending-deposit-info'; interface ConfirmationInfoComponentRequest { signatureRequestVersion?: string; @@ -77,6 +79,7 @@ interface InfoProps { const Info = ({ route }: InfoProps) => { const { approvalRequest } = useApprovalRequest(); const transactionMetadata = useTransactionMetadataRequest(); + const transactionBatchesMetadata = useTransactionBatchesMetadata(); const { isSigningQRObject } = useQRHardwareContext(); const { isDowngrade, isUpgradeOnly } = use7702TransactionType(); @@ -99,6 +102,23 @@ const Info = ({ route }: InfoProps) => { return ; } + if ( + transactionMetadata && + hasTransactionType(transactionMetadata, [TransactionType.lendingDeposit]) + ) { + return ; + } + + // Check for lending deposit in batch transactions (non-7702 chains like Linea) + if ( + approvalRequest?.type === ApprovalType.TransactionBatch && + transactionBatchesMetadata?.transactions?.some( + (tx) => tx.type === TransactionType.lendingDeposit, + ) + ) { + return ; + } + if ( transactionMetadata && hasTransactionType(transactionMetadata, [TransactionType.predictDeposit]) diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/index.ts b/app/components/Views/confirmations/components/info/lending-deposit-info/index.ts new file mode 100644 index 000000000000..b32b3db77cb0 --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/index.ts @@ -0,0 +1,5 @@ +export { + default as LendingDepositInfo, + LendingDepositInfoSkeleton, +} from './lending-deposit-info'; +export { useLendingDepositDetails } from './useLendingDepositDetails'; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-deposit-info.tsx b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-deposit-info.tsx new file mode 100644 index 000000000000..560effea84d0 --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-deposit-info.tsx @@ -0,0 +1,124 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { strings } from '../../../../../../../locales/i18n'; +import { Skeleton } from '../../../../../../component-library/components/Skeleton'; +import { EARN_EXPERIENCES } from '../../../../../UI/Earn/constants/experiences'; +import { EVENT_PROVIDERS } from '../../../../../UI/Stake/constants/events'; +import { ConfirmationInfoComponentIDs } from '../../../constants/info-ids'; +import { useConfirmationMetricEvents } from '../../../hooks/metrics/useConfirmationMetricEvents'; +import useClearConfirmationOnBackSwipe from '../../../hooks/ui/useClearConfirmationOnBackSwipe'; +import useNavbar from '../../../hooks/ui/useNavbar'; +import AdvancedDetailsRow from '../../rows/transactions/advanced-details-row/advanced-details-row'; +import GasFeesDetailsRow from '../../rows/transactions/gas-fee-details-row'; +import LendingDetails from './lending-details'; +import LendingHero from './lending-hero'; +import LendingReceiveSection from './lending-receive-section'; +import { useLendingDepositDetails } from './useLendingDepositDetails'; + +const skeletonStyles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + }, + heroContainer: { + alignItems: 'center', + paddingVertical: 24, + }, + tokenSkeleton: { + borderRadius: 32, + }, + amountSkeleton: { + borderRadius: 6, + marginTop: 16, + }, + fiatSkeleton: { + borderRadius: 4, + marginTop: 8, + }, + sectionSkeleton: { + borderRadius: 8, + marginTop: 16, + }, +}); + +export function LendingDepositInfoSkeleton() { + return ( + + {/* Hero skeleton */} + + + + + + {/* Details section skeleton */} + + {/* Receive section skeleton */} + + + ); +} + +const LendingDepositInfo = () => { + useClearConfirmationOnBackSwipe(); + + const details = useLendingDepositDetails(); + const title = `${strings('earn.supply')} ${details?.tokenSymbol ?? ''}`; + useNavbar(title); + const { trackPageViewedEvent, setConfirmationMetric } = + useConfirmationMetricEvents(); + + useEffect(() => { + if (!details?.tokenAmount) { + return; + } + + setConfirmationMetric({ + properties: { + selected_provider: EVENT_PROVIDERS.CONSENSYS, + transaction_amount: details.tokenAmount, + token: details.tokenSymbol, + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + }, + }); + }, [details?.tokenAmount, details?.tokenSymbol, setConfirmationMetric]); + + useEffect(() => { + trackPageViewedEvent(); + }, [trackPageViewedEvent]); + + // // Show spinner while loading (maintains visual continuity with parent loader) + // if (!details) { + // return ( + // + // + // + // ); + // } + + return ( + + + + + + + + ); +}; + +export default LendingDepositInfo; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts new file mode 100644 index 000000000000..fc0b42fc840f --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts @@ -0,0 +1,19 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + infoSectionContent: { + paddingVertical: 4, + paddingHorizontal: 8, + gap: 16, + }, + estAnnualReward: { + flexDirection: 'row', + gap: 8, + }, + aprTooltipContentContainer: { + gap: 8, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx new file mode 100644 index 000000000000..7203f09c0f30 --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { strings } from '../../../../../../../locales/i18n'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../../component-library/hooks'; +import KeyValueRow, { + TooltipSizes, +} from '../../../../../../component-library/components-temp/KeyValueRow'; +import ContractTag from '../../../../../UI/Stake/components/StakingConfirmation/ContractTag/ContractTag'; +import styleSheet from './lending-details.styles'; +import { useLendingDepositDetails } from './useLendingDepositDetails'; +import InfoSection from '../../UI/info-row/info-section/info-section'; +import { selectAvatarAccountType } from '../../../../../../selectors/settings'; + +const LENDING_DETAILS_TEST_ID = 'lending-details'; + +const LendingDetails = () => { + const { styles } = useStyles(styleSheet, {}); + const details = useLendingDepositDetails(); + const avatarAccountType = useSelector(selectAvatarAccountType); + + if (!details) { + return null; + } + + const { + apr, + annualRewardsFiat, + annualRewardsToken, + rewardFrequency, + withdrawalTime, + protocol, + protocolContractAddress, + } = details; + + return ( + + + {/* APR */} + + {strings('earn.tooltip_content.apr.part_one')} + {strings('earn.tooltip_content.apr.part_two')} + + ), + size: TooltipSizes.Sm, + }, + }} + value={{ + label: { + text: `${apr}%`, + variant: TextVariant.BodyMD, + color: TextColor.Success, + }, + }} + /> + + {/* Estimated Annual Reward */} + + {annualRewardsFiat} + + {annualRewardsToken} + + + ), + }} + /> + + {/* Reward Frequency */} + + + {/* Withdrawal Time */} + + + {/* Protocol */} + + ), + }} + /> + + + ); +}; + +export default LendingDetails; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.styles.ts b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.styles.ts new file mode 100644 index 000000000000..580d29fa5211 --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.styles.ts @@ -0,0 +1,33 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + container: { + paddingVertical: 16, + }, + assetAmountContainer: { + paddingTop: 8, + }, + assetAmountText: { + textAlign: 'center', + }, + assetFiatConversionText: { + textAlign: 'center', + color: theme.colors.text.alternative, + }, + networkAndTokenContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + networkAvatar: { + width: 24, + height: 24, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.tsx b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.tsx new file mode 100644 index 000000000000..9a548d8e4c3d --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-hero.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { View } from 'react-native'; +import { BigNumber } from 'bignumber.js'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../../../../component-library/components/Badges/Badge'; +import NetworkAssetLogo from '../../../../../UI/NetworkAssetLogo'; +import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; +import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; +import { renderFromTokenMinimalUnit } from '../../../../../../util/number'; +import Text, { + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import { getNetworkImageSource } from '../../../../../../util/networks'; +import { useStyles } from '../../../../../../component-library/hooks'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import styleSheet from './lending-hero.styles'; +import { useLendingDepositDetails } from './useLendingDepositDetails'; +import { TokenI } from '../../../../../UI/Tokens/types'; + +const TokenAvatar = ({ token }: { token: Partial }) => { + const { styles } = useStyles(styleSheet, {}); + + const testId = `earn-token-selector-${token.symbol}-${token.chainId}`; + + if (token.isNative) { + return ( + + ); + } + + return ( + + ); +}; + +const NetworkAndTokenImage = ({ token }: { token: Partial }) => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + } + > + + + + ); +}; + +const LendingHero = () => { + const { styles } = useStyles(styleSheet, {}); + const details = useLendingDepositDetails(); + const fiatFormatter = useFiatFormatter(); + + if (!details) { + return null; + } + + const { token, amountMinimalUnit, tokenDecimals, tokenFiat } = details; + + const displayTokenAmount = renderFromTokenMinimalUnit( + amountMinimalUnit, + tokenDecimals, + ); + + // Format fiat using the fiat formatter (handles locale, currency symbol, etc.) + const formattedFiat = fiatFormatter(new BigNumber(tokenFiat)); + + return ( + + + + + {displayTokenAmount} {token.symbol} + + + {formattedFiat} + + + + ); +}; + +export default LendingHero; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.styles.ts b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.styles.ts new file mode 100644 index 000000000000..41eb93a06aba --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.styles.ts @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + infoSectionContent: { + paddingVertical: 4, + paddingHorizontal: 8, + gap: 16, + }, + receiveRow: { + flexDirection: 'row', + }, + receiveTokenIcon: { + marginRight: 8, + }, + receiptTokenRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'baseline', + }, + receiptTokenRowLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + receiptTokenRowRight: { + justifyContent: 'center', + alignItems: 'flex-end', + }, + }); + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx new file mode 100644 index 000000000000..f8ed029652cc --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +import { View } from 'react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../../component-library/hooks'; +import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; +import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../../../../component-library/components/Buttons/ButtonIcon'; +import { + IconColor, + IconName, +} from '../../../../../../component-library/components/Icons/Icon'; +import InfoSection from '../../UI/info-row/info-section/info-section'; +import styleSheet from './lending-receive-section.styles'; +import { useLendingDepositDetails } from './useLendingDepositDetails'; +import useTooltipModal from '../../../../../hooks/useTooltipModal'; + +export const LENDING_RECEIVE_SECTION_TEST_ID = 'lending-receive-section'; + +const LendingReceiveSection = () => { + const { styles } = useStyles(styleSheet, {}); + const details = useLendingDepositDetails(); + const { openTooltipModal } = useTooltipModal(); + + const handleReceiveTooltip = useCallback(() => { + openTooltipModal( + strings('earn.receive'), + strings('earn.tooltip_content.receive'), + ); + }, [openTooltipModal]); + + if (!details) { + return null; + } + + const { + receiptTokenName, + receiptTokenAmount, + receiptTokenAmountFiat, + receiptTokenImage, + } = details; + + return ( + + + + + {strings('earn.receive')} + + + + + + + {receiptTokenName} + + + {receiptTokenAmount} + {receiptTokenAmountFiat} + + + + + ); +}; + +export default LendingReceiveSection; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/useLendingDepositDetails.ts b/app/components/Views/confirmations/components/info/lending-deposit-info/useLendingDepositDetails.ts new file mode 100644 index 000000000000..b1ce3b55c734 --- /dev/null +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/useLendingDepositDetails.ts @@ -0,0 +1,224 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { RootState } from '../../../../../../reducers'; +import { selectCurrentCurrency } from '../../../../../../selectors/currencyRateController'; +import { earnSelectors } from '../../../../../../selectors/earnController/earn'; +import { + renderFromTokenMinimalUnit, + addCurrencySymbol, +} from '../../../../../../util/number'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { strings } from '../../../../../../../locales/i18n'; +import { CHAIN_ID_TO_AAVE_POOL_CONTRACT } from '@metamask/stake-sdk'; +import { getDecimalChainId } from '../../../../../../util/networks'; +import { TokenI } from '../../../../../UI/Tokens/types'; +import { + decodeLendingTransactionData, + getLendingTransactionInfo, + LendingTransactionInfo, +} from '../../../../../UI/Earn/utils'; +import { getEstimatedAnnualRewards } from '../../../../../UI/Earn/utils/token'; +import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; + +export interface LendingDepositDetails { + tokenSymbol: string; + tokenAmount: string; + tokenFiat: string; + apr: string; + aprNumeric: number; + annualRewardsFiat: string; + annualRewardsToken: string; + rewardFrequency: string; + withdrawalTime: string; + protocol: string; + protocolContractAddress: string; + receiptTokenSymbol: string; + receiptTokenName: string; + receiptTokenAmount: string; + receiptTokenAmountFiat: string; + receiptTokenImage: string; + amountMinimalUnit: string; + tokenDecimals: number; + token: Partial; +} + +export const useLendingDepositDetails = (): LendingDepositDetails | null => { + const transactionMeta = useTransactionMetadataRequest(); + const transactionBatchesMetadata = useTransactionBatchesMetadata(); + const currentCurrency = useSelector(selectCurrentCurrency); + + // Get lending info from transactionMeta OR transactionBatchesMetadata + const lendingInfo = useMemo((): LendingTransactionInfo | null => { + // Try transactionMeta first (7702 flow) + if (transactionMeta) { + return getLendingTransactionInfo(transactionMeta); + } + + // Fallback to transactionBatchesMetadata (non-7702 like Linea) + if (transactionBatchesMetadata?.transactions) { + const lendingTx = transactionBatchesMetadata.transactions.find( + (tx) => + tx.type === TransactionType.lendingDeposit || + tx.type === TransactionType.lendingWithdraw, + ); + // @ts-expect-error TODO: fix this type mismatch + if (lendingTx?.params?.data) { + return { + type: lendingTx.type as + | TransactionType.lendingDeposit + | TransactionType.lendingWithdraw, + // @ts-expect-error TODO: fix this type mismatch + data: lendingTx.params?.data as string, + }; + } + } + + return null; + }, [transactionMeta, transactionBatchesMetadata]); + + // Decode the transaction data + const decodedData = useMemo(() => { + if (!lendingInfo) return null; + return decodeLendingTransactionData(lendingInfo); + }, [lendingInfo]); + + // Get chainId from either source + const chainId = + transactionMeta?.chainId ?? transactionBatchesMetadata?.chainId; + + // Create a token-like object for the selector + const tokenAsset = useMemo((): TokenI | null => { + if (!chainId || !decodedData?.tokenAddress) return null; + return { + chainId, + address: decodedData.tokenAddress, + isETH: false, + } as TokenI; + }, [chainId, decodedData?.tokenAddress]); + + // Get earn token pair from selector + const earnTokenPair = useSelector((state: RootState) => + tokenAsset ? earnSelectors.selectEarnTokenPair(state, tokenAsset) : null, + ); + + const earnToken = earnTokenPair?.earnToken; + const outputToken = earnTokenPair?.outputToken; + + // Calculate token amount and fiat values + const { + tokenAmount, + tokenFiat, + amountFiatNumber, + annualRewardsFiat, + annualRewardsToken, + } = useMemo(() => { + if (!earnToken || !decodedData?.amountMinimalUnit) { + return { + tokenAmount: '0', + tokenFiat: '0', + amountFiatNumber: 0, + annualRewardsFiat: '', + annualRewardsToken: '', + }; + } + + const amountMinimalUnit = decodedData.amountMinimalUnit; + const decimals = earnToken.decimals || 18; + const symbol = earnToken.ticker || earnToken.symbol || ''; + + // Calculate token amount from minimal units + const amount = parseFloat( + renderFromTokenMinimalUnit(amountMinimalUnit, decimals), + ); + + // Use tokenUsdExchangeRate from earnToken (direct token to USD rate) + const tokenUsdRate = earnToken.tokenUsdExchangeRate || 0; + const fiatValue = amount * tokenUsdRate; + + // Use the same utility function as the original component + const apr = earnToken.experience?.apr || '0'; + const estimatedRewards = getEstimatedAnnualRewards( + apr, + fiatValue, + amountMinimalUnit, + currentCurrency, + decimals, + symbol, + ); + + return { + tokenAmount: amount.toString(), + tokenFiat: fiatValue.toString(), + amountFiatNumber: fiatValue, + annualRewardsFiat: estimatedRewards.estimatedAnnualRewardsFormatted, + annualRewardsToken: estimatedRewards.estimatedAnnualRewardsTokenFormatted, + }; + }, [earnToken, decodedData, currentCurrency]); + + if ( + (!transactionMeta && !transactionBatchesMetadata) || + !earnToken || + !decodedData || + !chainId || + !outputToken + ) { + return null; + } + + const tokenSymbol = earnToken.ticker || earnToken.symbol || ''; + // APR value without % suffix - view will add it + const apr = earnToken.experience?.apr || '0'; + + // Extract numeric APR value (remove % sign if present) + const aprNumeric = parseFloat(String(apr).replace('%', '')) || 0; + + // Get protocol contract address + const protocolContractAddress = + CHAIN_ID_TO_AAVE_POOL_CONTRACT[getDecimalChainId(chainId)] ?? ''; + + const tokenDecimals = earnToken.decimals || 18; + + // Create token object for hero component + const token: Partial = { + address: decodedData.tokenAddress, + symbol: earnToken.ticker || earnToken.symbol || '', + decimals: tokenDecimals, + image: earnToken.image || '', + name: earnToken.name || '', + chainId, + isNative: false, + }; + + // Format receipt token amount like the original (amount + symbol) + const formattedReceiptTokenAmount = `${renderFromTokenMinimalUnit( + decodedData.amountMinimalUnit, + earnToken.decimals, + )} ${outputToken.ticker || outputToken.symbol || ''}`; + + return { + tokenSymbol, + tokenAmount, + tokenFiat, + apr, + aprNumeric, + annualRewardsFiat, + annualRewardsToken, + rewardFrequency: strings('earn.every_minute'), + withdrawalTime: strings('earn.immediate'), + protocol: 'Aave', + protocolContractAddress, + receiptTokenSymbol: outputToken.ticker || outputToken.symbol || '', + receiptTokenName: + outputToken.name || outputToken.ticker || outputToken.symbol || '', + receiptTokenAmount: formattedReceiptTokenAmount, + receiptTokenAmountFiat: addCurrencySymbol( + amountFiatNumber.toString(), + currentCurrency, + ), + receiptTokenImage: outputToken.image || '', + amountMinimalUnit: decodedData.amountMinimalUnit, + tokenDecimals, + token, + }; +}; diff --git a/app/components/Views/confirmations/constants/info-ids.ts b/app/components/Views/confirmations/constants/info-ids.ts index 87780894d62e..d77f67739fdc 100644 --- a/app/components/Views/confirmations/constants/info-ids.ts +++ b/app/components/Views/confirmations/constants/info-ids.ts @@ -2,6 +2,7 @@ export const ConfirmationInfoComponentIDs = { APPROVE: 'approve-info', CONTRACT_INTERACTION: 'contract-interaction-info', CONTRACT_DEPLOYMENT: 'contract-deployment-info', + LENDING_DEPOSIT: 'lending-deposit-info', PERSONAL_SIGN: 'personal-sign-info', SIGN_TYPED_DATA_V1: 'sign-typed-data-v1-info', SIGN_TYPED_DATA_V3V4: 'sign-typed-data-v3v4-info', From 5dd143d332b8109d93d64a7f7c339880af85c792 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Tue, 16 Dec 2025 00:38:05 -0500 Subject: [PATCH 24/40] feat: wip proof of concept of quick convert flow --- .js.env.example | 1 + .../MusdQuickConvertView.styles.ts | 28 + .../MusdQuickConvertView.types.ts | 12 + .../Earn/Views/MusdQuickConvertView/index.tsx | 241 +++++ .../EarnNetworkAvatar.styles.ts | 1 - .../ConvertTokenRow/ConvertTokenRow.styles.ts | 39 + .../ConvertTokenRow/ConvertTokenRow.types.ts | 35 + .../components/Musd/ConvertTokenRow/index.tsx | 158 +++ .../Musd/MusdConversionAssetListCta/index.tsx | 27 +- .../MusdMaxConvertSheet.styles.ts | 62 ++ .../MusdMaxConvertSheet.types.ts | 29 + .../Musd/MusdMaxConvertSheet/index.tsx | 338 +++++++ .../UI/Earn/hooks/useMusdConversionStatus.ts | 2 + .../UI/Earn/hooks/useMusdMaxConversion.ts | 205 ++++ app/components/UI/Earn/routes/index.tsx | 6 + .../UI/Earn/selectors/featureFlags/index.ts | 22 + .../UI/Earn/selectors/musdConversionStatus.ts | 185 ++++ .../transactions/useTransactionConfirm.ts | 1 + .../utils/musd-conversion.test.ts | 269 +++++ app/constants/navigation/Routes.ts | 1 + bitrise.yml | 3 + docs/musd/musd-convert-list.md | 955 ++++++++++++++++++ locales/languages/en.json | 11 +- 23 files changed, 2625 insertions(+), 6 deletions(-) create mode 100644 app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts create mode 100644 app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.types.ts create mode 100644 app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx create mode 100644 app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts create mode 100644 app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.types.ts create mode 100644 app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx create mode 100644 app/components/UI/Earn/components/Musd/MusdMaxConvertSheet/MusdMaxConvertSheet.styles.ts create mode 100644 app/components/UI/Earn/components/Musd/MusdMaxConvertSheet/MusdMaxConvertSheet.types.ts create mode 100644 app/components/UI/Earn/components/Musd/MusdMaxConvertSheet/index.tsx create mode 100644 app/components/UI/Earn/hooks/useMusdMaxConversion.ts create mode 100644 app/components/UI/Earn/selectors/musdConversionStatus.ts create mode 100644 app/components/Views/confirmations/utils/musd-conversion.test.ts create mode 100644 docs/musd/musd-convert-list.md diff --git a/.js.env.example b/.js.env.example index ae9913eb600a..24e30d1210ab 100644 --- a/.js.env.example +++ b/.js.env.example @@ -107,6 +107,7 @@ export MM_POOLED_STAKING_ENABLED="true" export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true" # mUSD export MM_MUSD_CONVERSION_FLOW_ENABLED="false" +export MM_MUSD_QUICK_CONVERT_ENABLED="false" # Allowlist of convertible tokens by chain # IMPORTANT: Must use SINGLE QUOTES to preserve JSON format # Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}' diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts new file mode 100644 index 000000000000..56b47072c95d --- /dev/null +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts @@ -0,0 +1,28 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + flex: 1, + }, + listContainer: { + flex: 1, + }, + headerContainer: { + paddingHorizontal: 16, + paddingVertical: 16, + }, + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.types.ts b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.types.ts new file mode 100644 index 000000000000..992e433467dd --- /dev/null +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.types.ts @@ -0,0 +1,12 @@ +/** + * Test IDs for the MusdQuickConvertView screen. + */ +// TODO: Consider centralizing these test IDs in a separate file later. +export const MusdQuickConvertViewTestIds = { + CONTAINER: 'musd-quick-convert-view-container', + TOKEN_LIST: 'musd-quick-convert-view-token-list', + EMPTY_STATE: 'musd-quick-convert-view-empty-state', + LOADING: 'musd-quick-convert-view-loading', + HEADER: 'musd-quick-convert-view-header', +} as const; + diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx new file mode 100644 index 000000000000..9de6b545264c --- /dev/null +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { View, FlatList, ActivityIndicator } from 'react-native'; +import { useSelector } from 'react-redux'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Hex } from '@metamask/utils'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../hooks/useStyles'; +import { strings } from '../../../../../../locales/i18n'; +import { getStakingNavbar } from '../../../Navbar'; +import { AssetType } from '../../../../Views/confirmations/types/token'; +import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; +import { useMusdConversion } from '../../hooks/useMusdConversion'; +import { + selectMusdConversionStatuses, + createTokenChainKey, + deriveConversionUIStatus, +} from '../../selectors/musdConversionStatus'; +import { + selectIsMusdConversionFlowEnabledFlag, + selectMusdQuickConvertEnabledFlag, +} from '../../selectors/featureFlags'; +import { + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_CONVERSION_DEFAULT_CHAIN_ID, +} from '../../constants/musd'; +import ConvertTokenRow from '../../components/Musd/ConvertTokenRow'; +import MusdMaxConvertSheet from '../../components/Musd/MusdMaxConvertSheet'; +import styleSheet from './MusdQuickConvertView.styles'; +import { MusdQuickConvertViewTestIds } from './MusdQuickConvertView.types'; + +/** + * Determines the output chain ID for mUSD conversion. + * Uses same-chain if mUSD is deployed there, otherwise defaults to Ethereum mainnet. + */ +// TODO: Breakout into util since this is also defined in the useMusdMaxConversion hook. +const getOutputChainId = (paymentTokenChainId: Hex): Hex => { + const mUsdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[paymentTokenChainId]; + if (mUsdAddress) { + return paymentTokenChainId; + } + return MUSD_CONVERSION_DEFAULT_CHAIN_ID as Hex; +}; + +/** + * Quick Convert Token List screen. + * + * Displays all convertible tokens the user holds with Max and Edit buttons. + * - Max: Opens a bottom sheet for quick full-balance conversion + * - Edit: Navigates to the existing custom amount confirmation screen + */ +const MusdQuickConvertView = () => { + const { styles, theme } = useStyles(styleSheet, {}); + const { colors } = theme; + const navigation = useNavigation(); + const { initiateConversion } = useMusdConversion(); + + // Feature flags + const isMusdFlowEnabled = useSelector(selectIsMusdConversionFlowEnabledFlag); + const isQuickConvertEnabled = useSelector(selectMusdQuickConvertEnabledFlag); + + // Get convertible tokens + const { tokens: conversionTokens } = useMusdConversionTokens(); + + // Get conversion statuses from TransactionController + const conversionStatuses = useSelector(selectMusdConversionStatuses); + + // State for the Max convert bottom sheet + const [selectedToken, setSelectedToken] = useState(null); + const [isMaxSheetVisible, setIsMaxSheetVisible] = useState(false); + + // TODO: Circle back to ensure the header looks like designs. + // Set up navigation header + useFocusEffect( + useCallback(() => { + navigation.setOptions( + getStakingNavbar( + strings('earn.musd_conversion.convert_to_musd'), + navigation, + colors, + ), + ); + }, [navigation, colors]), + ); + + // Handle Max button press - open bottom sheet + const handleMaxPress = useCallback((token: AssetType) => { + if (!token.rawBalance) { + // Can't proceed without raw balance + return; + } + setSelectedToken(token); + setIsMaxSheetVisible(true); + }, []); + + // Handle Edit button press - navigate to existing confirmation screen + const handleEditPress = useCallback( + async (token: AssetType) => { + const outputChainId = getOutputChainId(token.chainId as Hex); + + await initiateConversion({ + outputChainId, + preferredPaymentToken: { + address: token.address as Hex, + chainId: token.chainId as Hex, + }, + }); + }, + [initiateConversion], + ); + + // Handle Max sheet close + const handleMaxSheetClose = useCallback(() => { + setIsMaxSheetVisible(false); + setSelectedToken(null); + }, []); + + // Get status for a token + const getTokenStatus = useCallback( + (token: AssetType) => { + const key = createTokenChainKey(token.address, token.chainId ?? ''); + const statusInfo = conversionStatuses[key] ?? null; + return deriveConversionUIStatus(statusInfo); + }, + [conversionStatuses], + ); + + // Filter tokens to only show those with balance > 0 + const tokensWithBalance = useMemo( + () => + conversionTokens.filter( + (token) => token.rawBalance && token.rawBalance !== '0x0', + ), + [conversionTokens], + ); + + // Render individual token row + const renderTokenItem = useCallback( + ({ item }: { item: AssetType }) => ( + + ), + [handleMaxPress, handleEditPress, getTokenStatus], + ); + + // TODO: This may be the same as the createTokenChainKey util. If yes, replace with createTokenChainKey call. + // Key extractor for FlatList + const keyExtractor = useCallback( + (item: AssetType) => `${item.address}-${item.chainId}`, + [], + ); + + // Render empty state + const renderEmptyState = () => ( + + + {strings('earn.musd_conversion.no_tokens_to_convert')} + + + ); + + // Render loading state + const renderLoading = () => ( + + + + ); + + // If feature flags are not enabled, don't render + if (!isMusdFlowEnabled || !isQuickConvertEnabled) { + return null; + } + + // TODO: Add actual loading state when we have a way to detect initial load (if necessary). + const isLoading = false; + + if (isLoading) { + return ( + + {renderLoading()} + + ); + } + + return ( + + {/* Header section */} + + + {strings('earn.musd_conversion.quick_convert_description')} + + + + {/* Token list */} + + + {/* Max Convert Bottom Sheet */} + {/* TODO: Test thoroughly on Android. Android has history of issues with bottom sheets. */} + {isMaxSheetVisible && selectedToken && ( + + )} + + ); +}; + +export default MusdQuickConvertView; diff --git a/app/components/UI/Earn/components/EarnNetworkAvatar/EarnNetworkAvatar.styles.ts b/app/components/UI/Earn/components/EarnNetworkAvatar/EarnNetworkAvatar.styles.ts index e463c7e99445..f5137a3db6a8 100644 --- a/app/components/UI/Earn/components/EarnNetworkAvatar/EarnNetworkAvatar.styles.ts +++ b/app/components/UI/Earn/components/EarnNetworkAvatar/EarnNetworkAvatar.styles.ts @@ -5,7 +5,6 @@ const styleSheet = () => networkAvatar: { height: 32, width: 32, - flexGrow: 1, }, }); diff --git a/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts b/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts new file mode 100644 index 000000000000..84edba878562 --- /dev/null +++ b/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + }, + left: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + flex: 1, + }, + tokenInfo: { + flex: 1, + }, + right: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingLeft: 12, + }, + actionButton: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + spinnerContainer: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.types.ts b/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.types.ts new file mode 100644 index 000000000000..6b5206437c95 --- /dev/null +++ b/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.types.ts @@ -0,0 +1,35 @@ +import { AssetType } from '../../../../../Views/confirmations/types/token'; +import { ConversionUIStatus } from '../../../selectors/musdConversionStatus'; + +/** + * Props for the ConvertTokenRow component. + */ +export interface ConvertTokenRowProps { + /** + * The token to display. + */ + token: AssetType; + + onMaxPress: (token: AssetType) => void; + + onEditPress: (token: AssetType) => void; + + /** + * The conversion status for this token (derived from TransactionController). + */ + status: ConversionUIStatus; +} + +/** + * Test IDs for the ConvertTokenRow component. + */ +// TODO: Consider centralizing these test IDs in a separate file later. +export const ConvertTokenRowTestIds = { + CONTAINER: 'convert-token-row-container', + TOKEN_ICON: 'convert-token-row-token-icon', + TOKEN_NAME: 'convert-token-row-token-name', + TOKEN_BALANCE: 'convert-token-row-token-balance', + MAX_BUTTON: 'convert-token-row-max-button', + EDIT_BUTTON: 'convert-token-row-edit-button', + SPINNER: 'convert-token-row-spinner', +} as const; diff --git a/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx new file mode 100644 index 000000000000..4b6d57e96631 --- /dev/null +++ b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx @@ -0,0 +1,158 @@ +import React, { useCallback } from 'react'; +import { View } from 'react-native'; +import { useSelector } from 'react-redux'; +import Badge, { + BadgeVariant, +} from '../../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../../component-library/components/Badges/BadgeWrapper'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import Button, { + ButtonSize, + ButtonVariants, +} from '../../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; +import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; +import { selectNetworkName } from '../../../../../../selectors/networkInfos'; +import { useStyles } from '../../../../../hooks/useStyles'; +import { getNetworkImageSource } from '../../../../../../util/networks'; +import { strings } from '../../../../../../../locales/i18n'; +import { EarnNetworkAvatar } from '../../EarnNetworkAvatar'; +import { TokenIconWithSpinner } from '../../TokenIconWithSpinner'; +import styleSheet from './ConvertTokenRow.styles'; +import { + ConvertTokenRowProps, + ConvertTokenRowTestIds, +} from './ConvertTokenRow.types'; + +/** + * A row component for displaying a token in the Quick Convert list. + * + * Displays: + * TODO: Circle back on the loading/pending states. + * - Token icon with network badge (or spinner if pending) + * - Token name and balance + * - Max and Edit buttons (or spinner if conversion is pending) + */ +const ConvertTokenRow: React.FC = ({ + token, + onMaxPress, + onEditPress, + status, +}) => { + const { styles } = useStyles(styleSheet, {}); + const networkName = useSelector(selectNetworkName); + + const isPending = status === 'pending'; + + const handleMaxPress = useCallback(() => { + onMaxPress(token); + }, [onMaxPress, token]); + + const handleEditPress = useCallback(() => { + onEditPress(token); + }, [onEditPress, token]); + + // Render token icon - show spinner if pending + const renderTokenIcon = () => { + if (isPending) { + return ( + + + + ); + } + + return ( + + } + > + + + ); + }; + + // Render action buttons - hide if pending + const renderActions = () => { + if (isPending) { + // When pending, we show the spinner on the token icon, no buttons needed + return null; + } + + return ( + <> + + @@ -133,18 +145,19 @@ const ConvertTokenRow: React.FC = ({ variant={TextVariant.BodyMDMedium} numberOfLines={1} ellipsizeMode="tail" - testID={ConvertTokenRowTestIds.TOKEN_NAME} + testID={ConvertTokenRowTestIds.TOKEN_BALANCE} > - {token.name} + {/* TODO: Determine if we want to fallback to the token balance if fiat isn't available. This may not be desired. */} + {formatFiat(new BigNumber(token?.fiat?.balance ?? '0')) ?? + `${token.balance} ${token.symbol}`} - {/* TODO: Determine if we want to fallback to the token balance if fiat isn't available. This may not be desired. */} - {token.balanceFiat ?? `${token.balance} ${token.symbol}`} + {token.symbol} diff --git a/app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/MusdQuickConvertLearnMoreCta.styles.ts b/app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/MusdQuickConvertLearnMoreCta.styles.ts new file mode 100644 index 000000000000..340000016967 --- /dev/null +++ b/app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/MusdQuickConvertLearnMoreCta.styles.ts @@ -0,0 +1,28 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { colors } = params.theme; + + return StyleSheet.create({ + container: { + flexDirection: 'row', + paddingVertical: 8, + paddingHorizontal: 16, + borderWidth: 1, + borderRadius: 12, + borderColor: colors.border.muted, + alignItems: 'center', + }, + musdIcon: { + width: 78, + height: 78, + }, + textContainer: { + flex: 1, + marginLeft: 12, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/index.tsx new file mode 100644 index 000000000000..d24682b624a8 --- /dev/null +++ b/app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/index.tsx @@ -0,0 +1,60 @@ +import React, { useCallback } from 'react'; +import { View, Image } from 'react-native'; +import stylesheet from './MusdQuickConvertLearnMoreCta.styles'; +import { useStyles } from '../../../../../hooks/useStyles'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import musdIcon from '../../../../../../images/musd-icon-no-background-2x.png'; +import { strings } from '../../../../../../../locales/i18n'; +import useTooltipModal from '../../../../../hooks/useTooltipModal'; +import { MUSD_APY } from '../../../constants/musd'; + +export const MUSD_QUICK_CONVERT_LEARN_MORE_CTA_TEST_ID = + 'musd-quick-convert-learn-more-cta'; + +const MusdQuickConvertLearnMoreCta = () => { + const { styles } = useStyles(stylesheet, {}); + const { openTooltipModal } = useTooltipModal(); + + const handleLearnMorePress = useCallback(() => { + openTooltipModal( + strings('earn.musd_conversion.tooltip_title'), + strings('earn.musd_conversion.tooltip_content', { apy: MUSD_APY }), + ); + }, [openTooltipModal]); + + return ( + + + + + {strings('earn.musd_conversion.convert_to_musd')} + + + + {strings('earn.musd_conversion.cta_body_earn_apy', { + apy: MUSD_APY, + })}{' '} + + + {strings('earn.musd_conversion.learn_more')} + + + + + ); +}; + +export default MusdQuickConvertLearnMoreCta; diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index ad897604c5b4..c05abe2d54c7 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -72,3 +72,6 @@ export const CONVERTIBLE_STABLECOINS_BY_CHAIN: Record = (() => { } return result; })(); + +// Temp: Until we have actual APY. +export const MUSD_APY = '2%'; diff --git a/locales/languages/en.json b/locales/languages/en.json index f6f7ce67b726..625519753107 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5686,7 +5686,6 @@ "network_fee": "Network fee", "earning": "Earning", "convert": "Convert", - "quick_convert_description": "Select a token to convert your full balance or tap the edit icon to enter a custom amount.", "no_tokens_to_convert": "You don't have any tokens that can be converted to mUSD.", "toasts": { "converting": "Converting {{token}} → mUSD", @@ -5704,7 +5703,11 @@ "get_musd": "Get mUSD", "earn_rewards_when": "Earn rewards when", "you_convert_to": "you convert to", - "max": "Max" + "max": "Max", + "cta_body_earn_apy": "Earn {{apy}} yield automatically for holding mUSD.", + "learn_more": "Learn more", + "tooltip_title": "Earn yield with mUSD", + "tooltip_content": "Convert your USDC, USDT, or DAI for mUSD, MetaMask's dollar-backed stablecoin. Earn {{apy}} yield on every dollar you hold." }, "rewards": { "rewards_tag_label": "Rewards", From 9d770ff463460b1eb7be3dde5b45f7e0339ddb41 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 19 Dec 2025 13:29:04 -0500 Subject: [PATCH 27/40] feat: add configurable quick convert amount --- .../Earn/Views/MusdQuickConvertView/index.tsx | 55 ++++++------- .../components/Musd/ConvertTokenRow/index.tsx | 7 +- .../UI/Earn/hooks/useMusdCtaVisibility.ts | 3 +- .../UI/Earn/hooks/useMusdMaxConversion.ts | 37 +++------ .../hooks/useMusdQuickConvertPercentage.ts | 79 +++++++++++++++++++ .../UI/Earn/selectors/featureFlags/index.ts | 38 +++++++++ .../musd-max-conversion-info.tsx | 15 +++- .../transactions/useTransactionConfirm.ts | 19 +++-- 8 files changed, 182 insertions(+), 71 deletions(-) create mode 100644 app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx index b43f421160b4..e0ed9f5c45d2 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx @@ -24,26 +24,28 @@ import { selectIsMusdConversionFlowEnabledFlag, selectMusdQuickConvertEnabledFlag, } from '../../selectors/featureFlags'; -import { - MUSD_TOKEN_ADDRESS_BY_CHAIN, - MUSD_CONVERSION_DEFAULT_CHAIN_ID, -} from '../../constants/musd'; import ConvertTokenRow from '../../components/Musd/ConvertTokenRow'; import MusdQuickConvertLearnMoreCta from '../../components/Musd/MusdQuickConvertLearnMoreCta'; import styleSheet from './MusdQuickConvertView.styles'; import { MusdQuickConvertViewTestIds } from './MusdQuickConvertView.types'; +import Tag from '../../../../../component-library/components/Tags/Tag'; +import { TagProps } from '../../../../../component-library/components/Tags/Tag/Tag.types'; -/** - * Determines the output chain ID for mUSD conversion. - * Uses same-chain if mUSD is deployed there, otherwise defaults to Ethereum mainnet. - */ -// TODO: Breakout into util since this is also defined in the useMusdMaxConversion hook. -const getOutputChainId = (paymentTokenChainId: Hex): Hex => { - const mUsdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[paymentTokenChainId]; - if (mUsdAddress) { - return paymentTokenChainId; - } - return MUSD_CONVERSION_DEFAULT_CHAIN_ID as Hex; +// TODO: Breakout +interface TokenListDividerProps { + title: string; + tag: TagProps; +} + +const SectionHeader = ({ title, tag }: TokenListDividerProps) => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + {title} + + + ); }; /** @@ -60,6 +62,7 @@ const MusdQuickConvertView = () => { const { initiateConversion } = useMusdConversion(); const { createMaxConversion, isLoading: isMaxConversionLoading } = useMusdMaxConversion(); + const { getMusdOutputChainId } = useMusdConversionTokens(); // Track which token is currently loading for max conversion const [loadingTokenKey, setLoadingTokenKey] = useState(null); @@ -88,7 +91,7 @@ const MusdQuickConvertView = () => { }, [navigation, colors]), ); - // Handle Max button press - navigate to max conversion confirmation + // navigate to max conversion bottom sheet const handleMaxPress = useCallback( async (token: AssetType) => { if (!token.rawBalance) { @@ -107,10 +110,10 @@ const MusdQuickConvertView = () => { [createMaxConversion], ); - // Handle Edit button press - navigate to existing confirmation screen + // navigate to existing confirmation screen const handleEditPress = useCallback( async (token: AssetType) => { - const outputChainId = getOutputChainId(token.chainId as Hex); + const outputChainId = getMusdOutputChainId(token.chainId); await initiateConversion({ outputChainId, @@ -120,7 +123,7 @@ const MusdQuickConvertView = () => { }, }); }, - [initiateConversion], + [initiateConversion, getMusdOutputChainId], ); // Get status for a token @@ -140,7 +143,6 @@ const MusdQuickConvertView = () => { [conversionStatuses, isMaxConversionLoading, loadingTokenKey], ); - // Filter tokens to only show those with balance > 0 const tokensWithBalance = useMemo( () => conversionTokens.filter( @@ -236,18 +238,7 @@ const MusdQuickConvertView = () => { showsVerticalScrollIndicator={false} testID={MusdQuickConvertViewTestIds.TOKEN_LIST} ListHeaderComponent={ - - {/* TODO: Cleanup and replace hardcoded strings */} - Stablecoins - - - No fees - - - + } /> diff --git a/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx index 7c95b9889a36..93128e84007e 100644 --- a/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx +++ b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx @@ -24,9 +24,9 @@ import { AvatarSize } from '../../../../../../component-library/components/Avata import { selectNetworkName } from '../../../../../../selectors/networkInfos'; import { useStyles } from '../../../../../hooks/useStyles'; import { getNetworkImageSource } from '../../../../../../util/networks'; -import { strings } from '../../../../../../../locales/i18n'; import { EarnNetworkAvatar } from '../../EarnNetworkAvatar'; import { TokenIconWithSpinner } from '../../TokenIconWithSpinner'; +import { useMusdQuickConvertPercentage } from '../../../hooks/useMusdQuickConvertPercentage'; import styleSheet from './ConvertTokenRow.styles'; import { ConvertTokenRowProps, @@ -52,6 +52,7 @@ const ConvertTokenRow: React.FC = ({ }) => { const { styles } = useStyles(styleSheet, {}); const networkName = useSelector(selectNetworkName); + const { buttonLabel } = useMusdQuickConvertPercentage(); const isPending = status === 'pending'; @@ -117,9 +118,7 @@ const ConvertTokenRow: React.FC = ({ onPress={handleMaxPress} testID={ConvertTokenRowTestIds.MAX_BUTTON} > - - {strings('earn.musd_conversion.max')} - + {buttonLabel} { ]); return { - shouldShowCta, + // TEMP: TODO: Remove after building. + shouldShowCta: true, showNetworkIcon, selectedChainId, }; diff --git a/app/components/UI/Earn/hooks/useMusdMaxConversion.ts b/app/components/UI/Earn/hooks/useMusdMaxConversion.ts index f8b2bb9eaf97..a093ff350a23 100644 --- a/app/components/UI/Earn/hooks/useMusdMaxConversion.ts +++ b/app/components/UI/Earn/hooks/useMusdMaxConversion.ts @@ -10,11 +10,10 @@ import { MMM_ORIGIN } from '../../../Views/confirmations/constants/confirmations import Routes from '../../../../constants/navigation/Routes'; import { EVM_SCOPE } from '../constants/networks'; import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; -import { - MUSD_TOKEN_ADDRESS_BY_CHAIN, - MUSD_CONVERSION_DEFAULT_CHAIN_ID, -} from '../constants/musd'; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; import { AssetType } from '../../../Views/confirmations/types/token'; +import { useMusdConversionTokens } from './useMusdConversionTokens'; +import { useMusdQuickConvertPercentage } from './useMusdQuickConvertPercentage'; /** * Result from creating a max conversion transaction. @@ -26,21 +25,6 @@ export interface MaxConversionResult { outputChainId: Hex; } -/** - * Determines the output chain ID for mUSD conversion. - * Uses same-chain if mUSD is deployed there, otherwise defaults to Ethereum mainnet. - */ -// TODO: Use the useMusdConversionTokens hook to get the output chain ID instead of duplicating it here. -const getOutputChainId = (paymentTokenChainId: Hex): Hex => { - const mUsdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[paymentTokenChainId]; - if (mUsdAddress) { - // mUSD exists on this chain - same-chain conversion - return paymentTokenChainId; - } - // mUSD not on this chain - cross-chain to Ethereum mainnet - return MUSD_CONVERSION_DEFAULT_CHAIN_ID as Hex; -}; - /** * Hook for creating max-amount mUSD conversion transactions. * @@ -65,6 +49,8 @@ export const useMusdMaxConversion = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const navigation = useNavigation(); + const { getMusdOutputChainId } = useMusdConversionTokens(); + const { applyPercentage } = useMusdQuickConvertPercentage(); const selectedAccount = useSelector(selectSelectedInternalAccountByScope)( EVM_SCOPE, @@ -81,9 +67,7 @@ export const useMusdMaxConversion = () => { const tokenAddress = token.address as Hex; const tokenChainId = token.chainId as Hex; - // TODO: Consider using the useMusdConversionTokens hook to get the output chain ID instead of duplicating it here. - // Determine output chain (same-chain if mUSD exists, else mainnet) - const outputChainId = getOutputChainId(tokenChainId); + const outputChainId = getMusdOutputChainId(tokenChainId); try { setIsLoading(true); @@ -124,11 +108,14 @@ export const useMusdMaxConversion = () => { ); } - // Generate transfer data with max amount + // Generate transfer data with adjusted amount (based on percentage from feature flag) // Note: We use the token's rawBalance which is already in minimal units (hex) + // The applyPercentage function will return the full balance if percentage is 1 (max mode) + // or a reduced amount if percentage is < 1 (e.g., 90%) + const adjustedBalance = applyPercentage(token.rawBalance as Hex); const transferData = generateTransferData('transfer', { toAddress: selectedAddress, - amount: token.rawBalance, + amount: adjustedBalance, }); const { transactionMeta } = await TransactionController.addTransaction( @@ -199,7 +186,7 @@ export const useMusdMaxConversion = () => { setIsLoading(false); } }, - [navigation, selectedAddress], + [applyPercentage, getMusdOutputChainId, navigation, selectedAddress], ); /** diff --git a/app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts b/app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts new file mode 100644 index 000000000000..002f983f7113 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts @@ -0,0 +1,79 @@ +import { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import BigNumber from 'bignumber.js'; +import { toHex } from '@metamask/controller-utils'; +import { selectMusdQuickConvertPercentage } from '../selectors/featureFlags'; +import { strings } from '../../../../../locales/i18n'; + +/** + * Return type for the useMusdQuickConvertPercentage hook. + */ +export interface MusdQuickConvertPercentageResult { + /** The percentage value (0-1 range) */ + percentage: number; + /** Whether we're in max mode (percentage === 1) */ + isMaxMode: boolean; + /** The button label to display ("Max" or formatted percentage like "90%") */ + buttonLabel: string; + /** Applies the percentage to a raw balance (hex string) and returns the adjusted amount */ + applyPercentage: (rawBalance: Hex) => Hex; +} + +/** + * Hook for managing mUSD Quick Convert percentage logic. + * + * This hook provides utilities for: + * - Getting the configured percentage from the feature flag + * - Determining if we're in max mode (100%) or percentage mode + * - Getting the appropriate button label + * - Applying the percentage to a raw balance + * + * @example + * const { percentage, isMaxMode, buttonLabel, applyPercentage } = useMusdQuickConvertPercentage(); + * + * // In button: + * + * + * // When creating transaction: + * const adjustedBalance = applyPercentage(token.rawBalance); + */ +export const useMusdQuickConvertPercentage = + (): MusdQuickConvertPercentageResult => { + const percentage = useSelector(selectMusdQuickConvertPercentage); + + const isMaxMode = percentage === 1; + + const buttonLabel = useMemo(() => { + if (isMaxMode) { + return strings('earn.musd_conversion.max'); + } + // Convert decimal to percentage (e.g., 0.90 -> "90%") + return `${Math.round(percentage * 100)}%`; + }, [isMaxMode, percentage]); + + const applyPercentage = useCallback( + (rawBalance: Hex): Hex => { + if (isMaxMode) { + return rawBalance; + } + + // Convert hex to BigNumber, apply percentage, and convert back to hex + const balanceBN = new BigNumber(rawBalance); + const adjustedBalance = balanceBN + .multipliedBy(percentage) + .integerValue(BigNumber.ROUND_DOWN); + + return toHex(adjustedBalance) as Hex; + }, + [isMaxMode, percentage], + ); + + return { + percentage, + isMaxMode, + buttonLabel, + applyPercentage, + }; + }; + diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 28d7f917bf81..5b329a7f8292 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -104,6 +104,44 @@ export const selectMusdQuickConvertEnabledFlag = createSelector( }, ); +/** + * Selector for the mUSD Quick Convert percentage. + * Returns a number between 0 and 1 representing the percentage of balance to convert. + * + * - When `1.0`: Max mode (100% of balance, current behavior) + * - When `< 1.0` (e.g., `0.90`): Percentage mode (90% of balance) + * + * This is a workaround for the Relay quote system using `EXPECTED_OUTPUT` trade type, + * which adds fees on top of the requested amount causing insufficient funds for max conversions. + * Using a percentage (e.g., 90%) leaves room for fees until Relay supports `EXACT_INPUT`. + */ +export const selectMusdQuickConvertPercentage = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags): number => { + const DEFAULT_PERCENTAGE = 1; // Max mode by default + + // Try remote flag first + const remoteValue = remoteFeatureFlags?.earnMusdQuickConvertPercentage; + if ( + typeof remoteValue === 'number' && + remoteValue > 0 && + remoteValue <= 1 + ) { + return remoteValue; + } + + // Fallback to local env variable + const localValue = parseFloat( + process.env.MM_MUSD_QUICK_CONVERT_PERCENTAGE ?? '', + ); + if (!isNaN(localValue) && localValue > 0 && localValue <= 1) { + return localValue; + } + + return DEFAULT_PERCENTAGE; + }, +); + /** * Selects the allowed payment tokens for mUSD conversion from remote config or local fallback. * Returns a mapping of chain IDs to arrays of token addresses that users can pay with to convert to mUSD. diff --git a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx index ca4068361d68..05b8fe085fe6 100644 --- a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx +++ b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx @@ -25,6 +25,9 @@ import { selectNetworkName } from '../../../../../../selectors/networkInfos'; import { getNetworkImageSource } from '../../../../../../util/networks'; import { MUSD_TOKEN } from '../../../../../UI/Earn/constants/musd'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { getTokenTransferData } from '../../../utils/transaction-pay'; +import { parseStandardTokenTransactionData } from '../../../utils/transaction'; +import { calcTokenAmount } from '../../../../../../util/transactions'; import { useIsTransactionPayLoading, useTransactionPayQuotes, @@ -97,8 +100,16 @@ export const MusdMaxConversionInfo = () => { const isLoading = !transactionMetadata || isQuoteLoading || quotes?.length === 0; - // mUSD receive amount (1:1 stablecoin conversion, using the token balance) - const mUsdAmount = token?.balance ?? '0'; + // Parse the mUSD receive amount from transaction metadata + // This gives us the actual amount that will be converted (with percentage applied) + const tokenTransferData = getTokenTransferData(transactionMetadata); + const parsedData = parseStandardTokenTransactionData(tokenTransferData?.data); + const amountRaw = parsedData?.args?._value?.toString() ?? '0'; + // Convert from raw (minimal units) to human-readable using token decimals + const mUsdAmount = calcTokenAmount( + amountRaw, + token?.decimals ?? 18, + ).toString(); // Confirm button disabled state const isConfirmDisabled = isLoading || hasBlockingAlerts; diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index 9a510fdd547d..937c2981e475 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -133,13 +133,18 @@ export function useTransactionConfirm() { screen: Routes.PERPS.PERPS_HOME, }); } else if (type === TransactionType.musdConversion) { - // Fallback to wallet home if we can't go back - navigation.navigate(Routes.WALLET.HOME, { - screen: Routes.WALLET.TAB_STACK_FLOW, - params: { - screen: Routes.WALLET_VIEW, - }, - }); + // Full-screen mUSD conversions (e.g., from "Convert to mUSD" CTA) navigate to wallet home + // Non-full-screen mUSD conversions (e.g., max mode bottom sheet) go back to the previous screen + if (isFullScreenConfirmation) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + } else { + navigation.goBack(); + } } else if ( isFullScreenConfirmation && !hasTransactionType(transactionMetadata, GO_BACK_TYPES) From 8b6eeec6c39f3e134e4c05d2724d7425acfa2b34 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 19 Dec 2025 13:56:45 -0500 Subject: [PATCH 28/40] feat: replaced receive button on home page with quick convert link --- .js.env.example | 2 ++ .../AssetDetailsActions/AssetDetailsActions.tsx | 17 +++++++++++++---- locales/languages/en.json | 1 + 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.js.env.example b/.js.env.example index 24e30d1210ab..6e932eb100e2 100644 --- a/.js.env.example +++ b/.js.env.example @@ -113,6 +113,8 @@ export MM_MUSD_QUICK_CONVERT_ENABLED="false" # Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}' export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' export MM_MUSD_CTA_ENABLED="false" +export MM_MUSD_QUICK_CONVERT_PERCENTAGE=1 + # Activates remote feature flag override mode. # Remote feature flag values won't be updated, # and selectors should return their fallback values diff --git a/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx b/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx index 2ad32089136d..189ed3be7ce3 100644 --- a/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx +++ b/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx @@ -137,6 +137,15 @@ export const AssetDetailsActions: React.FC = ({ withNavigationLock(onReceive); }, [withNavigationLock, onReceive]); + // Navigate to mUSD Quick Convert view + const handleMusdQuickConvertPress = useCallback(() => { + withNavigationLock(() => { + navigate(Routes.EARN.ROOT, { + screen: Routes.EARN.MUSD.QUICK_CONVERT, + }); + }); + }, [withNavigationLock, navigate]); + return ( {displayBuyButton && ( @@ -172,10 +181,10 @@ export const AssetDetailsActions: React.FC = ({ diff --git a/locales/languages/en.json b/locales/languages/en.json index 625519753107..334c4a1e337e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5704,6 +5704,7 @@ "earn_rewards_when": "Earn rewards when", "you_convert_to": "you convert to", "max": "Max", + "quick_convert_button": "Convert", "cta_body_earn_apy": "Earn {{apy}} yield automatically for holding mUSD.", "learn_more": "Learn more", "tooltip_title": "Earn yield with mUSD", From 8909f37f4ecd92801a6c0634988046aca72ac311 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 19 Dec 2025 19:31:14 +0000 Subject: [PATCH 29/40] [skip ci] Bump version number to 3317 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index a66dba67dd71..92622a3e6e5f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.62.0" - versionCode 3092 + versionCode 3317 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 397cf85d7993..5268d63984e3 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3409,13 +3409,13 @@ app: VERSION_NAME: 7.62.0 - opts: is_expand: false - VERSION_NUMBER: 3168 + VERSION_NUMBER: 3317 - opts: is_expand: false FLASK_VERSION_NAME: 7.62.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3168 + FLASK_VERSION_NUMBER: 3317 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index cdc205aa31b5..bc65c0a1323e 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3317; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3317; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3317; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3317; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3317; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3317; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From e38a253ff73c9fdedb4c8052473181dcf1b71e45 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 19 Dec 2025 14:51:11 -0500 Subject: [PATCH 30/40] feat: updated mUSD asset list cta text --- .../components/Musd/MusdConversionAssetListCta/index.tsx | 7 +++++-- locales/languages/en.json | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx index 16f99eb6b0ac..788307eb5e78 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx @@ -11,6 +11,7 @@ import { ButtonVariant, } from '@metamask/design-system-react-native'; import { + MUSD_APY, MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN, MUSD_TOKEN_ASSET_ID_BY_CHAIN, @@ -158,8 +159,10 @@ const MusdConversionAssetListCta = () => { MetaMask USD - - {strings('earn.musd_conversion.earn_points_daily')} + + {strings('earn.musd_conversion.earn_apy', { + apy: MUSD_APY, + })} diff --git a/locales/languages/en.json b/locales/languages/en.json index 334c4a1e337e..46f8719ec56c 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5699,6 +5699,7 @@ "continue_button": "Get started" }, "earn_points_daily": "Earn points daily", + "earn_apy": "Earn {{apy}}", "buy_musd": "Buy mUSD", "get_musd": "Get mUSD", "earn_rewards_when": "Earn rewards when", From 725ddc7eba59b9d2d6cd4e56ba65b1471218c35a Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 19 Dec 2025 19:59:36 +0000 Subject: [PATCH 31/40] [skip ci] Bump version number to 3318 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 92622a3e6e5f..257b42695fe0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.62.0" - versionCode 3317 + versionCode 3318 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 5268d63984e3..1386687b5484 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3409,13 +3409,13 @@ app: VERSION_NAME: 7.62.0 - opts: is_expand: false - VERSION_NUMBER: 3317 + VERSION_NUMBER: 3318 - opts: is_expand: false FLASK_VERSION_NAME: 7.62.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3317 + FLASK_VERSION_NUMBER: 3318 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index bc65c0a1323e..db621b6ac3f5 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3317; + CURRENT_PROJECT_VERSION = 3318; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3317; + CURRENT_PROJECT_VERSION = 3318; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3317; + CURRENT_PROJECT_VERSION = 3318; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3317; + CURRENT_PROJECT_VERSION = 3318; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3317; + CURRENT_PROJECT_VERSION = 3318; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3317; + CURRENT_PROJECT_VERSION = 3318; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 91934aecdbfd87498615583954750b716300b0e4 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Fri, 19 Dec 2025 14:14:28 -0600 Subject: [PATCH 32/40] fix: outputToken not being available in useDepositConfirmationDetails --- .../lending-receive-section.tsx | 4 +- .../useLendingDepositDetails.ts | 48 ++++++++++++++----- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx index f8ed029652cc..df1d7ac9d724 100644 --- a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx @@ -65,9 +65,7 @@ const LendingReceiveSection = () => { { } as TokenI; }, [chainId, decodedData?.tokenAddress]); - // Get earn token pair from selector - const earnTokenPair = useSelector((state: RootState) => - tokenAsset ? earnSelectors.selectEarnTokenPair(state, tokenAsset) : null, + // Get earn token pair and snapshot using the hook + const { earnTokenPair, tokenSnapshot, getTokenSnapshot } = useEarnToken( + tokenAsset as TokenI, ); const earnToken = earnTokenPair?.earnToken; const outputToken = earnTokenPair?.outputToken; + // Fetch token snapshot when outputToken doesn't exist (user doesn't have Atoken) + useEffect(() => { + if (!outputToken && earnToken?.experience?.market?.outputToken?.address) { + getTokenSnapshot( + earnToken.chainId as Hex, + earnToken.experience.market.outputToken.address as Hex, + ); + } + }, [outputToken, getTokenSnapshot, earnToken]); + // Calculate token amount and fiat values const { tokenAmount, @@ -160,8 +170,7 @@ export const useLendingDepositDetails = (): LendingDepositDetails | null => { (!transactionMeta && !transactionBatchesMetadata) || !earnToken || !decodedData || - !chainId || - !outputToken + !chainId ) { return null; } @@ -190,11 +199,18 @@ export const useLendingDepositDetails = (): LendingDepositDetails | null => { isNative: false, }; + // Get receipt token symbol with fallback to tokenSnapshot + const receiptTokenSymbol = + outputToken?.ticker || + outputToken?.symbol || + tokenSnapshot?.token?.symbol || + ''; + // Format receipt token amount like the original (amount + symbol) const formattedReceiptTokenAmount = `${renderFromTokenMinimalUnit( decodedData.amountMinimalUnit, earnToken.decimals, - )} ${outputToken.ticker || outputToken.symbol || ''}`; + )} ${receiptTokenSymbol}`; return { tokenSymbol, @@ -208,15 +224,23 @@ export const useLendingDepositDetails = (): LendingDepositDetails | null => { withdrawalTime: strings('earn.immediate'), protocol: 'Aave', protocolContractAddress, - receiptTokenSymbol: outputToken.ticker || outputToken.symbol || '', + receiptTokenSymbol, receiptTokenName: - outputToken.name || outputToken.ticker || outputToken.symbol || '', + outputToken?.name || + outputToken?.ticker || + outputToken?.symbol || + tokenSnapshot?.token?.name || + '', receiptTokenAmount: formattedReceiptTokenAmount, receiptTokenAmountFiat: addCurrencySymbol( amountFiatNumber.toString(), currentCurrency, ), - receiptTokenImage: outputToken.image || '', + receiptTokenImage: + outputToken?.image || + tokenSnapshot?.token?.image || + earnToken?.image || + '', amountMinimalUnit: decodedData.amountMinimalUnit, tokenDecimals, token, From c0adf47f5938bbf341e1b79482848f56705e67f6 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Fri, 19 Dec 2025 14:24:11 -0600 Subject: [PATCH 33/40] chore: bitrise changes --- android/app/build.gradle | 4 ++-- bitrise.yml | 8 ++++---- ios/MetaMask.xcodeproj/project.pbxproj | 24 ++++++++++++------------ package.json | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 257b42695fe0..dbeb74d265db 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,8 +187,8 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.62.0" - versionCode 3318 + versionName "7.62.123" + versionCode 3319 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 1386687b5484..461d42ee43d0 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3406,16 +3406,16 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.62.0 + VERSION_NAME: 7.62.123 - opts: is_expand: false - VERSION_NUMBER: 3318 + VERSION_NUMBER: 3319 - opts: is_expand: false - FLASK_VERSION_NAME: 7.62.0 + FLASK_VERSION_NAME: 7.62.123 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3318 + FLASK_VERSION_NUMBER: 3319 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index db621b6ac3f5..0200c90b18f1 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3318; + CURRENT_PROJECT_VERSION = 3319; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.0; + MARKETING_VERSION = 7.62.123; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3318; + CURRENT_PROJECT_VERSION = 3319; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.0; + MARKETING_VERSION = 7.62.123; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3318; + CURRENT_PROJECT_VERSION = 3319; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.0; + MARKETING_VERSION = 7.62.123; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3318; + CURRENT_PROJECT_VERSION = 3319; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.0; + MARKETING_VERSION = 7.62.123; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3318; + CURRENT_PROJECT_VERSION = 3319; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.0; + MARKETING_VERSION = 7.62.123; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3318; + CURRENT_PROJECT_VERSION = 3319; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.0; + MARKETING_VERSION = 7.62.123; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index c35e29486d56..beb2d230b638 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.62.0", + "version": "7.62.123", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From a113727f6a6381fb959453e5b3628df74c9af650 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 7 Jan 2026 16:40:04 -0600 Subject: [PATCH 34/40] chore: update tooltips to match confirmations tooltips in desposit flow conf --- .../components/UI/info-row/info-row.tsx | 3 + .../lending-details.styles.ts | 5 - .../lending-deposit-info/lending-details.tsx | 186 +++++++----------- .../lending-receive-section.tsx | 29 +-- 4 files changed, 82 insertions(+), 141 deletions(-) diff --git a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx index 95aeb8831de0..0d56c9e4b7f7 100644 --- a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx @@ -28,6 +28,7 @@ export interface InfoRowProps { tooltip?: ReactNode; tooltipTitle?: string; tooltipColor?: IconColor; + tooltipIconName?: IconName; style?: Record; labelChildren?: React.ReactNode; testID?: string; @@ -52,6 +53,7 @@ const InfoRow = ({ tooltip, tooltipTitle, tooltipColor, + tooltipIconName, testID, variant = TextColor.Alternative, copyText, @@ -92,6 +94,7 @@ const InfoRow = ({ onPress={onTooltipPress} title={tooltipTitle ?? label} iconColor={tooltipColor} + iconName={tooltipIconName} /> )} diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts index fc0b42fc840f..c30c7917669d 100644 --- a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.styles.ts @@ -2,11 +2,6 @@ import { StyleSheet } from 'react-native'; const styleSheet = () => StyleSheet.create({ - infoSectionContent: { - paddingVertical: 4, - paddingHorizontal: 8, - gap: 16, - }, estAnnualReward: { flexDirection: 'row', gap: 8, diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx index 7203f09c0f30..64b3f538e66c 100644 --- a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-details.tsx @@ -4,16 +4,17 @@ import { useSelector } from 'react-redux'; import { strings } from '../../../../../../../locales/i18n'; import Text, { TextColor, - TextVariant, } from '../../../../../../component-library/components/Texts/Text'; +import { + IconColor, + IconName, +} from '../../../../../../component-library/components/Icons/Icon'; import { useStyles } from '../../../../../../component-library/hooks'; -import KeyValueRow, { - TooltipSizes, -} from '../../../../../../component-library/components-temp/KeyValueRow'; import ContractTag from '../../../../../UI/Stake/components/StakingConfirmation/ContractTag/ContractTag'; import styleSheet from './lending-details.styles'; import { useLendingDepositDetails } from './useLendingDepositDetails'; import InfoSection from '../../UI/info-row/info-section/info-section'; +import InfoRow from '../../UI/info-row'; import { selectAvatarAccountType } from '../../../../../../selectors/settings'; const LENDING_DETAILS_TEST_ID = 'lending-details'; @@ -39,120 +40,79 @@ const LendingDetails = () => { return ( - - {/* APR */} - - {strings('earn.tooltip_content.apr.part_one')} - {strings('earn.tooltip_content.apr.part_two')} - - ), - size: TooltipSizes.Sm, - }, - }} - value={{ - label: { - text: `${apr}%`, - variant: TextVariant.BodyMD, - color: TextColor.Success, - }, - }} - /> + {/* APR */} + + {strings('earn.tooltip_content.apr.part_one')} + {strings('earn.tooltip_content.apr.part_two')} + + } + tooltipTitle={strings('earn.apr')} + tooltipColor={IconColor.Alternative} + tooltipIconName={IconName.Question} + > + {`${apr}%`} + - {/* Estimated Annual Reward */} - - {annualRewardsFiat} - - {annualRewardsToken} - - - ), - }} - /> + {/* Estimated Annual Reward */} + + + {annualRewardsFiat} + + {annualRewardsToken} + + + - {/* Reward Frequency */} - + {/* Reward Frequency */} + + {rewardFrequency} + - {/* Withdrawal Time */} - + {/* Withdrawal Time */} + + {withdrawalTime} + - {/* Protocol */} - - ), - }} + {/* Protocol */} + + - + ); }; diff --git a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx index df1d7ac9d724..ba96b15dd28e 100644 --- a/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx +++ b/app/components/Views/confirmations/components/info/lending-deposit-info/lending-receive-section.tsx @@ -1,38 +1,24 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { View } from 'react-native'; import { strings } from '../../../../../../../locales/i18n'; import Text, { TextColor, TextVariant, } from '../../../../../../component-library/components/Texts/Text'; +import { IconColor } from '../../../../../../component-library/components/Icons/Icon'; import { useStyles } from '../../../../../../component-library/hooks'; import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; -import ButtonIcon, { - ButtonIconSizes, -} from '../../../../../../component-library/components/Buttons/ButtonIcon'; -import { - IconColor, - IconName, -} from '../../../../../../component-library/components/Icons/Icon'; import InfoSection from '../../UI/info-row/info-section/info-section'; +import Tooltip from '../../UI/Tooltip/Tooltip'; import styleSheet from './lending-receive-section.styles'; import { useLendingDepositDetails } from './useLendingDepositDetails'; -import useTooltipModal from '../../../../../hooks/useTooltipModal'; export const LENDING_RECEIVE_SECTION_TEST_ID = 'lending-receive-section'; const LendingReceiveSection = () => { const { styles } = useStyles(styleSheet, {}); const details = useLendingDepositDetails(); - const { openTooltipModal } = useTooltipModal(); - - const handleReceiveTooltip = useCallback(() => { - openTooltipModal( - strings('earn.receive'), - strings('earn.tooltip_content.receive'), - ); - }, [openTooltipModal]); if (!details) { return null; @@ -52,13 +38,10 @@ const LendingReceiveSection = () => { {strings('earn.receive')} - From fb989d92c78765b95eae5676038482edd913e07a Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 7 Jan 2026 18:59:31 -0600 Subject: [PATCH 35/40] Revert "feat: replaced receive button on home page with quick convert link" This reverts commit 8b6eeec6c39f3e134e4c05d2724d7425acfa2b34. --- .js.env.example | 2 -- .../AssetDetailsActions/AssetDetailsActions.tsx | 17 ++++------------- locales/languages/en.json | 1 - 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/.js.env.example b/.js.env.example index 1ea5fe4072bf..81851cbcd762 100644 --- a/.js.env.example +++ b/.js.env.example @@ -116,8 +116,6 @@ export MM_MUSD_CTA_TOKENS='' export MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST='' export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' export MM_MUSD_CTA_ENABLED="false" -export MM_MUSD_QUICK_CONVERT_PERCENTAGE=1 - # Activates remote feature flag override mode. # Remote feature flag values won't be updated, # and selectors should return their fallback values diff --git a/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx b/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx index 52d7558ea817..498e7ad56048 100644 --- a/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx +++ b/app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.tsx @@ -138,15 +138,6 @@ export const AssetDetailsActions: React.FC = ({ // withNavigationLock(onReceive); // }, [withNavigationLock, onReceive]); - // Navigate to mUSD Quick Convert view - const handleMusdQuickConvertPress = useCallback(() => { - withNavigationLock(() => { - navigate(Routes.EARN.ROOT, { - screen: Routes.EARN.MUSD.QUICK_CONVERT, - }); - }); - }, [withNavigationLock, navigate]); - return ( {displayBuyButton && ( @@ -182,10 +173,10 @@ export const AssetDetailsActions: React.FC = ({ diff --git a/locales/languages/en.json b/locales/languages/en.json index b17476b0d80a..5f470ec1b7d4 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5706,7 +5706,6 @@ "earn_rewards_when": "Earn rewards when", "you_convert_to": "you convert to", "max": "Max", - "quick_convert_button": "Convert", "cta_body_earn_apy": "Earn {{apy}} yield automatically for holding mUSD.", "learn_more": "Learn more", "tooltip_title": "Earn yield with mUSD", From da7693c1f545f8fe77ca4bfaeb881592163fbc3b Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 7 Jan 2026 18:59:48 -0600 Subject: [PATCH 36/40] Revert "feat: add configurable quick convert amount" This reverts commit 9d770ff463460b1eb7be3dde5b45f7e0339ddb41. --- .../Earn/Views/MusdQuickConvertView/index.tsx | 55 +++++++------ .../components/Musd/ConvertTokenRow/index.tsx | 7 +- .../UI/Earn/hooks/useMusdMaxConversion.ts | 37 ++++++--- .../hooks/useMusdQuickConvertPercentage.ts | 78 ------------------- .../UI/Earn/selectors/featureFlags/index.ts | 38 --------- .../musd-max-conversion-info.tsx | 15 +--- .../transactions/useTransactionConfirm.ts | 19 ++--- 7 files changed, 70 insertions(+), 179 deletions(-) delete mode 100644 app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx index e0ed9f5c45d2..b43f421160b4 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx @@ -24,28 +24,26 @@ import { selectIsMusdConversionFlowEnabledFlag, selectMusdQuickConvertEnabledFlag, } from '../../selectors/featureFlags'; +import { + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_CONVERSION_DEFAULT_CHAIN_ID, +} from '../../constants/musd'; import ConvertTokenRow from '../../components/Musd/ConvertTokenRow'; import MusdQuickConvertLearnMoreCta from '../../components/Musd/MusdQuickConvertLearnMoreCta'; import styleSheet from './MusdQuickConvertView.styles'; import { MusdQuickConvertViewTestIds } from './MusdQuickConvertView.types'; -import Tag from '../../../../../component-library/components/Tags/Tag'; -import { TagProps } from '../../../../../component-library/components/Tags/Tag/Tag.types'; - -// TODO: Breakout -interface TokenListDividerProps { - title: string; - tag: TagProps; -} - -const SectionHeader = ({ title, tag }: TokenListDividerProps) => { - const { styles } = useStyles(styleSheet, {}); - return ( - - {title} - - - ); +/** + * Determines the output chain ID for mUSD conversion. + * Uses same-chain if mUSD is deployed there, otherwise defaults to Ethereum mainnet. + */ +// TODO: Breakout into util since this is also defined in the useMusdMaxConversion hook. +const getOutputChainId = (paymentTokenChainId: Hex): Hex => { + const mUsdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[paymentTokenChainId]; + if (mUsdAddress) { + return paymentTokenChainId; + } + return MUSD_CONVERSION_DEFAULT_CHAIN_ID as Hex; }; /** @@ -62,7 +60,6 @@ const MusdQuickConvertView = () => { const { initiateConversion } = useMusdConversion(); const { createMaxConversion, isLoading: isMaxConversionLoading } = useMusdMaxConversion(); - const { getMusdOutputChainId } = useMusdConversionTokens(); // Track which token is currently loading for max conversion const [loadingTokenKey, setLoadingTokenKey] = useState(null); @@ -91,7 +88,7 @@ const MusdQuickConvertView = () => { }, [navigation, colors]), ); - // navigate to max conversion bottom sheet + // Handle Max button press - navigate to max conversion confirmation const handleMaxPress = useCallback( async (token: AssetType) => { if (!token.rawBalance) { @@ -110,10 +107,10 @@ const MusdQuickConvertView = () => { [createMaxConversion], ); - // navigate to existing confirmation screen + // Handle Edit button press - navigate to existing confirmation screen const handleEditPress = useCallback( async (token: AssetType) => { - const outputChainId = getMusdOutputChainId(token.chainId); + const outputChainId = getOutputChainId(token.chainId as Hex); await initiateConversion({ outputChainId, @@ -123,7 +120,7 @@ const MusdQuickConvertView = () => { }, }); }, - [initiateConversion, getMusdOutputChainId], + [initiateConversion], ); // Get status for a token @@ -143,6 +140,7 @@ const MusdQuickConvertView = () => { [conversionStatuses, isMaxConversionLoading, loadingTokenKey], ); + // Filter tokens to only show those with balance > 0 const tokensWithBalance = useMemo( () => conversionTokens.filter( @@ -238,7 +236,18 @@ const MusdQuickConvertView = () => { showsVerticalScrollIndicator={false} testID={MusdQuickConvertViewTestIds.TOKEN_LIST} ListHeaderComponent={ - + + {/* TODO: Cleanup and replace hardcoded strings */} + Stablecoins + + + No fees + + + } /> diff --git a/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx index 93128e84007e..7c95b9889a36 100644 --- a/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx +++ b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx @@ -24,9 +24,9 @@ import { AvatarSize } from '../../../../../../component-library/components/Avata import { selectNetworkName } from '../../../../../../selectors/networkInfos'; import { useStyles } from '../../../../../hooks/useStyles'; import { getNetworkImageSource } from '../../../../../../util/networks'; +import { strings } from '../../../../../../../locales/i18n'; import { EarnNetworkAvatar } from '../../EarnNetworkAvatar'; import { TokenIconWithSpinner } from '../../TokenIconWithSpinner'; -import { useMusdQuickConvertPercentage } from '../../../hooks/useMusdQuickConvertPercentage'; import styleSheet from './ConvertTokenRow.styles'; import { ConvertTokenRowProps, @@ -52,7 +52,6 @@ const ConvertTokenRow: React.FC = ({ }) => { const { styles } = useStyles(styleSheet, {}); const networkName = useSelector(selectNetworkName); - const { buttonLabel } = useMusdQuickConvertPercentage(); const isPending = status === 'pending'; @@ -118,7 +117,9 @@ const ConvertTokenRow: React.FC = ({ onPress={handleMaxPress} testID={ConvertTokenRowTestIds.MAX_BUTTON} > - {buttonLabel} + + {strings('earn.musd_conversion.max')} + { + const mUsdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[paymentTokenChainId]; + if (mUsdAddress) { + // mUSD exists on this chain - same-chain conversion + return paymentTokenChainId; + } + // mUSD not on this chain - cross-chain to Ethereum mainnet + return MUSD_CONVERSION_DEFAULT_CHAIN_ID as Hex; +}; + /** * Hook for creating max-amount mUSD conversion transactions. * @@ -49,8 +65,6 @@ export const useMusdMaxConversion = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const navigation = useNavigation(); - const { getMusdOutputChainId } = useMusdConversionTokens(); - const { applyPercentage } = useMusdQuickConvertPercentage(); const selectedAccount = useSelector(selectSelectedInternalAccountByScope)( EVM_SCOPE, @@ -67,7 +81,9 @@ export const useMusdMaxConversion = () => { const tokenAddress = token.address as Hex; const tokenChainId = token.chainId as Hex; - const outputChainId = getMusdOutputChainId(tokenChainId); + // TODO: Consider using the useMusdConversionTokens hook to get the output chain ID instead of duplicating it here. + // Determine output chain (same-chain if mUSD exists, else mainnet) + const outputChainId = getOutputChainId(tokenChainId); try { setIsLoading(true); @@ -108,14 +124,11 @@ export const useMusdMaxConversion = () => { ); } - // Generate transfer data with adjusted amount (based on percentage from feature flag) + // Generate transfer data with max amount // Note: We use the token's rawBalance which is already in minimal units (hex) - // The applyPercentage function will return the full balance if percentage is 1 (max mode) - // or a reduced amount if percentage is < 1 (e.g., 90%) - const adjustedBalance = applyPercentage(token.rawBalance as Hex); const transferData = generateTransferData('transfer', { toAddress: selectedAddress, - amount: adjustedBalance, + amount: token.rawBalance, }); const { transactionMeta } = await TransactionController.addTransaction( @@ -186,7 +199,7 @@ export const useMusdMaxConversion = () => { setIsLoading(false); } }, - [applyPercentage, getMusdOutputChainId, navigation, selectedAddress], + [navigation, selectedAddress], ); /** diff --git a/app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts b/app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts deleted file mode 100644 index c5f75f110618..000000000000 --- a/app/components/UI/Earn/hooks/useMusdQuickConvertPercentage.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { Hex } from '@metamask/utils'; -import BigNumber from 'bignumber.js'; -import { toHex } from '@metamask/controller-utils'; -import { selectMusdQuickConvertPercentage } from '../selectors/featureFlags'; -import { strings } from '../../../../../locales/i18n'; - -/** - * Return type for the useMusdQuickConvertPercentage hook. - */ -export interface MusdQuickConvertPercentageResult { - /** The percentage value (0-1 range) */ - percentage: number; - /** Whether we're in max mode (percentage === 1) */ - isMaxMode: boolean; - /** The button label to display ("Max" or formatted percentage like "90%") */ - buttonLabel: string; - /** Applies the percentage to a raw balance (hex string) and returns the adjusted amount */ - applyPercentage: (rawBalance: Hex) => Hex; -} - -/** - * Hook for managing mUSD Quick Convert percentage logic. - * - * This hook provides utilities for: - * - Getting the configured percentage from the feature flag - * - Determining if we're in max mode (100%) or percentage mode - * - Getting the appropriate button label - * - Applying the percentage to a raw balance - * - * @example - * const { percentage, isMaxMode, buttonLabel, applyPercentage } = useMusdQuickConvertPercentage(); - * - * // In button: - * - * - * // When creating transaction: - * const adjustedBalance = applyPercentage(token.rawBalance); - */ -export const useMusdQuickConvertPercentage = - (): MusdQuickConvertPercentageResult => { - const percentage = useSelector(selectMusdQuickConvertPercentage); - - const isMaxMode = percentage === 1; - - const buttonLabel = useMemo(() => { - if (isMaxMode) { - return strings('earn.musd_conversion.max'); - } - // Convert decimal to percentage (e.g., 0.90 -> "90%") - return `${Math.round(percentage * 100)}%`; - }, [isMaxMode, percentage]); - - const applyPercentage = useCallback( - (rawBalance: Hex): Hex => { - if (isMaxMode) { - return rawBalance; - } - - // Convert hex to BigNumber, apply percentage, and convert back to hex - const balanceBN = new BigNumber(rawBalance); - const adjustedBalance = balanceBN - .multipliedBy(percentage) - .integerValue(BigNumber.ROUND_DOWN); - - return toHex(adjustedBalance) as Hex; - }, - [isMaxMode, percentage], - ); - - return { - percentage, - isMaxMode, - buttonLabel, - applyPercentage, - }; - }; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 92fc0275615a..d9ef565c0687 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -129,44 +129,6 @@ export const selectMusdQuickConvertEnabledFlag = createSelector( }, ); -/** - * Selector for the mUSD Quick Convert percentage. - * Returns a number between 0 and 1 representing the percentage of balance to convert. - * - * - When `1.0`: Max mode (100% of balance, current behavior) - * - When `< 1.0` (e.g., `0.90`): Percentage mode (90% of balance) - * - * This is a workaround for the Relay quote system using `EXPECTED_OUTPUT` trade type, - * which adds fees on top of the requested amount causing insufficient funds for max conversions. - * Using a percentage (e.g., 90%) leaves room for fees until Relay supports `EXACT_INPUT`. - */ -export const selectMusdQuickConvertPercentage = createSelector( - selectRemoteFeatureFlags, - (remoteFeatureFlags): number => { - const DEFAULT_PERCENTAGE = 1; // Max mode by default - - // Try remote flag first - const remoteValue = remoteFeatureFlags?.earnMusdQuickConvertPercentage; - if ( - typeof remoteValue === 'number' && - remoteValue > 0 && - remoteValue <= 1 - ) { - return remoteValue; - } - - // Fallback to local env variable - const localValue = parseFloat( - process.env.MM_MUSD_QUICK_CONVERT_PERCENTAGE ?? '', - ); - if (!isNaN(localValue) && localValue > 0 && localValue <= 1) { - return localValue; - } - - return DEFAULT_PERCENTAGE; - }, -); - /** * Selects the allowed payment tokens for mUSD conversion from remote config or local fallback. * Returns a wildcard allowlist mapping chain IDs (or "*") to token symbols (or ["*"]). diff --git a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx index 05b8fe085fe6..ca4068361d68 100644 --- a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx +++ b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx @@ -25,9 +25,6 @@ import { selectNetworkName } from '../../../../../../selectors/networkInfos'; import { getNetworkImageSource } from '../../../../../../util/networks'; import { MUSD_TOKEN } from '../../../../../UI/Earn/constants/musd'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; -import { getTokenTransferData } from '../../../utils/transaction-pay'; -import { parseStandardTokenTransactionData } from '../../../utils/transaction'; -import { calcTokenAmount } from '../../../../../../util/transactions'; import { useIsTransactionPayLoading, useTransactionPayQuotes, @@ -100,16 +97,8 @@ export const MusdMaxConversionInfo = () => { const isLoading = !transactionMetadata || isQuoteLoading || quotes?.length === 0; - // Parse the mUSD receive amount from transaction metadata - // This gives us the actual amount that will be converted (with percentage applied) - const tokenTransferData = getTokenTransferData(transactionMetadata); - const parsedData = parseStandardTokenTransactionData(tokenTransferData?.data); - const amountRaw = parsedData?.args?._value?.toString() ?? '0'; - // Convert from raw (minimal units) to human-readable using token decimals - const mUsdAmount = calcTokenAmount( - amountRaw, - token?.decimals ?? 18, - ).toString(); + // mUSD receive amount (1:1 stablecoin conversion, using the token balance) + const mUsdAmount = token?.balance ?? '0'; // Confirm button disabled state const isConfirmDisabled = isLoading || hasBlockingAlerts; diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index 937c2981e475..9a510fdd547d 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -133,18 +133,13 @@ export function useTransactionConfirm() { screen: Routes.PERPS.PERPS_HOME, }); } else if (type === TransactionType.musdConversion) { - // Full-screen mUSD conversions (e.g., from "Convert to mUSD" CTA) navigate to wallet home - // Non-full-screen mUSD conversions (e.g., max mode bottom sheet) go back to the previous screen - if (isFullScreenConfirmation) { - navigation.navigate(Routes.WALLET.HOME, { - screen: Routes.WALLET.TAB_STACK_FLOW, - params: { - screen: Routes.WALLET_VIEW, - }, - }); - } else { - navigation.goBack(); - } + // Fallback to wallet home if we can't go back + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); } else if ( isFullScreenConfirmation && !hasTransactionType(transactionMetadata, GO_BACK_TYPES) From cd5ffe83c9d2c93f08f802ee992c85f16877f4ee Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 7 Jan 2026 19:02:33 -0600 Subject: [PATCH 37/40] Revert "feat: wip eod" This reverts commit ab63471f5f3004ea300a9d2e96f42cbea8dcb862. --- .../MusdQuickConvertView.styles.ts | 22 ++----- .../Earn/Views/MusdQuickConvertView/index.tsx | 20 +------ .../ConvertTokenRow/ConvertTokenRow.styles.ts | 16 ++--- .../components/Musd/ConvertTokenRow/index.tsx | 51 ++++++---------- .../MusdQuickConvertLearnMoreCta.styles.ts | 28 --------- .../MusdQuickConvertLearnMoreCta/index.tsx | 60 ------------------- app/components/UI/Earn/constants/musd.ts | 33 +++++++++- locales/languages/en.json | 7 +-- 8 files changed, 63 insertions(+), 174 deletions(-) delete mode 100644 app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/MusdQuickConvertLearnMoreCta.styles.ts delete mode 100644 app/components/UI/Earn/components/Musd/MusdQuickConvertLearnMoreCta/index.tsx diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts index 17a12e5be52e..56b47072c95d 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts @@ -1,42 +1,28 @@ import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../util/theme/models'; -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ +const styleSheet = () => + StyleSheet.create({ container: { flex: 1, - padding: 16, }, listContainer: { flex: 1, }, headerContainer: { + paddingHorizontal: 16, paddingVertical: 16, }, emptyContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', + paddingHorizontal: 32, }, loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', }, - listHeaderContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - noFeesTag: { - backgroundColor: colors.background.muted, - paddingHorizontal: 6, - borderRadius: 4, - }, }); -}; export default styleSheet; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx index b43f421160b4..e303673670d2 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx @@ -29,7 +29,6 @@ import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, } from '../../constants/musd'; import ConvertTokenRow from '../../components/Musd/ConvertTokenRow'; -import MusdQuickConvertLearnMoreCta from '../../components/Musd/MusdQuickConvertLearnMoreCta'; import styleSheet from './MusdQuickConvertView.styles'; import { MusdQuickConvertViewTestIds } from './MusdQuickConvertView.types'; @@ -217,13 +216,14 @@ const MusdQuickConvertView = () => { edges={['bottom']} testID={MusdQuickConvertViewTestIds.CONTAINER} > - Convert {/* Header section */} - + + {strings('earn.musd_conversion.quick_convert_description')} + {/* Token list */} @@ -235,20 +235,6 @@ const MusdQuickConvertView = () => { ListEmptyComponent={renderEmptyState} showsVerticalScrollIndicator={false} testID={MusdQuickConvertViewTestIds.TOKEN_LIST} - ListHeaderComponent={ - - {/* TODO: Cleanup and replace hardcoded strings */} - Stablecoins - - - No fees - - - - } /> ); diff --git a/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts b/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts index 03a9dc782cea..84edba878562 100644 --- a/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts +++ b/app/components/UI/Earn/components/Musd/ConvertTokenRow/ConvertTokenRow.styles.ts @@ -1,21 +1,18 @@ import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../util/theme/models'; -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ +const styleSheet = () => + StyleSheet.create({ container: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, + paddingHorizontal: 16, }, left: { flexDirection: 'row', alignItems: 'center', - gap: 16, + gap: 12, flex: 1, }, tokenInfo: { @@ -27,10 +24,6 @@ const styleSheet = (params: { theme: Theme }) => { gap: 8, paddingLeft: 12, }, - editButton: { - backgroundColor: colors.background.muted, - borderRadius: 12, - }, actionButton: { paddingHorizontal: 12, paddingVertical: 6, @@ -42,6 +35,5 @@ const styleSheet = (params: { theme: Theme }) => { justifyContent: 'center', }, }); -}; export default styleSheet; diff --git a/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx index 7c95b9889a36..4b6d57e96631 100644 --- a/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx +++ b/app/components/UI/Earn/components/Musd/ConvertTokenRow/index.tsx @@ -11,15 +11,11 @@ import Text, { TextColor, TextVariant, } from '../../../../../../component-library/components/Texts/Text'; -import { - Button, +import Button, { ButtonSize, - ButtonVariant, - IconName, - ButtonIcon, - ButtonIconSize, - IconSize, -} from '@metamask/design-system-react-native'; + ButtonVariants, +} from '../../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; import { selectNetworkName } from '../../../../../../selectors/networkInfos'; import { useStyles } from '../../../../../hooks/useStyles'; @@ -32,8 +28,6 @@ import { ConvertTokenRowProps, ConvertTokenRowTestIds, } from './ConvertTokenRow.types'; -import useFiatFormatter from '../../../../SimulationDetails/FiatDisplay/useFiatFormatter'; -import BigNumber from 'bignumber.js'; /** * A row component for displaying a token in the Quick Convert list. @@ -55,8 +49,6 @@ const ConvertTokenRow: React.FC = ({ const isPending = status === 'pending'; - const formatFiat = useFiatFormatter(); - const handleMaxPress = useCallback(() => { onMaxPress(token); }, [onMaxPress, token]); @@ -101,7 +93,6 @@ const ConvertTokenRow: React.FC = ({ ); }; - // TODO: Breakout into component for better debugging // Render action buttons - hide if pending const renderActions = () => { if (isPending) { @@ -112,20 +103,17 @@ const ConvertTokenRow: React.FC = ({ return ( <> - +