From e5c4eb23f74c20d4198133bf921dc1a10aa6afdd Mon Sep 17 00:00:00 2001 From: Nathan Seva Date: Tue, 11 Jun 2024 19:41:49 -0300 Subject: [PATCH] refactor format amount (#459) --- src/components/DollarValue/DollarValue.tsx | 6 +- src/components/Token/Token.tsx | 11 +- .../components/MASBalance.tsx | 2 +- src/lib/util/handlePercent.test.ts | 10 +- src/lib/util/handlePercent.ts | 2 +- src/lib/util/parseAmount.stories.tsx | 45 ++++++ src/lib/util/parseAmount.test.ts | 135 ++++++++--------- src/lib/util/parseAmount.ts | 142 +++++++----------- 8 files changed, 180 insertions(+), 173 deletions(-) create mode 100644 src/lib/util/parseAmount.stories.tsx diff --git a/src/components/DollarValue/DollarValue.tsx b/src/components/DollarValue/DollarValue.tsx index 47cb66db..c5572bfe 100644 --- a/src/components/DollarValue/DollarValue.tsx +++ b/src/components/DollarValue/DollarValue.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ComponentPropsWithoutRef } from 'react'; import { FiAlertTriangle } from 'react-icons/fi'; import { Tooltip } from '../Tooltip'; -import { formatFTAmount, parseAmount } from '../../lib/util/parseAmount'; +import { formatAmount, parseAmount } from '../../lib/util/parseAmount'; export interface DollarValueProps extends ComponentPropsWithoutRef<'p'> { dollarValue?: string; @@ -31,9 +31,7 @@ export function DollarValue(props: DollarValueProps) { let dollarValueFormatted = ''; if (dollarValue !== undefined && dollarValue !== '') { - const dollarValueBigInt = parseAmount(dollarValue, 2); - const { amountFormattedPreview } = formatFTAmount(dollarValueBigInt, 2); - dollarValueFormatted = amountFormattedPreview; + dollarValueFormatted = formatAmount(parseAmount(dollarValue, 2), 2).preview; } if (dollarValue !== undefined && dollarValue !== '') { diff --git a/src/components/Token/Token.tsx b/src/components/Token/Token.tsx index d16867e4..b15bc936 100644 --- a/src/components/Token/Token.tsx +++ b/src/components/Token/Token.tsx @@ -6,7 +6,7 @@ import { FiTrash2 } from 'react-icons/fi'; import { Button } from '../Button'; import { Tooltip } from '../Tooltip'; import { DollarValue } from '../DollarValue'; -import { formatFTAmount } from '../../lib/util/parseAmount'; +import { formatAmount } from '../../lib/util/parseAmount'; export interface TokenProps extends ComponentPropsWithoutRef<'div'> { logo?: React.ReactNode; @@ -41,12 +41,9 @@ export function Token(props: TokenProps) { let bigintBalance = BigInt(0); if (balance !== '') { bigintBalance = BigInt(balance); - const { amountFormattedPreview, amountFormattedFull } = formatFTAmount( - bigintBalance, - decimals, - ); - rawBalance = amountFormattedFull; - formattedBalance = amountFormattedPreview; + const { preview, full } = formatAmount(bigintBalance, decimals); + rawBalance = full; + formattedBalance = preview; } else { formattedBalance = undefined; rawBalance = undefined; diff --git a/src/lib/ConnectMassaWallets/components/MASBalance.tsx b/src/lib/ConnectMassaWallets/components/MASBalance.tsx index ecb08224..7ea99ac7 100644 --- a/src/lib/ConnectMassaWallets/components/MASBalance.tsx +++ b/src/lib/ConnectMassaWallets/components/MASBalance.tsx @@ -29,7 +29,7 @@ export function MASBalance() { const formattedBalance = formatAmount( fromMAS(balance?.candidateBalance || '0').toString(), 9, - ).amountFormattedFull; + ).full; return (
diff --git a/src/lib/util/handlePercent.test.ts b/src/lib/util/handlePercent.test.ts index 05ab6c74..06bd80e2 100644 --- a/src/lib/util/handlePercent.test.ts +++ b/src/lib/util/handlePercent.test.ts @@ -12,7 +12,7 @@ describe('handlePercent', () => { 18, 'ETH', ); - expect(result).toBe('0.250000000000000000'); + expect(result).toBe('0.25'); }); it('should return correctly formatted amount when symbol is massaToken and newAmount is within balance', () => { @@ -24,7 +24,7 @@ describe('handlePercent', () => { 9, massaToken, ); - expect(result).toBe('0.500000000'); + expect(result).toBe('0.5'); }); it('should return 0 when balance minus fees is less than 0', () => { @@ -36,7 +36,7 @@ describe('handlePercent', () => { 9, massaToken, ); - expect(result).toBe('0.000000000'); + expect(result).toBe('0'); }); it('should return balance minus fees when newAmount exceeds balance', () => { @@ -48,11 +48,11 @@ describe('handlePercent', () => { 9, massaToken, ); - expect(result).toBe('0.900000000'); + expect(result).toBe('0.9'); }); it('should handle zero amount correctly', () => { const result = handlePercent(0n, 10n, 0n, 1000n, 9, massaToken); - expect(result).toBe('0.000000000'); + expect(result).toBe('0'); }); }); diff --git a/src/lib/util/handlePercent.ts b/src/lib/util/handlePercent.ts index 88704ed1..a5e1cb05 100644 --- a/src/lib/util/handlePercent.ts +++ b/src/lib/util/handlePercent.ts @@ -21,5 +21,5 @@ export function handlePercent( } } - return formatAmount(newAmount.toString(), decimals).amountFormattedFull; + return formatAmount(newAmount.toString(), decimals).full; } diff --git a/src/lib/util/parseAmount.stories.tsx b/src/lib/util/parseAmount.stories.tsx new file mode 100644 index 00000000..f87c27d7 --- /dev/null +++ b/src/lib/util/parseAmount.stories.tsx @@ -0,0 +1,45 @@ +import { formatAmount } from './parseAmount'; + +export default { title: 'Amount/Format' }; + +const inputs = [ + '123456789123456789', + '1234567891234567890000', + '1234567', + '123', + '0', + '', + '1000000000200000000', + '1000000000', + '1000000000000000000', + '1000000000000000000234', + '1000000000000000000000000000000000', + '1000000000000000000000000000000001', +]; + +export const _FormatAmount = { + render: () => { + return ( +
+

input: preview / full

+ {[2, 9, 18].map((decimals) => { + return ( + <> +

+ Format amount that have {decimals} decimals +

+ {inputs.map((input) => { + const { preview, full } = formatAmount(input, decimals); + return ( +

+ {input}: {preview} / {full} +

+ ); + })} + + ); + })} +
+ ); + }, +}; diff --git a/src/lib/util/parseAmount.test.ts b/src/lib/util/parseAmount.test.ts index 66e5dd4a..f92748b2 100644 --- a/src/lib/util/parseAmount.test.ts +++ b/src/lib/util/parseAmount.test.ts @@ -1,161 +1,156 @@ import { formatAmount, - formatStandard, - roundDecimalPartToOneSignificantDigit, + roundDecimalPartToTwoSignificantDigit, } from './parseAmount'; describe('formatAmount', () => { test('formats an empty string', () => { const result = formatAmount('', 18); expect(result).toEqual({ - amountFormattedPreview: '0.0', - amountFormattedFull: '0.000000000000000000', + preview: '0', + full: '0', }); }); test('formats an amount with default parameters', () => { const result = formatAmount('123456789012345678901', 18); expect(result).toEqual({ - amountFormattedPreview: '123.46', - amountFormattedFull: '123.456789012345678901', + preview: '123.46', + full: '123.456789012345678901', + }); + }); + + test('formats an amount with BigInt', () => { + const result = formatAmount(123456789012345678901n, 18); + expect(result).toEqual({ + preview: '123.46', + full: '123.456789012345678901', }); }); test('formats an amount with less than the specified decimals', () => { const result = formatAmount('12345', 8); expect(result).toEqual({ - amountFormattedPreview: '0.0001', - amountFormattedFull: '0.00012345', + preview: '0.00012', + full: '0.00012345', }); }); test('formats an amount with custom separator', () => { const result = formatAmount('123456789012345678901', 9, "'"); expect(result).toEqual({ - amountFormattedPreview: "123'456'789'012.35", - amountFormattedFull: "123'456'789'012.345678901", + preview: "123'456'789'012.35", + full: "123'456'789'012.345678901", }); }); test('adds padding zeroes when necessary', () => { const result = formatAmount('1', 18, ','); expect(result).toEqual({ - amountFormattedPreview: '0.000000000000000001', - amountFormattedFull: '0.000000000000000001', + preview: '0.000000000000000001', + full: '0.000000000000000001', }); }); test('handles amount with exact decimals length', () => { const result = formatAmount('1000000000000000000', 18); expect(result).toEqual({ - amountFormattedPreview: '1.00', - amountFormattedFull: '1.000000000000000000', + preview: '1', + full: '1', }); }); test('formats an amount with less than the specified decimals and round up', () => { - const result = formatAmount('69000', 9); + const result = formatAmount('69500', 9); expect(result).toEqual({ - amountFormattedPreview: '0.00007', - amountFormattedFull: '0.000069000', + preview: '0.00007', + full: '0.0000695', }); }); -}); - -describe('roundDecimalPartToOneSignificantDigit', () => { - test("should return '0' when all digits are zero", () => { - expect(roundDecimalPartToOneSignificantDigit('000')).toEqual('0'); - }); - test('should handle a single zero without leading zeroes', () => { - expect(roundDecimalPartToOneSignificantDigit('0')).toEqual('0'); - }); - - test('should handle a single digit without rounding', () => { - expect(roundDecimalPartToOneSignificantDigit('4')).toEqual('4'); - }); - - test('should round down when the second digit is less than 5', () => { - expect(roundDecimalPartToOneSignificantDigit('004300')).toEqual('004'); - }); - - test('should round up when the second digit is 5 or more', () => { - expect(roundDecimalPartToOneSignificantDigit('00046')).toEqual('0005'); - }); - - test('should round up and handle carry-over with trailing zeroes', () => { - expect(roundDecimalPartToOneSignificantDigit('0099')).toEqual('01'); - }); -}); - -describe('formatStandard', () => { test('formats an empty string', () => { - const result = formatStandard('', 18); + const result = formatAmount('', 18).full; expect(result).toEqual('0'); }); test('formats an amount with default parameters', () => { - const result = formatStandard('123456789012345678901', 18); + const result = formatAmount('123456789012345678901', 18).full; expect(result).toEqual('123.456789012345678901'); }); test('formats an amount with less than the specified decimals', () => { - const result = formatStandard('12345', 8); + const result = formatAmount('12345', 8).full; expect(result).toEqual('0.00012345'); }); test('adds padding zeroes when necessary', () => { - const result = formatStandard('1', 18); + const result = formatAmount('1', 18).full; expect(result).toEqual('0.000000000000000001'); }); test('handles amount with exact decimals length', () => { - const result = formatStandard('1000000000000000000', 18); + const result = formatAmount('1000000000000000000', 18).full; expect(result).toEqual('1'); }); test('formats an amount with less than the specified decimals and round up', () => { - const result = formatStandard('69000', 9); + const result = formatAmount('69000', 9).full; expect(result).toEqual('0.000069'); }); - it('formatStandard with min string value', () => { + it('formatAmount with min string value', () => { const value = '0000000000'; - - const result = formatStandard(value.toString()); - + const result = formatAmount(value.toString()).full; expect(result).toBe('0'); }); - it('formatStandard with min bigint value', () => { + it('formatAmount with min bigint value', () => { const value = 0n; - - const result = formatStandard(value.toString()); - + const result = formatAmount(value.toString()).full; expect(result).toBe('0'); }); - it('formatStandard with mid range string value', () => { + it('formatAmount with mid range string value', () => { const value = '10000000000000'; - - const result = formatStandard(value.toString()); - + const result = formatAmount(value.toString()).full; expect(result).toBe('10,000'); }); - it('formatStandard with mid range bigint value', () => { + it('formatAmount with mid range bigint value', () => { const value = 10000000000000n; - - const result = formatStandard(value.toString()); - + const result = formatAmount(value.toString()).full; expect(result).toBe('10,000'); }); - it('formatStandard with max string value', () => { + it('formatAmount with max string value', () => { const value = '922337203600000000000'; + const result = formatAmount(value.toString()).full; + expect(result).toBe('922,337,203,600'); + }); +}); - const result = formatStandard(value.toString()); +describe('roundDecimalPartToOneSignificantDigit', () => { + test("should return '0' when all digits are zero", () => { + expect(roundDecimalPartToTwoSignificantDigit('000')).toEqual('0'); + }); - expect(result).toBe('922,337,203,600'); + test('should handle a single zero without leading zeroes', () => { + expect(roundDecimalPartToTwoSignificantDigit('0')).toEqual('0'); + }); + + test('should handle a single digit without rounding', () => { + expect(roundDecimalPartToTwoSignificantDigit('4')).toEqual('4'); + }); + + test('should round down when the second digit is less than 5', () => { + expect(roundDecimalPartToTwoSignificantDigit('0043100')).toEqual('0043'); + }); + + test('should round up when the second digit is 5 or more', () => { + expect(roundDecimalPartToTwoSignificantDigit('000468')).toEqual('00047'); + }); + + test('should round up and handle carry-over with trailing zeroes', () => { + expect(roundDecimalPartToTwoSignificantDigit('0099')).toEqual('0099'); }); }); diff --git a/src/lib/util/parseAmount.ts b/src/lib/util/parseAmount.ts index 40a6a388..d14088c0 100644 --- a/src/lib/util/parseAmount.ts +++ b/src/lib/util/parseAmount.ts @@ -1,22 +1,7 @@ import currency from 'currency.js'; -import { formatValue } from 'react-currency-input-field'; -import { formatUnits, parseUnits } from 'viem'; +import { parseUnits } from 'viem'; -export interface FormattedAmount { - amountFormattedPreview: string; - amountFormattedFull: string; -} - -export function removeTrailingZeros(numStr: string): string { - return numStr.replace(/\.?0+$/, ''); -} - -// Like format amount but remove the trailing zeros -export function formatStandard(amount: string, decimals = 9): string { - return removeTrailingZeros( - formatAmount(amount, decimals).amountFormattedFull, - ); -} +// Parse /** * reverse format FT amount @@ -25,35 +10,23 @@ export function parseAmount(amount: string, tokenDecimal: number): bigint { return parseUnits(amount, tokenDecimal); } -export function formatFTAmount( - bigintBalance: bigint, - decimals: number, -): FormattedAmount { - const amountFormattedPreview = formatValue({ - value: formatUnits(bigintBalance, decimals).toString(), - groupSeparator: ',', - decimalSeparator: '.', - decimalScale: 2, - }); - - const amountFormattedFull = formatValue({ - value: formatUnits(bigintBalance, decimals).toString(), - groupSeparator: ',', - decimalSeparator: '.', - decimalScale: decimals, - }); - - return { - amountFormattedPreview, - amountFormattedFull, - }; +// Format +export interface FormattedAmount { + preview: string; + full: string; } +// Format the amount, parameter must be a string in the smallest unit or a bigint export function formatAmount( - amount: string, + amount: string | bigint, decimals = 9, separator = ',', ): FormattedAmount { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof amount === 'bigint') { + amount = amount.toString(); + } + amount = amount as string; const decimal = '.'; if (amount.length < decimals) { @@ -70,83 +43,82 @@ export function formatAmount( precision: 0, }).format(); - let amountFormattedPreview: string; + let preview: string; if (formattedIntegerPart === '0' && decimalPart.startsWith('00')) { - amountFormattedPreview = `${formattedIntegerPart}${decimal}${roundDecimalPartToOneSignificantDigit( + preview = `${formattedIntegerPart}${decimal}${roundDecimalPartToTwoSignificantDigit( decimalPart, )}`; } else { - amountFormattedPreview = currency( - `${formattedIntegerPart}${decimal}${decimalPart}`, - { - separator: separator, - decimal: decimal, - symbol: '', - }, - ).format(); + preview = currency(`${formattedIntegerPart}${decimal}${decimalPart}`, { + separator, + decimal, + symbol: '', + }).format(); } return { - amountFormattedPreview, - amountFormattedFull: `${formattedIntegerPart}${decimal}${decimalPart}`, + preview: removeTrailingZeros(preview), + full: removeTrailingZeros( + `${formattedIntegerPart}${decimal}${decimalPart}`, + ), }; } +// Internal functions + +// Internal function to pad a string with zeros function padWithZeros(input: string, length: number): string { return input.padStart(length, '0'); } -export function roundDecimalPartToOneSignificantDigit( +// Internal function to remove trailing zeros +function removeTrailingZeros(numStr: string): string { + return numStr.replace(/\.?0+$/, ''); +} + +const leadingZeroes = /^0+/; + +function removeLeadingZeros(numStr: string): string { + return numStr.replace(leadingZeroes, ''); +} + +// Internal function to round the decimal part to significant digit +export function roundDecimalPartToTwoSignificantDigit( decimalPart: string, ): string { function countLeadingZeros(str: string) { // Match leading zeros using a regular expression - const result = str.match(/^0+/); + const result = str.match(leadingZeroes); // If the result isn't null (meaning there are leading zeros), return the length, otherwise return 0 return result ? result[0].length : 0; } // Strip leading zeroes - const significantPart = decimalPart.replace(/^0+/, ''); + const significantPart = removeLeadingZeros(decimalPart); if (significantPart === '') { // Input is all zeroes return '0'; } - // The first digit of the significant part is our significant digit - const firstDigit = significantPart[0]; - - // Determine if we need to round up - const shouldRoundUp = - significantPart.length > 1 && parseInt(significantPart[1]) >= 5; - - // Prepare the significant digit after rounding if necessary - const roundedDigit = shouldRoundUp - ? (parseInt(firstDigit) + 1).toString() - : firstDigit; + const formattedByCurrency = currency(`0.${significantPart}`, { + separator: '', + decimal: '.', + symbol: '', + }).format({ + // precision option is not taken in account + // https://github.com/scurker/currency.js/issues/293 + decimal: '', + }); + const trimmedZero = removeLeadingZeros( + removeTrailingZeros(formattedByCurrency), + ); // If rounding causes a carry-over (e.g. 0.009 -> 0.01), handle it - if (roundedDigit === '10') { - return '0'.repeat(countLeadingZeros(decimalPart) - 1) + roundedDigit[0]; + if (formattedByCurrency.startsWith('1.')) { + return '0'.repeat(countLeadingZeros(decimalPart) - 1) + trimmedZero; } - return '0'.repeat(countLeadingZeros(decimalPart)) + roundedDigit; -} - -export function formatAmountToDisplay( - amount: string | undefined, - tokenDecimal: number | undefined, -): FormattedAmount { - if (!tokenDecimal || !amount) { - return { - amountFormattedFull: '0', - amountFormattedPreview: '0', - }; - } - // parsing to Bigint to get correct amount - const amt = parseUnits(amount, tokenDecimal); - // formatting it to string for display - return formatAmount(amt.toString(), tokenDecimal); + return '0'.repeat(countLeadingZeros(decimalPart)) + trimmedZero; }