Skip to content

fix(audit/H03): revert with ExponentUnderflow instead of silent FLOAT_ZERO#202

Merged
thedavidmeister merged 4 commits into
mainfrom
2026-05-10-191-revert-underflow
May 10, 2026
Merged

fix(audit/H03): revert with ExponentUnderflow instead of silent FLOAT_ZERO#202
thedavidmeister merged 4 commits into
mainfrom
2026-05-10-191-revert-underflow

Conversation

@thedavidmeister
Copy link
Copy Markdown
Contributor

@thedavidmeister thedavidmeister commented May 10, 2026

Closes #191.

Summary

Every arithmetic op in LibDecimalFloat used to finalise with packLossy and discard the lossless flag. packLossy's two lossless=false modes have very different consequences:

  • Coefficient too big for int224: successive div-by-10, exponent goes up. Result is approximately the same value with N digits dropped. Order of magnitude survives. Composable.
  • Exponent below int32.min: result becomes FLOAT_ZERO. Magnitude is lost entirely. mul(small, small) == 0 propagates through all downstream code with no recovery.

Adds packArithmeticResult that distinguishes the two by the returned Float (the coefficient-truncation path never returns FLOAT_ZERO from a non-zero input). Reverts with ExponentUnderflow on the underflow mode, silently tolerates coefficient truncation.

Every arithmetic op in LibDecimalFloat now uses packArithmeticResult in place of packLossy. packLossy itself is unchanged so the parser still uses it to surface ParseDecimalPrecisionLoss rather than revert.

Test coverage

Regression tests for the four ops whose result exponent can fall below int32.min:

  • testMulRevertsOnExponentUnderflow — both inputs at int32.min exponent.
  • testDivRevertsOnExponentUnderflow — numerator at min, denominator at max.
  • testInvRevertsOnExponentUnderflow — input at max exponent.
  • testPow10RevertsOnExponentUnderflow — adversarial input found by fuzz.

Mutation-tested: removing the revert in packArithmeticResult makes the mul test fail. Re-adding it passes.

The existing fuzz comparison tests (add, mul, div, inv, pow10) are updated to call packArithmeticResult on the decomposed path so the public and decomposed paths revert together on underflow inputs.

Deploy constants

This source change invalidates DECIMAL_FLOAT_CONTRACT_HASH etc. in LibDecimalFloatDeploy. Triggered the Manual sol artifacts workflow on this branch; CI's testDeployAddress / testExpectedCodeHashDecimalFloat will pass once that completes and the new constants are committed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Arithmetic operations now revert when calculation results are too small to represent (exponent underflow) instead of silently producing zero-like values. This applies to addition, subtraction, multiplication, division, inverse, and numeric component operations, ensuring consistent error handling for edge cases.

Review Change Stack

Every arithmetic op in LibDecimalFloat used to finalise with packLossy
and discard the lossless flag. packLossy's two lossless=false modes
have very different consequences:

- Coefficient too big for int224: successive div-by-10, exponent goes
  up. Result is approximately the same value with N digits dropped.
  Order of magnitude survives. Composable.
- Exponent below int32.min: result becomes FLOAT_ZERO. Magnitude is
  lost entirely. `mul(small, small) == 0` propagates through all
  downstream code and there's no recovery.

Adds `packArithmeticResult` — distinguishes the two by the returned
Float (the coefficient-truncation path never returns FLOAT_ZERO from
a non-zero input). Reverts with `ExponentUnderflow` on the underflow
mode, silently tolerates coefficient truncation. Every arithmetic op
in LibDecimalFloat now uses `packArithmeticResult` in place of
`packLossy`. `packLossy` itself is unchanged so the parser still uses
it to surface `ParseDecimalPrecisionLoss` rather than revert.

Regression tests for the four ops whose result exponent can fall
below int32.min — mul, div, inv, pow10 — pin the revert. Add/sub
can't underflow (result exponent = min of input exponents, both fit
int32). Minus/floor/ceil/intFrac don't change exponents. log10's
result exponent is bounded by the input's magnitude.

Updates the existing fuzz comparison tests (add, mul, div, inv,
pow10) to call packArithmeticResult on the decomposed path so the
public and decomposed paths revert together on underflow inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this May 10, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

Warning

Rate limit exceeded

@thedavidmeister has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 44 minutes and 53 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 975543d9-93d8-4e36-aad1-cb7b8e829687

