Skip to content
392 changes: 201 additions & 191 deletions .gas-snapshot

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/concrete/DecimalFloat.sol
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ contract DecimalFloat {
return a.floor();
}

/// Exposes `LibDecimalFloat.ceil` for offchain use.
/// @param a The float to get the ceiling of.
/// @return The ceiled float.
function ceil(Float a) external pure returns (Float) {
return a.ceil();
}

/// Exposes `LibDecimalFloat.pow10` for offchain use.
/// @param a The float to raise to the power of 10.
/// @return The result of raising the float to the power of 10.
Expand Down
32 changes: 32 additions & 0 deletions src/lib/LibDecimalFloat.sol
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,10 @@ library LibDecimalFloat {
/// @param float The float to floor.
function floor(Float float) internal pure returns (Float) {
(int256 signedCoefficient, int256 exponent) = float.unpack();
// If the exponent is 0 or greater then the float is already an integer.
if (exponent >= 0) {
return float;
}
(int256 characteristic, int256 mantissa) =
LibDecimalFloatImplementation.characteristicMantissa(signedCoefficient, exponent);
(Float result, bool lossless) = packLossy(characteristic, exponent);
Expand All @@ -620,6 +624,34 @@ library LibDecimalFloat {
return result;
}

/// Smallest integer value greater than or equal to the float.
/// @param float The float to ceil.
function ceil(Float float) internal pure returns (Float) {
(int256 signedCoefficient, int256 exponent) = float.unpack();
// If the exponent is 0 or greater then the float is already an integer.
if (exponent >= 0) {
return float;
}
(int256 characteristic, int256 mantissa) =
LibDecimalFloatImplementation.characteristicMantissa(signedCoefficient, exponent);

// If the mantissa is 0, then the float is already an integer.
if (mantissa == 0) {
return float;
}
// Truncate the fractional part when exponent < 0:
// mantissa < 0 (input < 0) → truncation towards zero increases the value (correct ceil).
// mantissa == 0 → value is already an integer.
// mantissa > 0 (input > 0) → truncation decreases the value, so add 1 to round up.
else if (mantissa > 0) {
(characteristic, exponent) = LibDecimalFloatImplementation.add(characteristic, exponent, 1e75, -75);
}

(Float result, bool lossless) = packLossy(characteristic, exponent);
(lossless);
return result;
}

/// Same as power10, but accepts a Float struct instead of separate values.
/// Costs more gas but helps mitigate stack depth issues, and is more
/// ergonomic for the caller.
Expand Down
105 changes: 77 additions & 28 deletions src/lib/implementation/LibDecimalFloatImplementation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {LibDecimalFloat} from "../LibDecimalFloat.sol";

error WithTargetExponentOverflow(int256 signedCoefficient, int256 exponent, int256 targetExponent);

uint256 constant ADD_MAX_EXPONENT_DIFF = 37;
uint256 constant ADD_MAX_EXPONENT_DIFF = 76;

/// @dev The maximum exponent that can be normalized.
/// This is crazy large, so should never be a problem for any real use case.
Expand Down Expand Up @@ -56,6 +56,11 @@ int256 constant NORMALIZED_ZERO_SIGNED_COEFFICIENT = 0;
/// @dev The exponent of zero when normalized.
int256 constant NORMALIZED_ZERO_EXPONENT = 0;

/// @dev The signed coefficient of maximized zero.
int256 constant MAXIMIZED_ZERO_SIGNED_COEFFICIENT = NORMALIZED_ZERO_SIGNED_COEFFICIENT;
/// @dev The exponent of maximized zero.
int256 constant MAXIMIZED_ZERO_EXPONENT = NORMALIZED_ZERO_EXPONENT;

library LibDecimalFloatImplementation {
/// Negates and normalizes a float.
/// Equivalent to `0 - x`.
Expand Down Expand Up @@ -193,23 +198,18 @@ library LibDecimalFloatImplementation {
}
}

/// Add two floats together as a normalized result.
/// Add two floats together.
///
/// Note that because the input values can have arbitrary exponents that may
/// be very far apart, the normalization process is necessarily lossy.
/// For example, normalized 1 is 1e37 coefficient and -37 exponent.
/// Consider adding 1e37 coefficient with exponent 1.
/// These two numbers are identical in coefficient but their exponents are
/// 38 OOMs apart. While we can perform the addition and get the correct
/// result internally, as soon as we normalize the result, we will lose
/// precision and the result will be 1e37 coefficient with -37 exponent.
/// The precision of addition is therefore best case the full 37 decimals
/// representable in normalized form, if the two numbers share the same
/// exponent, but each step of exponent difference will lose a decimal of
/// precision in the output. In practise, this rarely matters as the onchain
/// conventions for amounts are typically 18 decimals or less, and so entire
/// token supplies are typically representable within ~26-33 decimals of
/// precision, making addition lossless for all actual possible values.
/// be very far apart, the addition process is necessarily lossy.
/// Consider adding 1e100 to 1e-100, for example. The result is 1e100.
/// This is because we can't fit 200 OOMs of precision into the result.
/// However, we can easily fit ~26-33 decimals of precision into values,
/// which covers most or all token supplies and amounts we care about in
/// practice. This means that addition is typically lossless for all values
/// we will receive onchain. However, precision loss is still to be expected
/// when combined with other operations such as division that can result in
/// infinite recursion such a 1/3.
///
/// https://speleotrove.com/decimal/daops.html#refaddsub
/// > add and subtract both take two operands. If either operand is a special
Expand Down Expand Up @@ -270,11 +270,11 @@ library LibDecimalFloatImplementation {
}
}

// Normalizing A and B gives us similar coefficients, which simplifies
// Maximizing A and B gives us similar coefficients, which simplifies
// detecting when their exponents are too far apart to add without
// simply ignoring one of them.
(signedCoefficientA, exponentA) = normalize(signedCoefficientA, exponentA);
(signedCoefficientB, exponentB) = normalize(signedCoefficientB, exponentB);
(signedCoefficientA, exponentA) = maximize(signedCoefficientA, exponentA);
(signedCoefficientB, exponentB) = maximize(signedCoefficientB, exponentB);

// We want A to represent the larger exponent. If this is not the case
// then swap them.
Expand All @@ -288,30 +288,27 @@ library LibDecimalFloatImplementation {
exponentB = tmp;
}

// After normalization the signed coefficients are the same OOM in
// After maximization the signed coefficients are the same OOM in
// magnitude. However, what we need is for the exponents to be the same.
// If the exponents are close enough we can multiply coefficient A by
// If the exponents are close enough we can divide coefficient B by
// some power of 10 to align their exponents without precision loss.
// If the exponents are too far apart, then all the information in B
// would be lost by the final normalization step, so we can just ignore
// B and return A.
uint256 multiplier;
// would be lost, so we can just ignore B and return A.
unchecked {
uint256 alignmentExponentDiff = uint256(exponentA - exponentB);
// The early return here allows us to do unchecked pow on the
// multiplier and means we never revert due to overflow here.
// scaler and means we never revert due to overflow here.
if (alignmentExponentDiff > ADD_MAX_EXPONENT_DIFF) {
return (signedCoefficientA, exponentA);
}
multiplier = 10 ** alignmentExponentDiff;
signedCoefficientB /= int256(10 ** alignmentExponentDiff);
}
signedCoefficientA *= int256(multiplier);

// The actual addition step.
unchecked {
signedCoefficientA += signedCoefficientB;
}
return (signedCoefficientA, exponentB);
return (signedCoefficientA, exponentA);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// @param signedCoefficientA The signed coefficient of the first floating
Expand Down Expand Up @@ -523,6 +520,58 @@ library LibDecimalFloatImplementation {
}
}

