Skip to content

Commit a84b764

Browse files
chore: update cta to respect buy regions and route correctly per chain (MetaMask#24048)
## **Description** Fixed an issue where the MUSD CTA was incorrectly displaying when a single unsupported chain (like BSC) was selected in the network filter. The bug occurred because when a non-buyable chain was selected, the code was still showing the CTA if mUSD was buyable on *any* chain (e.g., Mainnet or Linea), rather than hiding it completely. This was confusing for users since they couldn't actually buy mUSD on the selected chain. **Changes:** - When a non-buyable chain is selected (BSC, Polygon, Arbitrum, etc.), the CTA now correctly hides - When a buyable chain is selected but mUSD is not available in the user's region for that specific chain, the CTA now correctly hides ## **Changelog** CHANGELOG entry: Fixed mUSD CTA incorrectly appearing on unsupported chains like BSC ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-124 ## **Manual testing steps** ```gherkin Feature: MUSD CTA visibility Scenario: user selects BSC network Given user is on the token list view And user does not have any MUSD balance And user does not have any token balance When user selects BSC as the only network filter Then the MUSD CTA should not be displayed Scenario: user selects Linea network Given user is on the token list view And user does not have any MUSD balance And user does not have any token balance And mUSD is buyable in user's region on Linea When user selects Linea as the only network filter Then the MUSD CTA should be displayed with network icon Scenario: user selects Mainnet network Given user is on the token list view And user does not have any MUSD balance And user does not have any token balance And mUSD is buyable in user's region on Mainnet When user selects Mainnet as the only network filter Then the MUSD CTA should be displayed with network icon Scenario: user selects all networks Given user is on the token list view And user does not have any MUSD balance And user does not have any token balance And mUSD is buyable in user's region When user has all networks selected Then the MUSD CTA should be displayed without network icon Scenario: user selects Linea network Given user is on the token list view And user does not have any MUSD balance And user does has token balance When user selects any network filter Then the MUSD CTA should be displayed with network icon Then the MUSD CTA should direct to BuyWithToken component on the chain of token being converted (soon to be constrained to that chain specifically in. separate pr) ``` ## **Screenshots/Recordings** ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/45040e69-f737-4112-baad-36f0390c3453 <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Updates the mUSD CTA to show/hide and route per selected chain and regional availability, with a new feature flag, network badge, and supporting hooks/tests. > > - **Earn UI – mUSD CTA**: > - Integrates `useMusdCtaVisibility` to conditionally render CTA; hides on non-buyable/unsupported chains and when mUSD isn’t buyable in-region. > - Shows network badge for single supported selections using `BadgeWrapper` and `getNetworkImageSource`; aligns avatar layout. > - Adjusts buy flow to use `selectedChainId` (fallback to `MUSD_CONVERSION_DEFAULT_CHAIN_ID`). > - **Hooks/Logic**: > - Adds `useHasMusdBalance` to detect mUSD balances across chains. > - Adds `useMusdCtaVisibility` to compute `shouldShowCta`, `showNetworkIcon`, and `selectedChainId` based on feature flag, selected networks, ramp token availability, and balances. > - Introduces `MUSD_BUYABLE_CHAIN_IDS` (excludes BSC) and uses `MUSD_TOKEN_ASSET_ID_BY_CHAIN` for routing. > - **Feature Flags/Config**: > - Adds env flag `MM_MUSD_CTA_ENABLED` and selector `selectIsMusdCtaEnabledFlag`. > - **Tests**: > - Extensive new tests for `useHasMusdBalance`, `useMusdCtaVisibility`, and updated CTA tests for visibility, routing, and network badge behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cd04745. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 57050eb commit a84b764

10 files changed

Lines changed: 1275 additions & 7 deletions

File tree

.js.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export MM_MUSD_CONVERSION_FLOW_ENABLED="false"
111111
# IMPORTANT: Must use SINGLE QUOTES to preserve JSON format
112112
# Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}'
113113
export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST=''
114-
114+
export MM_MUSD_CTA_ENABLED="false"
115115
# Activates remote feature flag override mode.
116116
# Remote feature flag values won't be updated,
117117
# and selectors should return their fallback values

app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.styles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ const styleSheet = () =>
1111
assetInfo: {
1212
flexDirection: 'row',
1313
gap: 20,
14+
alignItems: 'center',
1415
},
1516
button: {
1617
alignSelf: 'center',
1718
height: 32,
1819
},
20+
badge: {
21+
alignSelf: 'center',
22+
},
1923
});
2024

