diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 8e9cf2dcbcd..94e6f9cf32e 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Shared fiat currency and token formatters ([#6577](https://github.com/MetaMask/core/pull/6577)) + ### Changed - Add `queryAllAccounts` parameter support to `AccountTrackerController.refresh()`, `AccountTrackerController._executePoll()`, and `TokenBalancesController.updateBalances()` for flexible account selection during balance updates ([#6600](https://github.com/MetaMask/core/pull/6600)) diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 0a113d6e5f7..041cae84907 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -227,3 +227,4 @@ export type { AssetListState, } from './selectors/token-selectors'; export { selectAssetsBySelectedAccountGroup } from './selectors/token-selectors'; +export { createFormatters } from './utils/formatters'; diff --git a/packages/assets-controllers/src/utils/formatters.test.ts b/packages/assets-controllers/src/utils/formatters.test.ts new file mode 100644 index 00000000000..f076f6bd64d --- /dev/null +++ b/packages/assets-controllers/src/utils/formatters.test.ts @@ -0,0 +1,177 @@ +import { createFormatters } from './formatters'; + +const locale = 'en-US'; + +const invalidValues = [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, +]; + +describe('formatCurrency', () => { + const { formatCurrency } = createFormatters({ locale }); + + const testCases = [ + { value: 1_234.56, expected: '$1,234.56' }, + { value: 0, expected: '$0.00' }, + { value: -42.5, expected: '-$42.50' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrency(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrency(input, 'USD')).toBe(''); + }); + }); + + it('formats values correctly with different locale', () => { + const { formatCurrency: formatCurrencyGB } = createFormatters({ + locale: 'en-GB', + }); + expect(formatCurrencyGB(1234.56, 'GBP')).toBe('£1,234.56'); + }); +}); + +describe('formatCurrencyWithMinThreshold', () => { + const { formatCurrencyWithMinThreshold } = createFormatters({ locale }); + + const testCases = [ + { value: 0, expected: '$0.00' }, + + // Values below minimum threshold + { value: 0.000001, expected: '<$0.01' }, + { value: 0.001, expected: '<$0.01' }, + { value: -0.001, expected: '<$0.01' }, + + // Values at and above minimum threshold + { value: 0.01, expected: '$0.01' }, + { value: 0.1, expected: '$0.10' }, + { value: 1, expected: '$1.00' }, + { value: -0.01, expected: '-$0.01' }, + { value: -1, expected: '-$1.00' }, + { value: -100, expected: '-$100.00' }, + { value: 1_000, expected: '$1,000.00' }, + { value: 1_000_000, expected: '$1,000,000.00' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrencyWithMinThreshold(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrencyWithMinThreshold(input, 'USD')).toBe(''); + }); + }); +}); + +describe('formatCurrencyTokenPrice', () => { + const { formatCurrencyTokenPrice } = createFormatters({ locale }); + + const testCases = [ + { value: 0, expected: '$0.00' }, + + // Values below minimum threshold + { value: 0.000000001, expected: '<$0.00000001' }, + { value: -0.000000001, expected: '<$0.00000001' }, + + // Values above minimum threshold but less than 1 + { value: 0.0000123, expected: '$0.0000123' }, + { value: 0.001, expected: '$0.00100' }, + { value: 0.999, expected: '$0.999' }, + + // Values at and above 1 but less than 1,000,000 + { value: 1, expected: '$1.00' }, + { value: -1, expected: '-$1.00' }, + { value: -500, expected: '-$500.00' }, + + // Values 1,000,000 and above + { value: 1_000_000, expected: '$1.00M' }, + { value: -2_000_000, expected: '-$2.00M' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrencyTokenPrice(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrencyTokenPrice(input, 'USD')).toBe(''); + }); + }); +}); + +describe('formatToken', () => { + const { formatToken } = createFormatters({ locale }); + + const testCases = [ + { value: 1.234, symbol: 'ETH', expected: '1.234 ETH' }, + { value: 0, symbol: 'USDC', expected: '0 USDC' }, + { value: 1_000, symbol: 'DAI', expected: '1,000 DAI' }, + ]; + + it('formats token values', () => { + testCases.forEach(({ value, symbol, expected }) => { + expect(formatToken(value, symbol)).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatToken(input, 'ETH')).toBe(''); + }); + }); +}); + +describe('formatTokenQuantity', () => { + const { formatTokenQuantity } = createFormatters({ locale }); + + const testCases = [ + { value: 0, symbol: 'ETH', expected: '0 ETH' }, + + // Values below minimum threshold + { value: 0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + { value: -0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + { value: 0.0000005, symbol: 'USDC', expected: '<0.00001 USDC' }, + + // Values above minimum threshold but less than 1 + { value: 0.00001, symbol: 'ETH', expected: '0.0000100 ETH' }, + { value: 0.001234, symbol: 'BTC', expected: '0.00123 BTC' }, + { value: 0.123456, symbol: 'USDC', expected: '0.123 USDC' }, + + // Values 1 and above but less than 1,000,000 + { value: 1, symbol: 'ETH', expected: '1 ETH' }, + { value: -1, symbol: 'ETH', expected: '-1 ETH' }, + { value: -25.5, symbol: 'ETH', expected: '-25.5 ETH' }, + { value: 1.2345678, symbol: 'BTC', expected: '1.235 BTC' }, + { value: 123.45678, symbol: 'USDC', expected: '123.457 USDC' }, + { value: 999_999, symbol: 'DAI', expected: '999,999 DAI' }, + + // Values 1,000,000 and above + { value: 1_000_000, symbol: 'ETH', expected: '1.00M ETH' }, + { value: -1_500_000, symbol: 'ETH', expected: '-1.50M ETH' }, + { value: 1_234_567, symbol: 'BTC', expected: '1.23M BTC' }, + { value: 1_000_000_000, symbol: 'USDC', expected: '1.00B USDC' }, + ]; + + it('formats token quantities correctly', () => { + testCases.forEach(({ value, symbol, expected }) => { + expect(formatTokenQuantity(value, symbol)).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatTokenQuantity(input, 'ETH')).toBe(''); + }); + }); +}); diff --git a/packages/assets-controllers/src/utils/formatters.ts b/packages/assets-controllers/src/utils/formatters.ts new file mode 100644 index 00000000000..2d3a536aff4 --- /dev/null +++ b/packages/assets-controllers/src/utils/formatters.ts @@ -0,0 +1,310 @@ +const FALLBACK_LOCALE = 'en'; + +const twoDecimals = { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}; + +const oneSignificantDigit = { + minimumSignificantDigits: 1, + maximumSignificantDigits: 1, +}; + +const threeSignificantDigits = { + minimumSignificantDigits: 3, + maximumSignificantDigits: 3, +}; + +const numberFormatCache: Record = {}; + +/** + * Get cached number format instance. + * + * @param locale - Locale string. + * @param options - Optional Intl.NumberFormat options. + * @returns Cached Intl.NumberFormat instance. + */ +function getCachedNumberFormat( + locale: string, + options: Intl.NumberFormatOptions = {}, +) { + const key = `${locale}_${JSON.stringify(options)}`; + + let format = numberFormatCache[key]; + + if (format) { + return format; + } + + try { + format = new Intl.NumberFormat(locale, options); + } catch (error) { + if (error instanceof RangeError) { + // Fallback for invalid options (e.g. currency code) + format = new Intl.NumberFormat(locale, twoDecimals); + } else { + throw error; + } + } + + numberFormatCache[key] = format; + return format; +} + +/** + * Format a value as a currency string. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @param options - Optional Intl.NumberFormat overrides. + * @returns Formatted currency string. + */ +function formatCurrency( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], + options: Intl.NumberFormatOptions = {}, +) { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, { + style: 'currency', + currency, + ...options, + }); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + return numberFormat.format(value); +} + +/** + * Compact currency formatting (e.g. $1.2K, $3.4M). + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @returns Formatted compact currency string. + */ +function formatCurrencyCompact( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], +) { + return formatCurrency(config, value, currency, { + notation: 'compact', + ...twoDecimals, + }); +} + +/** + * Currency formatting with minimum threshold for small values. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @returns Formatted currency string with threshold handling. + */ +function formatCurrencyWithMinThreshold( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], +) { + const minThreshold = 0.01; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatCurrency(config, 0, currency); + } + + if (absoluteValue < minThreshold) { + const formattedMin = formatCurrency(config, minThreshold, currency); + return `<${formattedMin}`; + } + + return formatCurrency(config, number, currency); +} + +/** + * Format a value as a token string with symbol. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param symbol - Token symbol. + * @param options - Optional Intl.NumberFormat overrides. + * @returns Formatted token string. + */ +function formatToken( + config: { locale: string }, + value: number | bigint | `${number}`, + symbol: string, + options: Intl.NumberFormatOptions = {}, +) { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, { + style: 'decimal', + ...options, + }); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + const formattedNumber = numberFormat.format(value); + + return `${formattedNumber} ${symbol}`; +} + +/** + * Format token price with varying precision based on value. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @returns Formatted token price string. + */ +function formatCurrencyTokenPrice( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], +) { + const minThreshold = 0.00000001; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatCurrency(config, 0, currency); + } + + if (absoluteValue < minThreshold) { + return `<${formatCurrency(config, minThreshold, currency, oneSignificantDigit)}`; + } + + if (absoluteValue < 1) { + return formatCurrency(config, number, currency, threeSignificantDigits); + } + + if (absoluteValue < 1_000_000) { + return formatCurrency(config, number, currency); + } + + return formatCurrencyCompact(config, number, currency); +} + +/** + * Format token quantity with varying precision based on value. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param symbol - Token symbol. + * @returns Formatted token quantity string. + */ +function formatTokenQuantity( + config: { locale: string }, + value: number | bigint | `${number}`, + symbol: string, +) { + const minThreshold = 0.00001; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatToken(config, 0, symbol); + } + + if (absoluteValue < minThreshold) { + return `<${formatToken(config, minThreshold, symbol, oneSignificantDigit)}`; + } + + if (absoluteValue < 1) { + return formatToken(config, number, symbol, threeSignificantDigits); + } + + if (absoluteValue < 1_000_000) { + return formatToken(config, number, symbol); + } + + return formatToken(config, number, symbol, { + notation: 'compact', + ...twoDecimals, + }); +} + +/** + * Create formatter functions with the given locale. + * + * @param options - Configuration options. + * @param options.locale - Locale string. + * @returns Object with formatter functions. + */ +export function createFormatters({ locale = FALLBACK_LOCALE }) { + return { + /** + * Format a value as a currency string. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code (e.g. 'USD'). + * @param options - Optional Intl.NumberFormat overrides. + */ + formatCurrency: formatCurrency.bind(null, { locale }), + /** + * Compact currency (e.g. $1.2K, $3.4M) with up to two decimal digits. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + */ + formatCurrencyCompact: formatCurrencyCompact.bind(null, { locale }), + /** + * Currency with thresholds for small values. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + */ + formatCurrencyWithMinThreshold: formatCurrencyWithMinThreshold.bind(null, { + locale, + }), + /** + * Format token price with varying precision based on value. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + */ + formatCurrencyTokenPrice: formatCurrencyTokenPrice.bind(null, { locale }), + /** + * Format a value as a token string with symbol. + * + * @param value - Numeric value to format. + * @param symbol - Token symbol (e.g. 'ETH', 'SepoliaETH'). + * @param options - Optional Intl.NumberFormat overrides. + */ + formatToken: formatToken.bind(null, { locale }), + /** + * Format token quantity with varying precision based on value. + * + * @param value - Numeric value to format. + * @param symbol - Token symbol (e.g. 'ETH', 'SepoliaETH'). + */ + formatTokenQuantity: formatTokenQuantity.bind(null, { locale }), + }; +}