📥 Commits

Reviewing files that changed from the base of the PR and between 9018d1a and 1ba7db9.

📒 Files selected for processing (5)
  • crates/float/src/error.rs
  • crates/float/src/lib.rs
  • src/lib/deploy/LibDecimalFloatDeploy.sol
  • test/src/lib/LibDecimalFloat.packArithmeticResult.t.sol
  • test/src/lib/LibDecimalFloat.pow10.t.sol

Walkthrough

This PR addresses audit finding H03 by preventing silent precision loss during arithmetic exponent underflow. A new ExponentUnderflow error and packArithmeticResult helper are introduced. Fourteen arithmetic operations (add, sub, minus, abs, mul, div, inv, integer, frac, floor, ceil, pow10, log10, pow) are updated to revert on exponent underflow instead of silently returning FLOAT_ZERO. Test helpers are refactored and new revert-case tests are added.

Changes

Exponent Underflow Protection

Layer / File(s) Summary
Error Definition
src/error/ErrDecimalFloat.sol
New ExponentUnderflow(int256 signedCoefficient, int256 exponent) error type documents revert behavior when arithmetic results underflow.
Arithmetic Result Packing
src/lib/LibDecimalFloat.sol
Import of ExponentUnderflow and new internal function packArithmeticResult that wraps packLossy and reverts when non-lossless packing produces FLOAT_ZERO due to exponent underflow.
Arithmetic Operations Finalization
src/lib/LibDecimalFloat.sol
All 14 arithmetic operations (add, sub, minus, abs, mul, div, inv, integer, frac, floor, ceil, pow10, log10, pow) now finalize via packArithmeticResult instead of packLossy, converting silent underflow returns to reverts.
Design Documentation
CLAUDE.md
Documents revised behavior: exponent overflow and underflow both revert at public arithmetic surface; coefficient truncation is silently tolerated; three packing modes defined with distinct underflow handling (packLossy returns FLOAT_ZERO for parsing; packArithmeticResult reverts for arithmetic).
Test Helpers & Revert Cases
test/src/lib/LibDecimalFloat.*.t.sol
Test helpers updated to call packArithmeticResult; new dedicated revert tests added for add, div, inv, mul, and pow10 to verify ExponentUnderflow is raised when result exponents fall below int32.min.

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: reverting on exponent underflow instead of silently returning FLOAT_ZERO. It directly maps to the audit finding H03 and primary changeset focus.
Linked Issues check ✅ Passed The PR fully addresses the objectives in issue #191 by implementing option 1: distinguishing coefficient truncation from exponent underflow and reverting with ExponentUnderflow on the latter, with comprehensive test coverage for mul, div, inv, and pow10.
Out of Scope Changes check ✅ Passed All changes are in scope: error definition, library modifications to use packArithmeticResult, test updates to call packArithmeticResult and assert revert behavior, and documentation updates explaining the new semantics.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-05-10-191-revert-underflow

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/src/lib/LibDecimalFloat.inv.t.sol`:
- Around line 22-28: Add an inline comment decoding the adversarial Float
literal used in testInvRevertsOnExponentUnderflow: explain that
Float.wrap(0x7fff...ffff) encodes coefficient = -1 (int224 all-ones) and
exponent = int32.max, so the value is -1 * 2^int32.max; place this comment next
to the Float.wrap call (or above the test) to clarify the bit layout for readers
and maintainers referencing the testInvRevertsOnExponentUnderflow and
invExternal usage.

In `@test/src/lib/LibDecimalFloat.pow10.t.sol`:
- Around line 22-26: Add a one-line comment next to the fuzz-derived literal in
testPow10RevertsOnExponentUnderflow that decodes the Float.wrap bit pattern into
its high-32-bit exponent and signed int224 coefficient (e.g., "exponent = 0x...,
coefficient = ...") so the regression intent is immediately readable; update the
comment near the Float.wrap literal in function
testPow10RevertsOnExponentUnderflow to show those decoded values and a short
note that this pattern triggers ExponentUnderflow when passed to pow10External.
- Around line 40-42: Collapse the multi-line vm.expectRevert call into a single
line to match the style used for the ExponentOverflow case; update the call that
uses vm.expectRevert(abi.encodeWithSelector(ExponentUnderflow.selector,
signedCoefficient, exponent)) so it is all on one line (preserving
ExponentUnderflow.selector, signedCoefficient and exponent) and run forge fmt to
ensure formatting is committed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 93c57635-4221-4548-9739-8c43fdc55aee

📥 Commits

Reviewing files that changed from the base of the PR and between e25378b and 9018d1a.

📒 Files selected for processing (9)
  • CLAUDE.md
  • crates/float/abi/DecimalFloat.json
  • src/error/ErrDecimalFloat.sol
  • src/lib/LibDecimalFloat.sol
  • test/src/lib/LibDecimalFloat.add.t.sol
  • test/src/lib/LibDecimalFloat.div.t.sol
  • test/src/lib/LibDecimalFloat.inv.t.sol
  • test/src/lib/LibDecimalFloat.mul.t.sol
  • test/src/lib/LibDecimalFloat.pow10.t.sol

Comment on lines +22 to +28
/// `inv` of a Float whose representable inverse magnitude falls below
/// `int32.min` reverts instead of silently producing `FLOAT_ZERO`.
function testInvRevertsOnExponentUnderflow() external {
Float float = Float.wrap(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
vm.expectPartialRevert(ExponentUnderflow.selector);
this.invExternal(float);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Optional: decode the adversarial Float in a comment.

This literal corresponds to coefficient -1 (int224 all-ones) at exponent int32.max; making that explicit avoids forcing readers to hand-parse the bit layout.

📝 Suggested annotation
     function testInvRevertsOnExponentUnderflow() external {
+        // coefficient = -1 (int224, all ones), exponent = int32.max.
+        // 1 / (−1 × 10^int32.max) requires scaling that drops exponent below int32.min.
         Float float = Float.wrap(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
         vm.expectPartialRevert(ExponentUnderflow.selector);
         this.invExternal(float);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// `inv` of a Float whose representable inverse magnitude falls below
/// `int32.min` reverts instead of silently producing `FLOAT_ZERO`.
function testInvRevertsOnExponentUnderflow() external {
Float float = Float.wrap(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
vm.expectPartialRevert(ExponentUnderflow.selector);
this.invExternal(float);
}
/// `inv` of a Float whose representable inverse magnitude falls below
/// `int32.min` reverts instead of silently producing `FLOAT_ZERO`.
function testInvRevertsOnExponentUnderflow() external {
// coefficient = -1 (int224, all ones), exponent = int32.max.
// 1 / (−1 × 10^int32.max) requires scaling that drops exponent below int32.min.
Float float = Float.wrap(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
vm.expectPartialRevert(ExponentUnderflow.selector);
this.invExternal(float);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/src/lib/LibDecimalFloat.inv.t.sol` around lines 22 - 28, Add an inline
comment decoding the adversarial Float literal used in
testInvRevertsOnExponentUnderflow: explain that Float.wrap(0x7fff...ffff)
encodes coefficient = -1 (int224 all-ones) and exponent = int32.max, so the
value is -1 * 2^int32.max; place this comment next to the Float.wrap call (or
above the test) to clarify the bit layout for readers and maintainers
referencing the testInvRevertsOnExponentUnderflow and invExternal usage.

