Skip to content

fix: pow checks mul's exponent precondition in the squaring loop (#276) - #286

Open
thedavidmeister wants to merge 2 commits into
mainfrom
2026-10-02-issue-276-pow-squaring-exponent-panic
Open

thedavidmeister wants to merge 2 commits into
mainfrom
2026-10-02-issue-276-pow-squaring-exponent-panic

Conversation

@thedavidmeister

Copy link
Copy Markdown
Contributor

Closes #276.

The issue hypothesised the overflow was on the b * log10(a) → pow10 leg. It is not: pow recurses on pow(a.inv(), b.minus()) for a negative b, and the exponentiation-by-squaring loop runs before log10 is ever called. Squaring doubles the base exponent each iteration, so after 224 squarings it is -3.6e76 and the 225th squaring's exponentA + exponentB in LibDecimalFloatImplementation.mul underflows int256 (min -5.8e76) — Panic(0x11).

mul's exponent range is a precondition, not an accident: its own fuzz tests already bound exponents to EXPONENT_MAX / 2 to stay inside it, and pow is the only caller in the library that iterates mul over its own output. So the typed check moves ahead of the arithmetic rather than mul saturating — saturating in a primitive every operation goes through would have it knowingly return a wrong exponent and charge every caller gas for a check only pow needs.

pow now checks both the base and the running result against mul's stated precondition at the top of each squaring iteration, reverting ExponentOverflow or ExponentUnderflow by the sign of the out-of-range exponent. The issue's counterexample gives ExponentUnderflow.

No input that previously succeeded can now revert: at iteration i the base is a**(2**i) and the loop only reaches i when the integer exponent is at least 2**i, so the final result's |log10| is at least the base's; a coefficient's 77 digits and the fractional leg's int32-capped |log10(a)| are nothing against a bound of ~2.9e76.

MUL_EXPONENT_LIFT_MAX is 128 rather than 76: mul's existing comment understates its own lift, whose worst case is 79 (78 digits of the product's high word plus one from unabsUnsignedMulOrDivLossy). A 76 margin is off by one.

QA

  • Discriminating tests: testPowExtremeIntegerExponentUnderflow (the issue's bit-exact repro) and testPowExtremeIntegerExponentOverflow - both revert Panic(0x11) on main and give the named typed errors here; plus testMulExponentPreconditionUpperBoundary and testMulExponentPreconditionLowerBoundary pinning mul's precondition at each end with the coefficients that maximise the lift.
  • Mutations applied: 2 - removing both checkPowSquaringExponent calls makes both new pow tests fail with exactly the issue's Panic(0x11); MUL_EXPONENT_LIFT_MAX = 0 makes the upper boundary test panic and the lower fail lift within allowance.
  • Oracle: the issue's own contract that a Panic is a failure, already encoded in the pre-existing assertExpectedPowError allow-list, plus a step-by-step replay of pow's squaring loop that located the overflow at the 225th squaring independently of the fix and supplied the exponents the tests pin.
  • Category check: the issue asks which operation overflows and whether the check moves ahead or the arithmetic saturates; both answered. testRoundTripFuzzPow is untouched - not bounded, not skipped, not weakened, and clean at 60,000 runs.

520 Solidity tests pass, 76 Rust. forge fmt --check, forge lint -D warnings and slither all clean.

🤖 Generated with Claude Code

thedavidmeister and others added 2 commits October 2, 2026 10:54
Fixes #276.

`pow` raises the integer part of `b` by exponentiation by squaring, and
`LibDecimalFloatImplementation.mul` sums its operands' exponents in checked
arithmetic. Squaring the base doubles its exponent every iteration, so a wide
enough integer exponent walks that sum past `int256` and the squaring `mul`
panics with `0x11` before any of `pow`'s own exponent checks run.

The issue's counterexample is `1.06e67 e1348563571` raised to `-7.84e69`.
`b` is negative so `pow` recurses on `1/a`, whose exponent is `-1348563705`,
and the integer exponent is 233 bits wide: the base exponent reaches
`-3.64e76` on the 224th squaring and the 225th overflows. Nothing on the
`b * log10(a)` to `pow10` leg is implicated. The squaring loop runs first and
never reaches it.

Of the two ways out, letting the arithmetic saturate and leaving the error to
the final `packArithmeticResult`, or putting the typed check ahead of the
arithmetic, this takes the check. `mul`'s exponent range is a real
precondition rather than an accident: its own fuzz tests already bound their
exponents to stay inside it. It is named here as `MUL_EXPONENT_MIN` /
`MUL_EXPONENT_MAX` and stated on `mul`. Saturating would instead widen a
primitive that every arithmetic operation goes through into knowingly
returning a wrong exponent, and would charge all of those callers for a check
only `pow` needs, because `pow` is the one caller that iterates `mul` over its
own output. So `pow`'s loop checks both running exponents before handing them
to `mul`.

Reaching the bound means the result is not representable. The base at
iteration `i` is `a ** (2 ** i)` and the loop only reaches `i` when the
integer exponent is at least `2 ** i`, so the final result's `log10` is at
least the base's in magnitude; the same holds for the running result, which is
`a ** m` for some `m` no greater than the integer exponent. A coefficient's 77
digits and the fractional leg's `|log10(a)|`, which an int32 exponent caps
just over 2.1e9, are nothing against a bound of ~2.9e76. The sign of the
out-of-range exponent is therefore the sign of the result's `log10` and names
the error: `ExponentUnderflow` going down, `ExponentOverflow` going up. The
issue's input now gives `ExponentUnderflow`.

`MUL_EXPONENT_LIFT_MAX` is the headroom the bound leaves `mul` for lifting the
summed exponent as it normalises the 512-bit product back to 256 bits. The
bound on that lift is 79 and the allowance is 128.

Tests: the issue's counterexample, and an overflow-direction counterpart built
from a coefficient of 1 so the squaring doubles the exponent and nothing else,
as concrete `pow` tests. Both are `Panic(0x11)` before and the named error
after. `mul` is pinned at each end of its stated precondition with the
coefficients that maximise the lift, which panics if the lift allowance is
trimmed away. `testRoundTripFuzzPow` is unchanged and still catches no
untyped revert at 60,000 runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 49 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: f140b827-f57e-430e-8fca-9f778e9afba7

📥 Commits

Reviewing files that changed from the base of the PR and between 3a69df1 and c769636.

📒 Files selected for processing (4)
  • src/lib/LibDecimalFloat.sol
  • src/lib/implementation/LibDecimalFloatImplementation.sol
  • test/src/lib/LibDecimalFloat.pow.t.sol
  • test/src/lib/implementation/LibDecimalFloatImplementation.mul.t.sol
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pow reverts with Panic(0x11) instead of a typed error for extreme exponents

1 participant