Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions app/components/UI/AssetOverview/Balance/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
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,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({
Expand Down Expand Up @@ -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(<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,
isLoading: false,
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,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(),
Expand Down Expand Up @@ -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(
<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'];
Loading
Loading