Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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"

# Activates remote feature flag override mode.
# Remote feature flag values won't be updated,
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 @@ -23,6 +23,7 @@
import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens';
import { EARN_TEST_IDS } from '../../constants/testIds';
import useStakingEligibility from '../../../Stake/hooks/useStakingEligibility';
import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility';

Check failure on line 26 in app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx

View workflow job for this annotation

GitHub Actions / scripts (lint)

'useMusdConversionEligibility' is defined but never used

const mockNavigate = jest.fn();
const mockDaiMainnet: EarnTokenDetails = {
Expand Down Expand Up @@ -150,6 +151,14 @@
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 +736,90 @@
).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'];
Loading
Loading