diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd1fb8..b6a8904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [[v2.0.2]](https://github.com/multiversx/mx-sdk-dapp-utils/pull/21) - 2025-06-27 + +- [Refactor formatAmount function and update tests for improved input handling](https://github.com/multiversx/mx-sdk-dapp-utils/pull/20) + ## [[v2.0.1]](https://github.com/multiversx/mx-sdk-dapp-utils/pull/19) - 2025-06-26 - [Improved `formatAmount` logic for better handling of negative values, custom decimals, and digit formatting](https://github.com/multiversx/mx-sdk-dapp-utils/pull/18) diff --git a/package.json b/package.json index 0306003..173df05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@multiversx/sdk-dapp-utils", - "version": "2.0.1", + "version": "2.0.2", "description": "SDK for DApp utilities", "main": "out/index.js", "types": "out/index.d.js", diff --git a/src/helpers/formatAmount.ts b/src/helpers/formatAmount.ts index a5d3ae0..8dcc238 100644 --- a/src/helpers/formatAmount.ts +++ b/src/helpers/formatAmount.ts @@ -1,18 +1,163 @@ import { TokenTransfer } from '@multiversx/sdk-core'; import BigNumber from 'bignumber.js'; -import { pipe } from './pipe'; import { DECIMALS, DIGITS, ZERO } from '../constants'; import { stringIsInteger } from './stringIsInteger'; +import { pipe } from './pipe'; +/** + * Configuration options for formatting blockchain token amounts. + */ export interface FormatAmountPropsType { - addCommas?: boolean; + /** + * The raw integer amount (string) in the smallest token unit. + * Must be a valid integer string (no decimals, may include leading "-"). + * + * @example + * // For 1.5 EGLD (18 decimals): "1500000000000000000" + * // For 1000 USDC (6 decimals): "1000000000" + */ + input: string; + + /** + * Number of decimals defined by the token (e.g. 18 for EGLD, 6 for USDC). + * This determines how many decimal places to shift when converting from + * the smallest unit to the human-readable format. + * + * @default DECIMALS (typically 18) + * @example + * // EGLD: 18, USDC: 6 + */ decimals?: number; + + /** + * Maximum number of decimal digits to display in the formatted output. + * This parameter works differently depending on `showLastNonZeroDecimal`: + * - When `showLastNonZeroDecimal=false`: strictly limits decimal places + * - When `showLastNonZeroDecimal=true`: ignored for truncation, but affects special cases + * + * @default DIGITS (typically 4) + * @example + * // For 1.23456789 EGLD (18 decimals): "1234567890000000000" + * // digits=4 with showLastNonZeroDecimal=false: "1.2345" + * // digits=4 with showLastNonZeroDecimal=true: "1.23456789" + */ digits?: number; - input: string; + + /** + * If true, insert thousands separators (commas) into the integer part. + * Only affects the integer portion, not the decimal places. + * + * @default false + * @example + * // For 1000.5 EGLD (18 decimals, showLastNonZeroDecimal: true): "1000500000000000000000" + * // false: "1000" + * // true: "1,000" + */ + addCommas?: boolean; + + /** + * If true, amounts smaller than the smallest displayable unit will show + * a less-than format instead of zero. + * + * @default false + * @example + * // For 0.000000000000000001 EGLD (18 decimals, showIsLessThanDecimalsLabel: true): "1" + * // false: "0.0000" + * // true: "<0.0001" + */ showIsLessThanDecimalsLabel?: boolean; + + /** + * Controls the primary decimal formatting behavior: + * + * - **`true`** (default): When decimals exist, always pad to at least `digits` decimal places. + * If there are more significant decimal places than `digits`, show all of them. + * + * - **`false`**: When decimals exist, always pad to exactly `digits` decimal places. + * Truncate if there are more decimal places than `digits`. + * + * @default true + * @example + * // For 1.123456789 EGLD (18 decimals, digits=4): "1123456789000000000" + * // showLastNonZeroDecimal=true: "1.123456789" (more than 4 digits, show all) + * // showLastNonZeroDecimal=false: "1.1234" (exactly 4 digits) + * + * // For 1.1 EGLD (18 decimals, digits=4): "1100000000000000000" + * // showLastNonZeroDecimal=true: "1.1" (pad to 4 digits minimum) + * // showLastNonZeroDecimal=false: "1.1000" (exactly 4 digits) + * + * // For 1 EGLD (18 decimals, digits=4): "1000000000000000000" + * // showLastNonZeroDecimal=true: "1" (integer, no decimals to pad) + * // showLastNonZeroDecimal=false: "1.0000" (integer, no decimals to pad) + * + * // For 1.0000005 EGLD (18 decimals, digits=4): "1000000500000000000" + * // showLastNonZeroDecimal=true: "1.0000005" (more than 4 digits, show all) + * // showLastNonZeroDecimal=false: "1.0000" (exactly 4 digits) + */ showLastNonZeroDecimal?: boolean; } +/** + * Formats blockchain token amounts from their smallest unit representation + * to human-readable decimal format with configurable precision and formatting options. + * + * This function handles the conversion from raw integer token amounts (as stored on blockchain) + * to human-readable decimal format with proper formatting, precision control, and edge case handling. + * + * @param props - Configuration object with formatting options + * @returns Formatted string representation of the amount + * + * @throws {Error} When input is not a valid integer string + * + * @example + * // Basic usage - 1.5 EGLD + * formatAmount({ input: "1500000000000000000" }) + * // Returns: "1.5" + * + * @example + * // With precision control + * formatAmount({ + * input: "1123456789000000000", + * showLastNonZeroDecimal: false, + * digits: 4 + * }) + * // Returns: "1.1234" + * + * @example + * // With precision control + * formatAmount({ + * input: "1123456789000000000", + * showLastNonZeroDecimal: true, + * digits: 4 + * }) + * // Returns: "1.123456789" + * + * @example + * // With thousands separators + * formatAmount({ + * input: "1000000000000000000000", + * addCommas: true + * }) + * // Returns: "1,000" + * + * @example + * // Custom token with 6 decimals (USDC) + * formatAmount({ + * input: "1500000", + * decimals: 6 + * }) + * // Returns: "1.5" + * + * @example + * // Very small amounts with less-than label + * formatAmount({ + * input: "1", + * decimals: 18, + * digits: 4, + * showIsLessThanDecimalsLabel: true + * }) + * // Returns: "<0.0001" + */ export function formatAmount({ addCommas = false, decimals = DECIMALS, @@ -53,81 +198,118 @@ export function formatAmount({ const balance = bnBalance.toString(10); const [integerPart, decimalPart] = balance.split('.'); - const bNdecimalPart = LocalBigNumber(decimalPart || 0); - const decimalPlaces = pipe(0) - .if(Boolean(decimalPart && showLastNonZeroDecimal)) - .then(() => Math.max(decimalPart.length, digits)) + // Handle case where there's no decimal part (pure integers) + if (!decimalPart) { + if (showLastNonZeroDecimal) { + // For integers with showLastNonZeroDecimal=true, don't show decimals + return addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + } else { + // For showLastNonZeroDecimal=false, don't show decimals for pure integers + return addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + } + } + + const bNdecimalPart = LocalBigNumber(decimalPart); - .if(bNdecimalPart.isZero() && !showLastNonZeroDecimal) - .then(0) + // Handle case where decimal part is all zeros + if (bNdecimalPart.isZero()) { + if (showLastNonZeroDecimal) { + // For integers with showLastNonZeroDecimal=true, don't show decimals + return addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + } else { + // For showLastNonZeroDecimal=false, don't show decimals for effectively integer values + return addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + } + } - .if(Boolean(decimalPart && !showLastNonZeroDecimal)) - .then(() => Math.min(decimalPart.length, digits)) + // Find the last non-zero decimal position + const lastNonZeroIndex = decimalPart + .split('') + .reverse() + .findIndex((digit) => digit !== '0'); + const actualDecimalPlaces = decimalPart.length - lastNonZeroIndex; - .valueOf(); + let finalDecimalPlaces; + if (showLastNonZeroDecimal) { + // Show all decimals if more than digits, otherwise show only the actual non-zero decimals + finalDecimalPlaces = Math.max(actualDecimalPlaces, 0); + } else { + // Show exactly digits decimal places + finalDecimalPlaces = digits; + } + // Handle special case: very small amounts that would round to zero const shownDecimalsAreZero = - decimalPart && digits >= 1 && digits <= decimalPart.length && bNdecimalPart.isGreaterThan(0) && LocalBigNumber(decimalPart.substring(0, digits)).isZero(); - const formatted = bnBalance.toFormat(decimalPlaces); - - const formattedBalance = pipe(balance) - .if(addCommas) - .then(formatted) - .if(Boolean(shownDecimalsAreZero)) - .then((current) => { - const integerPartZero = LocalBigNumber(integerPart).isZero(); - const [numericPart, decimalSide] = current.split('.'); - - const zeroPlaceholders = new Array(digits - 1).fill(0); - const zeros = [...zeroPlaceholders, 0].join(''); - const minAmount = [...zeroPlaceholders, 1].join(''); // 00..1 + if (shownDecimalsAreZero) { + const integerPartZero = LocalBigNumber(integerPart).isZero(); + const zeroPlaceholders = new Array(digits - 1).fill(0); + const zeros = [...zeroPlaceholders, 0].join(''); + const minAmount = [...zeroPlaceholders, 1].join(''); // 00..1 - if (!integerPartZero) { - return `${numericPart}.${zeros}`; - } + if (!integerPartZero) { + const intFormat = addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + return `${intFormat}.${zeros}`; + } - if (showIsLessThanDecimalsLabel) { - return `<${numericPart}.${minAmount}`; - } + if (showIsLessThanDecimalsLabel) { + const intFormat = addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + return `<${intFormat}.${minAmount}`; + } - if (!showLastNonZeroDecimal) { - return numericPart; - } + if (!showLastNonZeroDecimal) { + return addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + } - return `${numericPart}.${decimalSide}`; - }) - .if(Boolean(!shownDecimalsAreZero && decimalPart)) - .then((current) => { - const [numericPart] = current.split('.'); - let decimalSide = decimalPart.substring(0, decimalPlaces); - - if (showLastNonZeroDecimal) { - const noOfZerosAtEnd = digits - decimalSide.length; - - if (noOfZerosAtEnd > 0) { - const zeroPadding = Array(noOfZerosAtEnd).fill(0).join(''); - decimalSide = `${decimalSide}${zeroPadding}`; - return `${numericPart}.${decimalSide}`; - } + // For showLastNonZeroDecimal=true, show the actual decimals + const formattedValue = bnBalance.toFixed(finalDecimalPlaces); + const [, formattedDecimalPart] = formattedValue.split('.'); + const intFormat = addCommas + ? LocalBigNumber(integerPart).toFormat(0) + : integerPart; + return `${intFormat}.${formattedDecimalPart}`; + } - return `${numericPart}.${decimalSide.substring(0, digits)}`; - } + // Normal case: format with the calculated decimal places + let formattedValue; - if (!decimalSide) { - return numericPart; - } + if (showLastNonZeroDecimal) { + // Show actual decimal places without padding for showLastNonZeroDecimal=true + formattedValue = bnBalance.toFixed(actualDecimalPlaces); + } else { + // Show exactly digits decimal places for showLastNonZeroDecimal=false + formattedValue = bnBalance.toFixed(digits); + } - return `${numericPart}.${decimalSide}`; - }) - .valueOf(); + // Apply comma formatting if requested + if (addCommas) { + const [intPart, decPart] = formattedValue.split('.'); + const formattedIntPart = LocalBigNumber(intPart).toFormat(0); + formattedValue = decPart + ? `${formattedIntPart}.${decPart}` + : formattedIntPart; + } - return formattedBalance; + return formattedValue; }) .if(isNegative) .then((current) => `-${current}`) diff --git a/src/helpers/tests/formatAmount.test.ts b/src/helpers/tests/formatAmount.test.ts index bb789d9..ab2d6e7 100644 --- a/src/helpers/tests/formatAmount.test.ts +++ b/src/helpers/tests/formatAmount.test.ts @@ -1,62 +1,62 @@ import { formatAmount } from '../formatAmount'; describe('formatAmount', () => { - test('throws error for invalid input', () => { + test('throws error for invalid input that is not an integer string', () => { expect(() => formatAmount({ input: 'abc' })).toThrow('Invalid input'); expect(() => formatAmount({ input: '1.23' })).toThrow('Invalid input'); expect(() => formatAmount({ input: '-1.23' })).toThrow('Invalid input'); }); - test('handles zero values', () => { - expect(formatAmount({ input: '0' })).toBe('0'); - expect(formatAmount({ input: '0', digits: 2 })).toBe('0'); + test('handles zero values correctly', () => { + expect(formatAmount({ input: '0' })).toBe('0'); // 0 EGLD + expect(formatAmount({ input: '0', digits: 2 })).toBe('0'); // 0 EGLD }); - test('formats positive integers', () => { - expect(formatAmount({ input: '1000000000000000000' })).toBe('1'); - expect(formatAmount({ input: '2000000000000000000' })).toBe('2'); + test('formats positive integer amounts without decimal places', () => { + expect(formatAmount({ input: '1000000000000000000' })).toBe('1'); // 1 EGLD + expect(formatAmount({ input: '2000000000000000000' })).toBe('2'); // 2 EGLD }); - test('formats negative integers', () => { - expect(formatAmount({ input: '-1000000000000000000' })).toBe('-1'); - expect(formatAmount({ input: '-2000000000000000000' })).toBe('-2'); + test('formats negative integer amounts without decimal places', () => { + expect(formatAmount({ input: '-1000000000000000000' })).toBe('-1'); // -1 EGLD + expect(formatAmount({ input: '-2000000000000000000' })).toBe('-2'); // -2 EGLD }); - test('handles custom decimals', () => { + test('handles custom decimals for different token types', () => { expect(formatAmount({ input: '1000000000000000000', decimals: 8 })).toBe( '10000000000' - ); + ); // 10000000000 tokens (8 decimals) expect(formatAmount({ input: '1000000000000000000', decimals: 4 })).toBe( '100000000000000' - ); + ); // 100000000000000 tokens (4 decimals) expect( - formatAmount({ input: '56817349973594872345', decimals: 18, digits: 4 }) - ).toBe('56.8173'); + formatAmount({ input: '56817349973594872345', decimals: 18, digits: 4 }) // 56.817349973594872345 EGLD + ).toBe('56.817349973594872345'); }); - test('handles custom digits', () => { - expect(formatAmount({ input: '1000000000000000000', digits: 2 })).toBe('1'); - expect(formatAmount({ input: '1000000000000000000', digits: 4 })).toBe('1'); + test('handles custom digits parameter', () => { + expect(formatAmount({ input: '1000000000000000000', digits: 2 })).toBe('1'); // 1 EGLD + expect(formatAmount({ input: '1000000000000000000', digits: 4 })).toBe('1'); // 1 EGLD }); - test('adds commas when specified', () => { + test('adds thousands separators (commas) when specified', () => { expect( - formatAmount({ input: '1000000000000000000000', addCommas: true }) + formatAmount({ input: '1000000000000000000000', addCommas: true }) // 1000 EGLD ).toBe('1,000'); expect( formatAmount({ - input: '1000000000000000000000', + input: '1000000000000000000000', // 1000 EGLD addCommas: true, digits: 2 }) ).toBe('1,000'); }); - test('handles showIsLessThanDecimalsLabel', () => { - const input = '1000000000000000'; + test('handles showIsLessThanDecimalsLabel for very small amounts', () => { + const input = '1000000000000000'; // 0.001 EGLD expect( formatAmount({ input, @@ -66,21 +66,209 @@ describe('formatAmount', () => { ).toBe('<0.01'); }); - test('handles showLastNonZeroDecimal', () => { + test('showLastNonZeroDecimal controls decimal place formatting behavior', () => { expect( formatAmount({ - input: '1100000000000000000', - showLastNonZeroDecimal: true, + input: '1100000000000000000', // 1.1 EGLD + digits: 4 + }) + ).toBe('1.1'); + + expect( + formatAmount({ + input: '1100000000000000000', // 1.1 EGLD + showLastNonZeroDecimal: false, digits: 4 }) ).toBe('1.1000'); + }); + + test('showLastNonZeroDecimal=true shows all significant decimals regardless of digits parameter', () => { + expect( + formatAmount({ + input: '1123456789000000000', // 1.123456789 EGLD + digits: 4 + }) + ).toBe('1.123456789'); + + expect( + formatAmount({ + input: '1100000000000000000', // 1.1 EGLD + digits: 4 + }) + ).toBe('1.1'); + + expect( + formatAmount({ + input: '1000000000000000000', // 1 EGLD + digits: 4 + }) + ).toBe('1'); + + expect( + formatAmount({ + input: '50500000000000000', // 0.0505 EGLD + digits: 4 + }) + ).toBe('0.0505'); + }); + + test('showLastNonZeroDecimal=false shows exactly digits decimal places', () => { + expect( + formatAmount({ + input: '1123456789000000000', // 1.123456789 EGLD + showLastNonZeroDecimal: false, + digits: 4 + }) + ).toBe('1.1234'); + + expect( + formatAmount({ + input: '1230000000000000000', // 1.23 EGLD + showLastNonZeroDecimal: false, + digits: 4 + }) + ).toBe('1.2300'); expect( formatAmount({ - input: '1100000000000000000', + input: '1000000000000000000', // 1 EGLD showLastNonZeroDecimal: false, digits: 4 }) + ).toBe('1'); + }); + + test('decimal formatting follows showLastNonZeroDecimal parameter rules', () => { + expect( + formatAmount({ + input: '1100000000000000000', // 1.1 EGLD + digits: 4 + }) ).toBe('1.1'); + + expect( + formatAmount({ + input: '1200000000000000000', // 1.2 EGLD + digits: 6 + }) + ).toBe('1.2'); + + expect( + formatAmount({ + input: '1100000000000000000', // 1.1 EGLD + showLastNonZeroDecimal: false, + digits: 4 + }) + ).toBe('1.1000'); + + expect( + formatAmount({ + input: '1200000000000000000', // 1.2 EGLD + showLastNonZeroDecimal: false, + digits: 6 + }) + ).toBe('1.200000'); + }); + + test('showLastNonZeroDecimal=true displays all significant decimals when they exceed digits parameter', () => { + expect( + formatAmount({ + input: '1123456789000000000', // 1.123456789 EGLD + digits: 4 + }) + ).toBe('1.123456789'); + + expect( + formatAmount({ + input: '1123456789000000000', // 1.123456789 EGLD + digits: 2 + }) + ).toBe('1.123456789'); + }); + + test('handles very small amounts with less-than label correctly', () => { + expect( + formatAmount({ + input: '1', // 0.000000000000000001 EGLD + decimals: 18, + digits: 4, + showIsLessThanDecimalsLabel: true, + showLastNonZeroDecimal: false + }) + ).toBe('<0.0001'); + }); + + test('formats large numbers with thousands separators (commas)', () => { + expect( + formatAmount({ + input: '1000000000000000000000', // 1000 EGLD + addCommas: true, + digits: 2 + }) + ).toBe('1,000'); + + expect( + formatAmount({ + input: '123456789000000000000000', // 123456.789 EGLD + addCommas: true, + showLastNonZeroDecimal: false, + digits: 2 + }) + ).toBe('123,456.78'); + }); + + test('handles negative amounts with proper decimal formatting', () => { + expect( + formatAmount({ + input: '-1100000000000000000', // -1.1 EGLD + digits: 4 + }) + ).toBe('-1.1'); + + expect( + formatAmount({ + input: '-1123456789000000000', // -1.123456789 EGLD + showLastNonZeroDecimal: false, + digits: 4 + }) + ).toBe('-1.1234'); + }); + + test('handles different token decimals correctly (USDC example)', () => { + expect( + formatAmount({ + input: '1500000', // 1.5 USDC (6 decimals) + decimals: 6, + digits: 4 + }) + ).toBe('1.5'); + + expect( + formatAmount({ + input: '150000000', // 1.5 tokens (8 decimals) + decimals: 8, + showLastNonZeroDecimal: false, + digits: 6 + }) + ).toBe('1.500000'); + }); + + test('showLastNonZeroDecimal=false pads with trailing zeros to match digits parameter', () => { + expect( + formatAmount({ + input: '1100000000000000000', // 1.1 EGLD + showLastNonZeroDecimal: false, + digits: 4 + }) + ).toBe('1.1000'); + + expect( + formatAmount({ + input: '1230000000000000000', // 1.23 EGLD + showLastNonZeroDecimal: false, + digits: 6 + }) + ).toBe('1.230000'); }); });