2125
export default styleSheet;

app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Hex } from '@metamask/utils';
44

55
jest.mock('../../../hooks/useMusdConversionTokens');
66
jest.mock('../../../hooks/useMusdConversion');
7+
jest.mock('../../../hooks/useMusdCtaVisibility');
78
jest.mock('../../../../Ramp/hooks/useRampNavigation');
89
jest.mock('../../../../../../util/Logger');
910

@@ -22,6 +23,7 @@ import renderWithProvider from '../../../../../../util/test/renderWithProvider';
2223
import MusdConversionAssetListCta from '.';
2324
import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens';
2425
import { useMusdConversion } from '../../../hooks/useMusdConversion';
26+
import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility';
2527
import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation';
2628
import {
2729
MUSD_CONVERSION_DEFAULT_CHAIN_ID,
@@ -30,6 +32,8 @@ import {
3032
import { EARN_TEST_IDS } from '../../../constants/testIds';
3133
import initialRootState from '../../../../../../util/test/initial-root-state';
3234
import Logger from '../../../../../../util/Logger';
35+
import { CHAIN_IDS } from '@metamask/transaction-controller';
36+
import { BADGE_WRAPPER_BADGE_TEST_ID } from '../../../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants';
3337

3438
const mockToken = {
3539
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
@@ -68,6 +72,15 @@ describe('MusdConversionAssetListCta', () => {
6872
error: null,
6973
hasSeenConversionEducationScreen: true,
7074
});
75+
76+
// Default mock for visibility - show CTA without network icon
77+
(
78+
useMusdCtaVisibility as jest.MockedFunction<typeof useMusdCtaVisibility>
79+
).mockReturnValue({
80+
shouldShowCta: true,
81+
showNetworkIcon: false,
82+
selectedChainId: null,
83+
});
7184
});
7285

7386
afterEach(() => {
@@ -373,4 +386,152 @@ describe('MusdConversionAssetListCta', () => {
373386
});
374387
});
375388
});
389+
390+
describe('visibility behavior', () => {
391+
beforeEach(() => {
392+
(
393+
useMusdConversionTokens as jest.MockedFunction<
394+
typeof useMusdConversionTokens
395+
>
396+
).mockReturnValue({
397+
tokens: [],
398+
tokenFilter: jest.fn(),
399+
isConversionToken: jest.fn(),
400+
isMusdSupportedOnChain: jest.fn().mockReturnValue(true),
401+
getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex),
402+
});
403+
});
404+
405+
it('renders null when shouldShowCta is false', () => {
406+
(
407+
useMusdCtaVisibility as jest.MockedFunction<typeof useMusdCtaVisibility>
408+
).mockReturnValue({
409+
shouldShowCta: false,
410+
showNetworkIcon: false,
411+
selectedChainId: null,
412+
});
413+
414+
const { queryByTestId } = renderWithProvider(
415+
<MusdConversionAssetListCta />,
416+
{ state: initialRootState },
417+
);
418+
419+
expect(
420+
queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA),
421+
).toBeNull();
422+
});
423+
424+
it('renders component when shouldShowCta is true', () => {
425+
(
426+
useMusdCtaVisibility as jest.MockedFunction<typeof useMusdCtaVisibility>
427+
).mockReturnValue({
428+
shouldShowCta: true,
429+
showNetworkIcon: false,
430+
selectedChainId: null,
431+
});
432+
433+
const { getByTestId } = renderWithProvider(
434+
<MusdConversionAssetListCta />,
435+
{ state: initialRootState },
436+
);
437+
438+
expect(
439+
getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA),
440+
).toBeOnTheScreen();
441+
});
442+
});
443+
444+
describe('network badge', () => {
445+
beforeEach(() => {
446+
(
447+
useMusdConversionTokens as jest.MockedFunction<
448+
typeof useMusdConversionTokens
449+
>
450+
).mockReturnValue({
451+
tokens: [],
452+
tokenFilter: jest.fn(),
453+
isConversionToken: jest.fn(),
454+
isMusdSupportedOnChain: jest.fn().mockReturnValue(true),
455+
getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex),
456+
});
457+
});
458+
459+
it('renders without network badge when showNetworkIcon is false', () => {
460+
(
461+
useMusdCtaVisibility as jest.MockedFunction<typeof useMusdCtaVisibility>
462+
).mockReturnValue({
463+
shouldShowCta: true,
464+
showNetworkIcon: false,
465+
selectedChainId: null,
466+
});
467+
468+
const { getByTestId, queryByTestId } = renderWithProvider(
469+
<MusdConversionAssetListCta />,
470+
{ state: initialRootState },
471+
);
472+
473+
expect(
474+
getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA),
475+
).toBeOnTheScreen();
476+
// Badge wrapper is not rendered when showNetworkIcon is false
477+
expect(queryByTestId(BADGE_WRAPPER_BADGE_TEST_ID)).toBeNull();
478+
});
479+
480+
it('renders with network badge when showNetworkIcon is true and mainnet selected', () => {
481+
(
482+
useMusdCtaVisibility as jest.MockedFunction<typeof useMusdCtaVisibility>
483+
).mockReturnValue({
484+
shouldShowCta: true,
485+
showNetworkIcon: true,
486+
selectedChainId: CHAIN_IDS.MAINNET,
487+
});
488+
489+
const { getByTestId } = renderWithProvider(
490+
<MusdConversionAssetListCta />,
491+
{ state: initialRootState },
492+
);
493+
494+
expect(
495+
getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA),
496+
).toBeOnTheScreen();
497+
});
498+
499+
it('renders with network badge when showNetworkIcon is true and Linea selected', () => {
500+
(
501+
useMusdCtaVisibility as jest.MockedFunction<typeof useMusdCtaVisibility>
502+
).mockReturnValue({
503+
shouldShowCta: true,
504+
showNetworkIcon: true,
505+
selectedChainId: CHAIN_IDS.LINEA_MAINNET,
506+
});
507+
508+
const { getByTestId } = renderWithProvider(
509+
<MusdConversionAssetListCta />,
510+
{ state: initialRootState },
511+
);
512+
513+
expect(
514+
getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA),
515+
).toBeOnTheScreen();
516+
});
517+
518+
it('renders with network badge when showNetworkIcon is true and BSC selected', () => {
519+
(
520+
useMusdCtaVisibility as jest.MockedFunction<typeof useMusdCtaVisibility>
521+
).mockReturnValue({
522+
shouldShowCta: true,
523+
showNetworkIcon: true,
524+
selectedChainId: CHAIN_IDS.BSC,
525+
});
526+
527+
const { getByTestId } = renderWithProvider(
528+
<MusdConversionAssetListCta />,
529+
{ state: initialRootState },
530+
);
531+
532+
expect(
533+
getByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA),
534+
).toBeOnTheScreen();
535+
});
536+
});
376537
});

