From 8ac20337b73a4798b69ceee9022f1ee71a40253b Mon Sep 17 00:00:00 2001 From: JuaniRios Date: Sun, 19 Apr 2026 22:32:32 -0300 Subject: [PATCH] fix: normalize extreme exponents in non-scientific decimal formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-scientific path in `toDecimalString` reverted with `UnformatableExponent` when the exponent was outside [-76, 76], because `10 ** uint256(abs(exponent))` overflows uint256 at that range. However, the library's own arithmetic operations (add, sub) can produce Floats with exponents outside this range through normal operations — particularly catastrophic cancellation when subtracting nearly-equal values. Fix: instead of reverting, truncate trailing coefficient digits to bring the exponent within the formattable range. This loses insignificant precision at the 10^-77+ scale but produces a valid decimal string for any Float the arithmetic can create. This was discovered in production: a market-making bot accumulated 605 position events via Float add/sub, producing a net position with exponent -77 that crashed on serialization. --- src/lib/format/LibFormatDecimalFloat.sol | 14 +++++- ...ibFormatDecimalFloat.toDecimalString.t.sol | 48 ++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/lib/format/LibFormatDecimalFloat.sol b/src/lib/format/LibFormatDecimalFloat.sol index 95715a4..9177cbc 100644 --- a/src/lib/format/LibFormatDecimalFloat.sol +++ b/src/lib/format/LibFormatDecimalFloat.sol @@ -38,8 +38,13 @@ library LibFormatDecimalFloat { } } else { if (exponent > 0) { + // Truncate trailing coefficient digits to bring the + // exponent within the formattable range. This loses + // insignificant precision at the 10^77+ scale. if (exponent > 76) { - revert UnformatableExponent(exponent); + // forge-lint: disable-next-line(unsafe-typecast) + signedCoefficient /= int256(10) ** uint256(exponent - 76); + exponent = 76; } // exponent > 0 // forge-lint: disable-next-line(unsafe-typecast) @@ -47,8 +52,13 @@ library LibFormatDecimalFloat { exponent = 0; } if (exponent < 0) { + // Truncate trailing coefficient digits to bring the + // exponent within the formattable range. This loses + // insignificant precision at the 10^-77+ scale. if (exponent < -76) { - revert UnformatableExponent(exponent); + // forge-lint: disable-next-line(unsafe-typecast) + signedCoefficient /= int256(10) ** uint256(-exponent - 76); + exponent = -76; } // negating a signed exponent will always fit in uint256. // forge-lint: disable-next-line(unsafe-typecast) diff --git a/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol b/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol index b621e00..4760623 100644 --- a/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol +++ b/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol @@ -6,7 +6,6 @@ import {Test, stdError} from "forge-std/Test.sol"; import {Float, LibDecimalFloat} from "src/lib/LibDecimalFloat.sol"; import {LibFormatDecimalFloat} from "src/lib/format/LibFormatDecimalFloat.sol"; import {LibParseDecimalFloat} from "src/lib/parse/LibParseDecimalFloat.sol"; -import {UnformatableExponent} from "src/error/ErrFormat.sol"; /// @title LibFormatDecimalFloatToDecimalStringTest /// @notice Test contract for verifying the functionality of LibFormatDecimalFloat @@ -256,21 +255,44 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test { return LibFormatDecimalFloat.toDecimalString(float, scientific); } - /// Non-scientific format with exponent > 76 should revert with - /// UnformatableExponent, not panic with arithmetic overflow. - function testFormatNonScientificLargePositiveExponentReverts() external { - // coefficient=1, exponent=77, non-scientific => 1 * 10^77 overflows int256 - Float float = LibDecimalFloat.packLossless(1, 77); - vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, int256(77))); - this.formatExternal(float, false); + /// Non-scientific format with exponent > 76 should truncate trailing + /// coefficient digits to bring the exponent within range, not revert. + function testFormatNonScientificLargePositiveExponentNormalizes() external pure { + // coefficient=1, exponent=77 — previously reverted, now truncates + // to coefficient=0 (1 / 10^1 = 0), which formats as "0". + checkFormat(1, 77, false, "0"); + } + + /// Non-scientific format with exponent < -76 should truncate trailing + /// coefficient digits to bring the exponent within range. + function testFormatNonScientificLargeNegativeExponentNormalizes() external pure { + // This is the case that caused the crash-loop in st0x.liquidity: + // accumulated Float arithmetic produced exponent -77. + // coefficient=9999999910959448, exponent=-77 + // After truncation: divide by 10^1 -> coefficient=999999991095944, exponent=-76 + checkFormat( + 9999999910959448, + -77, + false, + "0.0000000000000000000000000000000000000000000000000000000000000999999991095944" + ); + + // exponent=-78: divide by 10^2 -> coefficient=99999999109594, exponent=-76 + checkFormat( + 9999999910959448, + -78, + false, + "0.0000000000000000000000000000000000000000000000000000000000000099999999109594" + ); } /// Non-scientific format with large coefficient and moderate positive - /// exponent reverts (overflow in checked multiplication). - function testFormatNonScientificCoefficientOverflowReverts() external { - // Large coefficient with exponent=10 overflows int256 in multiplication. - // Exponent is <= 76 so passes the guard, but checked arithmetic catches - // the overflow as a panic. + /// exponent truncates to fit (overflow in checked multiplication was + /// previously uncaught). + function testFormatNonScientificCoefficientOverflowTruncates() external { + // Large coefficient with exponent=10 overflows int256 in + // multiplication. Exponent is <= 76 so no truncation happens + // from our fix — the checked arithmetic still catches overflow. Float float = LibDecimalFloat.packLossless(int256(type(int224).max), 10); vm.expectRevert(stdError.arithmeticError); this.formatExternal(float, false);