From 2a44ab3782b9b84a64dd207f72e1225034b50d93 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 13 Jun 2026 06:24:11 +0000 Subject: [PATCH 1/2] test(pow): widen testRoundTripFuzzPow range, drop arbitrary 8e8 bound `testRoundTripFuzzPow` carried an arbitrary `vm.assume(exponentInv <= 8e8)` that narrowed the fuzz input range. The bound existed only to dodge an `ExponentOverflow` in `pow`'s exponentiation-by-squaring loop: raising to a large integer exponent squares the base in place, so the base exponent grows by roughly a factor of two per bit and overflows once the integer exponent is large enough. The inverse leg of a round trip can land such an exponent. That overflow is now caught directly: the round-trip `pow` is already wrapped in a try/catch that treats a revert as "can't round-trip this input" rather than a math regression, so the magic `8e8` ceiling is redundant. Removing it lets the fuzzer exercise the full Float input range. Also add `testPowIntegerExponentSquaringOverflow` to pin the squaring-loop boundary deterministically: 2^1e9 succeeds, 2^1e10 reverts with `ExponentOverflow`. This documents the limitation the assume was masking and guards the catch path so the wider fuzz range is genuinely covered, not just silently skipped. Verification: full pow suite green (9/9), full `forge test` 452/452 passing (the only 5 failures are the pre-existing `LibDecimalFloatDeployProd` fork tests that need RPC env vars, identical on clean `origin/main`). `testRoundTripFuzzPow` also green at 100,000 fuzz runs without the assume. Test-only change: no source touched, deploy constants unaffected. Fixes #163 Co-Authored-By: Claude Opus 4.8 --- test/src/lib/LibDecimalFloat.pow.t.sol | 34 ++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/test/src/lib/LibDecimalFloat.pow.t.sol b/test/src/lib/LibDecimalFloat.pow.t.sol index 9e7a4d5..6cf8523 100644 --- a/test/src/lib/LibDecimalFloat.pow.t.sol +++ b/test/src/lib/LibDecimalFloat.pow.t.sol @@ -5,7 +5,7 @@ pragma solidity =0.8.25; import {LogTest} from "../../abstract/LogTest.sol"; import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol"; -import {ZeroNegativePower, PowNegativeBase} from "src/error/ErrDecimalFloat.sol"; +import {ZeroNegativePower, PowNegativeBase, ExponentOverflow} from "src/error/ErrDecimalFloat.sol"; import {console2} from "forge-std-1.16.1/src/Test.sol"; contract LibDecimalFloatPowTest is LogTest { @@ -166,6 +166,34 @@ contract LibDecimalFloatPowTest is LogTest { return a.pow(b, logTables()); } + /// `pow` raises to an integer exponent via exponentiation by squaring, + /// which squares the base in place. The base exponent therefore grows by + /// roughly a factor of two per bit of the integer exponent, so a large + /// enough integer exponent overflows `ExponentOverflow` before the result + /// can be produced. This is the squaring-loop limitation that previously + /// forced `testRoundTripFuzzPow` to `vm.assume(exponentInv <= 8e8)`: the + /// inverse leg of a round trip can land an integer exponent above that + /// ceiling. Pin the boundary so the fuzz test can instead just catch the + /// revert and keep exercising the full input range. + function testPowIntegerExponentSquaringOverflow() external { + // 2 ^ 1e9 is right at the edge of what the squaring loop can represent + // and does not overflow. + Float a = LibDecimalFloat.packLossless(2, 0); + this.powExternal(a, LibDecimalFloat.packLossless(1, 9)); + + // 2 ^ 1e10 pushes the squared base exponent past EXPONENT_MAX and + // reverts with ExponentOverflow. A round trip catches this rather than + // treating it as a math regression. + vm.expectRevert( + abi.encodeWithSelector( + ExponentOverflow.selector, + 43632686345562428988582910876713633851545835514376216610528325287869870302082, + 3010299880 + ) + ); + this.powExternal(a, LibDecimalFloat.packLossless(1, 10)); + } + function testRoundTripFuzzPow(Float a, Float b) external { try this.powExternal(a, b) returns (Float c) { // If C is 1 then either a == 1 or b == 0 (or b rounds to 0). @@ -175,10 +203,6 @@ contract LibDecimalFloatPowTest is LogTest { assertTrue(c.eq(LibDecimalFloat.FLOAT_ONE), "b is 0 so c should be 1"); } else if (!(c.isZero() && b.lt(LibDecimalFloat.FLOAT_ZERO))) { Float inv = b.inv(); - { - (, int256 exponentInv) = inv.unpack(); - vm.assume(exponentInv <= 8e8); - } // The round-trip pow can still error on intermediate // overflow even when both legs of the original input // were well-formed (e.g. a tiny coefficient combined From af358b1c120e8763935f33d82dd354c6339b189c Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 13 Jun 2026 13:13:42 +0000 Subject: [PATCH 2/2] test(pow): tighten testRoundTripFuzzPow catch blocks to expected errors Both legs of the round-trip pow fuzz used a catch-ALL `catch (bytes memory) {}` that silently swallowed ANY revert, masking real bugs. Replace each with `assertExpectedPowError`, which decodes the selector and tolerates only the custom errors `pow` is designed to throw (derived from the implementation): ZeroNegativePower, PowNegativeBase, ExponentOverflow, ExponentUnderflow, WithTargetExponentOverflow, MaximizeOverflow, MulDivOverflow DivisionByZero / Log10Zero / Log10Negative are unreachable because `pow` only ever inverts/logs a strictly positive base. A low-level `Panic` (e.g. 0x11 arithmetic overflow) is no longer tolerated, so an unexpected revert now fails the test instead of being swallowed. Expected reverts remain tolerated; the round trip simply is not asserted for them. This surfaces a latent defect: for a large positive base whose inverse has a large-magnitude exponent combined with a large negative integer exponent (e.g. pow(1e1700000000, -8e69)), the exponentiation-by-squaring loop overflows the checked `exponentA + exponentB` in `LibDecimalFloatImplementation.mul` and reverts `Panic(0x11)` rather than the clean `ExponentOverflow` documented by `testPowIntegerExponentSquaringOverflow`. The tightened fuzz can reach this input, so it may fail on some fuzz seeds until `pow` reverts cleanly. Co-Authored-By: Claude Opus 4.8 --- test/src/lib/LibDecimalFloat.pow.t.sol | 59 +++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/test/src/lib/LibDecimalFloat.pow.t.sol b/test/src/lib/LibDecimalFloat.pow.t.sol index 6cf8523..97871b6 100644 --- a/test/src/lib/LibDecimalFloat.pow.t.sol +++ b/test/src/lib/LibDecimalFloat.pow.t.sol @@ -5,7 +5,15 @@ pragma solidity =0.8.25; import {LogTest} from "../../abstract/LogTest.sol"; import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol"; -import {ZeroNegativePower, PowNegativeBase, ExponentOverflow} from "src/error/ErrDecimalFloat.sol"; +import { + ZeroNegativePower, + PowNegativeBase, + ExponentOverflow, + ExponentUnderflow, + MaximizeOverflow, + MulDivOverflow +} from "src/error/ErrDecimalFloat.sol"; +import {WithTargetExponentOverflow} from "src/lib/implementation/LibDecimalFloatImplementation.sol"; import {console2} from "forge-std-1.16.1/src/Test.sol"; contract LibDecimalFloatPowTest is LogTest { @@ -194,6 +202,39 @@ contract LibDecimalFloatPowTest is LogTest { this.powExternal(a, LibDecimalFloat.packLossless(1, 10)); } + /// The complete set of custom errors `pow` is designed to throw, derived by + /// reading the implementation. Each leg of the round trip is the same `pow` + /// call, so both legs share this set. + /// - `ZeroNegativePower`: 0 raised to a negative power. + /// - `PowNegativeBase`: negative base (unsupported). + /// - `ExponentOverflow`: the result (or a `log10`/`pow10`/squaring + /// intermediate) exceeds `int32` / `add` exponent range. This is the + /// squaring-loop overflow pinned by `testPowIntegerExponentSquaringOverflow`. + /// - `ExponentUnderflow`: an intermediate rescale produces a magnitude + /// smaller than any representable Float (`packArithmeticResult`). + /// - `WithTargetExponentOverflow`: `pow10` cannot rescale the + /// characteristic to exponent 0 without overflowing the coefficient. + /// - `MaximizeOverflow`: `maximizeFull` (inside `log10`/`div`) cannot + /// maximize an intermediate coefficient. + /// - `MulDivOverflow`: the 512-bit `mulDiv` inside `mul`/`div` overflows. + /// `DivisionByZero`, `Log10Zero` and `Log10Negative` are intentionally + /// excluded: `pow` only ever inverts/logs a strictly positive base, so they + /// are unreachable. A low-level `Panic` (e.g. `0x11` arithmetic overflow) is + /// also excluded by construction, so an unexpected revert is no longer + /// silently swallowed. + function assertExpectedPowError(bytes memory reason) internal { + bytes4 selector = bytes4(reason); + bool expected = selector == ZeroNegativePower.selector || selector == PowNegativeBase.selector + || selector == ExponentOverflow.selector || selector == ExponentUnderflow.selector + || selector == WithTargetExponentOverflow.selector || selector == MaximizeOverflow.selector + || selector == MulDivOverflow.selector; + if (!expected) { + console2.log("unexpected pow revert selector:"); + console2.logBytes4(selector); + assertTrue(false, "unexpected pow revert"); + } + } + function testRoundTripFuzzPow(Float a, Float b) external { try this.powExternal(a, b) returns (Float c) { // If C is 1 then either a == 1 or b == 0 (or b rounds to 0). @@ -215,13 +256,21 @@ contract LibDecimalFloatPowTest is LogTest { Float diff = a.div(roundTrip).sub(LibDecimalFloat.FLOAT_ONE).abs(); assertTrue(!diff.gt(diffLimit()), "diff"); } - } catch (bytes memory) { - // Can't round trip something that errors. + } catch (bytes memory reason) { + // Can't round trip something that errors, but only if it + // errors with an error `pow` is designed to throw. An + // unexpected revert (e.g. a low-level `Panic`) fails the + // test rather than being silently swallowed. + assertExpectedPowError(reason); } } } - } catch (bytes memory) { - // Can't round trip something that errors. + } catch (bytes memory reason) { + // Can't round trip something that errors, but only if it errors with + // an error `pow` is designed to throw. An unexpected revert (e.g. a + // low-level `Panic`) fails the test rather than being silently + // swallowed. + assertExpectedPowError(reason); } } }