From 793073a5459840dbe6f41cf3e7cea34e21dbde4d Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 13 Jan 2026 15:49:04 -0600 Subject: [PATCH 01/10] feat(earn): add geo-blocking for mUSD conversion feature --- .js.env.example | 3 + .../EarnBalance/EarnBalance.test.tsx | 93 ++++++ .../UI/Earn/components/EarnBalance/index.tsx | 4 +- .../EarnLendingBalance.test.tsx | 96 ++++++ .../useMusdConversionEligibility.test.ts | 124 ++++++++ .../hooks/useMusdConversionEligibility.ts | 40 +++ .../Earn/hooks/useMusdCtaVisibility.test.ts | 220 ++++++++++++- .../UI/Earn/hooks/useMusdCtaVisibility.ts | 25 +- .../Earn/selectors/featureFlags/index.test.ts | 295 ++++++++++++++++++ .../UI/Earn/selectors/featureFlags/index.ts | 53 ++++ .../TokenListItem/TokenListItem.test.tsx | 44 +++ app/components/UI/Tokens/index.tsx | 56 ++-- .../hooks/earn/useCustomAmount.test.ts | 30 ++ .../hooks/earn/useCustomAmount.tsx | 4 +- 14 files changed, 1062 insertions(+), 25 deletions(-) create mode 100644 app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts create mode 100644 app/components/UI/Earn/hooks/useMusdConversionEligibility.ts diff --git a/.js.env.example b/.js.env.example index 2a5cf245108..a565318d210 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" # Activates remote feature flag override mode. # Remote feature flag values won't be updated, diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx index 5a02488cbe5..03c351faacc 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,6 +84,8 @@ jest.mock('../EarnLendingBalance', () => ({ default: jest.fn(), })); +import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; + jest.mock('../../hooks/useMusdConversionTokens', () => ({ __esModule: true, useMusdConversionTokens: jest.fn().mockReturnValue({ @@ -94,6 +97,36 @@ jest.mock('../../hooks/useMusdConversionTokens', () => ({ }), })); +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, + geolocation: 'US', + blockedCountries: [], + }), +})); + +const mockUseMusdConversionEligibility = + useMusdConversionEligibility as jest.MockedFunction< + typeof useMusdConversionEligibility + >; + jest.mock('../../hooks/useTronStakeApy', () => ({ __esModule: true, default: jest.fn().mockReturnValue({ @@ -252,4 +285,64 @@ 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), + tokenFilter: jest.fn(), + tokens: [], + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), + }); + + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: 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), + tokenFilter: jest.fn(), + tokens: [], + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), + }); + + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + 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 70300ad1470..93152cf4641 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -150,6 +150,16 @@ const mockUseStakingEligibility = useStakingEligibility as jest.MockedFunction< typeof useStakingEligibility >; +import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility'; + +jest.mock('../../hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: jest.fn().mockReturnValue({ + isEligible: true, + geolocation: 'US', + blockedCountries: [], + }), +})); + jest.mock('../../selectors/featureFlags', () => ({ selectIsMusdConversionFlowEnabledFlag: jest.fn(), selectPooledStakingEnabledFlag: jest.fn(), @@ -727,4 +737,90 @@ describe('EarnLendingBalance', () => { ).toBeOnTheScreen(); expect(queryByTestId(EARN_EMPTY_STATE_CTA_TEST_ID)).toBeNull(); }); + + 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, + }); + + ( + selectStablecoinLendingEnabledFlag as jest.MockedFunction< + typeof selectStablecoinLendingEnabledFlag + > + ).mockReturnValue(false); + + ( + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + > + ).mockReturnValue(true); + + // 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/hooks/useMusdConversionEligibility.test.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts new file mode 100644 index 00000000000..dbb484a97a0 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts @@ -0,0 +1,124 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useMusdConversionEligibility } from './useMusdConversionEligibility'; + +// Mock the selectors +const mockSelectGeolocation = jest.fn(); +const mockSelectMusdConversionBlockedCountries = jest.fn(); + +jest.mock('../../../../selectors/rampsController', () => ({ + selectGeolocation: (state: unknown) => mockSelectGeolocation(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(); + mockSelectGeolocation.mockReturnValue(null); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + }); + + describe('isEligible', () => { + it('returns true when geolocation is null', () => { + mockSelectGeolocation.mockReturnValue(null); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(true); + }); + + it('returns true when blockedCountries is empty', () => { + mockSelectGeolocation.mockReturnValue('GB'); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(true); + }); + + it('returns false when user is in a blocked country', () => { + mockSelectGeolocation.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', () => { + mockSelectGeolocation.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', () => { + mockSelectGeolocation.mockReturnValue('FR'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB', 'US']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(true); + }); + + it('performs case-insensitive comparison for country codes', () => { + mockSelectGeolocation.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', () => { + mockSelectGeolocation.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', () => { + mockSelectGeolocation.mockReturnValue('US-CA'); + mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(true); + }); + }); + + describe('return values', () => { + it('returns geolocation from selector', () => { + mockSelectGeolocation.mockReturnValue('FR'); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.geolocation).toBe('FR'); + }); + + it('returns blockedCountries from selector', () => { + const blockedCountries = ['GB', 'US']; + mockSelectGeolocation.mockReturnValue('FR'); + mockSelectMusdConversionBlockedCountries.mockReturnValue( + blockedCountries, + ); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.blockedCountries).toEqual(blockedCountries); + }); + }); +}); diff --git a/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts new file mode 100644 index 00000000000..2efa326ce37 --- /dev/null +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectGeolocation } from '../../../../selectors/rampsController'; +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. + * + * @returns Object containing: + * - isEligible: true if user is not in a blocked country (or if geolocation/blocked list unavailable) + * - 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(selectGeolocation); + const blockedCountries = useSelector(selectMusdConversionBlockedCountries); + + const isEligible = useMemo(() => { + // Default to eligible if geolocation is unknown or no blocked countries configured + if (!geolocation || 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 + const userCountry = geolocation.toUpperCase(); + return blockedCountries.every( + (blockedCountry) => !userCountry.startsWith(blockedCountry.toUpperCase()), + ); + }, [geolocation, blockedCountries]); + + return { + isEligible, + geolocation, + blockedCountries, + }; +}; diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index 017aba383ef..ebf2e969087 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'; @@ -21,6 +22,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'); @@ -49,8 +51,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: [], @@ -141,6 +146,11 @@ describe('useMusdCtaVisibility', () => { isMusdSupportedOnChain: jest.fn(), getMusdOutputChainId: jest.fn(), }); + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + geolocation: 'US', + blockedCountries: [], + }); }); afterEach(() => { @@ -664,6 +674,98 @@ describe('useMusdCtaVisibility', () => { }); }); + describe('geo blocking', () => { + it('returns shouldShowCta false when user is geo-blocked in all networks view', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: 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, + 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, + 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), + }); + + 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({ @@ -836,6 +938,88 @@ describe('useMusdCtaVisibility', () => { expect(isVisible).toBe(false); }); + + describe('geo blocking', () => { + it('returns false when user is geo-blocked', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: 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, + 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, + 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', () => { @@ -908,5 +1092,39 @@ describe('useMusdCtaVisibility', () => { expect(isVisible).toBe(false); }); + + describe('geo blocking', () => { + it('returns false when user is geo-blocked', () => { + mockIsMusdConversionAssetOverviewEnabled = true; + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: 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, + 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 416b5ac9b29..decc616d48f 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -24,6 +24,7 @@ import { toHex } from '@metamask/controller-utils'; import { AssetType } from '../../../Views/confirmations/types/token'; import { useMusdConversionTokens } from './useMusdConversionTokens'; import { isTokenInWildcardList } from '../utils/wildcardTokenList'; +import { useMusdConversionEligibility } from './useMusdConversionEligibility'; /** * Hook exposing helpers that decide whether to show various mUSD-related CTAs. @@ -51,6 +52,7 @@ export const useMusdCtaVisibility = () => { const isMusdConversionAssetOverviewEnabled = useSelector( selectIsMusdConversionAssetOverviewEnabledFlag, ); + const { isEligible: isGeoEligible } = useMusdConversionEligibility(); const { enabledNetworks } = useCurrentNetworkInfo(); const { areAllNetworksSelected } = useNetworksByCustomNamespace({ @@ -153,6 +155,15 @@ export const useMusdCtaVisibility = () => { }; } + // 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: @@ -201,6 +212,7 @@ export const useMusdCtaVisibility = () => { }, [ hasMusdBalanceOnAnyChain, hasMusdBalanceOnChain, + isGeoEligible, isMusdBuyableOnAnyChain, isMusdBuyableOnChain, isMusdGetBuyCtaEnabled, @@ -214,6 +226,11 @@ export const useMusdCtaVisibility = () => { return false; } + // If user is geo-blocked, don't show the CTA + if (!isGeoEligible) { + return false; + } + if (isPopularNetworksFilterSelected) { return hasMusdBalanceOnAnyChain && isTokenWithCta(asset); } @@ -226,6 +243,7 @@ export const useMusdCtaVisibility = () => { [ hasMusdBalanceOnAnyChain, hasMusdBalanceOnChain, + isGeoEligible, isMusdConversionTokenListItemCtaEnabled, isPopularNetworksFilterSelected, isTokenWithCta, @@ -238,9 +256,14 @@ export const useMusdCtaVisibility = () => { return false; } + // If user is geo-blocked, don't show the CTA + if (!isGeoEligible) { + return false; + } + return isTokenWithCta(asset); }, - [isMusdConversionAssetOverviewEnabled, isTokenWithCta], + [isMusdConversionAssetOverviewEnabled, isTokenWithCta, isGeoEligible], ); return { diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index fca37041d1d..31e290ecb64 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -11,6 +11,8 @@ import { selectMusdConversionPaymentTokensAllowlist, selectMusdConversionPaymentTokensBlocklist, selectIsMusdConversionRewardsUiEnabledFlag, + selectMusdConversionBlockedCountries, + parseBlockedCountriesEnv, } from '.'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; import type { Json } from '@metamask/utils'; @@ -1762,4 +1764,297 @@ 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 blockedCountries = ['GB', 'US']; + + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: { blockedCountries }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual(['GB', 'US']); + }); + + it('returns empty array when remote flag is undefined', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual([]); + }); + + it('returns empty array when blockedCountries is not an array', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: { + blockedCountries: 'GB', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual([]); + }); + + it('returns empty array when flag is null', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: null, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual([]); + }); + + it('returns empty array when flag has wrong structure', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + earnMusdConversionGeoBlockedCountries: ['GB', 'US'], + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual([]); + }); + + it('returns empty array when RemoteFeatureFlagController is undefined', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: undefined, + }, + }, + }; + + const result = selectMusdConversionBlockedCountries(state); + + expect(result).toEqual([]); + }); + + 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: { + blockedCountries: ['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 empty array 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([]); + }); + + 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']); + }); + }); + }); }); diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 5b262b0974a..027e9465e01 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -241,3 +241,56 @@ export const selectIsMusdConversionRewardsUiEnabledFlag = createSelector( return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; }, ); + +/** + * 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, returns [] (no geo-blocking). + */ +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 + return parseBlockedCountriesEnv( + process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES, + ); + }, +); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index f235e81b70e..ffa023711bb 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -85,6 +85,21 @@ jest.mock('../../../Earn/hooks/useMusdCtaVisibility', () => ({ }), })); +import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; + +jest.mock('../../../Earn/hooks/useMusdConversionEligibility', () => ({ + useMusdConversionEligibility: jest.fn(() => ({ + isEligible: true, + geolocation: 'US', + blockedCountries: [], + })), +})); + +const mockUseMusdConversionEligibility = + useMusdConversionEligibility as jest.MockedFunction< + typeof useMusdConversionEligibility + >; + jest.mock('../../../../../selectors/earnController/earn', () => ({ earnSelectors: { selectPrimaryEarnExperienceTypeForAsset: jest.fn(() => 'pooled-staking'), @@ -229,6 +244,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { pricePercentChange1d?: number; isMusdConversionEnabled?: boolean; isTokenWithCta?: boolean; + isGeoEligible?: boolean; } function prepareMocks({ @@ -236,6 +252,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { pricePercentChange1d = 5.67, isMusdConversionEnabled = false, isTokenWithCta = false, + isGeoEligible = true, }: PrepareMocksOptions = {}) { jest.clearAllMocks(); @@ -254,6 +271,11 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], }); + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: isGeoEligible, + geolocation: isGeoEligible ? 'US' : 'GB', + blockedCountries: isGeoEligible ? [] : ['GB'], + }); // Default mock setup mockUseSelector.mockImplementation( @@ -552,6 +574,28 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { expect(queryByText('Convert to mUSD')).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.tsx b/app/components/UI/Tokens/index.tsx index fa0dff8394b..88a58df6c8a 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); @@ -183,26 +185,19 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { return isHomepageRedesignV1Enabled ? 10 : undefined; }, [isFullView, isHomepageRedesignV1Enabled]); - return ( - - - {!hasInitialLoad ? ( + const renderTokenContent = () => { + if (!hasInitialLoad) { + return ( - ) : sortedTokenKeys.length > 0 ? ( + ); + } + + if (sortedTokenKeys.length > 0) { + return ( <> - {isMusdConversionFlowEnabled && ( + {isMusdConversionFlowEnabled && isGeoEligible && ( @@ -217,11 +212,30 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { isFullView={isFullView} /> - ) : ( - - - - )} + ); + } + + return ( + + + + ); + }; + + return ( + + + {renderTokenContent()} ({ ...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,11 @@ describe('useCustomAmount', () => { } return undefined; }); + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: true, + geolocation: 'US', + blockedCountries: [], + }); }); describe('non-mUSD transactions', () => { @@ -86,6 +97,25 @@ describe('useCustomAmount', () => { expect(result.current.shouldShowOutputAmountTag).toBe(false); }); + + it('returns shouldShowOutputAmountTag false when user is geo-blocked', () => { + mockUseMusdConversionEligibility.mockReturnValue({ + isEligible: 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 From e9d1bbd9379af7cc3faa0da24f9895bdbdb610bd Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 13 Jan 2026 20:09:22 -0600 Subject: [PATCH 02/10] fix: test unit update to blockRegion for feature flag object --- .../Earn/components/EarnBalance/EarnBalance.test.tsx | 10 +++++++--- .../EarnLendingBalance/EarnLendingBalance.test.tsx | 3 +-- .../UI/Earn/selectors/featureFlags/index.test.ts | 10 +++++----- .../TokenList/TokenListItem/TokenListItem.test.tsx | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx index 03c351faacc..5a7f8251441 100644 --- a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx @@ -90,7 +90,7 @@ 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'), @@ -292,8 +292,10 @@ describe('EarnBalance', () => { mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(true), + filterAllowedTokens: jest.fn(), tokens: [], + tokensWithCTAs: [], isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), }); @@ -321,8 +323,10 @@ describe('EarnBalance', () => { mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), - tokenFilter: jest.fn(), + isTokenWithCta: jest.fn().mockReturnValue(true), + filterAllowedTokens: jest.fn(), tokens: [], + tokensWithCTAs: [], isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), }); diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index 93152cf4641..37f5442f1f4 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -23,6 +23,7 @@ import { EARN_EMPTY_STATE_CTA_TEST_ID } from '../EmptyStateCta'; import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; import { EARN_TEST_IDS } from '../../constants/testIds'; import useStakingEligibility from '../../../Stake/hooks/useStakingEligibility'; +import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility'; const mockNavigate = jest.fn(); const mockDaiMainnet: EarnTokenDetails = { @@ -150,8 +151,6 @@ const mockUseStakingEligibility = useStakingEligibility as jest.MockedFunction< typeof useStakingEligibility >; -import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility'; - jest.mock('../../hooks/useMusdConversionEligibility', () => ({ useMusdConversionEligibility: jest.fn().mockReturnValue({ isEligible: true, diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index 31e290ecb64..d3831bd8f4e 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -1823,14 +1823,14 @@ describe('Earn Feature Flag Selectors', () => { describe('selectMusdConversionBlockedCountries', () => { it('returns blocked countries array when remote flag is valid', () => { - const blockedCountries = ['GB', 'US']; + const blockedRegions = ['GB', 'US']; const state = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - earnMusdConversionGeoBlockedCountries: { blockedCountries }, + earnMusdConversionGeoBlockedCountries: { blockedRegions }, }, cacheTimestamp: 0, }, @@ -1860,14 +1860,14 @@ describe('Earn Feature Flag Selectors', () => { expect(result).toEqual([]); }); - it('returns empty array when blockedCountries is not an array', () => { + it('returns empty array when blockedRegions is not an array', () => { const state = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { earnMusdConversionGeoBlockedCountries: { - blockedCountries: 'GB', + blockedRegions: 'GB', }, }, cacheTimestamp: 0, @@ -1966,7 +1966,7 @@ describe('Earn Feature Flag Selectors', () => { RemoteFeatureFlagController: { remoteFeatureFlags: { earnMusdConversionGeoBlockedCountries: { - blockedCountries: ['GB'], + blockedRegions: ['GB'], }, }, cacheTimestamp: 0, diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index ffa023711bb..fa04666f200 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -257,7 +257,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { jest.clearAllMocks(); mockShouldShowTokenListItemCta.mockReturnValue( - isMusdConversionEnabled && isTokenWithCta, + isMusdConversionEnabled && isTokenWithCta && isGeoEligible, ); // mUSD conversion mocks From 81c7228083bbf014be815627af059c007cee5969 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 15 Jan 2026 12:00:09 -0600 Subject: [PATCH 03/10] fix: unit test tsc --- .../UI/Earn/components/EarnBalance/EarnBalance.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx index 5a7f8251441..cd165adcbe5 100644 --- a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx @@ -292,10 +292,8 @@ describe('EarnBalance', () => { mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), - isTokenWithCta: jest.fn().mockReturnValue(true), filterAllowedTokens: jest.fn(), tokens: [], - tokensWithCTAs: [], isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), }); @@ -323,10 +321,8 @@ describe('EarnBalance', () => { mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), - isTokenWithCta: jest.fn().mockReturnValue(true), filterAllowedTokens: jest.fn(), tokens: [], - tokensWithCTAs: [], isMusdSupportedOnChain: jest.fn().mockReturnValue(true), getMusdOutputChainId: jest.fn().mockReturnValue('0x1'), }); From 99b4a75c96183600690758d3af4563772db32f31 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 15 Jan 2026 12:07:01 -0600 Subject: [PATCH 04/10] chore: address feedback, default to GB blocked, resolve conflict --- app/components/UI/Earn/constants/musd.ts | 6 +++++ .../Earn/selectors/featureFlags/index.test.ts | 24 +++++++++---------- .../UI/Earn/selectors/featureFlags/index.ts | 10 ++++++-- 3 files changed, 26 insertions(+), 14 deletions(-) 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/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index d3831bd8f4e..f8f086c7431 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -1843,7 +1843,7 @@ describe('Earn Feature Flag Selectors', () => { expect(result).toEqual(['GB', 'US']); }); - it('returns empty array when remote flag is undefined', () => { + it('returns default blocked countries when remote flag is undefined', () => { const state = { engine: { backgroundState: { @@ -1857,10 +1857,10 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionBlockedCountries(state); - expect(result).toEqual([]); + expect(result).toEqual(['GB']); }); - it('returns empty array when blockedRegions is not an array', () => { + it('returns default blocked countries when blockedRegions is not an array', () => { const state = { engine: { backgroundState: { @@ -1878,10 +1878,10 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionBlockedCountries(state); - expect(result).toEqual([]); + expect(result).toEqual(['GB']); }); - it('returns empty array when flag is null', () => { + it('returns default blocked countries when flag is null', () => { const state = { engine: { backgroundState: { @@ -1897,10 +1897,10 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionBlockedCountries(state); - expect(result).toEqual([]); + expect(result).toEqual(['GB']); }); - it('returns empty array when flag has wrong structure', () => { + it('returns default blocked countries when flag has wrong structure', () => { const state = { engine: { backgroundState: { @@ -1916,10 +1916,10 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionBlockedCountries(state); - expect(result).toEqual([]); + expect(result).toEqual(['GB']); }); - it('returns empty array when RemoteFeatureFlagController is undefined', () => { + it('returns default blocked countries when RemoteFeatureFlagController is undefined', () => { const state = { engine: { backgroundState: { @@ -1930,7 +1930,7 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionBlockedCountries(state); - expect(result).toEqual([]); + expect(result).toEqual(['GB']); }); describe('local env var fallback', () => { @@ -2018,7 +2018,7 @@ describe('Earn Feature Flag Selectors', () => { expect(result).toEqual(['GB', 'US']); }); - it('returns empty array when env var is empty string', () => { + it('returns default blocked countries when env var is empty string', () => { process.env.MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES = ''; const state = { @@ -2034,7 +2034,7 @@ describe('Earn Feature Flag Selectors', () => { const result = selectMusdConversionBlockedCountries(state); - expect(result).toEqual([]); + expect(result).toEqual(['GB']); }); it('filters out empty entries from env var', () => { diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 027e9465e01..ac05f58f26d 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, @@ -273,7 +274,7 @@ export const parseBlockedCountriesEnv = (envValue?: string): string[] => { * - 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, returns [] (no geo-blocking). + * If both remote and local are unavailable or invalid, defaults to blocking Great Britain. */ export const selectMusdConversionBlockedCountries = createSelector( selectRemoteFeatureFlags, @@ -289,8 +290,13 @@ export const selectMusdConversionBlockedCountries = createSelector( } // Fallback to local env var - return parseBlockedCountriesEnv( + 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; }, ); From ae60a5a96b7215bc8e49c4cc2cf3d147493e8941 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 15 Jan 2026 12:47:45 -0600 Subject: [PATCH 05/10] fix: eslint --- .../components/EarnLendingBalance/EarnLendingBalance.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index 37f5442f1f4..2af4ef6aee3 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -23,7 +23,6 @@ import { EARN_EMPTY_STATE_CTA_TEST_ID } from '../EmptyStateCta'; import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; import { EARN_TEST_IDS } from '../../constants/testIds'; import useStakingEligibility from '../../../Stake/hooks/useStakingEligibility'; -import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility'; const mockNavigate = jest.fn(); const mockDaiMainnet: EarnTokenDetails = { From 041502b5c4caece931a41216954351788c5b41fc Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 15 Jan 2026 13:02:07 -0600 Subject: [PATCH 06/10] chore: address bugbot feedback --- .../EarnBalance/EarnBalance.test.tsx | 3 ++ .../EarnLendingBalance.test.tsx | 1 + .../useMusdConversionEligibility.test.ts | 35 +++++++++++++++++-- .../hooks/useMusdConversionEligibility.ts | 19 ++++++++-- .../Earn/hooks/useMusdCtaVisibility.test.ts | 9 +++++ .../TokenListItem/TokenListItem.test.tsx | 2 ++ .../hooks/earn/useCustomAmount.test.ts | 2 ++ 7 files changed, 65 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx index cd165adcbe5..76958ac4c83 100644 --- a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx @@ -117,6 +117,7 @@ import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligi jest.mock('../../hooks/useMusdConversionEligibility', () => ({ useMusdConversionEligibility: jest.fn().mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: [], }), @@ -300,6 +301,7 @@ describe('EarnBalance', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false, + isLoading: false, geolocation: 'GB', blockedCountries: ['GB'], }); @@ -329,6 +331,7 @@ describe('EarnBalance', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: [], }); diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index 2af4ef6aee3..3ab16252944 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -153,6 +153,7 @@ const mockUseStakingEligibility = useStakingEligibility as jest.MockedFunction< jest.mock('../../hooks/useMusdConversionEligibility', () => ({ useMusdConversionEligibility: jest.fn().mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: [], }), diff --git a/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts index dbb484a97a0..86ffbc79073 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts @@ -26,16 +26,25 @@ describe('useMusdConversionEligibility', () => { }); describe('isEligible', () => { - it('returns true when geolocation is null', () => { + it('returns false when geolocation is null (blocks by default for compliance)', () => { mockSelectGeolocation.mockReturnValue(null); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); const { result } = renderHook(() => useMusdConversionEligibility()); - expect(result.current.isEligible).toBe(true); + expect(result.current.isEligible).toBe(false); }); - it('returns true when blockedCountries is empty', () => { + it('returns false when geolocation is null even with empty blocked list', () => { + mockSelectGeolocation.mockReturnValue(null); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isEligible).toBe(false); + }); + + it('returns true when blockedCountries is empty and geolocation is known', () => { mockSelectGeolocation.mockReturnValue('GB'); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); @@ -121,4 +130,24 @@ describe('useMusdConversionEligibility', () => { expect(result.current.blockedCountries).toEqual(blockedCountries); }); }); + + describe('isLoading', () => { + it('returns true when geolocation is null', () => { + mockSelectGeolocation.mockReturnValue(null); + mockSelectMusdConversionBlockedCountries.mockReturnValue([]); + + const { result } = renderHook(() => useMusdConversionEligibility()); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns false when geolocation is available', () => { + mockSelectGeolocation.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 index 2efa326ce37..9e8c75d9d9b 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts @@ -9,8 +9,13 @@ import { selectMusdConversionBlockedCountries } from '../selectors/featureFlags' * 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 if user is not in a blocked country (or if geolocation/blocked list unavailable) + * - 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 */ @@ -18,9 +23,16 @@ export const useMusdConversionEligibility = () => { const geolocation = useSelector(selectGeolocation); const blockedCountries = useSelector(selectMusdConversionBlockedCountries); + const isLoading = geolocation === null; + const isEligible = useMemo(() => { - // Default to eligible if geolocation is unknown or no blocked countries configured - if (!geolocation || blockedCountries.length === 0) { + // Block by default when geolocation is unknown for regulatory compliance + if (!geolocation) { + return false; + } + + // If no blocked countries configured, allow access + if (blockedCountries.length === 0) { return true; } @@ -34,6 +46,7 @@ export const useMusdConversionEligibility = () => { return { isEligible, + isLoading, geolocation, blockedCountries, }; diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index ebf2e969087..8019568eb4d 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -148,6 +148,7 @@ describe('useMusdCtaVisibility', () => { }); mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: [], }); @@ -678,6 +679,7 @@ describe('useMusdCtaVisibility', () => { it('returns shouldShowCta false when user is geo-blocked in all networks view', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false, + isLoading: false, geolocation: 'GB', blockedCountries: ['GB'], }); @@ -710,6 +712,7 @@ describe('useMusdCtaVisibility', () => { it('returns shouldShowCta false when user is geo-blocked on single supported chain', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false, + isLoading: false, geolocation: 'GB', blockedCountries: ['GB'], }); @@ -739,6 +742,7 @@ describe('useMusdCtaVisibility', () => { it('returns shouldShowCta true when user is not geo-blocked', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: ['GB'], }); @@ -943,6 +947,7 @@ describe('useMusdCtaVisibility', () => { it('returns false when user is geo-blocked', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false, + isLoading: false, geolocation: 'GB', blockedCountries: ['GB'], }); @@ -967,6 +972,7 @@ describe('useMusdCtaVisibility', () => { 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'], }); @@ -999,6 +1005,7 @@ describe('useMusdCtaVisibility', () => { it('returns true when user is not geo-blocked and conditions are met', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: ['GB'], }); @@ -1098,6 +1105,7 @@ describe('useMusdCtaVisibility', () => { mockIsMusdConversionAssetOverviewEnabled = true; mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false, + isLoading: false, geolocation: 'GB', blockedCountries: ['GB'], }); @@ -1114,6 +1122,7 @@ describe('useMusdCtaVisibility', () => { mockIsMusdConversionAssetOverviewEnabled = true; mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: ['GB'], }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 81948450756..71dd71e1b6a 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -101,6 +101,7 @@ jest.mock('../../../Earn/hooks/useMusdCtaVisibility', () => ({ jest.mock('../../../Earn/hooks/useMusdConversionEligibility', () => ({ useMusdConversionEligibility: jest.fn(() => ({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: [], })), @@ -297,6 +298,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { }); mockUseMusdConversionEligibility.mockReturnValue({ isEligible: isGeoEligible, + isLoading: false, geolocation: isGeoEligible ? 'US' : 'GB', blockedCountries: isGeoEligible ? [] : ['GB'], }); diff --git a/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts b/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts index fb581770b8c..f407f6d95e4 100644 --- a/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts +++ b/app/components/Views/confirmations/hooks/earn/useCustomAmount.test.ts @@ -34,6 +34,7 @@ describe('useCustomAmount', () => { }); mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true, + isLoading: false, geolocation: 'US', blockedCountries: [], }); @@ -101,6 +102,7 @@ describe('useCustomAmount', () => { it('returns shouldShowOutputAmountTag false when user is geo-blocked', () => { mockUseMusdConversionEligibility.mockReturnValue({ isEligible: false, + isLoading: false, geolocation: 'GB', blockedCountries: ['GB'], }); From c2a5d80d57ef4943756d1815169c4591c9da02bf Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 20 Jan 2026 11:59:18 -0600 Subject: [PATCH 07/10] chore: fix tests after merge conflict --- app/components/UI/Tokens/index.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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: () => , }; }); From 8c46a7eba1cb65fe2b102c8eece4184ff392f33d Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 20 Jan 2026 14:29:39 -0600 Subject: [PATCH 08/10] chore: update unit tests for removal of ramps selector --- .../UI/AssetOverview/Balance/index.test.tsx | 9 +++++ .../useMusdConversionEligibility.test.ts | 34 +++++++++---------- .../hooks/useMusdConversionEligibility.ts | 18 ++++++---- 3 files changed, 37 insertions(+), 24 deletions(-) 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/hooks/useMusdConversionEligibility.test.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts index 86ffbc79073..a1db2b92002 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts @@ -2,11 +2,11 @@ import { renderHook } from '@testing-library/react-hooks'; import { useMusdConversionEligibility } from './useMusdConversionEligibility'; // Mock the selectors -const mockSelectGeolocation = jest.fn(); +const mockGetDetectedGeolocation = jest.fn(); const mockSelectMusdConversionBlockedCountries = jest.fn(); -jest.mock('../../../../selectors/rampsController', () => ({ - selectGeolocation: (state: unknown) => mockSelectGeolocation(state), +jest.mock('../../../../reducers/fiatOrders', () => ({ + getDetectedGeolocation: (state: unknown) => mockGetDetectedGeolocation(state), })); jest.mock('../selectors/featureFlags', () => ({ @@ -21,13 +21,13 @@ jest.mock('react-redux', () => ({ describe('useMusdConversionEligibility', () => { beforeEach(() => { jest.clearAllMocks(); - mockSelectGeolocation.mockReturnValue(null); + mockGetDetectedGeolocation.mockReturnValue(null); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); }); describe('isEligible', () => { it('returns false when geolocation is null (blocks by default for compliance)', () => { - mockSelectGeolocation.mockReturnValue(null); + mockGetDetectedGeolocation.mockReturnValue(null); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -36,7 +36,7 @@ describe('useMusdConversionEligibility', () => { }); it('returns false when geolocation is null even with empty blocked list', () => { - mockSelectGeolocation.mockReturnValue(null); + mockGetDetectedGeolocation.mockReturnValue(null); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -45,7 +45,7 @@ describe('useMusdConversionEligibility', () => { }); it('returns true when blockedCountries is empty and geolocation is known', () => { - mockSelectGeolocation.mockReturnValue('GB'); + mockGetDetectedGeolocation.mockReturnValue('GB'); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -54,7 +54,7 @@ describe('useMusdConversionEligibility', () => { }); it('returns false when user is in a blocked country', () => { - mockSelectGeolocation.mockReturnValue('GB'); + mockGetDetectedGeolocation.mockReturnValue('GB'); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB', 'US']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -63,7 +63,7 @@ describe('useMusdConversionEligibility', () => { }); it('returns false when user country starts with blocked country code', () => { - mockSelectGeolocation.mockReturnValue('GB-ENG'); + mockGetDetectedGeolocation.mockReturnValue('GB-ENG'); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -72,7 +72,7 @@ describe('useMusdConversionEligibility', () => { }); it('returns true when user is not in any blocked country', () => { - mockSelectGeolocation.mockReturnValue('FR'); + mockGetDetectedGeolocation.mockReturnValue('FR'); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB', 'US']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -81,7 +81,7 @@ describe('useMusdConversionEligibility', () => { }); it('performs case-insensitive comparison for country codes', () => { - mockSelectGeolocation.mockReturnValue('gb'); + mockGetDetectedGeolocation.mockReturnValue('gb'); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -90,7 +90,7 @@ describe('useMusdConversionEligibility', () => { }); it('handles US state codes correctly when US is blocked', () => { - mockSelectGeolocation.mockReturnValue('US-CA'); + mockGetDetectedGeolocation.mockReturnValue('US-CA'); mockSelectMusdConversionBlockedCountries.mockReturnValue(['US']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -99,7 +99,7 @@ describe('useMusdConversionEligibility', () => { }); it('returns true for US user when only UK is blocked', () => { - mockSelectGeolocation.mockReturnValue('US-CA'); + mockGetDetectedGeolocation.mockReturnValue('US-CA'); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -110,7 +110,7 @@ describe('useMusdConversionEligibility', () => { describe('return values', () => { it('returns geolocation from selector', () => { - mockSelectGeolocation.mockReturnValue('FR'); + mockGetDetectedGeolocation.mockReturnValue('FR'); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -120,7 +120,7 @@ describe('useMusdConversionEligibility', () => { it('returns blockedCountries from selector', () => { const blockedCountries = ['GB', 'US']; - mockSelectGeolocation.mockReturnValue('FR'); + mockGetDetectedGeolocation.mockReturnValue('FR'); mockSelectMusdConversionBlockedCountries.mockReturnValue( blockedCountries, ); @@ -133,7 +133,7 @@ describe('useMusdConversionEligibility', () => { describe('isLoading', () => { it('returns true when geolocation is null', () => { - mockSelectGeolocation.mockReturnValue(null); + mockGetDetectedGeolocation.mockReturnValue(null); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -142,7 +142,7 @@ describe('useMusdConversionEligibility', () => { }); it('returns false when geolocation is available', () => { - mockSelectGeolocation.mockReturnValue('US'); + mockGetDetectedGeolocation.mockReturnValue('US'); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionEligibility()); diff --git a/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts index 9e8c75d9d9b..f3c6b8a9d33 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { selectGeolocation } from '../../../../selectors/rampsController'; +import { getDetectedGeolocation } from '../../../../reducers/fiatOrders'; import { selectMusdConversionBlockedCountries } from '../selectors/featureFlags'; /** @@ -20,14 +20,18 @@ import { selectMusdConversionBlockedCountries } from '../selectors/featureFlags' * - blockedCountries: array of blocked country codes from LaunchDarkly */ export const useMusdConversionEligibility = () => { - const geolocation = useSelector(selectGeolocation); + const geolocation = useSelector(getDetectedGeolocation); const blockedCountries = useSelector(selectMusdConversionBlockedCountries); - const isLoading = geolocation === null; + 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 (!geolocation) { + if (!userCountry) { return false; } @@ -38,16 +42,16 @@ export const useMusdConversionEligibility = () => { // Check if user's country starts with any blocked country code // Uses startsWith to handle both "GB" and "GB-ENG" formats - const userCountry = geolocation.toUpperCase(); + return blockedCountries.every( (blockedCountry) => !userCountry.startsWith(blockedCountry.toUpperCase()), ); - }, [geolocation, blockedCountries]); + }, [userCountry, blockedCountries]); return { isEligible, isLoading, - geolocation, + geolocation: userCountry, blockedCountries, }; }; From 14803fafe567e5ccd0222abaf4fa79bb95dae3d5 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 20 Jan 2026 16:28:17 -0600 Subject: [PATCH 09/10] chore: update tests bugbot comment --- .../hooks/useMusdConversionEligibility.test.ts | 14 +++++++------- .../UI/Earn/hooks/useMusdConversionEligibility.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts index a1db2b92002..93e65be10a1 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts @@ -21,13 +21,13 @@ jest.mock('react-redux', () => ({ describe('useMusdConversionEligibility', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetDetectedGeolocation.mockReturnValue(null); + mockGetDetectedGeolocation.mockReturnValue(undefined); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); }); describe('isEligible', () => { - it('returns false when geolocation is null (blocks by default for compliance)', () => { - mockGetDetectedGeolocation.mockReturnValue(null); + it('returns false when geolocation is undefined (blocks by default for compliance)', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -35,8 +35,8 @@ describe('useMusdConversionEligibility', () => { expect(result.current.isEligible).toBe(false); }); - it('returns false when geolocation is null even with empty blocked list', () => { - mockGetDetectedGeolocation.mockReturnValue(null); + it('returns false when geolocation is undefined even with empty blocked list', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionEligibility()); @@ -132,8 +132,8 @@ describe('useMusdConversionEligibility', () => { }); describe('isLoading', () => { - it('returns true when geolocation is null', () => { - mockGetDetectedGeolocation.mockReturnValue(null); + it('returns true when geolocation is undefined', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); mockSelectMusdConversionBlockedCountries.mockReturnValue([]); const { result } = renderHook(() => useMusdConversionEligibility()); diff --git a/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts index f3c6b8a9d33..40fb1df04f5 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionEligibility.ts @@ -22,7 +22,7 @@ import { selectMusdConversionBlockedCountries } from '../selectors/featureFlags' export const useMusdConversionEligibility = () => { const geolocation = useSelector(getDetectedGeolocation); const blockedCountries = useSelector(selectMusdConversionBlockedCountries); - const isLoading = geolocation === null; + const isLoading = geolocation === undefined; const userCountry = useMemo(() => { if (geolocation) return geolocation?.toUpperCase().split('-')[0]; From 96cb7bef94b16afe85be935a67053a36c76ccd00 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Tue, 20 Jan 2026 16:41:34 -0600 Subject: [PATCH 10/10] chore: fix bad merge resolution update test --- .../UI/Earn/hooks/useMusdCtaVisibility.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index 36c755b88ad..435a2a593b9 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -850,6 +850,20 @@ describe('useMusdCtaVisibility', () => { 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();