function maximize(int256 signedCoefficient, int256 exponent) internal pure returns (int256, int256) {
unchecked {
if (signedCoefficient == 0) {
return (MAXIMIZED_ZERO_SIGNED_COEFFICIENT, MAXIMIZED_ZERO_EXPONENT);
}
int256 initialExponent = exponent;

if (signedCoefficient / 1e76 != 0) {
signedCoefficient /= 10;
exponent += 1;

if (exponent < initialExponent) {
revert ExponentOverflow(signedCoefficient, exponent);
}
}
// Check if already maximized before dropping into a block full of
// jumps.
else if (signedCoefficient / 1e75 == 0) {
if (signedCoefficient / 1e38 == 0) {
signedCoefficient *= 1e38;
exponent -= 38;
}

if (signedCoefficient / 1e57 == 0) {
signedCoefficient *= 1e19;
exponent -= 19;
}

if (signedCoefficient / 1e66 == 0) {
signedCoefficient *= 1e10;
exponent -= 10;
}

while (signedCoefficient / 1e74 == 0) {
signedCoefficient *= 1e2;
exponent -= 2;
}

if (signedCoefficient / 1e75 == 0) {
signedCoefficient *= 10;
exponent -= 1;
}

if (initialExponent < exponent) {
revert ExponentOverflow(signedCoefficient, exponent);
}
}

return (signedCoefficient, exponent);
}
}