Comment on lines +22 to +26
function testPow10RevertsOnExponentUnderflow() external {
Float float = Float.wrap(0xffffffffffffffffffffff0000000000000000000000000000000000000000ff);
vm.expectPartialRevert(ExponentUnderflow.selector);
this.pow10External(float);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Annotate the fuzz-derived adversarial Float.

The bit pattern is opaque on its face. A one-liner decoding the exponent (high 32 bits) and the signed int224 coefficient would make the regression intent legible without forcing future readers to hand-parse the literal.

📝 Suggested annotation
     function testPow10RevertsOnExponentUnderflow() external {
+        // Fuzz-derived: exponent = 0xffffffff (-1 as int32); coefficient bits chosen so
+        // pow10's effective result exponent falls below int32.min before final packing.
         Float float = Float.wrap(0xffffffffffffffffffffff0000000000000000000000000000000000000000ff);
         vm.expectPartialRevert(ExponentUnderflow.selector);
         this.pow10External(float);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/src/lib/LibDecimalFloat.pow10.t.sol` around lines 22 - 26, Add a
one-line comment next to the fuzz-derived literal in
testPow10RevertsOnExponentUnderflow that decodes the Float.wrap bit pattern into
its high-32-bit exponent and signed int224 coefficient (e.g., "exponent = 0x...,
coefficient = ...") so the regression intent is immediately readable; update the
comment near the Float.wrap literal in function
testPow10RevertsOnExponentUnderflow to show those decoded values and a short
note that this pattern triggers ExponentUnderflow when passed to pow10External.

Comment thread test/src/lib/LibDecimalFloat.pow10.t.sol Outdated
thedavidmeister and others added 3 commits May 11, 2026 01:34
Pins the new helper's contract:
- testPackArithmeticResultLossless: roundtrip for int224/int32 inputs.
- testPackArithmeticResultToleratesCoefficientTruncation: large coefficients
  silently divide-by-10 to fit int224, exponent bumped, no revert.
- testPackArithmeticResultExponentUnderflowReverts: exp < int32.min reverts
  with ExponentUnderflow.
- testPackArithmeticResultExponentOverflowReverts: exp > int32.max reverts
  with ExponentOverflow (unchanged from packLossy).
- testPackArithmeticResultZeroCoefficient: coef=0 returns FLOAT_ZERO regardless
  of exponent.

Updates the rust crate's mul-underflow test to expect the revert via
FloatError::DecimalFloat(ExponentUnderflow) instead of asserting the result
is zero. Adds ExponentUnderflow to DecimalFloatErrorSelector and the
TryFrom<FixedBytes<4>> dispatch.

Also forge-fmt'd LibDecimalFloat.pow10.t.sol (single-line vm.expectRevert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflects the new bytecode after the #191 fix landed:
- ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS: c08C... -> 588F...
- DECIMAL_FLOAT_CONTRACT_HASH: 0x694f... -> 0xa44a...

Deploy already broadcast across the supported networks via
manual-sol-artifacts on this branch. Constants pinned from the
testDeployAddress/testExpectedCodeHash assertion failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister merged commit e13321b into main May 10, 2026
10 of 11 checks passed
@github-actions
Copy link
Copy Markdown

@coderabbitai assess this PR size classification for the totality of the PR with the following criterias and report it in your comment:

S/M/L PR Classification Guidelines:

This guide helps classify merged pull requests by effort and complexity rather than just line count. The goal is to assess the difficulty and scope of changes after they have been completed.

Small (S)

Characteristics:

  • Simple bug fixes, typos, or minor refactoring
  • Single-purpose changes affecting 1-2 files
  • Documentation updates
  • Configuration tweaks
  • Changes that require minimal context to review

Review Effort: Would have taken 5-10 minutes

Examples:

  • Fix typo in variable name
  • Update README with new instructions
  • Adjust configuration values
  • Simple one-line bug fixes
  • Import statement cleanup

Medium (M)

Characteristics:

  • Feature additions or enhancements
  • Refactoring that touches multiple files but maintains existing behavior
  • Breaking changes with backward compatibility
  • Changes requiring some domain knowledge to review

Review Effort: Would have taken 15-30 minutes

Examples:

  • Add new feature or component
  • Refactor common utility functions
  • Update dependencies with minor breaking changes
  • Add new component with tests
  • Performance optimizations
  • More complex bug fixes

Large (L)

Characteristics:

  • Major feature implementations
  • Breaking changes or API redesigns
  • Complex refactoring across multiple modules
  • New architectural patterns or significant design changes
  • Changes requiring deep context and multiple review rounds

Review Effort: Would have taken 45+ minutes

Examples:

  • Complete new feature with frontend/backend changes
  • Protocol upgrades or breaking changes
  • Major architectural refactoring
  • Framework or technology upgrades

Additional Factors to Consider

When deciding between sizes, also consider:

  • Test coverage impact: More comprehensive test changes lean toward larger classification
  • Risk level: Changes to critical systems bump up a size category
  • Team familiarity: Novel patterns or technologies increase complexity

Notes:

  • the assessment must be for the totality of the PR, that means comparing the base branch to the last commit of the PR
  • the assessment output must be exactly one of: S, M or L (single-line comment) in format of: SIZE={S/M/L}
  • do not include any additional text, only the size classification
  • your assessment comment must not include tips or additional sections
  • do NOT tag me or anyone else on your comment

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

Tip

For best results, initiate chat on the files or code changes.

SIZE=M

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.

audit/H03: packLossy lossless flag discarded by every caller in LibDecimalFloat

1 participant