From c17e792499506cb44525a9c16be7a2354dc5bf86 Mon Sep 17 00:00:00 2001 From: Karelian Pie Date: Tue, 11 Jul 2023 12:28:35 +0300 Subject: [PATCH] feat(amountV2): Move amountV2 from yearn.fi --- packages/web-lib/utils/format.number.test.ts | 376 ++++++++++++++++++- packages/web-lib/utils/format.number.ts | 198 ++++++++++ 2 files changed, 573 insertions(+), 1 deletion(-) diff --git a/packages/web-lib/utils/format.number.test.ts b/packages/web-lib/utils/format.number.test.ts index 85e2f82c..0290f734 100755 --- a/packages/web-lib/utils/format.number.test.ts +++ b/packages/web-lib/utils/format.number.test.ts @@ -1,4 +1,9 @@ -import {amount, formatNumberOver10K} from './format.number'; +import {amount, + amountV2, + assertValidNumber, + defaultOptions, + formatLocalAmount, + formatNumberOver10K} from './format.number'; describe('format.number', (): void => { describe('amount()', (): void => { @@ -74,4 +79,373 @@ describe('format.number', (): void => { expect(formatNumberOver10K(0)).toBe('0,00'); }); }); + + describe('assertValidNumber', (): void => { + it('Undefined should fallback to default value', (): void => { + expect(assertValidNumber(undefined, 22, '')).toBe(22); + }); + it('Negative value should fallback to default value', (): void => { + expect(assertValidNumber(-5, 10, '')).toBe(10); + }); + it('Value greater than 18 should be limited to 18', (): void => { + expect(assertValidNumber(20, 10, '')).toBe(18); + }); + it('NaN value should fallback to default value', (): void => { + expect(assertValidNumber(NaN, 10, '')).toBe(10); + }); + it('Non-integer value should fallback to default value', (): void => { + expect(assertValidNumber(10.5, 10, '')).toBe(10); + }); + it('Valid value should be returned as is', (): void => { + expect(assertValidNumber(15, 10, '')).toBe(15); + }); + it('Undefined should fallback to default value (0)', (): void => { + expect(assertValidNumber(undefined, 0, '')).toBe(0); + }); + it('Zero value should be returned as is', (): void => { + expect(assertValidNumber(0, 10, '')).toBe(0); + }); + it('Value equal to the upper limit (18) should be returned as is', (): void => { + expect(assertValidNumber(18, 10, '')).toBe(18); + }); + it('Floating-point value within the range should fallback', (): void => { + expect(assertValidNumber(17.5, 18, '')).toBe(18); + }); + it('Value greater than 18 should be limited to 18', (): void => { + expect(assertValidNumber(25, 10, '')).toBe(18); + }); + it('Invalid value should fallback to default value', (): void => { + expect(assertValidNumber('abc' as any, 10, '')).toBe(10); + }); + it('Non-integer value should fallback to default value', (): void => { + expect(assertValidNumber(10.7, 10, '')).toBe(10); + }); + it('Floating-point value with zero decimal should be returned as is', (): void => { + expect(assertValidNumber(10.0, 10, '')).toBe(10); + }); + it('Negative value should fallback to default value', (): void => { + expect(assertValidNumber(-20, 10, '')).toBe(10); + }); + it('Value within the range should be returned as is', (): void => { + expect(assertValidNumber(3, 10, '')).toBe(3); + }); + it('Value within the range should be returned as is', (): void => { + expect(assertValidNumber(7, 10, '')).toBe(7); + }); + it('Value within the range should be returned as is', (): void => { + expect(assertValidNumber(13, 10, '')).toBe(13); + }); + it('Value equal to the upper limit (18) should be returned as is', (): void => { + expect(assertValidNumber(18, 10, '')).toBe(18); + }); + it('Negative value should fallback to default value', (): void => { + expect(assertValidNumber(-1, 7, '')).toBe(7); + }); + it('Floating-point value within the range should fallback', (): void => { + expect(assertValidNumber(1.5, 10, '')).toBe(10); + }); + }); + + describe('formatLocalAmount', (): void => { + it('Currency symbol should be displayed', (): void => { + expect( + formatLocalAmount(1234.5678, 2, '$', defaultOptions).normalize( + 'NFKC' + )).toBe( + '1 234,57 $'); + + } + ); + + it('Currency symbol should be displayed', (): void => { + expect( + formatLocalAmount(1234.5678, 2, '$', defaultOptions).normalize( + 'NFKC' + )).toBe( + '1 234,57 $'); + + } + ); + + it('USD currency symbol should be displayed', (): void => { + expect( + formatLocalAmount( + 1234.5678, + 2, + 'USD', + defaultOptions + ).normalize('NFKC')).toBe( + '1 234,57 $'); + + } + ); + + it('DAI currency symbol should be displayed', (): void => { + expect( + formatLocalAmount( + 1234.5678, + 2, + 'DAI', + defaultOptions + ).normalize('NFKC')).toBe( + '1 234,57 DAI'); + + } + ); + + it('Percent symbol should be displayed', (): void => { + expect( + formatLocalAmount( + 0.123, + 2, + 'PERCENT', + defaultOptions + ).normalize('NFKC')).toBe( + '12,30 %'); + + } + ); + it('Empty symbol should not affect formatting', (): void => { + expect( + formatLocalAmount(1234.5678, 2, '', defaultOptions).normalize( + 'NFKC' + )).toBe( + '1 234,57'); + }); + it('Empty symbol should not affect formatting', (): void => { + expect( + formatLocalAmount(1234.5678, 2, '', { + shouldDisplaySymbol: false + }).normalize('NFKC')).toBe( + '1 234,568'); + + } + ); + it('Amount should be formatted in short notation', (): void => { + expect( + formatLocalAmount(12345.6789, 2, '$', defaultOptions).normalize( + 'NFKC' + )).toBe( + '12,35 k $'); + + } + ); + it('Amount should be formatted in short notation', (): void => { + expect( + formatLocalAmount( + 12345678.9, + 2, + 'USD', + defaultOptions + ).normalize('NFKC')).toBe( + '12,35 M $'); + + } + ); + it('Amount should be formatted with the specified number of decimals', (): void => { + expect( + formatLocalAmount(0.00000123, 8, '$', defaultOptions).normalize( + 'NFKC' + )).toBe( + '0,00000123 $'); + + } + ); + it('Amount should be formatted with the specified number of decimals', (): void => { + expect( + formatLocalAmount( + 0.00000000123, + 12, + 'USD', + defaultOptions + ).normalize('NFKC')).toBe( + '0,00000000123 $'); + + } + ); + it('Amount above 0.01 should be formatted as is', (): void => { + expect( + formatLocalAmount(0.01, 2, 'USD', defaultOptions).normalize( + 'NFKC' + )).toBe( + '0,01 $'); + + } + ); + it('Amount above 0.01 should be formatted as is', (): void => { + expect( + formatLocalAmount(0.001, 2, 'OPT', defaultOptions).normalize( + 'NFKC' + )).toBe( + '0,001 OPT'); + + } + ); + it('Amount should be formatted with the specified number of decimals', (): void => { + expect( + formatLocalAmount( + 0.000000000000123, + 18, + 'YFI', + defaultOptions + ).normalize('NFKC')).toBe( + '0,000000000000123 YFI'); + + } + ); + it("Amount should be 0,00 YFI when it's too small", (): void => { + expect( + formatLocalAmount( + 0.000000000000000000123, + 18, + 'YFI', + defaultOptions + ).normalize('NFKC')).toBe( + '0,00 YFI'); + + } + ); + }); + + describe('is ok for amountV2', (): void => { + it('Formatted amount with decimal places and currency symbol should be returned', (): void => { + expect( + amountV2({ + value: 1234.5678, + decimals: 2, + symbol: '$', + options: defaultOptions + }).normalize('NFKC')).toBe( + '1 234,57 $'); + + } + ); + it('Formatted amount with decimal places and USD currency symbol should be returned', (): void => { + expect( + amountV2({ + value: 1234.5678, + decimals: 2, + symbol: 'USD', + options: defaultOptions + }).normalize('NFKC')).toBe( + '1 234,57 $'); + + } + ); + it('Formatted amount with decimal places and EUR currency symbol should be returned', (): void => { + expect( + amountV2({ + value: 1234.5678, + decimals: 2, + symbol: 'DAI', + options: defaultOptions + }).normalize('NFKC')).toBe( + '1 234,57 DAI'); + + } + ); + it('Formatted amount with decimal places and no currency symbol should be returned', (): void => { + expect( + amountV2({ + value: 1234.5678, + decimals: 2, + symbol: 'USD', + options: {shouldDisplaySymbol: false} + }).normalize('NFKC')).toBe( + '1 234,57'); + + } + ); + it('Formatted amount with decimal places and percent symbol should be returned', (): void => { + expect( + amountV2({ + value: 1234.5678, + decimals: 2, + symbol: 'PERCENT', + options: defaultOptions + }).normalize('NFKC')).toBe( + '> 500,00 %'); + + } + ); + it('Formatted amount with decimal places and no percent symbol should be returned', (): void => { + expect( + amountV2({ + value: 1234.5678, + decimals: 2, + symbol: 'PERCENT', + options: {shouldDisplaySymbol: false} + }).normalize('NFKC')).toBe( + '1 234,57'); + + } + ); + it('Formatted zero amount with no symbol should be returned', (): void => { + expect( + amountV2({ + value: 0, + decimals: 2, + symbol: '', + options: defaultOptions + }).normalize('NFKC')).toBe( + '0,00'); + + } + ); + it('Formatted infinity amount should be returned', (): void => { + expect( + amountV2({ + value: Infinity, + decimals: 2, + symbol: '$', + options: defaultOptions + }).normalize('NFKC')).toBe( + '∞'); + + } + ); + it('Formatted BigInt amount with unit should be returned', (): void => { + expect( + amountV2({value: BigInt(123456789), decimals: 2, symbol: '$', options: defaultOptions}).normalize('NFKC')).toBe( + '1,23 M $'); + + } + ); + it('Formatted NaN amount should return infinity', (): void => { + expect( + amountV2({ + value: NaN, + decimals: 2, + symbol: '$', + options: defaultOptions + }).normalize('NFKC')).toBe( + '0,00 $'); + + } + ); + it('Formatted small amount with decimal places and no percent symbol should be returned', (): void => { + expect( + amountV2({ + value: 0.0000000012345678, + decimals: 2, + symbol: 'PERCENT', + options: {shouldDisplaySymbol: false} + }).normalize('NFKC')).toBe( + '0,000000001235'); + + } + ); + it('Format small amount to 0,00 and no percent symbol should be returned', (): void => { + expect( + amountV2({ + value: 0.000000000000012345678, + decimals: 2, + symbol: 'PERCENT', + options: {shouldDisplaySymbol: false} + }).normalize('NFKC')).toBe( + '0,00'); + + } + ); + }); }); diff --git a/packages/web-lib/utils/format.number.ts b/packages/web-lib/utils/format.number.ts index c2176354..0f4b8d53 100755 --- a/packages/web-lib/utils/format.number.ts +++ b/packages/web-lib/utils/format.number.ts @@ -1,3 +1,201 @@ +import {formatToNormalizedValue, toBigInt} from '@yearn-finance/web-lib/utils/format'; +import {isZero} from '@yearn-finance/web-lib/utils/isZero'; + +type TAmountOptions = { + minimumFractionDigits?: number, + maximumFractionDigits?: number, + displayDigits?: number, + shouldDisplaySymbol?: boolean + shouldCompactValue?: boolean +} + +export type TAmount = { + value: bigint | number + decimals: number | bigint + symbol?: string + options?: TAmountOptions +} + +type TFormatCurrencyWithPrecision = { + amount: number; + maxFractionDigits: number; + intlOptions: Intl.NumberFormatOptions; + locale: string; + symbol: string; +} + +export const defaultOptions: TAmountOptions = { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + displayDigits: 0, + shouldDisplaySymbol: true, + shouldCompactValue: true +}; + +export function assertValidNumber(value: number | undefined, defaultValue: number, label: string): number { + if (value === undefined) { + return defaultValue; + } + if (value < 0) { + console.warn(`formatAmount: ${label} should be positive.`); + return defaultValue; + } + if (value > 18) { + console.warn(`formatAmount: ${label} should be less than 18.`); + return (18); + } + if (Number.isNaN(value)) { + console.warn(`formatAmount: ${label} is NaN.`); + return (defaultValue); + } + if (!Number.isSafeInteger(value)) { + console.warn(`formatAmount: ${label} should be an integer.`); + return (defaultValue); + } + return value; +} + +function assignOptions(options?: TAmountOptions): TAmountOptions { + if (!options) { + return defaultOptions; + } + + /* 🔵 - Yearn Finance ************************************************************************* + ** We need to ensure that displayDigits is a valid number. It can be any positive integer + ** between 0 and 18. If the value is invalid, we display a warning and set the value to the + ** default value (0 or 18). + **********************************************************************************************/ + options.displayDigits = assertValidNumber(options?.displayDigits, 0, 'displayDigits'); + + /* 🔵 - Yearn Finance ************************************************************************* + ** We need to ensure that minimumFractionDigits is a valid number. It can be any positive + ** integer between 0 and 18. If the value is invalid, we display a warning and set the value to + ** the default value (2 or 18). + **********************************************************************************************/ + options.minimumFractionDigits = assertValidNumber(options?.minimumFractionDigits, 2, 'minimumFractionDigits'); + + /* 🔵 - Yearn Finance ************************************************************************* + ** We need to ensure that maximumFractionDigits is a valid number. It can be any positive + ** integer between 0 and 18. If the value is invalid, we display a warning and set the value to + ** the default value (2 or 18). + **********************************************************************************************/ + options.maximumFractionDigits = assertValidNumber(options?.maximumFractionDigits, 2, 'maximumFractionDigits'); + + /* 🔵 - Yearn Finance ************************************************************************* + ** We need to ensure that maximumFractionDigits is always bigger or equal to + ** minimumFractionDigits, otherwise we set them as equal. + **********************************************************************************************/ + if (options.maximumFractionDigits < options.minimumFractionDigits) { + options.maximumFractionDigits = options.minimumFractionDigits; + } + + options.shouldDisplaySymbol ??= true; + options.shouldCompactValue ??= true; + + return options; +} + +function formatCurrencyWithPrecision({amount, maxFractionDigits, intlOptions, locale, symbol}: TFormatCurrencyWithPrecision): string { + return new Intl.NumberFormat([locale, 'en-US'], { + ...intlOptions, + maximumFractionDigits: Math.max(maxFractionDigits, intlOptions.maximumFractionDigits || maxFractionDigits) + }).format(amount).replace('EUR', symbol); +} + +export function formatLocalAmount( + amount: number, + decimals: number, + symbol: string, + options: TAmountOptions +): string { + /* 🔵 - Yearn Finance ************************************************************************* + ** Define the normalized elements. + ** We use a few tricks here to get the benefits of the intl package and correct formating no + ** matter the provided local. + ** - Fallback formatting is set to `fr-FR` + ** - If symbol is USD, then we will display as `123,79 $` or `$123.79` (US) + ** - If smbol is percent, then we will display as `12 %` or `12%` (US) + ** - If symbol is any other token, we will display as `123,79 USDC` or `USDC 123.79` (US) + **********************************************************************************************/ + let locale = 'fr-FR'; + if (typeof(navigator) !== 'undefined') { + locale = navigator.language || 'fr-FR'; + } + const {shouldDisplaySymbol, shouldCompactValue, ...rest} = options; + const intlOptions: Intl.NumberFormatOptions = rest; + let isPercent = false; + if (symbol && shouldDisplaySymbol) { + const uppercaseSymbol = String(symbol).toLocaleUpperCase(); + const symbolToFormat = uppercaseSymbol === 'USD' ? 'USD' : 'EUR'; + intlOptions.style = uppercaseSymbol === 'PERCENT' ? 'percent' : 'currency', + intlOptions.currency = symbolToFormat, + intlOptions.currencyDisplay = symbolToFormat === 'EUR' ? 'code' : 'narrowSymbol'; + isPercent = uppercaseSymbol === 'PERCENT'; + } + + if (isPercent && amount > 5 && shouldCompactValue) { + return ( + `> ${new Intl.NumberFormat([locale, 'en-US'], intlOptions) + .format(5) + .replace('EUR', symbol)}` + ); + } + + /* 🔵 - Yearn Finance ************************************************************************* + ** If the amount is above 10k, we will format it to short notation: + ** - 123947 would be `123,95 k` or `123.95K` (US) + ** - 267839372 would be `267,84 M` or `267.84M` (US) + **********************************************************************************************/ + if (amount > 10_000 && shouldCompactValue) { + return ( + new Intl.NumberFormat([locale, 'en-US'], { + ...intlOptions, + notation: 'compact', + compactDisplay: 'short' + }).format(amount).replace('EUR', symbol) + ); + } + + /* 🔵 - Yearn Finance ************************************************************************* + ** If the amount is very small, we adjust the decimals to try to display something, up to + ** "decimals" number of decimals + **********************************************************************************************/ + if (amount < 0.01) { + if (amount > 0.00000001) { + return formatCurrencyWithPrecision({amount, maxFractionDigits: 8, intlOptions, locale, symbol}); + } + if (amount > 0.000000000001) { + return formatCurrencyWithPrecision({amount, maxFractionDigits: 12, intlOptions, locale, symbol}); + } + return formatCurrencyWithPrecision({amount, maxFractionDigits: decimals, intlOptions, locale, symbol}); + } + return ( + new Intl.NumberFormat([locale, 'en-US'], intlOptions) + .format(amount) + .replace('EUR', symbol) + ); +} + +export function amountV2(props: TAmount): string { + const {value} = props; + const options = assignOptions(props.options); + const decimals = assertValidNumber(Number(props.decimals), 18, 'decimals'); + let amount = 0; + if (typeof(value) === 'bigint') { + amount = formatToNormalizedValue(toBigInt(value), decimals); + } else if (typeof(value) === 'number' && !Number.isNaN(value)) { + amount = value; + } + + if (isZero(amount)) { + return formatLocalAmount(0, 0, props.symbol || '', options); + } + if (!Number.isFinite(amount)) { + return '∞'; + } + return formatLocalAmount(amount, decimals, props.symbol || '', options); +} + /* 🔵 - Yearn Finance ****************************************************** ** Bunch of function using the power of the browsers and standard functions ** to correctly format numbers, currency and date