Skip to content

Commit 077c173

Browse files
Merge pull request #243 from rainlanguage/2026-06-17-issue-184-nonscientific-positive-exp
fix: guard non-scientific formatter against positive-exp int224 overflow
2 parents f69622d + dd6ad63 commit 077c173

4 files changed

Lines changed: 157 additions & 43 deletions

File tree

crates/float/abi/DecimalFloat.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/lib/deploy/LibDecimalFloatDeploy.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ library LibDecimalFloatDeploy {
2525
/// @dev Address of the DecimalFloat contract deployed via Zoltu's
2626
/// deterministic deployment proxy.
2727
/// This address is the same across all EVM-compatible networks.
28-
address constant ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS = address(0xBee0eEFaffD046c9602109eB30A858Be301CC926);
28+
address constant ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS = address(0x799632d282178e770C7465cad54aDA1021A913D6);
2929

3030
/// @dev The expected codehash of the DecimalFloat contract deployed via
3131
/// Zoltu's deterministic deployment proxy.
32-
bytes32 constant DECIMAL_FLOAT_CONTRACT_HASH = 0x7a93d0311f7782b44157ba40e94ec936085ebe001c7893bdd74911c8351d3def;
32+
bytes32 constant DECIMAL_FLOAT_CONTRACT_HASH = 0xdc468883c345d41c0abd98ef2fd933c370bd1682522d37e6f6b729793301f55e;
3333

3434
/// Combines all log and anti-log tables into a single bytes array for
3535
/// deployment. These are using packed encoding to minimize size and remove

src/lib/format/LibFormatDecimalFloat.sol

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ library LibFormatDecimalFloat {
4040
/// division to place the decimal point; the divisor is always `1e75` or
4141
/// `1e76` which both fit in int256.
4242
function _toScientific(int256 signedCoefficient, int256 exponent) private pure returns (string memory) {
43+
int256 originalExponent = exponent;
4344
(signedCoefficient, exponent) = LibDecimalFloatImplementation.maximizeFull(signedCoefficient, exponent);
4445

4546
uint256 scale;
@@ -97,6 +98,16 @@ library LibFormatDecimalFloat {
9798
// to int256 cannot truncate.
9899
// forge-lint: disable-next-line(unsafe-typecast)
99100
int256 displayExponent = exponent + int256(scaleExponent);
101+
// The parser reconstructs this float by calling packLossless with the
102+
// display exponent cast to int32. Guard here so the formatter reverts
103+
// cleanly rather than silently producing a string whose exponent cannot
104+
// be represented in int32. Both sides are checked: maximizeFull reduces
105+
// the stored exponent by the digit-count delta (up to ~10 for an
106+
// int224 coefficient), so displayExponent can be up to ~76 above the
107+
// original exponent.
108+
if (displayExponent > type(int32).max || displayExponent < type(int32).min) {
109+
revert UnformatableExponent(originalExponent);
110+
}
100111
string memory exponentString =
101112
displayExponent == 0 ? "" : string.concat("e", Strings.toStringSigned(displayExponent));
102113
string memory prefix = isNeg ? "-" : "";
@@ -108,6 +119,7 @@ library LibFormatDecimalFloat {
108119
/// `10^exponent` as an integer, so the output is valid for any
109120
/// `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT` — including exponents below
110121
/// `-76` that arise from near-cancellation add/sub.
122+
//slither-disable-next-line cyclomatic-complexity
111123
function _toNonScientific(int256 signedCoefficient, int256 exponent) private pure returns (string memory) {
112124
if (exponent > MAX_NON_SCIENTIFIC_EXPONENT || exponent < -MAX_NON_SCIENTIFIC_EXPONENT) {
113125
revert UnformatableExponent(exponent);
@@ -127,6 +139,27 @@ library LibFormatDecimalFloat {
127139
absCoef = uint256(signedCoefficient);
128140
}
129141

142+
// When exponent > 0 the formatted integer is absCoef × 10^exponent,
143+
// which must fit in int224 for the parser to reconstruct the value
144+
// losslessly. For exponent ≥ 68, 10^68 > int224.max (≈1.34e67) so
145+
// even coefficient 1 overflows. Otherwise divide int224.max by
146+
// 10^exponent and check that absCoef doesn't exceed the quotient.
147+
if (exponent > 0) {
148+
// exponent > 0, so the cast to uint256 is safe.
149+
// forge-lint: disable-next-line(unsafe-typecast)
150+
uint256 uExp = uint256(exponent);
151+
if (uExp >= 68) {
152+
revert UnformatableExponent(exponent);
153+
}
154+
uint256 limit = uint256(int256(type(int224).max));
155+
for (uint256 i = 0; i < uExp; i++) {
156+
limit /= 10;
157+
}
158+
if (absCoef > limit) {
159+
revert UnformatableExponent(exponent);
160+
}
161+
}
162+
130163
bytes memory digits = bytes(Strings.toString(absCoef));
131164
uint256 k = digits.length;
132165

test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol

Lines changed: 120 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -259,32 +259,56 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
259259

260260
/// Fuzz: every Float round-trips through scientific format → parse → eq
261261
/// across the full int224 coefficient domain, with exponent bounded to
262-
/// leave headroom for the scientific display exponent.
262+
/// avoid the display-exponent overflow guard added by #185.
263263
///
264264
/// Scientific format renders `coef × 10^exp` as `d.dddd × 10^displayExp`
265265
/// where `displayExp = exp + 75 or 76` (after `maximizeFull` + scale).
266-
/// For exponents within ~76 of `int32.max`, the resulting display exponent
267-
/// exceeds `int32.max`, and the parser rejects it on re-pack. The
268-
/// headroom below avoids that asymmetric range; see separate issue for
269-
/// the format/parse exponent-range mismatch.
266+
/// The formatter now reverts `UnformatableExponent` when `displayExp`
267+
/// falls outside `[int32.min, int32.max]`. The headroom below keeps the
268+
/// fuzz in the round-trip-safe zone.
270269
function testFormatParseRoundTripScientificFullDomain(int224 coefficient, int32 exponent) external pure {
271270
int256 headroom = 80;
272-
// `bound` to a sub-range of int32 that avoids display-exponent overflow.
271+
// `bound` to a sub-range of int32 that avoids triggering the int32
272+
// display-exponent overflow guard.
273273
// forge-lint: disable-next-line(unsafe-typecast)
274274
exponent = int32(bound(exponent, int256(type(int32).min) + headroom, int256(type(int32).max) - headroom));
275275
_checkRoundTrip(coefficient, exponent, true);
276276
}
277277

278+
/// Scientific format reverts when the display exponent would overflow
279+
/// int32 (positive side). With coefficient = int224.max (~68 digits),
280+
/// maximizeFull extends it to ~78 digits, reducing the stored exponent
281+
/// by ~10. displayExponent = storedExp + scaleExponent = (exp - 10) + 76
282+
/// = exp + 66. For exp = int32.max - 50, displayExp = int32.max + 16,
283+
/// which overflows int32.
284+
function testFormatScientificRevertsNearPositiveInt32Limit() external {
285+
int256 exp = int256(type(int32).max) - 50;
286+
Float float = LibDecimalFloat.packLossless(int256(type(int224).max), exp);
287+
vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, exp));
288+
this.formatExternal(float, true);
289+
}
290+
291+
/// Scientific format reverts when the display exponent would overflow
292+
/// int32 (negative side). With coefficient = 1 (1 digit), maximizeFull
293+
/// extends to ~77 digits, reducing stored exponent by ~76. displayExponent
294+
/// = (exp - 76) + 76 = exp. For exp = int32.min, displayExp = int32.min,
295+
/// which fits in int32 — so this boundary does NOT trigger for k=1. Use a
296+
/// large negative coefficient so k > 1 and verify we remain safe.
297+
function testFormatScientificNegativeBoundaryDoesNotRevert() external pure {
298+
// (int224.max, int32.min + 80): headroom=80 ensures we stay in-range.
299+
Float float = LibDecimalFloat.packLossless(int256(type(int224).max), int256(type(int32).min) + 80);
300+
string memory s = LibFormatDecimalFloat.toDecimalString(float, true);
301+
assertGt(bytes(s).length, 0);
302+
}
303+
278304
/// Fuzz: every Float with non-positive exponent round-trips through
279305
/// non-scientific format → parse → eq, across the full int224 coefficient
280306
/// domain and exponent in `[-MAX_NON_SCIENTIFIC_EXPONENT, 0]`.
281307
///
282-
/// Positive exponents are NOT fuzzed here: the non-scientific formatter
283-
/// emits `coefficient_digits + exponent` trailing zeros, which can exceed
284-
/// the parser's int256 accumulator for modest positive exponents with
285-
/// non-trivial coefficients. That format/parse asymmetry is a separate
286-
/// concern (see issue for tracking); this fuzz covers the negative-exp
287-
/// range where #182-class bugs surface.
308+
/// Positive exponents are NOT fuzzed here: the formatter reverts when
309+
/// `absCoef * 10^exponent > int224.max` (the formatted integer would exceed
310+
/// the parser's lossless range). See `testFormatParseRoundTripNonScientificSafePosExp`
311+
/// for positive-exponent round-trip coverage within the safe range.
288312
function testFormatParseRoundTripNonScientificNegExpFullDomain(int224 coefficient, int32 exponent) external pure {
289313
int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
290314
// `bound` returns a value in [-cap, 0]; cap fits int32 so the cast back
@@ -341,15 +365,14 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
341365
assertEq(formatA, formatB, "Different representations formatted to different strings");
342366
}
343367

344-
/// Non-scientific format succeeds at the exact cap boundary.
345-
function testFormatNonScientificExponentAtPositiveCap() external pure {
368+
/// Non-scientific format reverts at the positive exponent cap boundary:
369+
/// MAX_NON_SCIENTIFIC_EXPONENT = 1000 >= 68, so 1 × 10^1000 >> int224.max
370+
/// and the int224 overflow guard fires. Use scientific mode for such values.
371+
function testFormatNonScientificExponentAtPositiveCapReverts() external {
346372
int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
347373
Float float = LibDecimalFloat.packLossless(1, cap);
348-
string memory s = LibFormatDecimalFloat.toDecimalString(float, false);
349-
// "1" followed by `cap` zeros.
350-
// forge-lint: disable-next-line(unsafe-typecast)
351-
assertEq(bytes(s).length, 1 + uint256(cap));
352-
assertEq(bytes(s)[0], bytes1("1"));
374+
vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, cap));
375+
this.formatExternal(float, false);
353376
}
354377

355378
function testFormatNonScientificExponentAtNegativeCap() external pure {
@@ -414,21 +437,74 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
414437
}
415438

416439
/// Fuzz: non-scientific format does not revert for any valid Float with
417-
/// `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT`, across the full int224
418-
/// coefficient range. Covers the positive-exponent sub-range that the
419-
/// parse round-trip fuzz cannot exercise (blocked on #184).
420-
function testFormatNonScientificSucceedsAcrossFullRange(int224 coefficient, int32 exponent) external pure {
440+
/// `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT` and non-positive exponent.
441+
/// When exponent <= 0 the formatted integer equals absCoef / 10^|exponent|
442+
/// which is at most absCoef <= int224.max, so the int224 overflow guard
443+
/// never fires. Positive exponents may revert with `UnformatableExponent`
444+
/// when `absCoef * 10^exponent > int224.max`.
445+
function testFormatNonScientificSucceedsForNonPositiveExponents(int224 coefficient, int32 exponent) external pure {
421446
int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
422-
// `bound` to [-cap, cap]; cap fits int32 so the cast is safe.
447+
// Bound to [-cap, 0]; negative and zero exponents never trigger the
448+
// int-digit-count guard.
423449
// forge-lint: disable-next-line(unsafe-typecast)
424-
exponent = int32(bound(exponent, -cap, cap));
450+
exponent = int32(bound(exponent, -cap, 0));
425451
Float float = LibDecimalFloat.packLossless(coefficient, exponent);
426452
// Should not revert.
427453
string memory s = LibFormatDecimalFloat.toDecimalString(float, false);
428-
// Non-empty output is a minimum sanity guarantee.
429454
assertGt(bytes(s).length, 0);
430455
}
431456

457+
/// The formatter reverts with `UnformatableExponent` when `exponent >= 68`
458+
/// (10^68 > int224.max ≈ 1.34e67, so even coefficient 1 overflows).
459+
/// Mutation test: remove the guard in `_toNonScientific` → this test
460+
/// fails because the call no longer reverts.
461+
function testFormatNonScientificRevertsOnLongPositiveExp() external {
462+
// (1, 68): 1 × 10^68 > int224.max → reverts.
463+
Float float = LibDecimalFloat.packLossless(1, 68);
464+
vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, int256(68)));
465+
this.formatExternal(float, false);
466+
}
467+
468+
/// The largest positive exponent where coefficient=1 still passes the
469+
/// int224 overflow guard: exponent=67 gives value 1×10^67 < int224.max
470+
/// (≈1.34e67). The formatter succeeds and the output round-trips.
471+
function testFormatNonScientificAtIntDigitBoundary() external pure {
472+
// (1, 67): value = 1e67 < int224.max; limit = floor(int224.max / 10^67) = 1.
473+
// absCoef=1 <= 1 → guard passes. Output: "1" + 67 zeros = 68 chars.
474+
Float float = LibDecimalFloat.packLossless(1, 67);
475+
string memory s = LibFormatDecimalFloat.toDecimalString(float, false);
476+
assertEq(bytes(s).length, 68, "output length");
477+
assertEq(bytes(s)[0], bytes1("1"), "leading digit");
478+
(bytes4 err, Float parsed) = LibParseDecimalFloat.parseDecimalFloat(s);
479+
assertEq(err, bytes4(0), "parse error");
480+
assertTrue(float.eq(parsed), "round-trip mismatch");
481+
}
482+
483+
/// Fuzz: for every non-zero int224 coefficient and positive exponent where
484+
/// the formatter does NOT revert (i.e. absCoef × 10^exponent <= int224.max),
485+
/// the output round-trips through parse. Uses the same limit computation as
486+
/// the formatter to skip cases that correctly revert.
487+
/// forge-config: default.fuzz.runs = 100
488+
function testFormatParseRoundTripNonScientificSafePosExp(int224 coefficient, int32 exponent) external pure {
489+
vm.assume(coefficient != 0);
490+
vm.assume(exponent > 0);
491+
// Mirror the formatter's guard: skip if exponent >= 68 or absCoef > limit.
492+
// forge-lint: disable-next-line(unsafe-typecast)
493+
uint256 uExp = uint256(uint32(exponent));
494+
vm.assume(uExp < 68);
495+
uint256 limit = uint256(int256(type(int224).max));
496+
for (uint256 i = 0; i < uExp; i++) {
497+
limit /= 10;
498+
}
499+
uint256 absCoef = coefficient < 0 ? uint256(-int256(coefficient)) : uint256(int256(coefficient));
500+
vm.assume(absCoef <= limit);
501+
Float float = LibDecimalFloat.packLossless(coefficient, exponent);
502+
string memory s = LibFormatDecimalFloat.toDecimalString(float, false);
503+
(bytes4 err, Float parsed) = LibParseDecimalFloat.parseDecimalFloat(s);
504+
assertEq(err, bytes4(0), string.concat("Parse error on: ", s));
505+
assertTrue(float.eq(parsed), string.concat("Round trip mismatch on: ", s));
506+
}
507+
432508
/// Fuzz: output shape properties for non-scientific format.
433509
/// - Never ends with "." (formatter always strips trailing zeros from the
434510
/// fractional part; a lone "." would indicate a bug).
@@ -441,8 +517,11 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
441517
// negation-symmetry check below.
442518
vm.assume(coefficient != type(int224).min);
443519
int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT;
520+
// Bound to [-cap, 0]: non-positive exponents never trigger the
521+
// positive-exponent int224 overflow guard, so the formatter never
522+
// reverts and shape assertions always apply.
444523
// forge-lint: disable-next-line(unsafe-typecast)
445-
exponent = int32(bound(exponent, -cap, cap));
524+
exponent = int32(bound(exponent, -cap, 0));
446525
Float float = LibDecimalFloat.packLossless(coefficient, exponent);
447526
bytes memory s = bytes(LibFormatDecimalFloat.toDecimalString(float, false));
448527
assertGt(s.length, 0);
@@ -482,21 +561,23 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test {
482561
assertEq(LibFormatDecimalFloat.toDecimalString(LibDecimalFloat.FLOAT_HALF, false), "0.5");
483562
}
484563

485-
/// Non-scientific format of `(1, 77)` produces "1" followed by 77 zeros.
486-
/// Historically this reverted because the implementation computed
487-
/// `10^exponent` as int256; the rewrite uses direct string placement and
488-
/// handles any `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT`.
489-
function testFormatNonScientificLargePositiveExponent() external pure {
490-
checkFormat(1, 77, false, "100000000000000000000000000000000000000000000000000000000000000000000000000000");
564+
/// Non-scientific format of (1, 77) now reverts: 1 × 10^77 far exceeds
565+
/// int224.max (≈1.34e67), so the formatted integer cannot be parsed back
566+
/// losslessly. The formatter reverts rather than silently producing a
567+
/// non-round-trippable string.
568+
function testFormatNonScientificLargePositiveExponent() external {
569+
Float float = LibDecimalFloat.packLossless(1, 77);
570+
vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, int256(77)));
571+
this.formatExternal(float, false);
491572
}
492573

493-
/// Non-scientific format of a large coefficient with moderate positive
494-
/// exponent formats without overflow. `int224.max = 2^223 - 1`, which has
495-
/// 68 decimal digits; with exponent 10 the output is 78 characters.
496-
function testFormatNonScientificLargeCoefficientLargeExponent() external pure {
574+
/// Non-scientific format of (int224.max, 10) reverts: int224.max × 10
575+
/// exceeds int224.max, so the output cannot round-trip through the parser.
576+
function testFormatNonScientificLargeCoefficientLargeExponent() external {
497577
int256 c = int256(type(int224).max);
498-
string memory expected = string.concat(Strings.toStringSigned(c), "0000000000");
499-
checkFormat(c, 10, false, expected);
578+
Float float = LibDecimalFloat.packLossless(c, 10);
579+
vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, int256(10)));
580+
this.formatExternal(float, false);
500581
}
501582

502583
/// Non-scientific format reverts when `|exponent|` exceeds the policy cap.

0 commit comments

Comments
 (0)