Skip to content

Commit 2ef73f3

Browse files
authored
fix: cp-8.3.0 convert pay-flow token amounts to USD instead of relabeling (#33321)
<!-- 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`. --> ## **Description** <!-- mms-check: type=text required=true --> Follow-up to #32631. That PR fixed the *label* on pay-flow token amounts. It didn't fix the *value*: `asset.fiat.balance` comes from the assets-controller in the user's preferred currency (e.g. EUR), and `Intl.NumberFormat({ currency: 'USD' })` only changes the symbol — it doesn't convert. Result: EUR-preferred users saw their EUR magnitude next to a `$` symbol. This PR converts the value instead of relabeling it, and encapsulates the pay-currency policy in a new hook. **Changes** - New `useAssetFiatFormatter` hook in [`hooks/pay/`](/app/components/Views/confirmations/hooks/pay/). Owns: reading pay currency, reading currency rates, re-scaling by `(native→USD) / (native→preferred)`, Intl formatting. Returns `undefined` when the target currency can't be safely computed (missing rate, missing chain config). - `useAccountTokens` becomes currency-agnostic. Drops all pay-flow imports; calls `useAssetFiatFormatter` and forwards its output to `balanceInSelectedCurrency`. Public API and all 9 consumers unchanged. - Zero balances short-circuit the conversion (0 is currency-invariant) so the enrichment `$0` placeholder always renders. ## **Changelog** <!-- mms-check: type=changelog required=true blocking=true --> CHANGELOG entry: Fixed a bug that caused pay-flow token amounts (Perps, Predict, Money Account, mUSD) to be shown with a USD symbol but the user's preferred-currency numeric value, when the user's preferred currency was not USD. ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: N/A (follow-up to #32631) Refs: #32631 ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```gherkin Feature: Pay-flow confirmations convert token amounts to USD, not just relabel Background: Given the user has set their preferred fiat currency to EUR Scenario: Pay With modal shows USD-converted numeric values Given the user opens a Perps / Predict / Money / mUSD conversion confirmation When the user opens the "Pay with" modal Then every row shows a "$" prefix And the number is the USD equivalent (not the EUR magnitude) Scenario: musdClaim keeps the user's preferred currency Given the user opens an mUSD claim confirmation Then token amounts render in EUR Scenario: Regular Send flow is unaffected Given the user opens the regular Send flow Then token amounts render in EUR Scenario: Preferred currency already USD Given the user has set their preferred currency to USD When the user opens any pay-flow confirmation Then amounts render exactly as before this PR Scenario: Missing USD rate for a chain Given a token on a chain whose usdConversionRate has not loaded When the user opens a pay-flow confirmation Then that row shows no fiat value (rather than a wrong-currency value) ``` ## **Screenshots/Recordings** <!-- mms-check: type=screenshot required=true --> ### **Before** N/A — visible symptom is the `$` prefix on a preferred-currency numeric value. Device recording will be attached with QA build. ### **After** https://github.com/user-attachments/assets/7913f4e7-5e91-4e07-ba78-965a26836482 ## **Pre-merge author checklist** <!-- mms-check: type=checklist required=true --> - [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 - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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] > **Medium Risk** > Changes how fiat amounts are computed and sorted in send/pay token lists; wrong rates or eligibility bugs could show incorrect USD values or hide balances, though missing-rate cases are explicitly guarded. > > **Overview** > Fixes pay-flow token rows showing a **USD symbol** while still using the **preferred-currency magnitude** from `asset.fiat.balance` by **computing EVM fiat as `balance × rate`** (via `useTokenFiatRates` with the active display currency) instead of formatting the assets-controller fiat field directly. > > Adds **`useAssetFiatFormatter`**, which picks **pay-flow USD** vs **user preferred currency** (through `useTransactionPayCurrency`) and handles **Intl currency formatting** only; rate selection stays in `useTokenFiatRates`. > > **`useAccountTokens`** delegates display formatting to that hook, builds rate requests only for **EVM hex-address** assets (non-EVM still uses `fiat.balance`), sorts by the **same derived fiat** shown in the UI, and keeps **$0** visible for zero balances when rates are missing while **hiding** non-zero rows without a rate. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1ad7b83. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dc305bf commit 2ef73f3

4 files changed

Lines changed: 588 additions & 172 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { useSelector } from 'react-redux';
3+
4+
import { useAssetFiatFormatter } from './useAssetFiatFormatter';
5+
import { useTransactionPayCurrency } from './useTransactionPayCurrency';
6+
import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController';
7+
import { getIntlNumberFormatter } from '../../../../../util/intl';
8+
9+
jest.mock('react-redux', () => ({
10+
useSelector: jest.fn(),
11+
}));
12+
13+
jest.mock('./useTransactionPayCurrency', () => ({
14+
useTransactionPayCurrency: jest.fn(),
15+
}));
16+
17+
jest.mock('../../../../../selectors/currencyRateController', () => ({
18+
selectCurrentCurrency: jest.fn(),
19+
}));
20+
21+
jest.mock('../../../../../util/intl', () => ({
22+
getIntlNumberFormatter: jest.fn(),
23+
}));
24+
25+
jest.mock('../../../../../../locales/i18n', () => ({
26+
locale: 'en-US',
27+
strings: jest.fn((key: string) => key),
28+
}));
29+
30+
const mockUseSelector = jest.mocked(useSelector);
31+
const mockUseTransactionPayCurrency = jest.mocked(useTransactionPayCurrency);
32+
const mockGetIntlNumberFormatter = jest.mocked(getIntlNumberFormatter);
33+
34+
const mockFormatter = { format: jest.fn() };
35+
36+
function buildState({ preferredCurrency = 'usd' } = {}) {
37+
mockUseSelector.mockImplementation((selector) => {
38+
if (selector === selectCurrentCurrency) return preferredCurrency;
39+
return undefined;
40+
});
41+
}
42+
43+
describe('useAssetFiatFormatter', () => {
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
mockUseTransactionPayCurrency.mockReturnValue(undefined);
47+
mockGetIntlNumberFormatter.mockReturnValue(
48+
mockFormatter as unknown as ReturnType<typeof getIntlNumberFormatter>,
49+
);
50+
mockFormatter.format.mockImplementation((n) => `formatted:${String(n)}`);
51+
});
52+
53+
describe('fiatCurrency selection', () => {
54+
it('uses the preferred currency outside pay flow', () => {
55+
buildState({ preferredCurrency: 'eur' });
56+
57+
const { result } = renderHook(() => useAssetFiatFormatter());
58+
59+
expect(result.current.fiatCurrency).toBe('eur');
60+
});
61+
62+
it('uses the pay currency when useTransactionPayCurrency returns a value', () => {
63+
buildState({ preferredCurrency: 'eur' });
64+
mockUseTransactionPayCurrency.mockReturnValue('USD');
65+
66+
const { result } = renderHook(() => useAssetFiatFormatter());
67+
68+
expect(result.current.fiatCurrency).toBe('USD');
69+
});
70+
});
71+
72+
describe('format', () => {
73+
it('formats a numeric amount using Intl currency', () => {
74+
buildState({ preferredCurrency: 'eur' });
75+
76+
const { result } = renderHook(() => useAssetFiatFormatter());
77+
const output = result.current.format('100');
78+
79+
expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith(
80+
'en-US',
81+
expect.objectContaining({ style: 'currency', currency: 'eur' }),
82+
);
83+
expect(mockFormatter.format).toHaveBeenCalledWith('100');
84+
expect(output).toBe('formatted:100');
85+
});
86+
87+
it('formats with the pay currency when in pay flow', () => {
88+
buildState({ preferredCurrency: 'eur' });
89+
mockUseTransactionPayCurrency.mockReturnValue('USD');
90+
91+
const { result } = renderHook(() => useAssetFiatFormatter());
92+
result.current.format('110');
93+
94+
expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith(
95+
'en-US',
96+
expect.objectContaining({ currency: 'USD' }),
97+
);
98+
expect(mockFormatter.format).toHaveBeenCalledWith('110');
99+
});
100+
101+
it('uses minimumFractionDigits=0 for integer amounts', () => {
102+
buildState({ preferredCurrency: 'eur' });
103+
104+
renderHook(() => useAssetFiatFormatter()).result.current.format('100');
105+
106+
expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith(
107+
'en-US',
108+
expect.objectContaining({ minimumFractionDigits: 0 }),
109+
);
110+
});
111+
112+
it('uses minimumFractionDigits=2 for non-integer amounts', () => {
113+
buildState({ preferredCurrency: 'eur' });
114+
115+
renderHook(() => useAssetFiatFormatter()).result.current.format('100.5');
116+
117+
expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith(
118+
'en-US',
119+
expect.objectContaining({ minimumFractionDigits: 2 }),
120+
);
121+
});
122+
123+
it('returns undefined when input is undefined', () => {
124+
buildState({ preferredCurrency: 'eur' });
125+
126+
const { result } = renderHook(() => useAssetFiatFormatter());
127+
const output = result.current.format(undefined);
128+
129+
expect(output).toBeUndefined();
130+
expect(mockFormatter.format).not.toHaveBeenCalled();
131+
});
132+
133+
it('falls back to `${value} ${currency}` when Intl throws', () => {
134+
buildState({ preferredCurrency: 'eur' });
135+
mockGetIntlNumberFormatter.mockImplementation(() => {
136+
throw new Error('boom');
137+
});
138+
139+
const { result } = renderHook(() => useAssetFiatFormatter());
140+
const output = result.current.format('42');
141+
142+
expect(output).toBe('42 eur');
143+
});
144+
});
145+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useSelector } from 'react-redux';
2+
import { useCallback } from 'react';
3+
import { BigNumber } from 'bignumber.js';
4+
5+
import I18n from '../../../../../../locales/i18n';
6+
import Logger from '../../../../../util/Logger';
7+
import { getIntlNumberFormatter } from '../../../../../util/intl';
8+
import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController';
9+
import { useTransactionPayCurrency } from './useTransactionPayCurrency';
10+
11+
export type AssetFiatFormatter = (
12+
amount: BigNumber.Value | undefined,
13+
) => string | undefined;
14+
15+
/**
16+
* Returns a currency formatter whose target currency follows the pay-flow
17+
* override: USD inside `PAY_TRANSACTION_TYPES` confirmations, otherwise the
18+
* user's preferred currency.
19+
*
20+
* This hook only formats — it does not derive rates or convert values.
21+
* Callers must pass an `amount` already denominated in `fiatCurrency`. Rate
22+
* derivation should go through `useTokenFiatRates`, which owns the
23+
* stablecoin exception and the USD/preferred-currency rate selection.
24+
*
25+
* Returns `undefined` when the input amount is `undefined`, so callers can
26+
* pass through a missing rate as "hide fiat".
27+
*/
28+
export function useAssetFiatFormatter(): {
29+
format: AssetFiatFormatter;
30+
fiatCurrency: string;
31+
} {
32+
const preferredCurrency = useSelector(selectCurrentCurrency);
33+
const payCurrency = useTransactionPayCurrency();
34+
const fiatCurrency = payCurrency ?? preferredCurrency;
35+
36+
const format = useCallback<AssetFiatFormatter>(
37+
(amount) => {
38+
if (amount === undefined) {
39+
return undefined;
40+
}
41+
42+
const value = new BigNumber(amount);
43+
const hasDecimals = !value.isInteger();
44+
45+
try {
46+
return getIntlNumberFormatter(I18n.locale, {
47+
style: 'currency',
48+
currency: fiatCurrency,
49+
minimumFractionDigits: hasDecimals ? 2 : 0,
50+
}).format(value.toFixed() as unknown as number);
51+
} catch (error) {
52+
Logger.error(error as Error);
53+
return `${value.toFixed()} ${fiatCurrency}`;
54+
}
55+
},
56+
[fiatCurrency],
57+
);
58+
59+
return { format, fiatCurrency };
60+
}

0 commit comments

Comments
 (0)