Skip to content

Commit 576ce80

Browse files
authored
feat: display 5 year projected balance on money account deposit page (#29607)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** Show 5 year projected balane on money account deposit page. ## **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: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1296 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <img width="396" height="854" alt="Screenshot 2026-05-01 at 5 54 30 PM" src="https://github.com/user-attachments/assets/8fd76b2f-92be-437c-a32e-5a203f3d4788" /> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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-only change that adds a derived display value based on APY; main risk is incorrect math/formatting or missing APY data causing the line to disappear. > > **Overview** > Shows a *projected 5-year balance* line on the custom amount confirmation when the transaction type is `moneyAccountDeposit`, replacing the usual `PayTokenAmount` display in that case. > > Adds a new `ProjectedFiveYearBalance` component that pulls vault APY via `useMoneyAccountBalance`, computes 5-year compounding with `BigNumber`, formats via `useFiatFormatter`, and safely returns `null` for loading/invalid inputs; includes unit tests and a new i18n string `confirm.custom_amount.projected_five_year_balance`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8c256e1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c03ef59 commit 576ce80

6 files changed

Lines changed: 230 additions & 7 deletions

File tree

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ jest.mock('../../../hooks/pay/useTransactionPayWithdraw', () => ({
6464
})),
6565
}));
6666
jest.mock('../../../../../../util/transaction-controller', () => ({}));
67+
jest.mock('../../../../../UI/Money/hooks/useMoneyAccountBalance', () => ({
68+
__esModule: true,
69+
default: () => ({
70+
vaultApyQuery: { data: { apy: 5.5 }, isLoading: false },
71+
}),
72+
}));
73+
jest.mock(
74+
'../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter',
75+
() => ({
76+
__esModule: true,
77+
default: () => (value: { toString: () => string }) =>
78+
`$${Number(value.toString()).toFixed(2)}`,
79+
}),
80+
);
6781
jest.mock('../../../../../../core/Engine', () => ({
6882
context: {
6983
TransactionPayController: {

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { ReactNode, memo, useCallback, useState } from 'react';
22
import { toCaipAssetType } from '@metamask/utils';
33
import { TransactionType } from '@metamask/transaction-controller';
44
import { PayTokenAmount, PayTokenAmountSkeleton } from '../../pay-token-amount';
5+
import { ProjectedFiveYearBalance } from '../../projected-five-year-balance';
56
import { PayWithRow, PayWithRowSkeleton } from '../../rows/pay-with-row';
67
import { BridgeFeeRow } from '../../rows/bridge-fee-row';
78
import { BridgeTimeRow } from '../../rows/bridge-time-row';
@@ -208,12 +209,16 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
208209
onPress={handleAmountPress}
209210
disabled={!hasTokens}
210211
/>
211-
{!hidePayTokenAmount && disablePay !== true && (
212-
<PayTokenAmount
213-
amountHuman={amountHuman}
214-
disabled={!hasTokens || isAccountSelectionNeeded}
215-
/>
216-
)}
212+
{!hidePayTokenAmount &&
213+
disablePay !== true &&
214+
(isMoneyAccountDeposit ? (
215+
<ProjectedFiveYearBalance amountFiat={amountFiat} />
216+
) : (
217+
<PayTokenAmount
218+
amountHuman={amountHuman}
219+
disabled={!hasTokens || isAccountSelectionNeeded}
220+
/>
221+
))}
217222
{!hidePayTokenAmount && children}
218223
</Box>
219224
<Box
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ProjectedFiveYearBalance } from './projected-five-year-balance';
2+
export type { ProjectedFiveYearBalanceProps } from './projected-five-year-balance';
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import BigNumber from 'bignumber.js';
4+
import { ProjectedFiveYearBalance } from './projected-five-year-balance';
5+
import useMoneyAccountBalance from '../../../../UI/Money/hooks/useMoneyAccountBalance';
6+
import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter';
7+
import { strings } from '../../../../../../locales/i18n';
8+
9+
jest.mock('../../../../UI/Money/hooks/useMoneyAccountBalance');
10+
jest.mock('../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter');
11+
12+
const useMoneyAccountBalanceMock = jest.mocked(useMoneyAccountBalance);
13+
const useFiatFormatterMock = jest.mocked(useFiatFormatter);
14+
15+
const LABEL = strings('confirm.custom_amount.projected_five_year_balance');
16+
17+
function mockBalance({
18+
apy,
19+
isLoading = false,
20+
}: {
21+
apy: number | undefined;
22+
isLoading?: boolean;
23+
}) {
24+
useMoneyAccountBalanceMock.mockReturnValue({
25+
vaultApyQuery: {
26+
data: apy === undefined ? undefined : { apy },
27+
isLoading,
28+
},
29+
} as unknown as ReturnType<typeof useMoneyAccountBalance>);
30+
}
31+
32+
describe('ProjectedFiveYearBalance', () => {
33+
const formatFiat = jest.fn(
34+
(value: BigNumber) => `$${value.toFixed(2, BigNumber.ROUND_HALF_UP)}`,
35+
);
36+
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
useFiatFormatterMock.mockReturnValue(formatFiat);
40+
});
41+
42+
it('renders label and projected balance for $1,000 at 5% APY over 5 years (~$1,276.28)', () => {
43+
mockBalance({ apy: 5 });
44+
45+
const { getByTestId, getByText } = render(
46+
<ProjectedFiveYearBalance amountFiat="1000" />,
47+
);
48+
49+
expect(getByTestId('projected-five-year-balance')).toBeOnTheScreen();
50+
expect(getByText(LABEL, { exact: false })).toBeOnTheScreen();
51+
// 1000 * (1.05)^5 = 1276.2815625
52+
expect(getByText('$1276.28')).toBeOnTheScreen();
53+
});
54+
55+
it('matches the Figma example: $1,000 at the design APY rounds to $1,114.36 when APY=2.18', () => {
56+
mockBalance({ apy: 2.18 });
57+
58+
const { getByText } = render(
59+
<ProjectedFiveYearBalance amountFiat="1000" />,
60+
);
61+
62+
// 1000 * (1.0218)^5 ≈ 1113.86 — sanity-checks the compounding formula
63+
// tracks the figma direction (label + green dollar amount); exact APY/value
64+
// is product-driven, this just guards the math.
65+
expect(getByText(/^\$1\d{3}\.\d{2}$/)).toBeOnTheScreen();
66+
});
67+
68+
it('returns null while APY is loading', () => {
69+
mockBalance({ apy: undefined, isLoading: true });
70+
71+
const { queryByTestId } = render(
72+
<ProjectedFiveYearBalance amountFiat="1000" />,
73+
);
74+
75+
expect(queryByTestId('projected-five-year-balance')).toBeNull();
76+
});
77+
78+
it('returns null when APY data is unavailable', () => {
79+
mockBalance({ apy: undefined });
80+
81+
const { queryByTestId } = render(
82+
<ProjectedFiveYearBalance amountFiat="1000" />,
83+
);
84+
85+
expect(queryByTestId('projected-five-year-balance')).toBeNull();
86+
});
87+
88+
it('returns null when APY is negative', () => {
89+
mockBalance({ apy: -1 });
90+
91+
const { queryByTestId } = render(
92+
<ProjectedFiveYearBalance amountFiat="1000" />,
93+
);
94+
95+
expect(queryByTestId('projected-five-year-balance')).toBeNull();
96+
});
97+
98+
it('returns null when APY is not finite', () => {
99+
mockBalance({ apy: Number.POSITIVE_INFINITY });
100+
101+
const { queryByTestId } = render(
102+
<ProjectedFiveYearBalance amountFiat="1000" />,
103+
);
104+
105+
expect(queryByTestId('projected-five-year-balance')).toBeNull();
106+
});
107+
108+
it('renders $0.00 when apy is 0% (compounding identity)', () => {
109+
mockBalance({ apy: 0 });
110+
111+
const { getByText } = render(<ProjectedFiveYearBalance amountFiat="0" />);
112+
113+
expect(getByText('$0.00')).toBeOnTheScreen();
114+
});
115+
116+
it('treats empty amountFiat as zero', () => {
117+
mockBalance({ apy: 5 });
118+
119+
const { getByText } = render(<ProjectedFiveYearBalance amountFiat="" />);
120+
121+
expect(getByText('$0.00')).toBeOnTheScreen();
122+
});
123+
124+
it('passes a BigNumber to the fiat formatter', () => {
125+
mockBalance({ apy: 5 });
126+
127+
render(<ProjectedFiveYearBalance amountFiat="1000" />);
128+
129+
expect(formatFiat).toHaveBeenCalledTimes(1);
130+
const passed = formatFiat.mock.calls[0][0];
131+
expect(BigNumber.isBigNumber(passed)).toBe(true);
132+
// 1000 * 1.05^5 = 1276.2815625
133+
expect(passed.toFixed(4)).toBe('1276.2816');
134+
});
135+
136+
it('returns null when amountFiat is non-numeric', () => {
137+
mockBalance({ apy: 5 });
138+
139+
const { queryByTestId } = render(
140+
<ProjectedFiveYearBalance amountFiat="abc" />,
141+
);
142+
143+
expect(queryByTestId('projected-five-year-balance')).toBeNull();
144+
});
145+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { useMemo } from 'react';
2+
import { View } from 'react-native';
3+
import BigNumber from 'bignumber.js';
4+
import {
5+
Text,
6+
TextColor,
7+
TextVariant,
8+
} from '@metamask/design-system-react-native';
9+
import useMoneyAccountBalance from '../../../../UI/Money/hooks/useMoneyAccountBalance';
10+
import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter';
11+
import { strings } from '../../../../../../locales/i18n';
12+
13+
const PROJECTION_YEARS = 5;
14+
15+
export interface ProjectedFiveYearBalanceProps {
16+
amountFiat: string;
17+
}
18+
19+
export function ProjectedFiveYearBalance({
20+
amountFiat,
21+
}: ProjectedFiveYearBalanceProps) {
22+
const { vaultApyQuery } = useMoneyAccountBalance();
23+
const formatFiat = useFiatFormatter();
24+
25+
const projected = useMemo(() => {
26+
const apy = vaultApyQuery.data?.apy;
27+
if (typeof apy !== 'number' || !isFinite(apy) || apy < 0) {
28+
return null;
29+
}
30+
31+
const amount = new BigNumber(amountFiat || '0');
32+
if (!amount.isFinite()) {
33+
return null;
34+
}
35+
36+
const growthFactor = new BigNumber(1).plus(
37+
new BigNumber(apy).dividedBy(100),
38+
);
39+
return amount.multipliedBy(growthFactor.pow(PROJECTION_YEARS));
40+
}, [amountFiat, vaultApyQuery.data?.apy]);
41+
42+
if (vaultApyQuery.isLoading || projected === null) {
43+
return null;
44+
}
45+
46+
return (
47+
<View testID="projected-five-year-balance">
48+
<Text variant={TextVariant.BodyMd} color={TextColor.TextAlternative}>
49+
{strings('confirm.custom_amount.projected_five_year_balance')}{' '}
50+
<Text variant={TextVariant.BodyMd} color={TextColor.SuccessDefault}>
51+
{formatFiat(projected)}
52+
</Text>
53+
</Text>
54+
</View>
55+
);
56+
}

locales/languages/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7107,7 +7107,8 @@
71077107
"custom_amount": {
71087108
"buy_button": "Buy crypto",
71097109
"buy_predict": "Add funds to your wallet to use Predictions.",
7110-
"buy_perps": "Add funds to your wallet to use Perps."
7110+
"buy_perps": "Add funds to your wallet to use Perps.",
7111+
"projected_five_year_balance": "Projected 5-year balance:"
71117112
},
71127113
"unlimited": "Unlimited",
71137114
"all": "All",

0 commit comments

Comments
 (0)