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