Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .js.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -83,17 +84,49 @@ 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,
geolocation: 'US',
blockedCountries: [],
}),
}));

const mockUseMusdConversionEligibility =
useMusdConversionEligibility as jest.MockedFunction<
typeof useMusdConversionEligibility
>;

jest.mock('../../hooks/useTronStakeApy', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({
Expand Down Expand Up @@ -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),
filterAllowedTokens: 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(<EarnBalance asset={mockDai as unknown as TokenI} />);

// 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,
geolocation: 'US',
blockedCountries: [],
});

const mockDai = {
isETH: false,
symbol: 'DAI',
chainId: '0x1',
};

renderWithProvider(<EarnBalance asset={mockDai as unknown as TokenI} />);

// EarnLendingBalance should be called because user is geo-eligible
expect(EarnLendingBalance).toHaveBeenCalled();
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
4 changes: 3 additions & 1 deletion app/components/UI/Earn/components/EarnBalance/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,6 +37,7 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => {
);

const { isConversionToken } = useMusdConversionTokens();
const { isEligible: isGeoEligible } = useMusdConversionEligibility();
///: BEGIN:ONLY_INCLUDE_IF(tron)
const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled);

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ const mockUseStakingEligibility = useStakingEligibility as jest.MockedFunction<
typeof useStakingEligibility
>;

jest.mock('../../hooks/useMusdConversionEligibility', () => ({
useMusdConversionEligibility: jest.fn().mockReturnValue({
isEligible: true,
geolocation: 'US',
blockedCountries: [],
}),
}));

jest.mock('../../selectors/featureFlags', () => ({
selectIsMusdConversionFlowEnabledFlag: jest.fn(),
selectPooledStakingEnabledFlag: jest.fn(),
Expand Down Expand Up @@ -727,4 +735,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(
<EarnLendingBalance asset={mockDaiMainnet} />,
{ 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(
<EarnLendingBalance asset={mockDaiMainnet} />,
{ state: mockInitialState },
);

// mUSD CTA visible because geo-eligible (useMusdCtaVisibility returns true)
expect(
getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA),
).toBeOnTheScreen();
});
});
6 changes: 6 additions & 0 deletions app/components/UI/Earn/constants/musd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record<Hex, string> = {

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'];
124 changes: 124 additions & 0 deletions app/components/UI/Earn/hooks/useMusdConversionEligibility.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading