diff --git a/.js.env.example b/.js.env.example index dfe837fbbf1..4af052467c1 100644 --- a/.js.env.example +++ b/.js.env.example @@ -120,6 +120,9 @@ export MM_MUSD_CONVERSION_ASSET_OVERVIEW_CTA="false" # CTA displayed in token list item ("Convert to mUSD") export MM_MUSD_CONVERSION_TOKEN_LIST_ITEM_CTA="false" export MM_MUSD_CONVERSION_REWARDS_UI_ENABLED="false" +# Geo-blocked countries for mUSD conversion (comma-separated ISO 3166-1 alpha-2 codes) +# LaunchDarkly takes precedence; this is a fallback. Default blocks UK. +export MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES="GB" export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01" # Activates remote feature flag override mode. diff --git a/app/components/UI/AssetOverview/Balance/index.test.tsx b/app/components/UI/AssetOverview/Balance/index.test.tsx index 33b3c4742e4..3feb35a67d1 100644 --- a/app/components/UI/AssetOverview/Balance/index.test.tsx +++ b/app/components/UI/AssetOverview/Balance/index.test.tsx @@ -61,6 +61,15 @@ jest.mock('../../Earn/hooks/useMusdConversionTokens', () => ({ }), })); +jest.mock('../../Earn/hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: () => ({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + }), +})); + const mockDAI = { address: '0x6b175474e89094c44da98b954eedeac495271d0f', aggregators: ['Metamask', 'Coinmarketcap'], diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx index 5a02488cbe5..76958ac4c83 100644 --- a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx @@ -7,6 +7,7 @@ import EarnLendingBalance from '../EarnLendingBalance'; import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled'; import { selectTronResourcesBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list'; import TronStakingButtons from '../Tron/TronStakingButtons'; +import { selectIsMusdConversionFlowEnabledFlag } from '../../selectors/featureFlags'; /** * We mock underlying components because we only care about the conditional rendering. @@ -83,17 +84,50 @@ jest.mock('../EarnLendingBalance', () => ({ default: jest.fn(), })); +import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; + jest.mock('../../hooks/useMusdConversionTokens', () => ({ __esModule: true, useMusdConversionTokens: jest.fn().mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), + filterAllowedTokens: jest.fn(), tokens: [], isMusdSupportedOnChain: jest.fn().mockReturnValue(false), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), }), })); +const mockUseMusdConversionTokens = + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + >; + +jest.mock('../../selectors/featureFlags', () => ({ + ...jest.requireActual('../../selectors/featureFlags'), + selectIsMusdConversionFlowEnabledFlag: jest.fn().mockReturnValue(false), +})); + +const mockSelectIsMusdConversionFlowEnabledFlag = + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + >; + +import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility'; + +jest.mock('../../hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: jest.fn().mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + }), +})); + +const mockUseMusdConversionEligibility = + useMusdConversionEligibility as jest.MockedFunction< + typeof useMusdConversionEligibility + >; + jest.mock('../../hooks/useTronStakeApy', () => ({ __esModule: true, default: jest.fn().mockReturnValue({ @@ -252,4 +286,66 @@ describe('EarnBalance', () => { expect(props.hasStakedPositions).toBe(true); }); }); + + describe('Geo-blocking', () => { + it('does not render EarnLendingBalance for convertible stablecoin when user is geo-blocked', () => { + mockSelectIsMusdConversionFlowEnabledFlag.mockReturnValue(true); + + mockUseMusdConversionTokens.mockReturnValue({ + isConversionToken: jest.fn().mockReturnValue(true), + filterAllowedTokens: jest.fn(), + tokens: [], + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), + }); + + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: false, + isLoading: false, + geolocation: 'GB', + blockedCountries: ['GB'], + }); + + const mockDai = { + isETH: false, + symbol: 'DAI', + chainId: '0x1', + }; + + renderWithProvider(); + + // EarnLendingBalance should not be called because geo-blocking is active + expect(EarnLendingBalance).not.toHaveBeenCalled(); + }); + + it('renders EarnLendingBalance for convertible stablecoin when user is geo-eligible', () => { + mockSelectIsMusdConversionFlowEnabledFlag.mockReturnValue(true); + + mockUseMusdConversionTokens.mockReturnValue({ + isConversionToken: jest.fn().mockReturnValue(true), + filterAllowedTokens: jest.fn(), + tokens: [], + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), + }); + + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + }); + + const mockDai = { + isETH: false, + symbol: 'DAI', + chainId: '0x1', + }; + + renderWithProvider(); + + // EarnLendingBalance should be called because user is geo-eligible + expect(EarnLendingBalance).toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Earn/components/EarnBalance/index.tsx b/app/components/UI/Earn/components/EarnBalance/index.tsx index f071a25d058..7e5d7a653a5 100644 --- a/app/components/UI/Earn/components/EarnBalance/index.tsx +++ b/app/components/UI/Earn/components/EarnBalance/index.tsx @@ -14,6 +14,7 @@ import { hasStakedTrxPositions as hasStakedTrxPositionsUtil } from '../../utils/ import useTronStakeApy from '../../hooks/useTronStakeApy'; ///: END:ONLY_INCLUDE_IF import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; +import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility'; import { selectIsMusdConversionFlowEnabledFlag } from '../../selectors/featureFlags'; export interface EarnBalanceProps { asset: TokenI; @@ -36,6 +37,7 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => { ); const { isConversionToken } = useMusdConversionTokens(); + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); ///: BEGIN:ONLY_INCLUDE_IF(tron) const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled); @@ -74,7 +76,7 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => { ///: END:ONLY_INCLUDE_IF const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); + isMusdConversionFlowEnabled && isConversionToken(asset) && isGeoEligible; // EVM staking: only when stakeable and not a staked output token if (isStakeableToken && !asset.isStaked) { diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index fd824b026cf..aa7659f94e8 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -150,6 +150,15 @@ const mockUseStakingEligibility = useStakingEligibility as jest.MockedFunction< typeof useStakingEligibility >; +jest.mock('../../hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: jest.fn().mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + }), +})); + jest.mock('../../selectors/featureFlags', () => ({ selectIsMusdConversionFlowEnabledFlag: jest.fn(), selectPooledStakingEnabledFlag: jest.fn(), @@ -775,4 +784,77 @@ describe('EarnLendingBalance', () => { updatedState.user.musdConversionAssetDetailCtasSeen[expectedCtaKey], ).toBe(true); }); + + it('hides mUSD conversion CTA when user is geo-blocked', () => { + const mockEmptyReceiptToken = { + ...mockADAIMainnet, + balanceMinimalUnit: '0', + balanceFormatted: '0 ADAI', + balanceFiatNumber: 0, + }; + + ( + earnSelectors.selectEarnTokenPair as jest.MockedFunction< + typeof earnSelectors.selectEarnTokenPair + > + ).mockReturnValue({ + outputToken: mockEmptyReceiptToken, + earnToken: mockDaiMainnet, + }); + + // Geo-blocked: shouldShowAssetOverviewCta returns false + mockShouldShowAssetOverviewCta.mockReturnValue(false); + + const { queryByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + // mUSD CTA hidden because geo-blocked (useMusdCtaVisibility returns false) + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeNull(); + }); + + it('shows mUSD conversion CTA when user is geo-eligible', () => { + const mockEmptyReceiptToken = { + ...mockADAIMainnet, + balanceMinimalUnit: '0', + balanceFormatted: '0 ADAI', + balanceFiatNumber: 0, + }; + + ( + earnSelectors.selectEarnTokenPair as jest.MockedFunction< + typeof earnSelectors.selectEarnTokenPair + > + ).mockReturnValue({ + outputToken: mockEmptyReceiptToken, + earnToken: mockDaiMainnet, + }); + + ( + selectStablecoinLendingEnabledFlag as jest.MockedFunction< + typeof selectStablecoinLendingEnabledFlag + > + ).mockReturnValue(false); + + ( + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + > + ).mockReturnValue(true); + + // Geo-eligible: shouldShowAssetOverviewCta returns true + mockShouldShowAssetOverviewCta.mockReturnValue(true); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + // mUSD CTA visible because geo-eligible (useMusdCtaVisibility returns true) + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index 4da3414f8ea..0b4b709dfe9 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -41,3 +41,9 @@ export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { export const MUSD_CURRENCY = 'MUSD'; export const MUSD_CONVERSION_APY = 3; + +/** + * Default blocked countries for mUSD conversion when no remote or env config is available. + * This is a safety fallback to ensure geo-blocking is always active. + */ +export const DEFAULT_MUSD_BLOCKED_COUNTRIES = ['GB']; diff --git a/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts new file mode 100644 index 00000000000..93e65be10a1 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts @@ -0,0 +1,153 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useMusdConversionEligibility } from './useMusdConversionEligibility'; + +// Mock the selectors +const mockGetDetectedGeolocation = jest.fn(); +const mockSelectMusdConversionBlockedCountries = jest.fn(); + +jest.mock('../../../../reducers/fiatOrders', () => ({ + getDetectedGeolocation: (state: unknown) => mockGetDetectedGeolocation(state), +})); + +jest.mock('../selectors/featureFlags', () => ({ + selectMusdConversionBlockedCountries: (state: unknown) => + mockSelectMusdConversionBlockedCountries(state), +})); + +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); + +describe('useMusdConversionEligibility', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetDetectedGeolocation.mockReturnValue(undefined); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + }); + + describe('isEligible', () => { + it('returns false when geolocation is undefined (blocks by default for compliance)', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(false); + }); + + it('returns false when geolocation is undefined even with empty blocked list', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(false); + }); + + it('returns true when blockedCountries is empty and geolocation is known', () => { + mockGetDetectedGeolocation.mockReturnValue('GB'); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(true); + }); + + it('returns false when user is in a blocked country', () => { + mockGetDetectedGeolocation.mockReturnValue('GB'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB', 'US']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(false); + }); + + it('returns false when user country starts with blocked country code', () => { + mockGetDetectedGeolocation.mockReturnValue('GB-ENG'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(false); + }); + + it('returns true when user is not in any blocked country', () => { + mockGetDetectedGeolocation.mockReturnValue('FR'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB', 'US']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(true); + }); + + it('performs case-insensitive comparison for country codes', () => { + mockGetDetectedGeolocation.mockReturnValue('gb'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(false); + }); + + it('handles US state codes correctly when US is blocked', () => { + mockGetDetectedGeolocation.mockReturnValue('US-CA'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['US']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(false); + }); + + it('returns true for US user when only UK is blocked', () => { + mockGetDetectedGeolocation.mockReturnValue('US-CA'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(true); + }); + }); + + describe('return values', () => { + it('returns geolocation from selector', () => { + mockGetDetectedGeolocation.mockReturnValue('FR'); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.geolocation).toBe('FR'); + }); + + it('returns blockedCountries from selector', () => { + const blockedCountries = ['GB', 'US']; + mockGetDetectedGeolocation.mockReturnValue('FR'); + mockSelectMusdConversionBlockedCountries.mockReturnValue( + blockedCountries, + ); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.blockedCountries).toEqual(blockedCountries); + }); + }); + + describe('isLoading', () => { + it('returns true when geolocation is undefined', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns false when geolocation is available', () => { + mockGetDetectedGeolocation.mockReturnValue('US'); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isLoading).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts new file mode 100644 index 00000000000..40fb1df04f5 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { getDetectedGeolocation } from '../../../../reducers/fiatOrders'; +import { selectMusdConversionBlockedCountries } from '../selectors/featureFlags'; + +/** + * Hook to determine if the user is eligible for mUSD conversion based on their geolocation. + * + * Uses the Ramps geolocation API (via RampsController) to get the user's country code + * and compares it against a list of blocked countries from LaunchDarkly. + * + * IMPORTANT: Defaults to BLOCKING when geolocation is unknown (null) to ensure + * regulatory compliance. Users in blocked regions cannot bypass restrictions + * by having geolocation fail to load. + * + * @returns Object containing: + * - isEligible: true only if geolocation is known AND user is not in a blocked country + * - isLoading: true if geolocation is still pending (null) + * - geolocation: the user's country/region code (e.g., "GB", "US-CA") or null + * - blockedCountries: array of blocked country codes from LaunchDarkly + */ +export const useMusdConversionEligibility = () => { + const geolocation = useSelector(getDetectedGeolocation); + const blockedCountries = useSelector(selectMusdConversionBlockedCountries); + const isLoading = geolocation === undefined; + + const userCountry = useMemo(() => { + if (geolocation) return geolocation?.toUpperCase().split('-')[0]; + return null; + }, [geolocation]); + + const isEligible = useMemo(() => { + // Block by default when geolocation is unknown for regulatory compliance + if (!userCountry) { + return false; + } + + // If no blocked countries configured, allow access + if (blockedCountries.length === 0) { + return true; + } + + // Check if user's country starts with any blocked country code + // Uses startsWith to handle both "GB" and "GB-ENG" formats + + return blockedCountries.every( + (blockedCountry) => !userCountry.startsWith(blockedCountry.toUpperCase()), + ); + }, [userCountry, blockedCountries]); + + return { + isEligible, + isLoading, + geolocation: userCountry, + blockedCountries, + }; +}; diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index 189efd82686..435a2a593b9 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -4,6 +4,7 @@ import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useMusdCtaVisibility } from './useMusdCtaVisibility'; import { useMusdBalance } from './useMusdBalance'; import { useMusdConversionTokens } from './useMusdConversionTokens'; +import { useMusdConversionEligibility } from './useMusdConversionEligibility'; import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; import { useNetworksByCustomNamespace } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; @@ -23,6 +24,7 @@ import type { AssetType } from '../../../Views/confirmations/types/token'; jest.mock('./useMusdBalance'); jest.mock('./useMusdConversionTokens'); +jest.mock('./useMusdConversionEligibility'); jest.mock('../../../hooks/useCurrentNetworkInfo'); jest.mock('../../../hooks/useNetworksByNamespace/useNetworksByNamespace'); jest.mock('../../Ramp/hooks/useRampTokens'); @@ -60,8 +62,11 @@ const mockUseMusdConversionTokens = useMusdConversionTokens as jest.MockedFunction< typeof useMusdConversionTokens >; +const mockUseMusdConversionEligibility = + useMusdConversionEligibility as jest.MockedFunction< + typeof useMusdConversionEligibility + >; const mockUseSelector = useSelector as jest.MockedFunction; - describe('useMusdCtaVisibility', () => { const defaultNetworkInfo = { enabledNetworks: [], @@ -180,6 +185,12 @@ describe('useMusdCtaVisibility', () => { isMusdSupportedOnChain: jest.fn(), getMusdOutputChainId: jest.fn(), }); + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + }); }); afterEach(() => { @@ -752,6 +763,115 @@ describe('useMusdCtaVisibility', () => { }); }); + describe('geo blocking', () => { + it('returns shouldShowCta false when user is geo-blocked in all networks view', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: false, + isLoading: false, + geolocation: 'GB', + blockedCountries: ['GB'], + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + balancesByChain: {}, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(false), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + const { shouldShowCta, showNetworkIcon, selectedChainId } = + result.current.shouldShowBuyGetMusdCta(); + + expect(shouldShowCta).toBe(false); + expect(showNetworkIcon).toBe(false); + expect(selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta false when user is geo-blocked on single supported chain', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: false, + isLoading: false, + geolocation: 'GB', + blockedCountries: ['GB'], + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + balancesByChain: {}, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(false), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + const { shouldShowCta, showNetworkIcon, selectedChainId } = + result.current.shouldShowBuyGetMusdCta(); + + expect(shouldShowCta).toBe(false); + expect(showNetworkIcon).toBe(false); + expect(selectedChainId).toBeNull(); + }); + + it('returns shouldShowCta true when user is not geo-blocked', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: ['GB'], + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [ + { chainId: CHAIN_IDS.MAINNET, enabled: true }, + { chainId: CHAIN_IDS.LINEA_MAINNET, enabled: true }, + ], + }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + balancesByChain: {}, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(false), + }); + // Provide tokens with chainId so canConvert is true + mockUseMusdConversionTokens.mockReturnValue({ + tokens: [ + createMockToken({ + name: 'USDC', + symbol: 'USDC', + chainId: CHAIN_IDS.MAINNET, + }) as TokenI, + ], + filterAllowedTokens: jest.fn(), + isConversionToken: jest.fn().mockReturnValue(true), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn().mockReturnValue(CHAIN_IDS.MAINNET), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + const { shouldShowCta } = result.current.shouldShowBuyGetMusdCta(); + + expect(shouldShowCta).toBe(true); + }); + }); + describe('edge cases', () => { it('returns shouldShowCta false with empty enabledNetworks', () => { mockUseNetworksByCustomNamespace.mockReturnValue({ @@ -1108,6 +1228,91 @@ describe('useMusdCtaVisibility', () => { expect(isVisible).toBe(true); }); + + describe('geo blocking', () => { + it('returns false when user is geo-blocked', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: false, + isLoading: false, + geolocation: 'GB', + blockedCountries: ['GB'], + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(true), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + const isVisible = + result.current.shouldShowTokenListItemCta(listItemToken); + + expect(isVisible).toBe(false); + }); + + it('returns false when user is geo-blocked even with mUSD balance on single chain', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: false, + isLoading: false, + geolocation: 'GB', + blockedCountries: ['GB'], + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + hasMusdBalanceOnChain: jest + .fn() + .mockImplementation( + (chainId: Hex) => chainId === CHAIN_IDS.MAINNET, + ), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + const isVisible = + result.current.shouldShowTokenListItemCta(listItemToken); + + expect(isVisible).toBe(false); + }); + + it('returns true when user is not geo-blocked and conditions are met', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: ['GB'], + }); + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: true, + }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + balancesByChain: { [CHAIN_IDS.MAINNET]: '0x1234' }, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(true), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + const isVisible = + result.current.shouldShowTokenListItemCta(listItemToken); + + expect(isVisible).toBe(true); + }); + }); }); describe('shouldShowAssetOverviewCta', () => { @@ -1194,5 +1399,41 @@ describe('useMusdCtaVisibility', () => { expect(isVisible).toBe(false); }); + + describe('geo blocking', () => { + it('returns false when user is geo-blocked', () => { + mockIsMusdConversionAssetOverviewEnabled = true; + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: false, + isLoading: false, + geolocation: 'GB', + blockedCountries: ['GB'], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + const isVisible = + result.current.shouldShowAssetOverviewCta(assetOverviewToken); + + expect(isVisible).toBe(false); + }); + + it('returns true when user is not geo-blocked and token is configured for CTA', () => { + mockIsMusdConversionAssetOverviewEnabled = true; + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: ['GB'], + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + + const isVisible = + result.current.shouldShowAssetOverviewCta(assetOverviewToken); + + expect(isVisible).toBe(true); + }); + }); }); }); diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts index 58effedc369..3c0efed9a5e 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -25,6 +25,7 @@ import { toHexadecimal } from '../../../../util/number'; import { AssetType } from '../../../Views/confirmations/types/token'; import { useMusdConversionTokens } from './useMusdConversionTokens'; import { isTokenInWildcardList } from '../utils/wildcardTokenList'; +import { useMusdConversionEligibility } from './useMusdConversionEligibility'; import { selectAccountGroupBalanceForEmptyState } from '../../../../selectors/assets/balances'; import { isNonEvmChainId } from '../../../../core/Multichain/utils'; @@ -54,6 +55,7 @@ export const useMusdCtaVisibility = () => { const isMusdConversionAssetOverviewEnabled = useSelector( selectIsMusdConversionAssetOverviewEnabledFlag, ); + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); const musdConversionAssetDetailCtasSeen = useSelector( selectMusdConversionAssetDetailCtasSeen, ); @@ -188,6 +190,15 @@ export const useMusdCtaVisibility = () => { return hiddenResult; } + // If user is geo-blocked, don't show the CTA + if (!isGeoEligible) { + return { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + }; + } + // If all networks are selected if (isPopularNetworksFilterSelected) { // Show the buy/get mUSD CTA without network icon if: @@ -231,6 +242,7 @@ export const useMusdCtaVisibility = () => { canConvert, hasMusdBalanceOnAnyChain, hasMusdBalanceOnChain, + isGeoEligible, isEmptyWallet, isMusdBuyableOnAnyChain, isMusdBuyableOnChain, @@ -245,6 +257,11 @@ export const useMusdCtaVisibility = () => { return false; } + // If user is geo-blocked, don't show the CTA + if (!isGeoEligible) { + return false; + } + // mUSD needs to be available only on EVM chains if (isNonEvmChainId(asset.chainId)) { return false; @@ -263,6 +280,7 @@ export const useMusdCtaVisibility = () => { [ hasMusdBalanceOnAnyChain, hasMusdBalanceOnChain, + isGeoEligible, isMusdConversionTokenListItemCtaEnabled, isPopularNetworksFilterSelected, isTokenWithCta, @@ -285,12 +303,18 @@ export const useMusdCtaVisibility = () => { return false; } + // If user is geo-blocked, don't show the CTA + if (!isGeoEligible) { + return false; + } + return isTokenWithCta(asset); }, [ isMusdConversionAssetOverviewEnabled, isTokenWithCta, musdConversionAssetDetailCtasSeen, + isGeoEligible, ], ); diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index 2cad3b1d9d4..0bd392cd5b5 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -10,8 +10,10 @@ import { selectMusdConversionCTATokens, selectMusdConversionPaymentTokensAllowlist, selectMusdConversionPaymentTokensBlocklist, - selectMusdConversionMinAssetBalanceRequired, selectIsMusdConversionRewardsUiEnabledFlag, + selectMusdConversionBlockedCountries, + parseBlockedCountriesEnv, + selectMusdConversionMinAssetBalanceRequired, } from '.'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; import type { Json } from '@metamask/utils'; @@ -1764,6 +1766,299 @@ describe('Earn Feature Flag Selectors', () => { }); }); + describe('parseBlockedCountriesEnv', () => { + it('returns empty array for undefined input', () => { + const result = parseBlockedCountriesEnv(undefined); + + expect(result).toEqual([]); + }); + + it('returns empty array for empty string', () => { + const result = parseBlockedCountriesEnv(''); + + expect(result).toEqual([]); + }); + + it('returns empty array for whitespace-only string', () => { + const result = parseBlockedCountriesEnv(' '); + + expect(result).toEqual([]); + }); + + it('parses single country code', () => { + const result = parseBlockedCountriesEnv('GB'); + + expect(result).toEqual(['GB']); + }); + + it('parses comma-separated country codes', () => { + const result = parseBlockedCountriesEnv('GB,US,FR'); + + expect(result).toEqual(['GB', 'US', 'FR']); + }); + + it('trims whitespace around country codes', () => { + const result = parseBlockedCountriesEnv('GB , US , FR'); + + expect(result).toEqual(['GB', 'US', 'FR']); + }); + + it('converts country codes to uppercase', () => { + const result = parseBlockedCountriesEnv('gb,us,fr'); + + expect(result).toEqual(['GB', 'US', 'FR']); + }); + + it('filters out empty entries', () => { + const result = parseBlockedCountriesEnv('GB,,US,'); + + expect(result).toEqual(['GB', 'US']); + }); + + it('handles mixed case and whitespace', () => { + const result = parseBlockedCountriesEnv(' gb , Us, FR '); + + expect(result).toEqual(['GB', 'US', 'FR']); + }); + }); + + describe('selectMusdConversionBlockedCountries', () => { + it('returns blocked countries array when remote flag is valid', () => { + const blockedRegions = ['GB', 'US']; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: { blockedRegions }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB', 'US']); + }); + + it('returns default blocked countries when remote flag is undefined', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB']); + }); + + it('returns default blocked countries when blockedRegions is not an array', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: { + blockedRegions: 'GB', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB']); + }); + + it('returns default blocked countries when flag is null', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: null, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB']); + }); + + it('returns default blocked countries when flag has wrong structure', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: ['GB', 'US'], + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB']); + }); + + it('returns default blocked countries when RemoteFeatureFlagController is undefined', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: undefined, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB']); + }); + + describe('local env var fallback', () => { + afterEach(() => { + delete process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES; + }); + + it('falls back to local env var when remote flag is unavailable', () => { + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES = 'GB,US'; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB', 'US']); + }); + + it('remote flag takes precedence over local env var', () => { + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES = 'FR,DE'; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: { + blockedRegions: ['GB'], + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB']); + }); + + it('parses comma-separated country codes from env var', () => { + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES = 'GB, US, FR'; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB', 'US', 'FR']); + }); + + it('converts country codes to uppercase', () => { + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES = 'gb,us'; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB', 'US']); + }); + + it('returns default blocked countries when env var is empty string', () => { + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES = ''; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB']); + }); + + it('filters out empty entries from env var', () => { + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES = 'GB,,US,'; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB', 'US']); + }); + }); + }); + describe('selectMusdConversionMinAssetBalanceRequired', () => { afterEach(() => { delete process.env.MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index de61deca7d9..cae26ccb8d3 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -8,6 +8,7 @@ import { getWildcardTokenListFromConfig, WildcardTokenList, } from '../../utils/wildcardTokenList'; +import { DEFAULT_MUSD_BLOCKED_COUNTRIES } from '../../constants/musd'; export const selectPooledStakingEnabledFlag = createSelector( selectRemoteFeatureFlags, @@ -242,6 +243,64 @@ export const selectIsMusdConversionRewardsUiEnabledFlag = createSelector( }, ); +/** + * Parses a comma-separated string of country codes into an array. + * Returns empty array if input is undefined/empty. + * + * @param envValue - Comma-separated country codes (e.g., "GB,US,FR") + * @returns Array of country codes + */ +export const parseBlockedCountriesEnv = (envValue?: string): string[] => { + if (!envValue || envValue.trim() === '') { + return []; + } + return envValue + .split(',') + .map((code) => code.trim().toUpperCase()) + .filter((code) => code.length > 0); +}; + +/** + * Selects the geo-blocked countries for mUSD conversion from remote config or local fallback. + * Returns an array of ISO 3166-1 alpha-2 country codes (e.g., ['GB', 'US']). + * + * The Ramps geolocation API returns country codes like "GB" or "US-CA" (country-region). + * Matching uses startsWith to handle both country-only and country-region formats. + * + * Remote flag takes precedence over local env var. + * + * Examples: + * - Remote: { "blockedRegions": ["GB"] } - Block users in Great Britain + * - Remote: { "blockedRegions": ["GB", "US"] } - Block users in GB and US + * - Local env: "GB,US,FR" - Block users in GB, US, and FR + * + * If both remote and local are unavailable or invalid, defaults to blocking Great Britain. + */ +export const selectMusdConversionBlockedCountries = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags): string[] => { + // Try remote flag first (takes precedence) + const remoteFlag = + remoteFeatureFlags?.earnMusdConversionGeoBlockedCountries as + | { blockedRegions?: string[] } + | undefined; + + if (Array.isArray(remoteFlag?.blockedRegions)) { + return remoteFlag.blockedRegions; + } + + // Fallback to local env var + const envBlockedCountries = parseBlockedCountriesEnv( + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES, + ); + + // If env var is also empty, use default blocked countries + return envBlockedCountries.length > 0 + ? envBlockedCountries + : DEFAULT_MUSD_BLOCKED_COUNTRIES; + }, +); + const FALLBACK_MIN_ASSET_BALANCE_REQUIRED = 0.01; // 1 cent export const selectMusdConversionMinAssetBalanceRequired = createSelector( diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index f8a3224cef1..c701248758e 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -17,6 +17,11 @@ import Routes from '../../../../../constants/navigation/Routes'; import { toHex } from '@metamask/controller-utils'; import { strings } from '../../../../../../locales/i18n'; +import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; +import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; +import { selectIsMusdConversionFlowEnabledFlag } from '../../../Earn/selectors/featureFlags'; +import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd'; + jest.mock('../../../Stake/components/StakeButton', () => ({ __esModule: true, StakeButton: () => null, @@ -72,8 +77,6 @@ jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ }), })); -import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; - jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ useMusdConversionTokens: jest.fn(() => ({ isConversionToken: jest.fn().mockReturnValue(false), @@ -96,6 +99,20 @@ jest.mock('../../../Earn/hooks/useMusdCtaVisibility', () => ({ }), })); +jest.mock('../../../Earn/hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: jest.fn(() => ({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + })), +})); + +const mockUseMusdConversionEligibility = + useMusdConversionEligibility as jest.MockedFunction< + typeof useMusdConversionEligibility + >; + jest.mock('../../../../Views/confirmations/hooks/useNetworkName', () => ({ useNetworkName: () => 'Ethereum Mainnet', })); @@ -112,9 +129,6 @@ jest.mock('../../../Stake/hooks/useStakingChain', () => ({ useStakingChainByChainId: () => ({ isStakingSupportedChain: false }), })); -import { selectIsMusdConversionFlowEnabledFlag } from '../../../Earn/selectors/featureFlags'; -import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd'; - jest.mock('../../../Earn/selectors/featureFlags', () => ({ selectPooledStakingEnabledFlag: jest.fn(() => true), selectStablecoinLendingEnabledFlag: jest.fn(() => false), @@ -249,6 +263,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { pricePercentChange1d?: number; isMusdConversionEnabled?: boolean; isTokenWithCta?: boolean; + isGeoEligible?: boolean; } function prepareMocks({ @@ -256,6 +271,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { pricePercentChange1d = 5.67, isMusdConversionEnabled = false, isTokenWithCta = false, + isGeoEligible = true, }: PrepareMocksOptions = {}) { jest.clearAllMocks(); @@ -267,7 +283,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { })); mockShouldShowTokenListItemCta.mockReturnValue( - isMusdConversionEnabled && isTokenWithCta, + isMusdConversionEnabled && isTokenWithCta && isGeoEligible, ); // mUSD conversion mocks @@ -281,6 +297,12 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: isGeoEligible, + isLoading: false, + geolocation: isGeoEligible ? 'US' : 'GB', + blockedCountries: isGeoEligible ? [] : ['GB'], + }); // Default mock setup mockUseSelector.mockImplementation( @@ -579,6 +601,28 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { expect(queryByText('Get 3% mUSD bonus')).toBeNull(); }); + it('hides mUSD conversion CTA when user is geo-blocked', () => { + prepareMocks({ + asset: usdcAsset, + pricePercentChange1d: 1.5, + isMusdConversionEnabled: true, + isTokenWithCta: true, + isGeoEligible: false, + }); + + const { getByText, queryByText } = renderWithProvider( + , + ); + + expect(getByText('+1.50%')).toBeOnTheScreen(); + expect(queryByText('Convert to mUSD')).toBeNull(); + }); + it('calls initiateConversion with correct parameters when secondary balance is pressed', async () => { prepareMocks({ asset: usdcAsset, diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 10a3b4d594c..62289e497ad 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -31,6 +31,15 @@ import * as RemoveEvmTokenModule from './util/removeEvmToken'; // eslint-disable-next-line import/no-namespace import * as RemoveNonEvmTokenModule from './util/removeNonEvmToken'; +jest.mock('../Earn/hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: () => ({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + }), +})); + // Mocking versioning for some selectors jest.mock('react-native-device-info', () => ({ getVersion: jest.fn().mockReturnValue('1.0.0'), @@ -38,10 +47,10 @@ jest.mock('react-native-device-info', () => ({ // Mock MusdConversionAssetListCta to prevent deep dependency chain issues jest.mock('../Earn/components/Musd/MusdConversionAssetListCta', () => { - const { View } = jest.requireActual('react-native'); + const { View: MockView } = jest.requireActual('react-native'); return { __esModule: true, - default: () => , + default: () => , }; }); diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index ddcef99257d..7e0e66083b9 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -35,6 +35,7 @@ import { TokensEmptyState } from '../TokensEmptyState'; import MusdConversionAssetListCta from '../Earn/components/Musd/MusdConversionAssetListCta'; import { selectIsMusdConversionFlowEnabledFlag } from '../Earn/selectors/featureFlags'; import RemoveTokenBottomSheet from './TokenList/RemoveTokenBottomSheet'; +import { useMusdConversionEligibility } from '../Earn/hooks/useMusdConversionEligibility'; interface TokenListNavigationParamList { AddAsset: { assetType: string }; @@ -84,6 +85,7 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { const isMusdConversionFlowEnabled = useSelector( selectIsMusdConversionFlowEnabledFlag, ); + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); const [showScamWarningModal, setShowScamWarningModal] = useState(false); const [hasInitialLoad, setHasInitialLoad] = useState(false); @@ -207,7 +209,7 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { if (sortedTokenKeys.length > 0) { return ( <> - {isMusdConversionFlowEnabled && ( + {isMusdConversionFlowEnabled && isGeoEligible && ( @@ -241,6 +243,7 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { showRemoveMenu, handleScamWarningModal, maxItems, + isGeoEligible, ]); return ( diff --git a/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts b/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts index cc8b51371b4..f407f6d95e4 100644 --- a/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts +++ b/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts @@ -4,6 +4,7 @@ import { useSelector } from 'react-redux'; import { useCustomAmount } from './useCustomAmount'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { selectIsMusdConversionFlowEnabledFlag } from '../../../../UI/Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../../../UI/Earn/hooks/useMusdConversionEligibility'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -11,6 +12,11 @@ jest.mock('react-redux', () => ({ })); jest.mock('../transactions/useTransactionMetadataRequest'); jest.mock('../../../../UI/Earn/selectors/featureFlags'); +jest.mock('../../../../UI/Earn/hooks/useMusdConversionEligibility'); + +const mockUseMusdConversionEligibility = jest.mocked( + useMusdConversionEligibility, +); const mockUseSelector = jest.mocked(useSelector); const mockUseTransactionMetadataRequest = jest.mocked( @@ -26,6 +32,12 @@ describe('useCustomAmount', () => { } return undefined; }); + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + isLoading: false, + geolocation: 'US', + blockedCountries: [], + }); }); describe('non-mUSD transactions', () => { @@ -86,6 +98,26 @@ describe('useCustomAmount', () => { expect(result.current.shouldShowOutputAmountTag).toBe(false); }); + + it('returns shouldShowOutputAmountTag false when user is geo-blocked', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: false, + isLoading: false, + geolocation: 'GB', + blockedCountries: ['GB'], + }); + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.musdConversion, + } as ReturnType); + + const { result } = renderHook(() => + useCustomAmount({ amountHuman: '100' }), + ); + + expect(result.current.shouldShowOutputAmountTag).toBe(false); + expect(result.current.outputAmount).toBeNull(); + expect(result.current.outputSymbol).toBeNull(); + }); }); describe('output amount tag', () => { diff --git a/app/components/Views/confirmations/hooks/earn/useCustomAmount.tsx b/app/components/Views/confirmations/hooks/earn/useCustomAmount.tsx index a72af0a0d70..09edeeaf054 100644 --- a/app/components/Views/confirmations/hooks/earn/useCustomAmount.tsx +++ b/app/components/Views/confirmations/hooks/earn/useCustomAmount.tsx @@ -5,6 +5,7 @@ import { limitToMaximumDecimalPlaces } from '../../../../../util/number'; import { selectIsMusdConversionFlowEnabledFlag } from '../../../../UI/Earn/selectors/featureFlags'; import { hasTransactionType } from '../../utils/transaction'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useMusdConversionEligibility } from '../../../../UI/Earn/hooks/useMusdConversionEligibility'; export interface UseCustomAmountParams { /** @@ -36,10 +37,11 @@ export const useCustomAmount = ({ const isMusdConversionFlowEnabled = useSelector( selectIsMusdConversionFlowEnabledFlag, ); - + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); const transactionMeta = useTransactionMetadataRequest(); const isMusdConversion = isMusdConversionFlowEnabled && + isGeoEligible && hasTransactionType(transactionMeta, [TransactionType.musdConversion]); // Output amount tag logic - currently for mUSD conversion only