diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 55e69ffe02a..13c14514deb 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -64,6 +64,20 @@ jest.mock('../../../hooks/pay/useTransactionPayWithdraw', () => ({ })), })); jest.mock('../../../../../../util/transaction-controller', () => ({})); +jest.mock('../../../../../UI/Money/hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: () => ({ + vaultApyQuery: { data: { apy: 5.5 }, isLoading: false }, + }), +})); +jest.mock( + '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', + () => ({ + __esModule: true, + default: () => (value: { toString: () => string }) => + `$${Number(value.toString()).toFixed(2)}`, + }), +); jest.mock('../../../../../../core/Engine', () => ({ context: { TransactionPayController: { diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index a0c343562c6..6d200a55316 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -2,6 +2,7 @@ import React, { ReactNode, memo, useCallback, useState } from 'react'; import { toCaipAssetType } from '@metamask/utils'; import { TransactionType } from '@metamask/transaction-controller'; import { PayTokenAmount, PayTokenAmountSkeleton } from '../../pay-token-amount'; +import { ProjectedFiveYearBalance } from '../../projected-five-year-balance'; import { PayWithRow, PayWithRowSkeleton } from '../../rows/pay-with-row'; import { BridgeFeeRow } from '../../rows/bridge-fee-row'; import { BridgeTimeRow } from '../../rows/bridge-time-row'; @@ -208,12 +209,16 @@ export const CustomAmountInfo: React.FC = memo( onPress={handleAmountPress} disabled={!hasTokens} /> - {!hidePayTokenAmount && disablePay !== true && ( - - )} + {!hidePayTokenAmount && + disablePay !== true && + (isMoneyAccountDeposit ? ( + + ) : ( + + ))} {!hidePayTokenAmount && children} ); +} + +describe('ProjectedFiveYearBalance', () => { + const formatFiat = jest.fn( + (value: BigNumber) => `$${value.toFixed(2, BigNumber.ROUND_HALF_UP)}`, + ); + + beforeEach(() => { + jest.clearAllMocks(); + useFiatFormatterMock.mockReturnValue(formatFiat); + }); + + it('renders label and projected balance for $1,000 at 5% APY over 5 years (~$1,276.28)', () => { + mockBalance({ apy: 5 }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId('projected-five-year-balance')).toBeOnTheScreen(); + expect(getByText(LABEL, { exact: false })).toBeOnTheScreen(); + // 1000 * (1.05)^5 = 1276.2815625 + expect(getByText('$1276.28')).toBeOnTheScreen(); + }); + + it('matches the Figma example: $1,000 at the design APY rounds to $1,114.36 when APY=2.18', () => { + mockBalance({ apy: 2.18 }); + + const { getByText } = render( + , + ); + + // 1000 * (1.0218)^5 ≈ 1113.86 — sanity-checks the compounding formula + // tracks the figma direction (label + green dollar amount); exact APY/value + // is product-driven, this just guards the math. + expect(getByText(/^\$1\d{3}\.\d{2}$/)).toBeOnTheScreen(); + }); + + it('returns null while APY is loading', () => { + mockBalance({ apy: undefined, isLoading: true }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY data is unavailable', () => { + mockBalance({ apy: undefined }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY is negative', () => { + mockBalance({ apy: -1 }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY is not finite', () => { + mockBalance({ apy: Number.POSITIVE_INFINITY }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('renders $0.00 when apy is 0% (compounding identity)', () => { + mockBalance({ apy: 0 }); + + const { getByText } = render(); + + expect(getByText('$0.00')).toBeOnTheScreen(); + }); + + it('treats empty amountFiat as zero', () => { + mockBalance({ apy: 5 }); + + const { getByText } = render(); + + expect(getByText('$0.00')).toBeOnTheScreen(); + }); + + it('passes a BigNumber to the fiat formatter', () => { + mockBalance({ apy: 5 }); + + render(); + + expect(formatFiat).toHaveBeenCalledTimes(1); + const passed = formatFiat.mock.calls[0][0]; + expect(BigNumber.isBigNumber(passed)).toBe(true); + // 1000 * 1.05^5 = 1276.2815625 + expect(passed.toFixed(4)).toBe('1276.2816'); + }); + + it('returns null when amountFiat is non-numeric', () => { + mockBalance({ apy: 5 }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); +}); diff --git a/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx b/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx new file mode 100644 index 00000000000..527af80e6ed --- /dev/null +++ b/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import BigNumber from 'bignumber.js'; +import { + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import useMoneyAccountBalance from '../../../../UI/Money/hooks/useMoneyAccountBalance'; +import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { strings } from '../../../../../../locales/i18n'; + +const PROJECTION_YEARS = 5; + +export interface ProjectedFiveYearBalanceProps { + amountFiat: string; +} + +export function ProjectedFiveYearBalance({ + amountFiat, +}: ProjectedFiveYearBalanceProps) { + const { vaultApyQuery } = useMoneyAccountBalance(); + const formatFiat = useFiatFormatter(); + + const projected = useMemo(() => { + const apy = vaultApyQuery.data?.apy; + if (typeof apy !== 'number' || !isFinite(apy) || apy < 0) { + return null; + } + + const amount = new BigNumber(amountFiat || '0'); + if (!amount.isFinite()) { + return null; + } + + const growthFactor = new BigNumber(1).plus( + new BigNumber(apy).dividedBy(100), + ); + return amount.multipliedBy(growthFactor.pow(PROJECTION_YEARS)); + }, [amountFiat, vaultApyQuery.data?.apy]); + + if (vaultApyQuery.isLoading || projected === null) { + return null; + } + + return ( + + + {strings('confirm.custom_amount.projected_five_year_balance')}{' '} + + {formatFiat(projected)} + + + + ); +} diff --git a/locales/languages/en.json b/locales/languages/en.json index 4ec5a87b335..bdf71339d1f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7076,7 +7076,8 @@ "custom_amount": { "buy_button": "Buy crypto", "buy_predict": "Add funds to your wallet to use Predictions.", - "buy_perps": "Add funds to your wallet to use Perps." + "buy_perps": "Add funds to your wallet to use Perps.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Unlimited", "all": "All",