function isNormalized(int256 signedCoefficient, int256 exponent) internal pure returns (bool) {
bool result;
uint256 normalizedMaxPlusOne = NORMALIZED_MAX_PLUS_ONE;
Expand Down
27 changes: 27 additions & 0 deletions test/src/concrete/DecimalFloat.ceil.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: CAL
pragma solidity =0.8.25;

import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol";
import {Test} from "forge-std/Test.sol";
import {DecimalFloat} from "src/concrete/DecimalFloat.sol";

contract DecimalFloatCeilTest is Test {
using LibDecimalFloat for Float;

function ceilExternal(Float a) external pure returns (Float) {
return a.ceil();
}

function testCeilDeployed(Float a) external {
DecimalFloat deployed = new DecimalFloat();

Comment on lines +15 to +17
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 (assertive)

Optional: reuse a single DecimalFloat instance to reduce per-test deployment cost.

Repeated new DecimalFloat() per fuzz case inflates gas/time. Consider a shared instance via setUp() unless test isolation requires fresh deployments.

-        DecimalFloat deployed = new DecimalFloat();
+        // Use a shared instance (initialized in setUp()).
+        DecimalFloat deployed = deployedInstance;

Add once to the contract:

DecimalFloat internal deployedInstance;

function setUp() public {
    deployedInstance = new DecimalFloat();
}
🧰 Tools
🪛 GitHub Actions: Git is clean

[error] git diff --exit-code failed (exit code 1). Detected end-of-file newline issue: 'No newline at end of file' in test/src/concrete/DecimalFloat.ceil.t.sol.

🤖 Prompt for AI Agents
In test/src/concrete/DecimalFloat.ceil.t.sol around lines 15 to 17, avoid
creating a new DecimalFloat() inside each fuzz test; instead add a
contract-level DecimalFloat variable and initialize it once in a setUp()
function, then update testCeilDeployed to use that shared deployedInstance;
ensure visibility is internal or private as needed and keep tests that require
fresh state as exceptions.

try this.ceilExternal(a) returns (Float b) {
Float deployedB = deployed.ceil(a);

assertEq(Float.unwrap(b), Float.unwrap(deployedB));
} catch (bytes memory err) {
vm.expectRevert(err);
deployed.ceil(a);
}
}
Comment on lines +15 to +26
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 (assertive)

Good parity test between library and deployed wrapper

  • try/catch with vm.expectRevert(err) correctly asserts revert parity.
  • Success path unwraps and compares raw values, avoiding equality pitfalls.

Consider renaming ceilExternal to internal helper in a separate contract if you later expand tests, but current approach is fine.

🤖 Prompt for AI Agents
test/src/concrete/DecimalFloat.ceil.t.sol lines 15-26: the test is correct and
requires no functional changes; keep the try/catch + vm.expectRevert pattern and
the unwrap comparison as-is for parity checks, but if you plan to expand tests
later consider moving ceilExternal into a small helper contract (rename
ceilExternal to a more descriptive internal helper or create a dedicated test
helper contract) to improve code organization and reuse.

}
107 changes: 107 additions & 0 deletions test/src/lib/LibDecimalFloat.ceil.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: CAL
pragma solidity =0.8.25;

import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol";
import {LibDecimalFloatImplementation} from "src/lib/implementation/LibDecimalFloatImplementation.sol";

import {Test, console2} from "forge-std/Test.sol";

contract LibDecimalFloatCeilTest is Test {
using LibDecimalFloat for Float;

function testCeilNotReverts(Float float) external pure {
float.ceil();
}

function checkCeil(
int256 signedCoefficient,
int256 exponent,
int256 expectedSignedCoefficient,
int256 expectedExponent
) internal pure {
(signedCoefficient, exponent) =
LibDecimalFloat.ceil(LibDecimalFloat.packLossless(signedCoefficient, exponent)).unpack();

if (!LibDecimalFloatImplementation.eq(signedCoefficient, exponent, expectedSignedCoefficient, expectedExponent))
{
console2.log("signedCoefficient", signedCoefficient);
console2.log("exponent", exponent);
console2.log("expectedSignedCoefficient", expectedSignedCoefficient);
console2.log("expectedExponent", expectedExponent);
revert("Ceil check failed");
}
}

/// Every non negative exponent is identity for ceil.
function testCeilNonNegative(int224 x, int256 exponent) external pure {
exponent = bound(exponent, 0, type(int32).max);
checkCeil(x, exponent, x, exponent);
}
Comment thread
thedavidmeister marked this conversation as resolved.

/// If the exponent is less than -76 then the ceil is 1 if x is positive,
/// or 0 if x is negative.
function testCeilLessThanMin(int224 x, int256 exponent) external pure {
exponent = bound(exponent, type(int32).min, -77);
if (x <= 0) {
checkCeil(x, exponent, 0, exponent);
} else {
checkCeil(x, exponent, 1, 0);
}
}
Comment thread
thedavidmeister marked this conversation as resolved.
Comment thread
thedavidmeister marked this conversation as resolved.

/// For exponents [-76,-1] the ceil is the + 1.
function testCeilInRange(int224 x, int256 exponent) external pure {
exponent = bound(exponent, -76, -1);
int256 scale = int256(10 ** uint256(-exponent));
Comment on lines +52 to +55
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 (assertive)

Clarify docstring for the in-range behavior

Doc is truncated. Suggest explicit statement of the rule used below.

-    /// For exponents [-76,-1] the ceil is the + 1.
+    /// For exponents in [-76, -1], ceil(x) = characteristic + 1 iff mantissa > 0; otherwise ceil(x) = characteristic.
📝 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
/// For exponents [-76,-1] the ceil is the + 1.
function testCeilInRange(int224 x, int256 exponent) external pure {
exponent = bound(exponent, -76, -1);
int256 scale = int256(10 ** uint256(-exponent));
/// For exponents in [-76, -1], ceil(x) = characteristic + 1 iff mantissa > 0; otherwise ceil(x) = characteristic.
function testCeilInRange(int224 x, int256 exponent) external pure {
exponent = bound(exponent, -76, -1);
int256 scale = int256(10 ** uint256(-exponent));
🤖 Prompt for AI Agents
In test/src/lib/LibDecimalFloat.ceil.t.sol around lines 48 to 51, the docstring
for the testCeilInRange function is incomplete and unclear about the behavior
for exponents in the range [-76, -1]. Update the docstring to explicitly state
the rule applied in this range, clarifying that for these exponents, the ceiling
operation results in adding 1 to the value. Make sure the explanation is concise
and fully describes the expected behavior.


int256 characteristic = x / scale;
if (characteristic == 0) {
if (x > 0) {
// If the characteristic is 0 and x is positive then the ceil is 1.
checkCeil(x, exponent, 1, 0);
} else {
// If the characteristic is 0 and x is negative then the ceil is 0.
checkCeil(x, exponent, 0, exponent);
}
} else {
// If the characteristic is non-zero then we can just add 1 to it
// if the mantissa is non-zero.
int256 mantissa = x % scale;
if (mantissa > 0) {
// If the mantissa is greater than 0, we need to add 1 to
// the characteristic to get the ceiling.
characteristic += 1;
}
checkCeil(x, exponent, characteristic * scale, exponent);
}
}

/// Examples
function testCeilExamples() external pure {
checkCeil(123456789, 0, 123456789, 0);
checkCeil(123456789, -1, 12345679000000000000000000000000000000000000000000000000000000000000, -60);
checkCeil(123456789, -2, 12345680000000000000000000000000000000000000000000000000000000000000, -61);
checkCeil(123456789, -3, 12345700000000000000000000000000000000000000000000000000000000000000, -62);
checkCeil(123456789, -4, 12346000000000000000000000000000000000000000000000000000000000000000, -63);
checkCeil(123456789, -5, 12350000000000000000000000000000000000000000000000000000000000000000, -64);
checkCeil(123456789, -6, 12400000000000000000000000000000000000000000000000000000000000000000, -65);
checkCeil(123456789, -7, 13000000000000000000000000000000000000000000000000000000000000000000, -66);
checkCeil(123456789, -8, 2000000000000000000000000000000000000000000000000000000000000000000, -66);
checkCeil(123456789, -9, 1, 0);
checkCeil(123456789, -10, 1, 0);
checkCeil(123456789, -11, 1, 0);
checkCeil(type(int224).max, 0, type(int224).max, 0);
checkCeil(type(int224).min, 0, type(int224).min, 0);
checkCeil(2.5e37, -37, 3e66, -66);
}

/// Test some zeros.
function testCeilZero(int32 exponent) external pure {
Float wrapZero = Float.wrap(0);
Float packZeroBasic = LibDecimalFloat.packLossless(0, 0);
Float packZero = LibDecimalFloat.packLossless(0, exponent);
assertTrue(wrapZero.ceil().eq(packZero));
assertTrue(wrapZero.ceil().eq(packZeroBasic));
assertEq(Float.unwrap(wrapZero.ceil()), Float.unwrap(packZeroBasic));
}
Comment thread
thedavidmeister marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion test/src/lib/LibDecimalFloat.pow.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ contract LibDecimalFloatPowTest is LogTest {
return a.pow(b, logTables());
}

function testRoundTripFuzz(Float a, Float b) external {
function testRoundTripFuzzPow(Float a, Float b) external {
try this.powExternal(a, b) returns (Float c) {
// If b is zero we'll divide by zero on the inv.
// If c is 1 then it's not round trippable because 1^x = 1 for all x.
Expand Down
4 changes: 2 additions & 2 deletions test/src/lib/LibDecimalFloat.sqrt.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ contract LibDecimalFloatSqrtTest is LogTest {
checkSqrt(0, 0, 0, 0);
checkSqrt(2, 0, 1415, -3);
checkSqrt(4, 0, 2e3, -3);
checkSqrt(16, 0, 399950000000000000000000000000000000000000, -41);
checkSqrt(16, 0, 3999500000000000000000000000000000000000000000000000000000000000000, -66);
}

function testSqrtNegative(Float a) external {
Expand Down Expand Up @@ -84,7 +84,7 @@ contract LibDecimalFloatSqrtTest is LogTest {
checkRoundTrip(100000000, 0);
}

function testRoundTripFuzz(int224 signedCoefficient, int32 exponent) external {
function testRoundTripFuzzSqrt(int224 signedCoefficient, int32 exponent) external {
signedCoefficient = int224(bound(signedCoefficient, 1, type(int224).max));
exponent = int32(bound(exponent, type(int16).min, type(int16).max));
checkRoundTrip(signedCoefficient, exponent);
Expand Down
Loading
Loading