diff --git a/.github/workflows/manual-sol-artifacts.yaml b/.github/workflows/manual-sol-artifacts.yaml index e7e481c..2d540d1 100644 --- a/.github/workflows/manual-sol-artifacts.yaml +++ b/.github/workflows/manual-sol-artifacts.yaml @@ -9,7 +9,6 @@ on: options: - log-tables - decimal-float - jobs: deploy: runs-on: ubuntu-latest @@ -18,7 +17,6 @@ jobs: with: submodules: recursive fetch-depth: 0 - - uses: nixbuild/nix-quick-install-action@v30 with: nix_conf: | @@ -35,22 +33,19 @@ jobs: # before trying to save a new cache # 1G = 1073741824 gc-max-store-size-linux: 1G - - run: nix develop -c rainix-sol-prelude - run: nix develop -c forge selectors up --all - run: nix develop -c forge script script/Deploy.sol:Deploy -vvvvv --slow --broadcast --verify env: DEPLOYMENT_SUITE: ${{ inputs.suite }} - DEPLOYMENT_KEY: ${{ github.ref == 'refs/heads/main' && secrets.PRIVATE_KEY || secrets.PRIVATE_KEY_DEV }} - + DEPLOYMENT_KEY: ${{ secrets.PRIVATE_KEY }} CI_DEPLOY_ARBITRUM_RPC_URL: ${{ secrets.CI_DEPLOY_ARBITRUM_RPC_URL || vars.CI_DEPLOY_ARBITRUM_RPC_URL || '' }} CI_DEPLOY_BASE_RPC_URL: ${{ secrets.CI_DEPLOY_BASE_RPC_URL || vars.CI_DEPLOY_BASE_RPC_URL || '' }} CI_DEPLOY_BASE_SEPOLIA_RPC_URL: ${{ secrets.CI_DEPLOY_BASE_SEPOLIA_RPC_URL || vars.CI_DEPLOY_BASE_SEPOLIA_RPC_URL || '' }} CI_DEPLOY_FLARE_RPC_URL: ${{ secrets.CI_DEPLOY_FLARE_RPC_URL || vars.CI_DEPLOY_FLARE_RPC_URL || '' }} CI_DEPLOY_POLYGON_RPC_URL: ${{ secrets.CI_DEPLOY_POLYGON_RPC_URL || vars.CI_DEPLOY_POLYGON_RPC_URL || '' }} - CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY || vars.CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY || '' }} CI_DEPLOY_BASE_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_BASE_ETHERSCAN_API_KEY || vars.CI_DEPLOY_BASE_ETHERSCAN_API_KEY || '' }} CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY || vars.CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY || '' }} CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_FLARE_ETHERSCAN_API_KEY || vars.CI_DEPLOY_FLARE_ETHERSCAN_API_KEY || '' }} - CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY || vars.CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY || '' }} \ No newline at end of file + CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY: ${{ secrets.CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY || vars.CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY || '' }} diff --git a/CLAUDE.md b/CLAUDE.md index 38fdd14..4d4a1d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,24 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. ## Project Overview -Decimal floating-point math library for Rainlang/DeFi. The `Float` type packs a 224-bit signed coefficient and 32-bit signed exponent into a single `bytes32`. Decimal (not binary) representation ensures exact decimal values (e.g., `0.1`). No NaN, Infinity, or negative zero — operations error on nonsense rather than producing special values. +Decimal floating-point math library for Rainlang/DeFi. The `Float` type packs a +224-bit signed coefficient and 32-bit signed exponent into a single `bytes32`. +Decimal (not binary) representation ensures exact decimal values (e.g., `0.1`). +No NaN, Infinity, or negative zero — operations error on nonsense rather than +producing special values. -Dual implementation: Solidity for on-chain, Rust/WASM for off-chain JS/TS consumption. The Rust crate uses revm to execute Solidity via an in-memory EVM, ensuring identical behavior. +Dual implementation: Solidity for on-chain, Rust/WASM for off-chain JS/TS +consumption. The Rust crate uses revm to execute Solidity via an in-memory EVM, +ensuring identical behavior. ## Build Commands ### Solidity (Foundry) + ```bash forge build # Compile contracts forge test # Run all Solidity tests (5096 fuzz runs) @@ -19,6 +27,7 @@ forge test -vvvv # Verbose trace output for debugging ``` ### Rust + ```bash cargo build # Build native cargo build --target wasm32-unknown-unknown --lib -r # Build WASM @@ -26,9 +35,11 @@ cargo test # Run Rust tests cargo test test_name # Run specific test ``` -Rust tests depend on Foundry build artifacts (`out/`). Run `forge build` before `cargo test` if artifacts are missing. +Rust tests depend on Foundry build artifacts (`out/`). Run `forge build` before +`cargo test` if artifacts are missing. ### JavaScript/WASM + ```bash npm install npm run build # Full pipeline: Rust WASM → wasm-bindgen → base64 embed → CJS/ESM dist @@ -36,55 +47,101 @@ npm test # TypeScript type check + vitest (tests in test_js/) ``` ### Nix + ```bash nix develop # Enter dev shell with all tooling ``` ### Deployment -Contracts are deployed deterministically via the Zoltu proxy to the same address on all supported networks (Arbitrum, Base, Base Sepolia, Flare, Polygon). Two deployment suites (log-tables must be deployed first): + +Contracts are deployed deterministically via the Zoltu proxy to the same address +on all supported networks (Arbitrum, Base, Base Sepolia, Flare, Polygon). The +deterministic address is a function of bytecode + salt only — not the branch or +deployer — so a successful deploy from any branch lands at the same address a +main-branch deploy would. + +**Typical flow for a source-changing PR**: trigger the `Manual sol artifacts` +GitHub workflow on the PR's branch before merge. +`gh workflow run manual-sol-artifacts.yaml --ref -f suite=decimal-float` +(use `log-tables` only when table bytecode changes, which is rare). The workflow +runs `script/Deploy.sol` with `--broadcast --verify` across all networks, using +`PRIVATE_KEY` regardless of ref. Do NOT wait for merge before deploying — there +is nothing to gain from waiting, and the CI deploy-constant tests need updating +anyway based on the deployed address. + +**Two deployment suites** (log-tables must be deployed first if redeploying +tables): + ```bash DEPLOYMENT_KEY= DEPLOYMENT_SUITE=log-tables forge script script/Deploy.sol:Deploy --broadcast --verify DEPLOYMENT_KEY= DEPLOYMENT_SUITE=decimal-float forge script script/Deploy.sol:Deploy --broadcast --verify ``` -Expected addresses and code hashes are in `src/lib/deploy/LibDecimalFloatDeploy.sol`. Network RPC URLs are configured in `foundry.toml` via `CI_DEPLOY_*_RPC_URL` env vars. + +Expected addresses and code hashes are in +`src/lib/deploy/LibDecimalFloatDeploy.sol`. Any source change to +`LibDecimalFloat` or `LibFormatDecimalFloat` invalidates these constants; CI's +`testDeployAddress` and `testExpectedCodeHashDecimalFloat` will fail until +they're regenerated and committed. Network RPC URLs are configured in +`foundry.toml` via `CI_DEPLOY_*_RPC_URL` env vars. ## Architecture ### Solidity Layer (`src/`) -- **`lib/LibDecimalFloat.sol`** — Public API: arithmetic, comparison, conversion, formatting, parsing. User-defined type `Float` wrapping `bytes32`. -- **`lib/implementation/`** — Internal arithmetic (512-bit intermediates for mul/div), normalization, packing. + +- **`lib/LibDecimalFloat.sol`** — Public API: arithmetic, comparison, + conversion, formatting, parsing. User-defined type `Float` wrapping `bytes32`. +- **`lib/implementation/`** — Internal arithmetic (512-bit intermediates for + mul/div), normalization, packing. - **`lib/parse/`** — String-to-Float parsing. - **`lib/format/`** — Float-to-string formatting. -- **`lib/table/`** — Log lookup tables (deployed as a data contract at a deterministic address). -- **`concrete/DecimalFloat.sol`** — Exposes library functions as contract methods (required for Rust/revm interop via ABI). -- **`error/`** — Custom error definitions (CoefficientOverflow, ExponentOverflow, DivisionByZero, etc.). +- **`lib/table/`** — Log lookup tables (deployed as a data contract at a + deterministic address). +- **`concrete/DecimalFloat.sol`** — Exposes library functions as contract + methods (required for Rust/revm interop via ABI). +- **`error/`** — Custom error definitions (CoefficientOverflow, + ExponentOverflow, DivisionByZero, etc.). ### Scripts (`script/`) -- **`Deploy.sol`** — Production deployment script using Zoltu deterministic proxy. Deploys log tables and DecimalFloat contract to all supported networks. -- **`BuildPointers.sol`** — Generates `src/generated/LogTables.pointers.sol` (committed to repo; must be regenerated if log table data changes). + +- **`Deploy.sol`** — Production deployment script using Zoltu deterministic + proxy. Deploys log tables and DecimalFloat contract to all supported networks. +- **`BuildPointers.sol`** — Generates `src/generated/LogTables.pointers.sol` + (committed to repo; must be regenerated if log table data changes). ### Rust Layer (`crates/float/`) -- **`lib.rs`** — `Float` struct wrapping `B256`, implements `Add`/`Sub`/`Mul`/`Div`/`Neg`. Uses `alloy::sol!` macro to generate bindings from Foundry JSON artifacts in `out/`. -- **`js_api.rs`** — `#[wasm_bindgen]` exports for JS consumption (parse, format, arithmetic, conversions). -- **`evm.rs`** — In-memory EVM setup via revm. All Rust float operations delegate to Solidity through this. + +- **`lib.rs`** — `Float` struct wrapping `B256`, implements + `Add`/`Sub`/`Mul`/`Div`/`Neg`. Uses `alloy::sol!` macro to generate bindings + from Foundry JSON artifacts in `out/`. +- **`js_api.rs`** — `#[wasm_bindgen]` exports for JS consumption (parse, format, + arithmetic, conversions). +- **`evm.rs`** — In-memory EVM setup via revm. All Rust float operations + delegate to Solidity through this. - **`error.rs`** — Maps Solidity error selectors to Rust error types. ### JavaScript Layer -- **`scripts/build.js`** — Build pipeline: compiles WASM, runs wasm-bindgen, base64-encodes WASM into JS modules for both CJS and ESM. + +- **`scripts/build.js`** — Build pipeline: compiles WASM, runs wasm-bindgen, + base64-encodes WASM into JS modules for both CJS and ESM. - **`test_js/`** — Vitest tests for the WASM bindings. - **`dist/`** — Generated output (CJS + ESM with embedded WASM). ### Dependencies (`lib/`) -Git submodules: forge-std, rain.string, rain.datacontract, rain.math.fixedpoint, rain.deploy, rain.sol.codegen. + +Git submodules: forge-std, rain.string, rain.datacontract, rain.math.fixedpoint, +rain.deploy, rain.sol.codegen. ## Key Design Details - 512-bit intermediate values in multiply/divide to preserve precision. - Exponent underflow silently rounds toward zero; exponent overflow reverts. -- Log/power use lookup table approximations with linear interpolation (table deployed as a data contract). -- Two packing modes: lossless (reverts on precision loss) and lossy (returns bool flag). +- Log/power use lookup table approximations with linear interpolation (table + deployed as a data contract). +- Two packing modes: lossless (reverts on precision loss) and lossy (returns + bool flag). - Solidity compiler: 0.8.25, EVM target: Cancun, optimizer: 1,000,000 runs. ## License -LicenseRef-DCL-1.0 (Rain Decentralized Computer License). All source files require SPDX headers per REUSE.toml. +LicenseRef-DCL-1.0 (Rain Decentralized Computer License). All source files +require SPDX headers per REUSE.toml. diff --git a/src/lib/LibDecimalFloat.sol b/src/lib/LibDecimalFloat.sol index 9da48f2..d9375cc 100644 --- a/src/lib/LibDecimalFloat.sol +++ b/src/lib/LibDecimalFloat.sol @@ -13,6 +13,22 @@ import { } from "../error/ErrDecimalFloat.sol"; import {LibDecimalFloatImplementation} from "./implementation/LibDecimalFloatImplementation.sol"; +/// A decimal floating point number packed into 32 bytes. The high 32 bits are +/// a signed int32 exponent; the low 224 bits are a signed int224 coefficient. +/// The value represented is `coefficient × 10^exponent`. +/// +/// Representations are non-canonical by design. Every non-zero value has an +/// infinite family of `(coefficient, exponent)` pairs that represent it — for +/// example `(5, 0)`, `(50, -1)`, and `(5000, -3)` all equal the number `5` and +/// pack to different `bytes32`. Equality between Floats is therefore numeric +/// (via `eq`, which rescales before comparing), not byte-level. `packLossy` +/// does not strip trailing decimal zeros from the coefficient; it only +/// shrinks the coefficient when it does not fit int224. This is deliberate: +/// canonicalization is not free on the arithmetic hot path, and most +/// operations do not care. Consumers that need a canonical form (raw-byte +/// equality, hashing as a map key, downstream range checks) must canonicalize +/// locally at the point of use. See `LibDecimalFloatImplementation.eq` for +/// the operative numeric-equality contract. type Float is bytes32; /// @title LibDecimalFloat @@ -25,6 +41,11 @@ type Float is bytes32; /// - There is no concept of rounding modes. /// - There is no negative zero. /// - This is a decimal floating point library, not binary. +/// - Representations are non-canonical. Multiple `(coefficient, exponent)` +/// pairs can encode the same numeric value; equality is numeric, not +/// byte-level. Canonicalization is deferred to consumers that need it +/// rather than enforced in packing, to keep the arithmetic hot path cheap. +/// See the docstring on the `Float` type for detail. /// /// This means that operations such as divide by 0 will revert, rather than /// produce nonsense like NaN or Infinity. This is a deliberate design choice diff --git a/src/lib/deploy/LibDecimalFloatDeploy.sol b/src/lib/deploy/LibDecimalFloatDeploy.sol index 3812ba8..b120061 100644 --- a/src/lib/deploy/LibDecimalFloatDeploy.sol +++ b/src/lib/deploy/LibDecimalFloatDeploy.sol @@ -24,11 +24,11 @@ library LibDecimalFloatDeploy { /// @dev Address of the DecimalFloat contract deployed via Zoltu's /// deterministic deployment proxy. /// This address is the same across all EVM-compatible networks. - address constant ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS = address(0x18e859f1a5d323b0baE73732211ABD979E2D3246); + address constant ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS = address(0xf8206b5dF01D68a3625Ce48Ce593C6D89B7E8144); /// @dev The expected codehash of the DecimalFloat contract deployed via /// Zoltu's deterministic deployment proxy. - bytes32 constant DECIMAL_FLOAT_CONTRACT_HASH = 0x87ca4d55f93e5db8777b3e723b796e3a039c4b4ae2a486f69129c434edfb110e; + bytes32 constant DECIMAL_FLOAT_CONTRACT_HASH = 0x624040915402064e37887b92f6da7e8a46d5364afbfa4258402c1c24d99b33e2; /// Combines all log and anti-log tables into a single bytes array for /// deployment. These are using packed encoding to minimize size and remove diff --git a/src/lib/format/LibFormatDecimalFloat.sol b/src/lib/format/LibFormatDecimalFloat.sol index 95715a4..63ff696 100644 --- a/src/lib/format/LibFormatDecimalFloat.sol +++ b/src/lib/format/LibFormatDecimalFloat.sol @@ -11,69 +11,53 @@ import {UnformatableExponent} from "../../error/ErrFormat.sol"; /// Not particularly efficient as it is intended for offchain use that doesn't /// cost gas. library LibFormatDecimalFloat { + /// Maximum `|exponent|` supported by non-scientific formatting. Exponents + /// outside `[-MAX_NON_SCIENTIFIC_EXPONENT, MAX_NON_SCIENTIFIC_EXPONENT]` + /// revert with `UnformatableExponent`. The cap exists to prevent unbounded + /// memory use when building the output string; callers that need to render + /// such values should use scientific mode. + int256 internal constant MAX_NON_SCIENTIFIC_EXPONENT = 1000; + /// Format a decimal float as a string. /// Not particularly efficient as it is intended for offchain use that /// doesn't cost gas. /// @param float The decimal float to format. /// @param scientific Whether to format in scientific notation (e.g. 1e10). /// @return The string representation of the decimal float. - //slither-disable-next-line cyclomatic-complexity function toDecimalString(Float float, bool scientific) internal pure returns (string memory) { (int256 signedCoefficient, int256 exponent) = LibDecimalFloat.unpack(float); if (signedCoefficient == 0) { return "0"; } + if (scientific) { + return _toScientific(signedCoefficient, exponent); + } + return _toNonScientific(signedCoefficient, exponent); + } + + /// Scientific notation: render as `d.dddeN` where the leading digit is the + /// most significant digit of the maximized coefficient. Uses big-integer + /// division to place the decimal point; the divisor is always `1e75` or + /// `1e76` which both fit in int256. + function _toScientific(int256 signedCoefficient, int256 exponent) private pure returns (string memory) { + (signedCoefficient, exponent) = LibDecimalFloatImplementation.maximizeFull(signedCoefficient, exponent); + uint256 scale; uint256 scaleExponent; - uint256 scale = 0; - if (scientific) { - (signedCoefficient, exponent) = LibDecimalFloatImplementation.maximizeFull(signedCoefficient, exponent); - - if (signedCoefficient / 1e76 != 0) { - scaleExponent = 76; - scale = 1e76; - } else { - scaleExponent = 75; - scale = 1e75; - } + if (signedCoefficient / 1e76 != 0) { + scaleExponent = 76; + scale = 1e76; } else { - if (exponent > 0) { - if (exponent > 76) { - revert UnformatableExponent(exponent); - } - // exponent > 0 - // forge-lint: disable-next-line(unsafe-typecast) - signedCoefficient *= int256(10) ** uint256(exponent); - exponent = 0; - } - if (exponent < 0) { - if (exponent < -76) { - revert UnformatableExponent(exponent); - } - // negating a signed exponent will always fit in uint256. - // forge-lint: disable-next-line(unsafe-typecast) - scale = uint256(10) ** uint256(-exponent); - // negating a signed exponent will always fit in uint256. - // forge-lint: disable-next-line(unsafe-typecast) - scaleExponent = uint256(-exponent); - } else { - // exponent is zero here. - scaleExponent = 0; - } + scaleExponent = 75; + scale = 1e75; } - int256 integral = signedCoefficient; - int256 fractional = int256(0); - if (scale != 0) { - // scale is one of two possible values so won't truncate when cast - // or explicitly has a guard against it truncating. - // forge-lint: disable-next-line(unsafe-typecast) - integral = signedCoefficient / int256(scale); - // scale is one of two possible values so won't truncate when cast - // or explicitly has a guard against it truncating. - // forge-lint: disable-next-line(unsafe-typecast) - fractional = signedCoefficient % int256(scale); - } + // scale is one of two hardcoded values (1e76, 1e75), both fit int256. + // forge-lint: disable-next-line(unsafe-typecast) + int256 integral = signedCoefficient / int256(scale); + // scale is one of two hardcoded values (1e76, 1e75), both fit int256. + // forge-lint: disable-next-line(unsafe-typecast) + int256 fractional = signedCoefficient % int256(scale); bool isNeg = false; if (integral < 0) { @@ -86,46 +70,127 @@ library LibFormatDecimalFloat { } string memory fractionalString = ""; - { + if (fractional != 0) { + uint256 fracLeadingZeros = 0; + uint256 fracScale = scale / 10; + // fracScale is scale/10 of a hardcoded power of 10, fits int256. + // forge-lint: disable-next-line(unsafe-typecast) + while (fractional / int256(fracScale) == 0) { + fracScale /= 10; + fracLeadingZeros++; + } + string memory fracLeadingZerosString = ""; + for (uint256 i = 0; i < fracLeadingZeros; i++) { + fracLeadingZerosString = string.concat(fracLeadingZerosString, "0"); + } - if (fractional != 0) { - uint256 fracLeadingZeros = 0; - uint256 fracScale = scale / 10; - // fracScale being 10x less than scale means it cannot overflow - // when cast to `int256`. - // forge-lint: disable-next-line(unsafe-typecast) - while (fractional / int256(fracScale) == 0) { - fracScale /= 10; - fracLeadingZeros++; - } - - for (uint256 i = 0; i < fracLeadingZeros; i++) { - fracLeadingZerosString = string.concat(fracLeadingZerosString, "0"); - } - - while (fractional % 10 == 0) { - fractional /= 10; - } + while (fractional % 10 == 0) { + fractional /= 10; } - fractionalString = - fractional == 0 ? "" : string.concat(".", fracLeadingZerosString, Strings.toStringSigned(fractional)); + fractionalString = string.concat(".", fracLeadingZerosString, Strings.toStringSigned(fractional)); } string memory integralString = Strings.toStringSigned(integral); - // scaleExponent comes from either hardcoded values or `exponent` which - // is an `int256` that was cast to `uint256` above, which can be cast - // back to `int256` without truncation. + // scaleExponent is a hardcoded small value (75 or 76); the cast back + // to int256 cannot truncate. // forge-lint: disable-next-line(unsafe-typecast) int256 displayExponent = exponent + int256(scaleExponent); string memory exponentString = - (displayExponent == 0 || !scientific) ? "" : string.concat("e", Strings.toStringSigned(displayExponent)); + displayExponent == 0 ? "" : string.concat("e", Strings.toStringSigned(displayExponent)); + string memory prefix = isNeg ? "-" : ""; + return string.concat(prefix, integralString, fractionalString, exponentString); + } + + /// Non-scientific notation: render by placing a decimal point inside the + /// coefficient's digit string according to the exponent. Does not compute + /// `10^exponent` as an integer, so the output is valid for any + /// `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT` — including exponents below + /// `-76` that arise from near-cancellation add/sub. + function _toNonScientific(int256 signedCoefficient, int256 exponent) private pure returns (string memory) { + if (exponent > MAX_NON_SCIENTIFIC_EXPONENT || exponent < -MAX_NON_SCIENTIFIC_EXPONENT) { + revert UnformatableExponent(exponent); + } + + bool isNeg = signedCoefficient < 0; + uint256 absCoef; + if (isNeg) { + // signedCoefficient came from `unpack` so |signedCoefficient| fits + // int224; negation always fits uint256. + // forge-lint: disable-next-line(unsafe-typecast) + absCoef = uint256(-signedCoefficient); + } else { + // signedCoefficient is non-negative and fits int224, so fits + // uint256. + // forge-lint: disable-next-line(unsafe-typecast) + absCoef = uint256(signedCoefficient); + } + + bytes memory digits = bytes(Strings.toString(absCoef)); + uint256 k = digits.length; + + // Strip trailing decimal zeros of the coefficient, raising the + // exponent by the same count. Value-preserving, and simplifies + // downstream cases by eliminating redundant zeros. + uint256 trailingZeros = 0; + while (trailingZeros < k && digits[k - 1 - trailingZeros] == "0") { + trailingZeros++; + } + uint256 sigK = k - trailingZeros; + // k <= 78 (int224 max has ~68 decimal digits), so int256(trailingZeros) + // cannot overflow. + // forge-lint: disable-next-line(unsafe-typecast) + int256 effExp = exponent + int256(trailingZeros); string memory prefix = isNeg ? "-" : ""; - string memory fullString = string.concat(prefix, integralString, fractionalString, exponentString); + if (effExp >= 0) { + // Significant digits followed by `effExp` trailing zeros. + // effExp is bounded by MAX_NON_SCIENTIFIC_EXPONENT + ~78. + // forge-lint: disable-next-line(unsafe-typecast) + uint256 uEffExp = uint256(effExp); + bytes memory out = new bytes(sigK + uEffExp); + for (uint256 i = 0; i < sigK; i++) { + out[i] = digits[i]; + } + for (uint256 i = 0; i < uEffExp; i++) { + out[sigK + i] = "0"; + } + return string.concat(prefix, string(out)); + } - return fullString; + // effExp < 0 + // effExp >= -MAX_NON_SCIENTIFIC_EXPONENT (by the guard above) so + // -effExp is positive and fits uint256. + // forge-lint: disable-next-line(unsafe-typecast) + uint256 absEffExp = uint256(-effExp); + + if (sigK > absEffExp) { + // Decimal point sits inside the significant digits. + uint256 splitAt = sigK - absEffExp; + bytes memory out = new bytes(sigK + 1); + for (uint256 i = 0; i < splitAt; i++) { + out[i] = digits[i]; + } + out[splitAt] = "."; + for (uint256 i = 0; i < absEffExp; i++) { + out[splitAt + 1 + i] = digits[splitAt + i]; + } + return string.concat(prefix, string(out)); + } else { + // "0." + (absEffExp - sigK) leading zeros + significant digits. + uint256 leadingZerosCount = absEffExp - sigK; + bytes memory out = new bytes(2 + leadingZerosCount + sigK); + out[0] = "0"; + out[1] = "."; + for (uint256 i = 0; i < leadingZerosCount; i++) { + out[2 + i] = "0"; + } + for (uint256 i = 0; i < sigK; i++) { + out[2 + leadingZerosCount + i] = digits[i]; + } + return string.concat(prefix, string(out)); + } } } diff --git a/test/src/lib/LibDecimalFloat.exoticFloat.t.sol b/test/src/lib/LibDecimalFloat.exoticFloat.t.sol new file mode 100644 index 0000000..1e43e1a --- /dev/null +++ b/test/src/lib/LibDecimalFloat.exoticFloat.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {Float, LibDecimalFloat} from "src/lib/LibDecimalFloat.sol"; +import {LibFormatDecimalFloat} from "src/lib/format/LibFormatDecimalFloat.sol"; + +/// Probe hand-constructed "exotic" Floats — valid packings with extreme +/// trailing-zero coefficients and negative exponents beyond the formatter's +/// -76 limit — for observable problems across each library operation. The +/// hypothesis under test: does Approach A (trailing-zero stripping in +/// `packLossy`) fix anything that Approach B (formatter rewrite) would not +/// also fix? If every op except non-scientific format works correctly on +/// exotic inputs, A buys nothing beyond formatting. +contract LibDecimalFloatExoticFloatTest is Test { + using LibDecimalFloat for Float; + using LibFormatDecimalFloat for Float; + + // 1e66 × 10^-78 = 1e-12. Coefficient has 66 trailing decimal zeros, + // exponent is beyond the -76 formatter range. Canonical form is (1, -12). + int256 constant EXOTIC_COEF = int256(1e66); + int256 constant EXOTIC_EXP = -78; + int256 constant CANONICAL_COEF = 1; + int256 constant CANONICAL_EXP = -12; + + function exotic() internal pure returns (Float) { + return LibDecimalFloat.packLossless(EXOTIC_COEF, EXOTIC_EXP); + } + + function canonical() internal pure returns (Float) { + return LibDecimalFloat.packLossless(CANONICAL_COEF, CANONICAL_EXP); + } + + /// Exotic and canonical representations compare equal. + function testExoticEq() external pure { + assertTrue(exotic().eq(canonical())); + assertTrue(canonical().eq(exotic())); + } + + /// Ordering works across representations. + function testExoticOrdering() external pure { + Float bigger = LibDecimalFloat.packLossless(2, -12); + assertTrue(exotic().lt(bigger)); + assertTrue(bigger.gt(exotic())); + assertTrue(exotic().lte(canonical())); + assertTrue(exotic().gte(canonical())); + } + + /// Addition of exotics produces a numerically correct result. + function testExoticAdd() external pure { + Float sum = exotic().add(exotic()); + Float expected = LibDecimalFloat.packLossless(2, -12); + assertTrue(sum.eq(expected)); + } + + /// Subtraction of exotic minus canonical same-value = 0. + function testExoticSubSelfToZero() external pure { + Float diff = exotic().sub(canonical()); + assertTrue(diff.eq(LibDecimalFloat.packLossless(0, 0))); + } + + /// Multiplication of exotics. + function testExoticMul() external pure { + Float product = exotic().mul(exotic()); + Float expected = LibDecimalFloat.packLossless(1, -24); + assertTrue(product.eq(expected)); + } + + /// Division of exotics = 1. + function testExoticDivSelf() external pure { + Float q = exotic().div(exotic()); + assertTrue(q.eq(LibDecimalFloat.packLossless(1, 0))); + } + + /// Negation preserves value semantics. + function testExoticMinus() external pure { + Float neg = exotic().minus(); + Float expected = LibDecimalFloat.packLossless(-1, -12); + assertTrue(neg.eq(expected)); + } + + /// Inverse produces numerically correct result. + function testExoticInv() external pure { + Float i = exotic().inv(); + Float expected = LibDecimalFloat.packLossless(1, 12); + assertTrue(i.eq(expected)); + } + + /// Floor of a tiny positive number is 0. + function testExoticFloor() external pure { + Float f = exotic().floor(); + assertTrue(f.eq(LibDecimalFloat.packLossless(0, 0))); + } + + /// Ceiling of a tiny positive number is 1. + function testExoticCeil() external pure { + Float c = exotic().ceil(); + assertTrue(c.eq(LibDecimalFloat.packLossless(1, 0))); + } + + /// Scientific formatting works on exotic Floats. + function testExoticToScientificString() external pure { + string memory s = exotic().toDecimalString(true); + string memory sc = canonical().toDecimalString(true); + assertEq(s, sc, "Scientific format should match canonical"); + } + + /// Non-scientific formatting of the exotic form now succeeds. Prior to + /// the #182 fix this reverted with `UnformatableExponent` because the + /// formatter computed `10^78` as int256. The rewritten formatter uses + /// direct string placement and renders the correct value. + function testExoticToDecimalStringMatchesCanonical() external pure { + assertEq(exotic().toDecimalString(false), canonical().toDecimalString(false)); + assertEq(exotic().toDecimalString(false), "0.000000000001"); + } + + /// Canonical form formats fine in non-sci mode. + function testCanonicalToDecimalStringWorks() external pure { + string memory s = canonical().toDecimalString(false); + assertEq(s, "0.000000000001"); + } +} diff --git a/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol b/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol index b621e00..200b206 100644 --- a/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol +++ b/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol @@ -7,6 +7,7 @@ import {Float, LibDecimalFloat} from "src/lib/LibDecimalFloat.sol"; import {LibFormatDecimalFloat} from "src/lib/format/LibFormatDecimalFloat.sol"; import {LibParseDecimalFloat} from "src/lib/parse/LibParseDecimalFloat.sol"; import {UnformatableExponent} from "src/error/ErrFormat.sol"; +import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; /// @title LibFormatDecimalFloatToDecimalStringTest /// @notice Test contract for verifying the functionality of LibFormatDecimalFloat @@ -256,23 +257,272 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test { return LibFormatDecimalFloat.toDecimalString(float, scientific); } - /// Non-scientific format with exponent > 76 should revert with - /// UnformatableExponent, not panic with arithmetic overflow. - function testFormatNonScientificLargePositiveExponentReverts() external { - // coefficient=1, exponent=77, non-scientific => 1 * 10^77 overflows int256 - Float float = LibDecimalFloat.packLossless(1, 77); - vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, int256(77))); + /// Fuzz: every Float round-trips through scientific format → parse → eq + /// across the full int224 coefficient domain, with exponent bounded to + /// leave headroom for the scientific display exponent. + /// + /// Scientific format renders `coef × 10^exp` as `d.dddd × 10^displayExp` + /// where `displayExp = exp + 75 or 76` (after `maximizeFull` + scale). + /// For exponents within ~76 of `int32.max`, the resulting display exponent + /// exceeds `int32.max`, and the parser rejects it on re-pack. The + /// headroom below avoids that asymmetric range; see separate issue for + /// the format/parse exponent-range mismatch. + function testFormatParseRoundTripScientificFullDomain(int224 coefficient, int32 exponent) external pure { + int256 headroom = 80; + // `bound` to a sub-range of int32 that avoids display-exponent overflow. + // forge-lint: disable-next-line(unsafe-typecast) + exponent = int32(bound(exponent, int256(type(int32).min) + headroom, int256(type(int32).max) - headroom)); + _checkRoundTrip(coefficient, exponent, true); + } + + /// Fuzz: every Float with non-positive exponent round-trips through + /// non-scientific format → parse → eq, across the full int224 coefficient + /// domain and exponent in `[-MAX_NON_SCIENTIFIC_EXPONENT, 0]`. + /// + /// Positive exponents are NOT fuzzed here: the non-scientific formatter + /// emits `coefficient_digits + exponent` trailing zeros, which can exceed + /// the parser's int256 accumulator for modest positive exponents with + /// non-trivial coefficients. That format/parse asymmetry is a separate + /// concern (see issue for tracking); this fuzz covers the negative-exp + /// range where #182-class bugs surface. + function testFormatParseRoundTripNonScientificNegExpFullDomain(int224 coefficient, int32 exponent) external pure { + int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT; + // `bound` returns a value in [-cap, 0]; cap fits int32 so the cast back + // cannot truncate. + // forge-lint: disable-next-line(unsafe-typecast) + exponent = int32(bound(exponent, -cap, 0)); + _checkRoundTrip(coefficient, exponent, false); + } + + function _checkRoundTrip(int256 coefficient, int256 exponent, bool scientific) internal pure { + Float original = LibDecimalFloat.packLossless(coefficient, exponent); + string memory formatted = LibFormatDecimalFloat.toDecimalString(original, scientific); + (bytes4 err, Float parsed) = LibParseDecimalFloat.parseDecimalFloat(formatted); + assertEq(err, bytes4(0), string.concat("Parse error on: ", formatted)); + assertTrue(original.eq(parsed), string.concat("Round trip mismatch on: ", formatted)); + string memory reFormatted = LibFormatDecimalFloat.toDecimalString(parsed, scientific); + assertEq(formatted, reFormatted, "Formatting not canonical"); + } + + /// Fuzz: two representations of the same numeric value format to identical + /// strings. Covers the formatter's canonicalization behavior (trailing + /// zeros in coefficient vs expressed via exponent) without going through + /// the parser. Runs for both scientific and non-scientific modes, + /// including the full non-scientific positive-exp range that the + /// round-trip fuzz cannot cover (see #184). + function testFormatCanonicalAcrossRepresentations(int128 base, uint8 shift, bool scientific) external pure { + vm.assume(base != 0); + int256 baseInt = int256(base); + int256 absBase = baseInt < 0 ? -baseInt : baseInt; + + // Find the largest shift `s` such that `absBase * 10^s` fits int224. + uint256 maxShift = 0; + int256 scale = 1; + while (scale <= type(int224).max / 10 / absBase) { + scale *= 10; + maxShift++; + } + if (maxShift == 0) return; + + uint256 s = bound(shift, 1, maxShift); + int256 scaled = baseInt * int256(10 ** s); + + // Exponent pair chosen so both representations are well inside int32. + // forge-lint: disable-next-line(unsafe-typecast) + int256 baseExp = scientific ? int256(0) : -int256(s); + + Float a = LibDecimalFloat.packLossless(baseInt, baseExp); + // forge-lint: disable-next-line(unsafe-typecast) + Float b = LibDecimalFloat.packLossless(scaled, baseExp - int256(s)); + assertTrue(a.eq(b), "precondition: representations should be equal"); + + string memory formatA = LibFormatDecimalFloat.toDecimalString(a, scientific); + string memory formatB = LibFormatDecimalFloat.toDecimalString(b, scientific); + assertEq(formatA, formatB, "Different representations formatted to different strings"); + } + + /// Non-scientific format succeeds at the exact cap boundary. + function testFormatNonScientificExponentAtPositiveCap() external pure { + int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT; + Float float = LibDecimalFloat.packLossless(1, cap); + string memory s = LibFormatDecimalFloat.toDecimalString(float, false); + // "1" followed by `cap` zeros. + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(bytes(s).length, 1 + uint256(cap)); + assertEq(bytes(s)[0], bytes1("1")); + } + + function testFormatNonScientificExponentAtNegativeCap() external pure { + int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT; + Float float = LibDecimalFloat.packLossless(1, -cap); + string memory s = LibFormatDecimalFloat.toDecimalString(float, false); + // "0." + (cap - 1) leading zeros + "1". + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(bytes(s).length, 2 + uint256(cap - 1) + 1); + assertEq(bytes(s)[0], bytes1("0")); + assertEq(bytes(s)[1], bytes1(".")); + assertEq(bytes(s)[bytes(s).length - 1], bytes1("1")); + } + + /// Non-scientific format on the int224 signed range boundaries. + function testFormatNonScientificInt224MaxCoefficient() external pure { + Float float = LibDecimalFloat.packLossless(int256(type(int224).max), 0); + string memory s = LibFormatDecimalFloat.toDecimalString(float, false); + assertEq(s, Strings.toString(uint256(int256(type(int224).max)))); + } + + function testFormatNonScientificInt224MinCoefficient() external pure { + Float float = LibDecimalFloat.packLossless(int256(type(int224).min), 0); + string memory s = LibFormatDecimalFloat.toDecimalString(float, false); + // int224.min negated fits uint256 (= 2^223). + assertEq(s, string.concat("-", Strings.toString(uint256(-int256(type(int224).min))))); + } + + /// `_toNonScientific` branch coverage: effective exponent ends up exactly + /// at zero after trailing-zero stripping. Exercises the `effExp >= 0` + /// path with `uEffExp = 0`. + function testFormatNonScientificEffExpZero() external pure { + // (100, -2) has trailingZeros=2, sigK=1, effExp=0 → output "1". + checkFormat(100, -2, false, "1"); + // (12340000, -4) → trailingZeros=4, sigK=4, effExp=0 → "1234". + checkFormat(12340000, -4, false, "1234"); + } + + /// `_toNonScientific` branch coverage: sigK == absEffExp, decimal point + /// lands exactly at the start of the significant digits. + function testFormatNonScientificDecimalAtStart() external pure { + // (123, -3) → sigK=3, absEffExp=3, so output = "0." + 0 leading zeros + "123". + checkFormat(123, -3, false, "0.123"); + // Negative variant. + checkFormat(-123, -3, false, "-0.123"); + } + + /// `_toNonScientific` branch coverage: decimal point in the middle of the + /// significant digits. + function testFormatNonScientificDecimalInside() external pure { + // (12345, -2) → sigK=5, absEffExp=2, splitAt=3. Output "123.45". + checkFormat(12345, -2, false, "123.45"); + checkFormat(-12345, -2, false, "-123.45"); + } + + /// `_toNonScientific` branch coverage: leading zeros after "0." (sigK < + /// absEffExp). This is the #182 shape. + function testFormatNonScientificLeadingZeros() external pure { + // (5, -5) → sigK=1, absEffExp=5, leadingZeros=4. Output "0.00005". + checkFormat(5, -5, false, "0.00005"); + checkFormat(-5, -5, false, "-0.00005"); + } + + /// Fuzz: non-scientific format does not revert for any valid Float with + /// `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT`, across the full int224 + /// coefficient range. Covers the positive-exponent sub-range that the + /// parse round-trip fuzz cannot exercise (blocked on #184). + function testFormatNonScientificSucceedsAcrossFullRange(int224 coefficient, int32 exponent) external pure { + int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT; + // `bound` to [-cap, cap]; cap fits int32 so the cast is safe. + // forge-lint: disable-next-line(unsafe-typecast) + exponent = int32(bound(exponent, -cap, cap)); + Float float = LibDecimalFloat.packLossless(coefficient, exponent); + // Should not revert. + string memory s = LibFormatDecimalFloat.toDecimalString(float, false); + // Non-empty output is a minimum sanity guarantee. + assertGt(bytes(s).length, 0); + } + + /// Fuzz: output shape properties for non-scientific format. + /// - Never ends with "." (formatter always strips trailing zeros from the + /// fractional part; a lone "." would indicate a bug). + /// - If the output contains ".", no trailing zeros after it. + /// - If negative, leading character is "-" and remainder has same shape + /// as the positive case. + function testFormatNonScientificOutputShape(int224 coefficient, int32 exponent) external pure { + vm.assume(coefficient != 0); + // int224.min negated exceeds int224.max, so skip it for the + // negation-symmetry check below. + vm.assume(coefficient != type(int224).min); + int256 cap = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT; + // forge-lint: disable-next-line(unsafe-typecast) + exponent = int32(bound(exponent, -cap, cap)); + Float float = LibDecimalFloat.packLossless(coefficient, exponent); + bytes memory s = bytes(LibFormatDecimalFloat.toDecimalString(float, false)); + assertGt(s.length, 0); + + // Never ends with ".". + assertNotEq(uint8(s[s.length - 1]), uint8(bytes1("."))); + + // If a "." is present, no trailing zero after it. + bool hasDot; + for (uint256 i = 0; i < s.length; i++) { + if (s[i] == ".") { + hasDot = true; + break; + } + } + if (hasDot) { + assertNotEq(uint8(s[s.length - 1]), uint8(bytes1("0")), "trailing zero after decimal point"); + } + + // Negative outputs start with "-" and have the same shape as the + // positive counterpart. + if (coefficient < 0) { + assertEq(uint8(s[0]), uint8(bytes1("-"))); + Float positive = LibDecimalFloat.packLossless(-int256(coefficient), exponent); + string memory pos = LibFormatDecimalFloat.toDecimalString(positive, false); + assertEq(string(s), string.concat("-", pos)); + } + } + + /// Constants format as expected in both modes. + function testFormatDecimalFloatConstants() external pure { + assertEq(LibFormatDecimalFloat.toDecimalString(LibDecimalFloat.FLOAT_ZERO, true), "0"); + assertEq(LibFormatDecimalFloat.toDecimalString(LibDecimalFloat.FLOAT_ZERO, false), "0"); + assertEq(LibFormatDecimalFloat.toDecimalString(LibDecimalFloat.FLOAT_ONE, true), "1"); + assertEq(LibFormatDecimalFloat.toDecimalString(LibDecimalFloat.FLOAT_ONE, false), "1"); + assertEq(LibFormatDecimalFloat.toDecimalString(LibDecimalFloat.FLOAT_HALF, true), "5e-1"); + assertEq(LibFormatDecimalFloat.toDecimalString(LibDecimalFloat.FLOAT_HALF, false), "0.5"); + } + + /// Non-scientific format of `(1, 77)` produces "1" followed by 77 zeros. + /// Historically this reverted because the implementation computed + /// `10^exponent` as int256; the rewrite uses direct string placement and + /// handles any `|exponent| <= MAX_NON_SCIENTIFIC_EXPONENT`. + function testFormatNonScientificLargePositiveExponent() external pure { + checkFormat(1, 77, false, "100000000000000000000000000000000000000000000000000000000000000000000000000000"); + } + + /// Non-scientific format of a large coefficient with moderate positive + /// exponent formats without overflow. `int224.max = 2^223 - 1`, which has + /// 68 decimal digits; with exponent 10 the output is 78 characters. + function testFormatNonScientificLargeCoefficientLargeExponent() external pure { + int256 c = int256(type(int224).max); + string memory expected = string.concat(Strings.toStringSigned(c), "0000000000"); + checkFormat(c, 10, false, expected); + } + + /// Non-scientific format reverts when `|exponent|` exceeds the policy cap. + function testFormatNonScientificExponentAboveCapReverts() external { + int256 exp = LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT + 1; + Float float = LibDecimalFloat.packLossless(1, exp); + vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, exp)); this.formatExternal(float, false); } - /// Non-scientific format with large coefficient and moderate positive - /// exponent reverts (overflow in checked multiplication). - function testFormatNonScientificCoefficientOverflowReverts() external { - // Large coefficient with exponent=10 overflows int256 in multiplication. - // Exponent is <= 76 so passes the guard, but checked arithmetic catches - // the overflow as a panic. - Float float = LibDecimalFloat.packLossless(int256(type(int224).max), 10); - vm.expectRevert(stdError.arithmeticError); + function testFormatNonScientificExponentBelowCapReverts() external { + int256 exp = -LibFormatDecimalFloat.MAX_NON_SCIENTIFIC_EXPONENT - 1; + Float float = LibDecimalFloat.packLossless(1, exp); + vm.expectRevert(abi.encodeWithSelector(UnformatableExponent.selector, exp)); this.formatExternal(float, false); } + + /// The exact #182 reproduction: `add` of two near-cancelling values + /// produces a Float with exponent -77. The non-scientific formatter must + /// render this without reverting. + function testFormatNonScientificIssue182Reproduction() external pure { + Float net = LibDecimalFloat.packLossless(-9999999910959448, -17); + Float fill = LibDecimalFloat.packLossless(99999999, -9); + Float result = net.add(fill); + // Numeric value is -1.0959448e-10. + string memory formatted = result.toDecimalString(false); + assertEq(formatted, "-0.00000000010959448"); + } } diff --git a/test/src/lib/implementation/LibDecimalFloatImplementation.eq.t.sol b/test/src/lib/implementation/LibDecimalFloatImplementation.eq.t.sol index 761ec0e..b6eac39 100644 --- a/test/src/lib/implementation/LibDecimalFloatImplementation.eq.t.sol +++ b/test/src/lib/implementation/LibDecimalFloatImplementation.eq.t.sol @@ -109,4 +109,67 @@ contract LibDecimalFloatImplementationEqTest is Test { assertEq(actual, expected); } + + /// Equal values with exponent difference exactly 76 (the last diff that + /// does NOT take the overflow-guard branch in `compareRescale`). Construct + /// the pair as (1, 76) and (10^76, 0): both fit int256 (max ≈ 5.79e76), + /// both represent 10^76. + function testEqDiff76Boundary() external pure { + int256 cSmall = 1; + int256 eSmall = 76; + int256 cBig = int256(1e76); + int256 eBig = 0; + assertTrue(LibDecimalFloatImplementation.eq(cSmall, eSmall, cBig, eBig)); + assertTrue(LibDecimalFloatImplementation.eq(cBig, eBig, cSmall, eSmall)); + } + + /// Equal-value pairs with exponent difference > 76 are not constructible + /// at the int256 interface. The overflow-guard branch in `compareRescale` + /// (`sgt(exponentDiff, 76)`) only fires when the values are already + /// unequal to at least 10^77x apart. Any attempted equal-value pair with + /// diff ≥ 77 requires a coefficient ≥ 10^77, which exceeds int256 max. + /// This test exercises diff = 77 with unequal values and asserts eq is + /// correctly false — the guard's truncation is sound here. + function testEqDiff77OverflowGuardUnequal() external pure { + int256 cA = 1; + int256 eA = 77; + int256 cB = 1; + int256 eB = 0; + // 10^77 != 10^0. Diff = 77 takes the overflow-guard path. + assertFalse(LibDecimalFloatImplementation.eq(cA, eA, cB, eB)); + assertFalse(LibDecimalFloatImplementation.eq(cB, eB, cA, eA)); + } + + /// Fuzz: for any `(base, shift)` where `base × 10^shift` fits int256, + /// `(base, 0)` and `(base × 10^shift, -shift)` represent the same value + /// and must compare equal. Covers the range of constructible equal-value + /// pairs. + function testEqSameValueDifferentRepresentations(int256 base, uint8 shift) external pure { + vm.assume(base != 0); + vm.assume(base != type(int256).min); + int256 absBase = base < 0 ? -base : base; + + // Largest `shift` such that `absBase * 10^shift` still fits int256. + uint256 maxShift = 0; + int256 scale = 1; + while (scale <= type(int256).max / 10 / absBase) { + scale *= 10; + maxShift++; + } + if (maxShift == 0) { + return; + } + uint256 s = bound(shift, 1, maxShift); + int256 scaled = base * int256(10 ** s); + + // `s` is bounded to `maxShift` which is at most ~76 (the loop above + // stops when `10^s * absBase` would exceed int256 max). The cast to + // int256 is safe. + // forge-lint: disable-next-line(unsafe-typecast) + int256 negS = -int256(s); + bool result = LibDecimalFloatImplementation.eq(base, 0, scaled, negS); + assertTrue(result, "eq returned false for equivalent representations"); + bool reversed = LibDecimalFloatImplementation.eq(scaled, negS, base, 0); + assertTrue(reversed, "eq returned false for equivalent representations (reversed)"); + } }