Skip to content

Commit 793073a

Browse files
committed
feat(earn): add geo-blocking for mUSD conversion feature
1 parent 77fb038 commit 793073a

14 files changed

Lines changed: 1062 additions & 25 deletions

File tree

.js.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ export MM_MUSD_CONVERSION_ASSET_OVERVIEW_CTA="false"
120120
# CTA displayed in token list item ("Convert to mUSD")
121121
export MM_MUSD_CONVERSION_TOKEN_LIST_ITEM_CTA="false"
122122
export MM_MUSD_CONVERSION_REWARDS_UI_ENABLED="false"
123+
# Geo-blocked countries for mUSD conversion (comma-separated ISO 3166-1 alpha-2 codes)
124+
# LaunchDarkly takes precedence; this is a fallback. Default blocks UK.
125+
export MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES="GB"
123126

124127
# Activates remote feature flag override mode.
125128
# Remote feature flag values won't be updated,

app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import EarnLendingBalance from '../EarnLendingBalance';
77
import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled';
88
import { selectTronResourcesBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list';
99
import TronStakingButtons from '../Tron/TronStakingButtons';
10+
import { selectIsMusdConversionFlowEnabledFlag } from '../../selectors/featureFlags';
1011

1112
/**
1213
* We mock underlying components because we only care about the conditional rendering.
@@ -83,6 +84,8 @@ jest.mock('../EarnLendingBalance', () => ({
8384
default: jest.fn(),
8485
}));
8586

87+
import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens';
88+
8689
jest.mock('../../hooks/useMusdConversionTokens', () => ({
8790
__esModule: true,
8891
useMusdConversionTokens: jest.fn().mockReturnValue({
@@ -94,6 +97,36 @@ jest.mock('../../hooks/useMusdConversionTokens', () => ({
9497
}),
9598
}));
9699

100+
const mockUseMusdConversionTokens =
101+
useMusdConversionTokens as jest.MockedFunction<
102+
typeof useMusdConversionTokens
103+
>;
104+
105+
jest.mock('../../selectors/featureFlags', () => ({
106+
...jest.requireActual('../../selectors/featureFlags'),
107+
selectIsMusdConversionFlowEnabledFlag: jest.fn().mockReturnValue(false),
108+
}));
109+
110+
const mockSelectIsMusdConversionFlowEnabledFlag =
111+
selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction<
112+
typeof selectIsMusdConversionFlowEnabledFlag
113+
>;
114+
115+
import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility';
116+
117+
jest.mock('../../hooks/useMusdConversionEligibility', () => ({
118+
useMusdConversionEligibility: jest.fn().mockReturnValue({
119+
isEligible: true,
120+
geolocation: 'US',
121+
blockedCountries: [],
122+
}),
123+
}));
124+
125+
const mockUseMusdConversionEligibility =
126+
useMusdConversionEligibility as jest.MockedFunction<
127+
typeof useMusdConversionEligibility
128+
>;
129+
97130
jest.mock('../../hooks/useTronStakeApy', () => ({
98131
__esModule: true,
99132
default: jest.fn().mockReturnValue({
@@ -252,4 +285,64 @@ describe('EarnBalance', () => {
252285
expect(props.hasStakedPositions).toBe(true);
253286
});
254287
});
288+
289+
describe('Geo-blocking', () => {
290+
it('does not render EarnLendingBalance for convertible stablecoin when user is geo-blocked', () => {
291+
mockSelectIsMusdConversionFlowEnabledFlag.mockReturnValue(true);
292+
293+
mockUseMusdConversionTokens.mockReturnValue({
294+
isConversionToken: jest.fn().mockReturnValue(true),
295+
tokenFilter: jest.fn(),
296+
tokens: [],
297+
isMusdSupportedOnChain: jest.fn().mockReturnValue(true),
298+
getMusdOutputChainId: jest.fn().mockReturnValue('0x1'),
299+
});
300+
301+
mockUseMusdConversionEligibility.mockReturnValue({
302+
isEligible: false,
303+
geolocation: 'GB',
304+
blockedCountries: ['GB'],
305+
});
306+
307+
const mockDai = {
308+
isETH: false,
309+
symbol: 'DAI',
310+
chainId: '0x1',
311+
};
312+
313+
renderWithProvider(<EarnBalance asset={mockDai as unknown as TokenI} />);
314+
315+
// EarnLendingBalance should not be called because geo-blocking is active
316+
expect(EarnLendingBalance).not.toHaveBeenCalled();
317+
});
318+
319+
it('renders EarnLendingBalance for convertible stablecoin when user is geo-eligible', () => {
320+
mockSelectIsMusdConversionFlowEnabledFlag.mockReturnValue(true);
321+
322+
mockUseMusdConversionTokens.mockReturnValue({
323+
isConversionToken: jest.fn().mockReturnValue(true),
324+
tokenFilter: jest.fn(),
325+
tokens: [],
326+
isMusdSupportedOnChain: jest.fn().mockReturnValue(true),
327+
getMusdOutputChainId: jest.fn().mockReturnValue('0x1'),
328+
});
329+
330+
mockUseMusdConversionEligibility.mockReturnValue({
331+
isEligible: true,
332+
geolocation: 'US',
333+
blockedCountries: [],
334+
});
335+
336+
const mockDai = {
337+
isETH: false,
338+
symbol: 'DAI',
339+
chainId: '0x1',
340+
};
341+
342+
renderWithProvider(<EarnBalance asset={mockDai as unknown as TokenI} />);
343+
344+
// EarnLendingBalance should be called because user is geo-eligible
345+
expect(EarnLendingBalance).toHaveBeenCalled();
346+
});
347+
});
255348
});

app/components/UI/Earn/components/EarnBalance/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { hasStakedTrxPositions as hasStakedTrxPositionsUtil } from '../../utils/
1414
import useTronStakeApy from '../../hooks/useTronStakeApy';
1515
///: END:ONLY_INCLUDE_IF
1616
import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens';
17+
import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility';
1718
import { selectIsMusdConversionFlowEnabledFlag } from '../../selectors/featureFlags';
1819
export interface EarnBalanceProps {
1920
asset: TokenI;
@@ -36,6 +37,7 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => {
3637
);
3738

3839
const { isConversionToken } = useMusdConversionTokens();
40+
const { isEligible: isGeoEligible } = useMusdConversionEligibility();
3941
///: BEGIN:ONLY_INCLUDE_IF(tron)
4042
const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled);
4143

@@ -74,7 +76,7 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => {
7476
///: END:ONLY_INCLUDE_IF
7577

7678
const isConvertibleStablecoin =
77-
isMusdConversionFlowEnabled && isConversionToken(asset);
79+
isMusdConversionFlowEnabled && isConversionToken(asset) && isGeoEligible;
7880

7981
// EVM staking: only when stakeable and not a staked output token
8082
if (isStakeableToken && !asset.isStaked) {

app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@ const mockUseStakingEligibility = useStakingEligibility as jest.MockedFunction<
150150
typeof useStakingEligibility
151151
>;
152152

153+
import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility';
154+
155+
jest.mock('../../hooks/useMusdConversionEligibility', () => ({
156+
useMusdConversionEligibility: jest.fn().mockReturnValue({
157+
isEligible: true,
158+
geolocation: 'US',
159+
blockedCountries: [],
160+
}),
161+
}));
162+
153163
jest.mock('../../selectors/featureFlags', () => ({
154164
selectIsMusdConversionFlowEnabledFlag: jest.fn(),
155165
selectPooledStakingEnabledFlag: jest.fn(),
@@ -727,4 +737,90 @@ describe('EarnLendingBalance', () => {
727737
).toBeOnTheScreen();
728738
expect(queryByTestId(EARN_EMPTY_STATE_CTA_TEST_ID)).toBeNull();
729739
});
740+
741+
it('hides mUSD conversion CTA when user is geo-blocked', () => {
742+
const mockEmptyReceiptToken = {
743+
...mockADAIMainnet,
744+
balanceMinimalUnit: '0',
745+
balanceFormatted: '0 ADAI',
746+
balanceFiatNumber: 0,
747+
};
748+
749+
(
750+
earnSelectors.selectEarnTokenPair as jest.MockedFunction<
751+
typeof earnSelectors.selectEarnTokenPair
752+
>
753+
).mockReturnValue({
754+
outputToken: mockEmptyReceiptToken,
755+
earnToken: mockDaiMainnet,
756+
});
757+
758+
(
759+
selectStablecoinLendingEnabledFlag as jest.MockedFunction<
760+
typeof selectStablecoinLendingEnabledFlag
761+
>
762+
).mockReturnValue(false);
763+
764+
(
765+
selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction<
766+
typeof selectIsMusdConversionFlowEnabledFlag
767+
>
768+
).mockReturnValue(true);
769+
770+
// Geo-blocked: shouldShowAssetOverviewCta returns false
771+
mockShouldShowAssetOverviewCta.mockReturnValue(false);
772+
773+
const { queryByTestId } = renderWithProvider(
774+
<EarnLendingBalance asset={mockDaiMainnet} />,
775+
{ state: mockInitialState },
776+
);
777+
778+
// mUSD CTA hidden because geo-blocked (useMusdCtaVisibility returns false)
779+
expect(
780+
queryByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA),
781+
).toBeNull();
782+
});
783+
784+
it('shows mUSD conversion CTA when user is geo-eligible', () => {
785+
const mockEmptyReceiptToken = {
786+
...mockADAIMainnet,
787+
balanceMinimalUnit: '0',
788+
balanceFormatted: '0 ADAI',
789+
balanceFiatNumber: 0,
790+
};
791+
792+
(
793+
earnSelectors.selectEarnTokenPair as jest.MockedFunction<
794+
typeof earnSelectors.selectEarnTokenPair
795+
>
796+
).mockReturnValue({
797+
outputToken: mockEmptyReceiptToken,
798+
earnToken: mockDaiMainnet,
799+
});
800+
801+
(
802+
selectStablecoinLendingEnabledFlag as jest.MockedFunction<
803+
typeof selectStablecoinLendingEnabledFlag
804+
>
805+
).mockReturnValue(false);
806+
807+
(
808+
selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction<
809+
typeof selectIsMusdConversionFlowEnabledFlag
810+
>
811+
).mockReturnValue(true);
812+
813+
// Geo-eligible: shouldShowAssetOverviewCta returns true
814+
mockShouldShowAssetOverviewCta.mockReturnValue(true);
815+
816+
const { getByTestId } = renderWithProvider(
817+
<EarnLendingBalance asset={mockDaiMainnet} />,
818+
{ state: mockInitialState },
819+
);
820+
821+
// mUSD CTA visible because geo-eligible (useMusdCtaVisibility returns true)
822+
expect(
823+
getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA),
824+
).toBeOnTheScreen();
825+
});
730826
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { useMusdConversionEligibility } from './useMusdConversionEligibility';
3+
4+
// Mock the selectors
5+
const mockSelectGeolocation = jest.fn();
6+
const mockSelectMusdConversionBlockedCountries = jest.fn();
7+
8+
jest.mock('../../../../selectors/rampsController', () => ({
9+
selectGeolocation: (state: unknown) => mockSelectGeolocation(state),
10+
}));
11+
12+
jest.mock('../selectors/featureFlags', () => ({
13+
selectMusdConversionBlockedCountries: (state: unknown) =>
14+
mockSelectMusdConversionBlockedCountries(state),
15+
}));
16+
17+
jest.mock('react-redux', () => ({
18+
useSelector: (selector: (state: unknown) => unknown) => selector({}),
19+
}));
20+
21+
describe('useMusdConversionEligibility', () => {
22+
beforeEach(() => {
23+
jest.clearAllMocks();
24+
mockSelectGeolocation.mockReturnValue(null);
25+
mockSelectMusdConversionBlockedCountries.mockReturnValue([]);
26+
});
27+
28+
describe('isEligible', () => {
29+
it('returns true when geolocation is null', () => {
30+
mockSelectGeolocation.mockReturnValue(null);
31+
mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']);
32+
33+
const { result } = renderHook(() => useMusdConversionEligibility());
34+
35+
expect(result.current.isEligible).toBe(true);
36+
});
37+
38+
it('returns true when blockedCountries is empty', () => {
39+
mockSelectGeolocation.mockReturnValue('GB');
40+
mockSelectMusdConversionBlockedCountries.mockReturnValue([]);
41+
42+
const { result } = renderHook(() => useMusdConversionEligibility());
43+
44+
expect(result.current.isEligible).toBe(true);
45+
});
46+
47+
it('returns false when user is in a blocked country', () => {
48+
mockSelectGeolocation.mockReturnValue('GB');
49+
mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB', 'US']);
50+
51+
const { result } = renderHook(() => useMusdConversionEligibility());
52+
53+
expect(result.current.isEligible).toBe(false);
54+
});
55+
56+
it('returns false when user country starts with blocked country code', () => {
57+
mockSelectGeolocation.mockReturnValue('GB-ENG');
58+
mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']);
59+
60+
const { result } = renderHook(() => useMusdConversionEligibility());
61+
62+
expect(result.current.isEligible).toBe(false);
63+
});
64+
65+
it('returns true when user is not in any blocked country', () => {
66+
mockSelectGeolocation.mockReturnValue('FR');
67+
mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB', 'US']);
68+
69+
const { result } = renderHook(() => useMusdConversionEligibility());
70+
71+
expect(result.current.isEligible).toBe(true);
72+
});
73+
74+
it('performs case-insensitive comparison for country codes', () => {
75+
mockSelectGeolocation.mockReturnValue('gb');
76+
mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']);
77+
78+
const { result } = renderHook(() => useMusdConversionEligibility());
79+
80+
expect(result.current.isEligible).toBe(false);
81+
});
82+
83+
it('handles US state codes correctly when US is blocked', () => {
84+
mockSelectGeolocation.mockReturnValue('US-CA');
85+
mockSelectMusdConversionBlockedCountries.mockReturnValue(['US']);
86+
87+
const { result } = renderHook(() => useMusdConversionEligibility());
88+
89+
expect(result.current.isEligible).toBe(false);
90+
});
91+
92+
it('returns true for US user when only UK is blocked', () => {
93+
mockSelectGeolocation.mockReturnValue('US-CA');
94+
mockSelectMusdConversionBlockedCountries.mockReturnValue(['GB']);
95+
96+
const { result } = renderHook(() => useMusdConversionEligibility());
97+
98+
expect(result.current.isEligible).toBe(true);
99+
});
100+
});
101+
102+
describe('return values', () => {
103+
it('returns geolocation from selector', () => {
104+
mockSelectGeolocation.mockReturnValue('FR');
105+
mockSelectMusdConversionBlockedCountries.mockReturnValue([]);
106+
107+
const { result } = renderHook(() => useMusdConversionEligibility());
108+
109+
expect(result.current.geolocation).toBe('FR');
110+
});
111+
112+
it('returns blockedCountries from selector', () => {
113+
const blockedCountries = ['GB', 'US'];
114+
mockSelectGeolocation.mockReturnValue('FR');
115+
mockSelectMusdConversionBlockedCountries.mockReturnValue(
116+
blockedCountries,
117+
);
118+
119+
const { result } = renderHook(() => useMusdConversionEligibility());
120+
121+
expect(result.current.blockedCountries).toEqual(blockedCountries);
122+
});
123+
});
124+
});

0 commit comments

Comments
 (0)