app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,17 @@ import Logger from '../../../../../../util/Logger';
2424
import { useStyles } from '../../../../../hooks/useStyles';
2525
import { useMusdConversionTokens } from '../../../hooks/useMusdConversionTokens';
2626
import { useMusdConversion } from '../../../hooks/useMusdConversion';
27+
import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility';
2728
import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
2829
import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar';
2930
import { toChecksumAddress } from '../../../../../../util/address';
31+
import Badge, {
32+
BadgeVariant,
33+
} from '../../../../../../component-library/components/Badges/Badge';
34+
import BadgeWrapper, {
35+
BadgePosition,
36+
} from '../../../../../../component-library/components/Badges/BadgeWrapper';
37+
import { getNetworkImageSource } from '../../../../../../util/networks';
3038

3139
const MusdConversionAssetListCta = () => {
3240
const { styles } = useStyles(styleSheet, {});
@@ -37,6 +45,9 @@ const MusdConversionAssetListCta = () => {
3745

3846
const { initiateConversion } = useMusdConversion();
3947

48+
const { shouldShowCta, showNetworkIcon, selectedChainId } =
49+
useMusdCtaVisibility();
50+
4051
const canConvert = useMemo(
4152
() => Boolean(tokens.length > 0 && tokens?.[0]?.chainId !== undefined),
4253
[tokens],
@@ -54,7 +65,10 @@ const MusdConversionAssetListCta = () => {
5465
// Redirect users to deposit flow if they don't have any stablecoins to convert.
5566
if (!canConvert) {
5667
const rampIntent: RampIntent = {
57-
assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID],
68+
assetId:
69+
MUSD_TOKEN_ASSET_ID_BY_CHAIN[
70+
selectedChainId || MUSD_CONVERSION_DEFAULT_CHAIN_ID
71+
],
5872
};
5973
goToBuy(rampIntent);
6074
return;
@@ -84,17 +98,43 @@ const MusdConversionAssetListCta = () => {
8498
}
8599
};
86100

101+
// Don't render if visibility conditions are not met
102+
if (!shouldShowCta) {
103+
return null;
104+
}
105+
106+
const renderTokenAvatar = () => (
107+
<AvatarToken
108+
name={MUSD_TOKEN.symbol}
109+
imageSource={MUSD_TOKEN.imageSource}
110+
size={AvatarSize.Lg}
111+
/>
112+
);
113+
87114
return (
88115
<View
89116
style={styles.container}
90117
testID={EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA}
91118
>
92119
<View style={styles.assetInfo}>
93-
<AvatarToken
94-
name={MUSD_TOKEN.symbol}
95-
imageSource={MUSD_TOKEN.imageSource}
96-
size={AvatarSize.Lg}
97-
/>
120+
{showNetworkIcon && selectedChainId ? (
121+
<BadgeWrapper
122+
style={styles.badge}
123+
badgePosition={BadgePosition.BottomRight}
124+
badgeElement={
125+
<Badge
126+
variant={BadgeVariant.Network}
127+
imageSource={getNetworkImageSource({
128+
chainId: selectedChainId,
129+
})}
130+
/>
131+
}
132+
>
133+
{renderTokenAvatar()}
134+
</BadgeWrapper>
135+
) : (
136+
renderTokenAvatar()
137+
)}
98138
<View>
99139
<Text variant={TextVariant.BodyMDMedium} color={TextColor.Default}>
100140
MetaMask USD

app/components/UI/Earn/constants/musd.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record<Hex, Hex> = {
2222
[CHAIN_IDS.BSC]: '0xaca92e438df0b2401ff60da7e4337b687a2435da',
2323
};
2424

25+
/**
26+
* Chains where mUSD CTA should show (buy routes available).
27+
* BSC is excluded as buy routes are not yet available.
28+
*/
29+
export const MUSD_BUYABLE_CHAIN_IDS: Hex[] = [
30+
CHAIN_IDS.MAINNET,
31+
CHAIN_IDS.LINEA_MAINNET,
32+
// CHAIN_IDS.BSC, // TODO: Uncomment once buy routes are available
33+
];
34+
2535
export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record<Hex, string> = {
2636
[CHAIN_IDS.MAINNET]:
2737
'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA',

0 commit comments

Comments
 (0)