Skip to content

Commit e4b8256

Browse files
authored
fix: cp-7.64.0 MUSD-268 only render Earn CTA when above minimum required balance of 1 cent (MetaMask#25454)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Hides the Earn CTA for asset when the asset's balance is less than minimum required. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: updated Earn CTAs to not render when the asset's balance is less than minimum required ## **Related issues** Fixes: [MUSD-270: Earn CTA is displayed for zero balance tokens](https://consensyssoftware.atlassian.net/browse/MUSD-268) ## **Manual testing steps** ```gherkin Feature: Earn CTA visibility based on token balance Scenario: user views a token with balance below minimum earn threshold Given user has a token in the wallet with balance below 0.01 When user views the token in the token list Then no "Earn" call-to-action is displayed Scenario: user views a token with balance at or above minimum earn threshold Given user has a token in the wallet with balance at or above 0.01 When user views the token in the token list Then an "Earn" call-to-action is displayed ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> <img width="450" height="81" alt="image" src="https://github.com/user-attachments/assets/10f7fab9-8826-43dd-a7d6-99ffe8e01210" /> <img width="450" height="209" alt="image" src="https://github.com/user-attachments/assets/71f2daba-9590-482c-8bec-69f2c66fcf6e" /> ### **After** <!-- [screenshots/recordings] --> <img width="456" height="82" alt="image" src="https://github.com/user-attachments/assets/241a9ff1-da48-4e3e-8802-62c0e6d08e90" /> <img width="450" height="209" alt="image" src="https://github.com/user-attachments/assets/2b16a88f-ac36-4a2c-8cb3-75844dccbfff" /> ## **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] > **Low Risk** > Low risk UI gating change that only affects when Earn CTAs render; main risk is incorrectly hiding/showing CTAs due to balance/fiat conversions. > > **Overview** > Introduces `MINIMUM_BALANCE_FOR_EARN_CTA` (0.01) and uses it to *suppress Earn/Stake CTAs* for assets whose `earnToken.balanceFiatNumber` is below the threshold. > > `StakeButton` now derives an `earnToken` via `useEarnToken` and returns `null` when the user is ineligible, the earn token is missing, or the minimum balance isn’t met; `TokenListItem` similarly only shows the stablecoin lending Earn CTA when the minimum is met (otherwise it falls back to percentage change). > > Updates and adds unit tests to cover the new threshold behavior and adjusts mocks to use `earnSelectors.selectEarnToken`/`useStablecoinLendingRedirect` accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e49bf26. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e31b2a3 commit e4b8256

5 files changed

Lines changed: 249 additions & 84 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
export const TOKENS_REQUIRING_ALLOWANCE_RESET: Record<string, string[]> = {
33
'0x1': ['USDT'],
44
};
5+
6+
export const MINIMUM_BALANCE_FOR_EARN_CTA = 0.01;

app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx

Lines changed: 103 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -21,62 +21,10 @@ import {
2121
} from '../../../Earn/selectors/featureFlags';
2222
import { TokenI } from '../../../Tokens/types';
2323
import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
24+
import { MINIMUM_BALANCE_FOR_EARN_CTA } from '../../../Earn/constants/token';
2425

2526
const mockNavigate = jest.fn();
2627

27-
const MOCK_APR_VALUES: { [symbol: string]: string } = {
28-
Ethereum: '2.3',
29-
USDC: '4.5',
30-
USDT: '4.1',
31-
DAI: '5.0',
32-
};
33-
34-
const mockGetEarnToken = jest.fn((token: TokenI) => {
35-
const experienceType =
36-
token.symbol === 'USDC'
37-
? EARN_EXPERIENCES.STABLECOIN_LENDING
38-
: EARN_EXPERIENCES.POOLED_STAKING;
39-
40-
const experiences = [
41-
{
42-
type: experienceType as EARN_EXPERIENCES,
43-
apr: MOCK_APR_VALUES?.[token.symbol] ?? '',
44-
estimatedAnnualRewardsFormatted: '',
45-
estimatedAnnualRewardsFiatNumber: 0,
46-
},
47-
];
48-
49-
const baseEarnToken = {
50-
...token,
51-
balanceFormatted: token.symbol === 'USDC' ? '6.84314 USDC' : '0',
52-
balanceFiat: token.symbol === 'USDC' ? '$6.84' : '$0.00',
53-
balanceMinimalUnit: token.symbol === 'USDC' ? '6.84314' : '0',
54-
balanceFiatNumber: token.symbol === 'USDC' ? 6.84314 : 0,
55-
};
56-
57-
const adjustedEarnToken =
58-
token.symbol === 'TRX'
59-
? {
60-
...baseEarnToken,
61-
balanceMinimalUnit: '1',
62-
}
63-
: baseEarnToken;
64-
65-
return {
66-
...adjustedEarnToken,
67-
experiences,
68-
tokenUsdExchangeRate: 0,
69-
experience: experiences[0],
70-
};
71-
});
72-
73-
jest.mock('../../../Earn/hooks/useEarnTokens', () => ({
74-
__esModule: true,
75-
default: () => ({
76-
getEarnToken: (token: TokenI) => mockGetEarnToken(token),
77-
}),
78-
}));
79-
8028
jest.mock('@react-navigation/native', () => {
8129
const actualReactNavigation = jest.requireActual('@react-navigation/native');
8230
return {
@@ -115,6 +63,19 @@ jest.mock('../../../../../selectors/earnController/earn', () => ({
11563
selectPrimaryEarnExperienceTypeForAsset: jest.fn((_state, asset) =>
11664
asset.symbol === 'USDC' ? 'STABLECOIN_LENDING' : 'POOLED_STAKING',
11765
),
66+
selectEarnToken: jest.fn((_state, asset) => {
67+
const balanceFiatNumber = Number(asset?.balance ?? '0') || 0;
68+
69+
return {
70+
...asset,
71+
// `StakeButton` checks this value against `MINIMUM_BALANCE_FOR_EARN_CTA`
72+
balanceFiatNumber,
73+
// Ensure ETH-specific conditions behave consistently
74+
isETH: asset?.symbol === 'ETH' || asset?.isETH,
75+
};
76+
}),
77+
selectEarnOutputToken: jest.fn(() => undefined),
78+
selectEarnTokenPair: jest.fn(() => undefined),
11879
},
11980
}));
12081

@@ -218,11 +179,38 @@ const STATE_MOCK = {
218179
},
219180
} as unknown as RootState;
220181

221-
const renderComponent = (state = STATE_MOCK) =>
222-
renderWithProvider(<StakeButton asset={MOCK_ETH_MAINNET_ASSET} />, {
223-
state,
182+
const MOCK_MINIMUM_BALANCE_AS_STRING = String(MINIMUM_BALANCE_FOR_EARN_CTA);
183+
184+
const MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE: TokenI = {
185+
...MOCK_ETH_MAINNET_ASSET,
186+
balance: MOCK_MINIMUM_BALANCE_AS_STRING,
187+
};
188+
189+
const MOCK_USDC_MAINNET_ASSET_WITH_MINIMUM_BALANCE: TokenI = {
190+
...MOCK_USDC_MAINNET_ASSET,
191+
balance: MOCK_MINIMUM_BALANCE_AS_STRING,
192+
};
193+
194+
const getExpectedNavigationToken = (token: TokenI) =>
195+
expect.objectContaining({
196+
address: token.address,
197+
chainId: token.chainId,
198+
symbol: token.symbol,
199+
decimals: token.decimals,
200+
isNative: token.isNative,
201+
isETH: token.isETH,
202+
balance: token.balance,
203+
balanceFiatNumber: expect.any(Number),
224204
});
225205

206+
const renderComponent = (state = STATE_MOCK) =>
207+
renderWithProvider(
208+
<StakeButton asset={MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE} />,
209+
{
210+
state,
211+
},
212+
);
213+
226214
const selectPrimaryEarnExperienceTypeForAssetMock = jest.requireMock(
227215
'../../../../../selectors/earnController/earn',
228216
).earnSelectors.selectPrimaryEarnExperienceTypeForAsset as jest.Mock;
@@ -235,6 +223,17 @@ describe('StakeButton', () => {
235223
beforeEach(() => {
236224
jest.clearAllMocks();
237225

226+
(
227+
selectPooledStakingEnabledFlag as jest.MockedFunction<
228+
typeof selectPooledStakingEnabledFlag
229+
>
230+
).mockReturnValue(true);
231+
(
232+
selectStablecoinLendingEnabledFlag as jest.MockedFunction<
233+
typeof selectStablecoinLendingEnabledFlag
234+
>
235+
).mockReturnValue(true);
236+
238237
mockUseStakingEligibility.mockReturnValue({
239238
isEligible: true,
240239
isLoadingEligibility: false,
@@ -258,7 +257,9 @@ describe('StakeButton', () => {
258257
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
259258
screen: Routes.STAKING.STAKE,
260259
params: {
261-
token: MOCK_ETH_MAINNET_ASSET,
260+
token: getExpectedNavigationToken(
261+
MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE,
262+
),
262263
},
263264
});
264265
});
@@ -314,7 +315,9 @@ describe('StakeButton', () => {
314315
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
315316
screen: Routes.STAKING.STAKE,
316317
params: {
317-
token: { ...MOCK_ETH_MAINNET_ASSET },
318+
token: getExpectedNavigationToken(
319+
MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE,
320+
),
318321
},
319322
});
320323
});
@@ -324,7 +327,7 @@ describe('StakeButton', () => {
324327
describe('Stablecoin Lending', () => {
325328
it('navigates to Lending Input View when earn button is pressed', async () => {
326329
const { getByTestId } = renderWithProvider(
327-
<StakeButton asset={MOCK_USDC_MAINNET_ASSET} />,
330+
<StakeButton asset={MOCK_USDC_MAINNET_ASSET_WITH_MINIMUM_BALANCE} />,
328331
{
329332
state: STATE_MOCK,
330333
},
@@ -336,7 +339,9 @@ describe('StakeButton', () => {
336339
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
337340
screen: Routes.STAKING.STAKE,
338341
params: {
339-
token: MOCK_USDC_MAINNET_ASSET,
342+
token: getExpectedNavigationToken(
343+
MOCK_USDC_MAINNET_ASSET_WITH_MINIMUM_BALANCE,
344+
),
340345
},
341346
});
342347
});
@@ -352,6 +357,7 @@ describe('StakeButton', () => {
352357
isNative: true,
353358
// Ensure ETH-specific logic does not apply
354359
isETH: false,
360+
balance: MOCK_MINIMUM_BALANCE_AS_STRING,
355361
};
356362

357363
it('navigates to Stake Input screen when TRX has POOLED_STAKING experience', async () => {
@@ -373,7 +379,7 @@ describe('StakeButton', () => {
373379
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
374380
screen: Routes.STAKING.STAKE,
375381
params: {
376-
token: MOCK_TRX_ASSET,
382+
token: getExpectedNavigationToken(MOCK_TRX_ASSET),
377383
},
378384
});
379385
});
@@ -426,4 +432,40 @@ describe('StakeButton', () => {
426432

427433
expect(queryByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeNull();
428434
});
435+
436+
describe('Earn CTA minimum balance threshold', () => {
437+
it('does not render button when asset balance is below minimum earn cta threshold', () => {
438+
// Arrange
439+
const asset = { ...MOCK_ETH_MAINNET_ASSET, balance: '0.009' };
440+
441+
// Act
442+
const { queryByTestId } = renderWithProvider(
443+
<StakeButton asset={asset} />,
444+
{
445+
state: STATE_MOCK,
446+
},
447+
);
448+
449+
// Assert
450+
expect(queryByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeNull();
451+
});
452+
453+
it('renders button when asset balance meets minimum earn cta threshold', () => {
454+
// Arrange
455+
const asset = MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE;
456+
457+
// Act
458+
const { getByTestId } = renderWithProvider(
459+
<StakeButton asset={asset} />,
460+
{
461+
state: STATE_MOCK,
462+
},
463+
);
464+
465+
// Assert
466+
expect(
467+
getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON),
468+
).toBeOnTheScreen();
469+
});
470+
});
429471
});

app/components/UI/Stake/components/StakeButton/index.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
import { getDecimalChainId } from '../../../../../util/networks';
2020
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
2121
import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
22-
import useEarnTokens from '../../../Earn/hooks/useEarnTokens';
2322
import {
2423
selectPooledStakingEnabledFlag,
2524
selectStablecoinLendingEnabledFlag,
@@ -38,6 +37,10 @@ import { isTronChainId } from '../../../../../core/Multichain/utils';
3837
import useTronStakeApy from '../../../Earn/hooks/useTronStakeApy';
3938
import useStakingEligibility from '../../hooks/useStakingEligibility';
4039
///: END:ONLY_INCLUDE_IF
40+
import BigNumber from 'bignumber.js';
41+
import { MINIMUM_BALANCE_FOR_EARN_CTA } from '../../../Earn/constants/token';
42+
import useEarnToken from '../../../Earn/hooks/useEarnToken';
43+
import { EarnTokenDetails } from '../../../Earn/types/lending.types';
4144

4245
const styles = StyleSheet.create({
4346
stakeButton: {
@@ -48,12 +51,13 @@ const styles = StyleSheet.create({
4851
marginRight: 2,
4952
},
5053
});
51-
interface StakeButtonProps {
52-
asset: TokenI;
54+
55+
interface StakeButtonContentProps {
56+
earnToken: EarnTokenDetails;
5357
}
5458

5559
// TODO: Rename to EarnCta to better describe this component's purpose.
56-
const StakeButtonContent = ({ asset }: StakeButtonProps) => {
60+
const StakeButtonContent = ({ earnToken }: StakeButtonContentProps) => {
5761
const navigation = useNavigation();
5862
const { trackEvent, createEventBuilder } = useMetrics();
5963
const chainId = useSelector(selectEvmChainId);
@@ -66,18 +70,16 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
6670

6771
///: BEGIN:ONLY_INCLUDE_IF(tron)
6872
const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled);
69-
const isTronNative = asset?.isNative && isTronChainId(asset.chainId as Hex);
73+
const isTronNative =
74+
earnToken?.isNative && isTronChainId(earnToken.chainId as Hex);
7075
const { apyPercent: tronApyPercent } = useTronStakeApy();
7176
///: END:ONLY_INCLUDE_IF
7277
const network = useSelector((state: RootState) =>
73-
selectNetworkConfigurationByChainId(state, asset.chainId as Hex),
78+
selectNetworkConfigurationByChainId(state, earnToken?.chainId as Hex),
7479
);
7580

76-
const { getEarnToken } = useEarnTokens();
77-
const earnToken = getEarnToken(asset);
78-
7981
const primaryExperienceType = useSelector((state: RootState) =>
80-
earnSelectors.selectPrimaryEarnExperienceTypeForAsset(state, asset),
82+
earnSelectors.selectPrimaryEarnExperienceTypeForAsset(state, earnToken),
8183
);
8284

8385
const areEarnExperiencesDisabled =
@@ -90,11 +92,11 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
9092
trackEvent(
9193
createEventBuilder(MetaMetricsEvents.STAKE_BUTTON_CLICKED)
9294
.addProperties({
93-
chain_id: getDecimalChainId(asset.chainId as Hex),
95+
chain_id: getDecimalChainId(earnToken.chainId as Hex),
9496
location: EVENT_LOCATIONS.HOME_SCREEN,
9597
action_type: 'deposit',
9698
text: 'Earn',
97-
token: asset.symbol,
99+
token: earnToken.symbol,
98100
network: network?.name,
99101
experience: EARN_EXPERIENCES.POOLED_STAKING,
100102
})
@@ -104,7 +106,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
104106
navigation.navigate('StakeScreens', {
105107
screen: Routes.STAKING.STAKE,
106108
params: {
107-
token: asset,
109+
token: earnToken,
108110
},
109111
});
110112
return;
@@ -124,7 +126,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
124126
location: EVENT_LOCATIONS.HOME_SCREEN,
125127
action_type: 'deposit',
126128
text: 'Earn',
127-
token: asset.symbol,
129+
token: earnToken.symbol,
128130
network: network?.name,
129131
url: AppConstants.STAKE.URL,
130132
experience: EARN_EXPERIENCES.POOLED_STAKING,
@@ -135,13 +137,13 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
135137
navigation.navigate('StakeScreens', {
136138
screen: Routes.STAKING.STAKE,
137139
params: {
138-
token: asset,
140+
token: earnToken,
139141
},
140142
});
141143
};
142144

143145
const handleLendingRedirect = useStablecoinLendingRedirect({
144-
asset,
146+
asset: earnToken,
145147
location: EVENT_LOCATIONS.HOME_SCREEN,
146148
});
147149

@@ -195,15 +197,29 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
195197
);
196198
};
197199

200+
interface StakeButtonProps {
201+
asset: TokenI;
202+
}
203+
198204
export const StakeButton = (props: StakeButtonProps) => {
199205
const { isEligible } = useStakingEligibility();
206+
const { earnToken } = useEarnToken(props.asset);
207+
208+
if (!isEligible || !earnToken) {
209+
return null;
210+
}
200211

201-
if (!isEligible) {
212+
if (
213+
new BigNumber(earnToken?.balanceFiatNumber || '0').lt(
214+
MINIMUM_BALANCE_FOR_EARN_CTA,
215+
)
216+
) {
202217
return null;
203218
}
219+
204220
return (
205221
<StakeSDKProvider>
206-
<StakeButtonContent {...props} />
222+
<StakeButtonContent earnToken={earnToken} />
207223
</StakeSDKProvider>
208224
);
209225
};

0 commit comments

Comments
 (0)