diff --git a/.gitignore b/.gitignore index fcb4b97..74b1b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ target temp dist node_modules -.env \ No newline at end of file +.env +.fixes \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b8a9fbc..38fdd14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,14 @@ npm test # TypeScript type check + vitest (tests in test_js/) 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): +```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. + ## Architecture ### Solidity Layer (`src/`) @@ -51,6 +59,10 @@ nix develop # Enter dev shell with all tooling - **`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). + ### 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). diff --git a/audit/2026-03-10-01/pass0/process.md b/audit/2026-03-10-01/pass0/process.md new file mode 100644 index 0000000..f35856a --- /dev/null +++ b/audit/2026-03-10-01/pass0/process.md @@ -0,0 +1,39 @@ +# Audit Pass 0: Process Review + +**Date:** 2026-03-10 +**Files reviewed:** CLAUDE.md, README.md, REUSE.toml, foundry.toml + +## Evidence of Reading + +### CLAUDE.md +- Sections: Project Overview, Build Commands (Solidity, Rust, JavaScript/WASM, Nix), Architecture (Solidity Layer, Rust Layer, JavaScript Layer, Dependencies), Key Design Details, License +- Build commands: forge build/test, cargo build/test, npm install/build/test, nix develop +- Architecture references: LibDecimalFloat.sol, implementation/, parse/, format/, table/, DecimalFloat.sol, error/, lib.rs, js_api.rs, evm.rs, error.rs, build.js, test_js/, dist/ +- Dependencies listed: forge-std, rain.string, rain.datacontract, rain.math.fixedpoint, rain.deploy, rain.sol.codegen + +### README.md +- Sections: Context, Rounding vs. erroring vs. approximating (Rounding direction, Approach to preserving precision, Exponent underflow, Packing, Fixed decimal conversions, Exponent overflow, Other overflows, Uncalculable values, Unimplemented math, Parsing/formatting issues, Lossy conversions, Approximations) +- External reference: rainlanguage/rain.math.float#88 + +### REUSE.toml +- Single annotations block covering config/metadata files +- SPDX: LicenseRef-DCL-1.0 + +### foundry.toml +- Settings: solc 0.8.25, optimizer 1000000 runs, evm cancun, fuzz 5096 runs +- RPC endpoints: arbitrum, base, base_sepolia, flare, polygon +- Etherscan keys for same 5 networks + +## Findings + +### A01-1: CLAUDE.md omits `script/` directory from architecture (LOW) + +The Architecture section documents `src/`, `crates/float/`, `scripts/`, `test_js/`, and `dist/` but does not mention `script/` which contains `Deploy.sol` and `BuildPointers.sol`. These are operationally critical — Deploy.sol is the production deployment script, and BuildPointers.sol generates committed source code (`src/generated/LogTables.pointers.sol`). A future session could be unaware of how deployment or pointer regeneration works. + +### A01-2: CLAUDE.md omits deployment workflow documentation (LOW) + +There is no mention of how to deploy contracts, what networks are supported, what environment variables are needed (`DEPLOYMENT_KEY`, `DEPLOYMENT_SUITE`, `CI_DEPLOY_*_RPC_URL`), or where deterministic addresses are defined (`LibDecimalFloatDeploy.sol`). A future session asked to deploy would need to reverse-engineer the workflow from `script/Deploy.sol` and the CI config. + +### A01-3: CLAUDE.md omits test directory structure (INFO) + +The architecture section doesn't describe the `test/` directory or its organization. Test files mirror the source tree under `test/src/` but this convention is undocumented. diff --git a/audit/2026-03-10-01/pass1/DecimalFloat.md b/audit/2026-03-10-01/pass1/DecimalFloat.md new file mode 100644 index 0000000..d9e2c5d --- /dev/null +++ b/audit/2026-03-10-01/pass1/DecimalFloat.md @@ -0,0 +1,113 @@ +# Audit Pass 1 (Security) -- `src/concrete/DecimalFloat.sol` + +**Auditor:** A01 +**Date:** 2026-03-10 +**File:** `src/concrete/DecimalFloat.sol` (320 lines) + +--- + +## Evidence of Reading + +### Contract + +- **`DecimalFloat`** (line 9) -- concrete contract that exposes `LibDecimalFloat`, `LibFormatDecimalFloat`, and `LibParseDecimalFloat` library functions as external/public methods for off-chain (Rust/revm) interop. + +### Imports (lines 5-7) + +| Import | Source | +|--------|--------| +| `LibDecimalFloat`, `Float` | `../lib/LibDecimalFloat.sol` | +| `LibFormatDecimalFloat` | `../lib/format/LibFormatDecimalFloat.sol` | +| `LibParseDecimalFloat` | `../lib/parse/LibParseDecimalFloat.sol` | + +### Constants (lines 12-20) + +| Name | Line | Description | +|------|------|-------------| +| `FORMAT_DEFAULT_SCIENTIFIC_MIN` | 14 | `1e-4`, default lower bound for scientific formatting | +| `FORMAT_DEFAULT_SCIENTIFIC_MAX` | 19 | `1e9`, default upper bound for scientific formatting | + +### Functions + +| Function | Line | Visibility | Mutability | Delegates to | +|----------|------|------------|------------|-------------| +| `maxPositiveValue()` | 24 | external | pure | `LibDecimalFloat.FLOAT_MAX_POSITIVE_VALUE` | +| `minPositiveValue()` | 30 | external | pure | `LibDecimalFloat.FLOAT_MIN_POSITIVE_VALUE` | +| `maxNegativeValue()` | 36 | external | pure | `LibDecimalFloat.FLOAT_MAX_NEGATIVE_VALUE` | +| `minNegativeValue()` | 42 | external | pure | `LibDecimalFloat.FLOAT_MIN_NEGATIVE_VALUE` | +| `zero()` | 48 | external | pure | `LibDecimalFloat.FLOAT_ZERO` | +| `e()` | 54 | external | pure | `LibDecimalFloat.FLOAT_E` | +| `parse(string)` | 64 | external | pure | `LibParseDecimalFloat.parseDecimalFloat` | +| `format(Float, Float, Float)` | 78 | public | pure | `LibFormatDecimalFloat.toDecimalString` | +| `format(Float, bool)` | 89 | external | pure | `LibFormatDecimalFloat.toDecimalString` | +| `format(Float)` | 97 | external | pure | calls `format(Float, Float, Float)` | +| `add(Float, Float)` | 105 | external | pure | `a.add(b)` | +| `sub(Float, Float)` | 113 | external | pure | `a.sub(b)` | +| `minus(Float)` | 120 | external | pure | `a.minus()` | +| `abs(Float)` | 127 | external | pure | `a.abs()` | +| `mul(Float, Float)` | 135 | external | pure | `a.mul(b)` | +| `div(Float, Float)` | 143 | external | pure | `a.div(b)` | +| `inv(Float)` | 150 | external | pure | `a.inv()` | +| `eq(Float, Float)` | 158 | external | pure | `a.eq(b)` | +| `lt(Float, Float)` | 166 | external | pure | `a.lt(b)` | +| `gt(Float, Float)` | 175 | external | pure | `a.gt(b)` | +| `lte(Float, Float)` | 184 | external | pure | `a.lte(b)` | +| `gte(Float, Float)` | 193 | external | pure | `a.gte(b)` | +| `integer(Float)` | 200 | external | pure | `a.integer()` | +| `frac(Float)` | 207 | external | pure | `a.frac()` | +| `floor(Float)` | 214 | external | pure | `a.floor()` | +| `ceil(Float)` | 221 | external | pure | `a.ceil()` | +| `pow10(Float)` | 228 | external | view | `a.pow10(LOG_TABLES_ADDRESS)` | +| `log10(Float)` | 235 | external | view | `a.log10(LOG_TABLES_ADDRESS)` | +| `pow(Float, Float)` | 243 | external | view | `a.pow(b, LOG_TABLES_ADDRESS)` | +| `sqrt(Float)` | 250 | external | view | `a.sqrt(LOG_TABLES_ADDRESS)` | +| `min(Float, Float)` | 258 | external | pure | `a.min(b)` | +| `max(Float, Float)` | 266 | external | pure | `a.max(b)` | +| `isZero(Float)` | 273 | external | pure | `a.isZero()` | +| `fromFixedDecimalLossless(uint256, uint8)` | 284 | external | pure | `LibDecimalFloat.fromFixedDecimalLosslessPacked` | +| `toFixedDecimalLossless(Float, uint8)` | 293 | external | pure | `LibDecimalFloat.toFixedDecimalLossless` | +| `fromFixedDecimalLossy(uint256, uint8)` | 305 | external | pure | `LibDecimalFloat.fromFixedDecimalLossyPacked` | +| `toFixedDecimalLossy(Float, uint8)` | 316 | external | pure | `LibDecimalFloat.toFixedDecimalLossy` | + +### Types / Errors / Assembly + +- No custom types or errors defined in this file. +- No assembly blocks. +- No state-modifying storage writes. +- No reentrancy vectors (all functions are `pure` or `view` with no external calls except reads from `LOG_TABLES_ADDRESS`). + +--- + +## Security Findings + +### A01-3: `require` uses string revert message instead of custom error [LOW] + +**Location:** `src/concrete/DecimalFloat.sol`, line 79 + +**Description:** + +```solidity +require(scientificMin.lt(scientificMax), "scientificMin must be less than scientificMax"); +``` + +The entire codebase consistently uses custom errors (defined in `src/error/ErrDecimalFloat.sol` and `src/error/ErrFormat.sol`). This is the only location in the `src/` tree that uses a string-based `require`. String-based reverts: + +1. Are more expensive at deployment and at revert time (ABI-encodes the string as `Error(string)`). +2. Are harder to catch and decode programmatically on-chain compared to a 4-byte custom error selector. +3. Break the project's own convention of using custom errors everywhere. + +Because this contract is primarily used as an off-chain interop target (Rust/revm calls), the practical on-chain impact is low. However, the inconsistency is a code-quality concern and a minor gas inefficiency. + +**Severity:** LOW + +**Recommendation:** Define a custom error (e.g., `ScientificMinNotLessThanMax(Float scientificMin, Float scientificMax)`) in the appropriate error file and replace the `require` with a conditional revert. + +--- + +### Summary + +| ID | Severity | Title | +|----|----------|-------| +| A01-3 | LOW | `require` uses string revert message instead of custom error | + +No MEDIUM, HIGH, or CRITICAL findings. The contract is a thin delegation layer with no storage, no assembly, no reentrancy surface, and no arithmetic of its own. All logic is delegated to the underlying library functions. diff --git a/audit/2026-03-10-01/pass1/LibDecimalFloat.md b/audit/2026-03-10-01/pass1/LibDecimalFloat.md new file mode 100644 index 0000000..c56a371 --- /dev/null +++ b/audit/2026-03-10-01/pass1/LibDecimalFloat.md @@ -0,0 +1,45 @@ +# Audit Pass 1: Security — `src/lib/LibDecimalFloat.sol` (A06) + +**Date:** 2026-03-10 | **File:** 796 lines + +## Evidence of Reading + +**Library:** `LibDecimalFloat` (line 44) — Main public API library. Defines `Float` UDT wrapping `bytes32`. + +**Type:** `Float` (line 16) — Upper 32 bits: int32 exponent, lower 224 bits: int224 coefficient. + +**Constants (10):** `LOG_TABLES_ADDRESS` (L50), `FLOAT_ZERO` (L53), `FLOAT_ONE` (L56), `FLOAT_HALF` (L60), `FLOAT_TWO` (L64), `FLOAT_MAX_POSITIVE_VALUE` (L68), `FLOAT_MIN_POSITIVE_VALUE` (L73), `FLOAT_MAX_NEGATIVE_VALUE` (L79), `FLOAT_MIN_NEGATIVE_VALUE` (L85), `FLOAT_E` (L91). + +**Functions (32):** +- Conversion: `fromFixedDecimalLossy` (L104), `fromFixedDecimalLossyPacked` (L132), `fromFixedDecimalLossless` (L144), `fromFixedDecimalLosslessPacked` (L158), `toFixedDecimalLossy` (L176, L254), `toFixedDecimalLossless` (L265, L286) +- Packing: `packLossy` (L299), `packLossless` (L358), `unpack` (L373) +- Arithmetic: `add` (L388), `sub` (L405), `minus` (L421), `abs` (L440), `mul` (L474), `div` (L491), `inv` (L507) +- Comparison: `eq` (L520), `lt` (L531), `gt` (L545), `lte` (L557), `gte` (L569) +- Rounding: `integer` (L582), `frac` (L593), `floor` (L603), `ceil` (L621) +- Transcendental: `pow10` (L652), `log10` (L668), `pow` (L690), `sqrt` (L764) +- Utility: `min` (L773), `max` (L781), `isZero` (L788) + +**Assembly blocks (3):** `packLossy` (L347-349), `unpack` (L375-378), `isZero` (L790-793) + +## Findings + +No LOW+ security findings. + +### A06-1 [INFO]: Silent precision loss in arithmetic operations without caller notification + +All packed arithmetic functions discard the `lossless` bool from `packLossy`. By design and documented. The ~67 decimal digits of int224 precision makes packing-induced loss extremely rare. + +### A06-2 [INFO]: `pow` exponentiation-by-squaring loop may consume excessive gas for large integer exponents + +The loop iterates O(log2(exponentBInteger)) times with no explicit upper bound. In practice, intermediate overflows cause reverts before gas exhaustion. + +## Verified as Correct + +- All 3 assembly blocks: pure stack operations, correct signextend/sar usage, no memory safety issues +- Pack/unpack round-trip for edge cases (int224.min, int224.max, negative exponents) +- All 10 constants encode correct values +- `compareRescale` edge cases (zero, different signs, extreme exponent diffs) handled correctly +- `toFixedDecimalLossy` overflow checks correct +- `packLossy` truncation loop and exponent overflow handling correct +- `pow` function flow: negative b recursion, zero base, identity case all correct +- `floor`/`ceil` sign-aware rounding logic correct diff --git a/audit/2026-03-10-01/pass1/LibDecimalFloatDeploy.md b/audit/2026-03-10-01/pass1/LibDecimalFloatDeploy.md new file mode 100644 index 0000000..383fd47 --- /dev/null +++ b/audit/2026-03-10-01/pass1/LibDecimalFloatDeploy.md @@ -0,0 +1,94 @@ +# Audit Pass 1 (Security) -- LibDecimalFloatDeploy.sol + +**Agent:** A07 +**File:** `src/lib/deploy/LibDecimalFloatDeploy.sol` +**Date:** 2026-03-10 + +--- + +## Evidence of Thorough Reading + +### Library Name +- `LibDecimalFloatDeploy` (line 19) + +### Functions +| Function | Line | Visibility | Mutability | +|---|---|---|---| +| `combinedTables()` | 42 | `internal` | `pure` | + +### Constants +| Name | Line | Type | Value | +|---|---|---|---| +| `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` | 23 | `address` | `0xc51a14251b0dcF0ae24A96b7153991378938f5F5` | +| `LOG_TABLES_DATA_CONTRACT_HASH` | 27 | `bytes32` | `0x2573004ac3a9ee7fc8d73654d76386f1b6b99e34cdf86a689c4691e47143420f` | +| `ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` | 32 | `address` | `0x12A66eFbE556e38308A17e34cC86f21DcA1CDB73` | +| `DECIMAL_FLOAT_CONTRACT_HASH` | 36 | `bytes32` | `0x705cdef2ed9538557152f86cd0988c748e0bd647a49df00b3e4f100c3544a583` | + +### Types / Errors Defined +None defined in this file. `WriteError` is imported but unused. + +### Imports +| Import | Source | Used in File Body? | +|---|---|---| +| `LOG_TABLES` | `LogTables.pointers.sol` | Yes (line 44) | +| `LOG_TABLES_SMALL` | `LogTables.pointers.sol` | Yes (line 45) | +| `LOG_TABLES_SMALL_ALT` | `LogTables.pointers.sol` | Yes (line 46) | +| `ANTI_LOG_TABLES` | `LogTables.pointers.sol` | Yes (line 47) | +| `ANTI_LOG_TABLES_SMALL` | `LogTables.pointers.sol` | Yes (line 48) | +| `LibDataContract`, `DataContractMemoryContainer` | `rain.datacontract` | No | +| `LibBytes` | `rain.solmem` | No | +| `LibMemCpy`, `Pointer` | `rain.solmem` | No | +| `DecimalFloat` | `concrete/DecimalFloat.sol` | No (re-exported for consumers) | +| `LOG_TABLE_DISAMBIGUATOR` | `LibLogTable.sol` | Yes (line 49) | +| `WriteError` | `ErrDecimalFloat.sol` | No | + +--- + +## Security Analysis + +### Hardcoded Addresses and Code Hashes + +The file defines two hardcoded addresses and their expected codehashes for deterministic deployment via the Zoltu proxy. These constants are consumed by: + +1. **`script/Deploy.sol`** -- The deployment script passes these constants to `LibRainDeploy.deployAndBroadcast`, which verifies the deployed codehash matches before proceeding. +2. **`test/src/lib/deploy/LibDecimalFloatDeploy.t.sol`** -- Tests verify that deploying via Zoltu produces the expected address and codehash. +3. **`test/src/lib/deploy/LibDecimalFloatDeployProd.t.sol`** -- Production fork tests verify that all supported networks (Arbitrum, Base, Base Sepolia, Flare, Polygon) have the contracts deployed at the expected addresses with correct codehashes. +4. **`test/abstract/LogTest.sol`** -- Test helper deploys combined tables and verifies codehash. + +The addresses are deterministic (derived from Zoltu's deployment proxy with known creation code), so they are correctly constant across EVM-compatible chains. The codehash provides a second layer of verification that the deployed bytecode is exactly as expected. + +The runtime library (`LibDecimalFloat.sol`) does NOT hardcode the tables address. Instead, `log10`, `pow10`, and `pow` accept `tablesDataContract` as an explicit parameter, so there is no risk of silently using the wrong tables at runtime -- the caller is responsible for providing the correct address. + +### Data Integrity of Combined Tables + +The `combinedTables()` function uses `abi.encodePacked` to concatenate five table byte constants and the `LOG_TABLE_DISAMBIGUATOR`. The use of `abi.encodePacked` on fixed-size `bytes` constants is safe here because there is no dynamic-length ambiguity -- each constant is a fixed hex literal. The disambiguator (`keccak256("LOG_TABLE_DISAMBIGUATOR_1")`) ensures the creation code is unique even if table contents happen to collide with other data contract deployments. + +### Deployment to Wrong Address + +The deployment flow in `Deploy.sol` passes both the expected address and expected codehash to `LibRainDeploy.deployAndBroadcast`. The Zoltu deterministic deployment proxy guarantees address determinism. If the creation code changes (e.g., table data modified), the address and codehash would both change, and the deployment script would catch the mismatch. This is well-guarded. + +--- + +## Findings + +### A07-1 [INFO] Unused Imports + +**Lines:** 12-15, 17 + +Several imports are present in the file but never used in its body: +- `LibDataContract`, `DataContractMemoryContainer` (line 12) +- `LibBytes` (line 13) +- `LibMemCpy`, `Pointer` (line 14) +- `WriteError` (line 17) + +`DecimalFloat` (line 15) is imported but only used in NatSpec comments; however, it serves the purpose of being re-exported to downstream consumers (e.g., `test/src/lib/deploy/LibDecimalFloatDeploy.t.sol` imports both `LibDecimalFloatDeploy` and `DecimalFloat` from this file's path). + +**Severity:** Informational. Unused imports have no security impact but increase compile-time noise and may confuse auditors/reviewers. The Solidity compiler warns about these. + +**Recommendation:** Remove `LibDataContract`, `DataContractMemoryContainer`, `LibBytes`, `LibMemCpy`, `Pointer`, and `WriteError` imports. Keep `DecimalFloat` only if it is intentionally re-exported. + +--- + +## Summary + +No security findings at LOW or above. The file is a well-structured deployment configuration library. The hardcoded addresses and codehashes are properly verified through both deterministic deployment mechanics and test coverage across multiple networks. The runtime library does not rely on these constants, requiring callers to explicitly provide the tables address. diff --git a/audit/2026-03-10-01/pass1/LibDecimalFloatImplementation.md b/audit/2026-03-10-01/pass1/LibDecimalFloatImplementation.md new file mode 100644 index 0000000..4ff8fd4 --- /dev/null +++ b/audit/2026-03-10-01/pass1/LibDecimalFloatImplementation.md @@ -0,0 +1,230 @@ +# Audit Pass 1 (Security) - LibDecimalFloatImplementation.sol + +**Auditor:** A09 +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol` +**Lines:** 1307 + +## Evidence of Thorough Reading + +### Library +- `LibDecimalFloatImplementation` (line 52) + +### Constants (file-level) +| Name | Line | Type | +|------|------|------| +| `ADD_MAX_EXPONENT_DIFF` | 24 | `uint256` (76) | +| `EXPONENT_MAX` | 29 | `int256` (type(int256).max / 2) | +| `EXPONENT_MIN` | 34 | `int256` (-EXPONENT_MAX) | +| `MAXIMIZED_ZERO_SIGNED_COEFFICIENT` | 37 | `int256` (0) | +| `MAXIMIZED_ZERO_EXPONENT` | 40 | `int256` (0) | +| `LOG10_Y_EXPONENT` | 44 | `int256` (-76) | + +### Error (file-level) +| Name | Line | +|------|------| +| `WithTargetExponentOverflow` | 21 | + +### Imported Errors +`ExponentOverflow`, `Log10Negative`, `Log10Zero`, `MulDivOverflow`, `DivisionByZero`, `MaximizeOverflow` (lines 6-11) + +### Imported Constants +`LOG_TABLE_SIZE_BYTES`, `LOG_TABLE_SIZE_BASE`, `LOG_MANTISSA_LAST_INDEX`, `ANTILOG_IDX_LAST_INDEX` (lines 14-17) + +### Functions +| Function | Line | Visibility | Mutability | +|----------|------|------------|------------| +| `minus` | 71 | internal | pure | +| `absUnsignedSignedCoefficient` | 89 | internal | pure | +| `unabsUnsignedMulOrDivLossy` | 116 | internal | pure | +| `mul` | 160 | internal | pure | +| `div` | 272 | internal | pure | +| `mul512` | 466 | internal | pure | +| `mulDiv` | 479 | internal | pure | +| `add` | 610 | internal | pure | +| `sub` | 703 | internal | pure | +| `eq` | 724 | internal | pure | +| `inv` | 736 | internal | pure | +| `lookupLogTableVal` | 744 | internal | view | +| `log10` | 783 | internal | view | +| `pow10` | 902 | internal | view | +| `maximize` | 957 | internal | pure | +| `maximizeFull` | 1011 | internal | pure | +| `compareRescale` | 1047 | internal | pure | +| `withTargetExponent` | 1127 | internal | pure | +| `intFrac` | 1169 | internal | pure | +| `mantissa4` | 1197 | internal | pure | +| `lookupAntilogTableY1Y2` | 1227 | internal | view | +| `unitLinearInterpolation` | 1267 | internal | pure | + +### Assembly Blocks +1. `mul` line 166-168: zero check +2. `mul512` lines 470-474: 512-bit multiply (CRT-based) +3. `mulDiv` lines 500-507: remainder subtraction +4. `mulDiv` lines 516-529: power-of-two factoring +5. `add` lines 618-620: zero check +6. `add` lines 673-677: overflow detection +7. `compareRescale` lines 1060-1071: no-op rescale check +8. `compareRescale` lines 1092-1094: overflow check +9. `lookupLogTableVal` lines 748-769: table read via extcodecopy +10. `lookupAntilogTableY1Y2` lines 1237-1253: table read via extcodecopy (with internal Yul function) + +--- + +## Findings + +### A09-1: `unabsUnsignedMulOrDivLossy` missing exponent overflow check on `exponent + 1` (LOW) + +**Location:** Lines 132, 144 + +**Description:** +When `signedCoefficientAbs` exceeds `type(int256).max` and is not the exact `type(int256).min` value, the function divides by 10 and increments `exponent` by 1. These lines are in checked arithmetic (not within an `unchecked` block), so if `exponent == type(int256).max`, the `exponent + 1` will revert with a raw `Panic(0x11)` instead of the project's custom `ExponentOverflow` error. + +This contrasts with the pattern in `minus` (line 75) and `add` (line 680) where `exponent == type(int256).max` is explicitly checked before incrementing. + +**Impact:** Low. Through the packed Float API (32-bit exponents from `unpack`), exponents cannot reach `type(int256).max`. The `mul` and `div` functions call this with computed exponents that include additive adjustments, but these stay far below `type(int256).max` for any reasonable input. If somehow reached, the revert is a `Panic(0x11)` rather than a descriptive error, which complicates off-chain debugging and error handling but does not cause incorrect results. + +**Recommendation:** Add an explicit exponent overflow check (or document the invariant that callers guarantee bounded exponents) at lines 132 and 144, consistent with the pattern used elsewhere. + +--- + +### A09-2: `mul` adjustExponent comment `[0, 76]` is incorrect; actual maximum is 77 (INFORMATIONAL) + +**Location:** Line 208 + +**Description:** +The comment `// adjustExponent [0, 76]` is inaccurate. The maximum 512-bit product occurs when both absolute coefficients are `2^255` (the abs of `type(int256).min`). The high word `prod1` would be `2^254 ~ 2.89e76`. Tracing through the binary divisions: +- `2.89e76 / 1e37 ~ 2.89e39` (adjust = 37) +- `2.89e39 / 1e18 ~ 2.89e21` (adjust = 55) +- `2.89e21 / 1e9 ~ 2.89e12` (adjust = 64) +- `2.89e12 / 1e4 ~ 2.89e8` (adjust = 68) +- The while loop adds 9 more iterations (adjust = 77) + +The actual range is `[0, 77]`. This does not cause a functional issue because `int256(77)` is valid and `10^77 < 2^256`. However, the inaccurate comment could mislead future maintainers. + +**Impact:** None. Pure documentation issue. + +--- + +### A09-3: `EXPONENT_MAX` and `EXPONENT_MIN` constants defined but never enforced in production code (INFORMATIONAL) + +**Location:** Lines 29, 34 + +**Description:** +`EXPONENT_MAX` (`type(int256).max / 2`) and `EXPONENT_MIN` (`-EXPONENT_MAX`) are defined and used in test fuzzing bounds but are never checked or enforced in any production function. The implementation functions accept full `int256` exponents. + +This is safe in practice because the public API uses packed Float with 32-bit signed exponents (range `[-2^31, 2^31-1]`), and `maximize` adjusts exponents by at most ~77. The resulting exponent range is well within `int256` without overflow risk. + +**Impact:** None for the current call graph. These constants serve as documentation of the intended safe range but are enforced only by convention (and test bounding), not by code. + +--- + +### A09-4: `div` unchecked exponent subtraction at line 435 can wrap for extreme `int256` exponents (INFORMATIONAL) + +**Location:** Line 435 + +**Description:** +The expression `exponent = exponentA + underflowExponentBy - exponentB` is in an `unchecked` block. The underflow detection at lines 430-433 only guards the case where `exponentA < 0 && exponentB > 0`. When `exponentA > 0 && exponentB < 0` (which makes the subtraction even larger), the underflow check is skipped because the comment says "This is the only case that can underflow." While true for subtraction underflow, the converse case (large positive difference wrapping) is also possible with extreme `int256` exponents. + +Example: `exponentA = type(int256).max - 76` and `exponentB = type(int256).min + 76` would produce a wrapping subtraction. + +**Impact:** None via the public API. The packed Float type constrains exponents to 32-bit signed values. After `maximize` adjustments (~77 digits), the effective exponent range is approximately `[-2^31 - 77, 2^31 + 77]`, making this wrapping unreachable. This is an internal-only concern that would only matter if these implementation functions were called with arbitrary `int256` exponents from new code paths. + +--- + +### A09-5: `mul512` and `mulDiv` correctness verification (NO FINDING) + +**Location:** Lines 466-555 + +**Description:** +These functions are standard implementations of the well-known Remco Bloemen 512-bit multiply and mulDiv algorithm, widely audited in OpenZeppelin, PRB Math, and Solady. The implementation was verified line-by-line against OpenZeppelin Math v5.x: + +- `mul512` (lines 470-474): CRT-based 512-bit multiplication. Correct. +- `mulDiv` remainder subtraction (lines 500-507): Correct carry handling. +- `mulDiv` power-of-two factoring (lines 516-529): Correct lpotdod computation and division. +- `mulDiv` Newton-Raphson modular inverse (lines 538-547): Six iterations for 256-bit precision. Correct. +- `mulDiv` overflow guard (line 490): `prod1 >= denominator` check. Correct. + +No issues found. + +--- + +### A09-6: `add` overflow detection assembly is correct (NO FINDING) + +**Location:** Lines 673-677 + +**Description:** +The overflow detection for signed addition uses the standard pattern: overflow occurs when both operands have the same sign but the result has a different sign. The assembly: +``` +let sameSignAB := iszero(shr(0xff, xor(signedCoefficientA, signedCoefficientB))) +let sameSignAC := iszero(shr(0xff, xor(signedCoefficientA, c))) +didOverflow := and(sameSignAB, iszero(sameSignAC)) +``` +Correctly checks: (A and B have same sign) AND (A and result have different sign). Verified correct. + +The recovery path (dividing both by 10 and incrementing exponent) is also sound: after `maximizeFull`, coefficients are in `[1e75, type(int256).max]` (positive) or `[-type(int256).max, -1e75]` (negative). Division by 10 yields values in approximately `[1e74, 1.16e76]`, and the sum of two such values fits in `int256`. + +--- + +### A09-7: `compareRescale` handles unchecked subtraction wrapping correctly (NO FINDING) + +**Location:** Lines 1090-1094 + +**Description:** +The unchecked `exponentDiff = exponentA - exponentB` at line 1090 can wrap for extreme exponent values. However, the subsequent assembly check `slt(exponentDiff, 0)` catches any wrapping (since `exponentA >= exponentB` after the swap, a negative result always indicates wrapping). The `sgt(exponentDiff, 76)` branch handles exponents that are too far apart. Both cases return a comparison result that correctly reflects one operand dominating the other. + +--- + +### A09-8: `lookupLogTableVal` and `lookupAntilogTableY1Y2` memory safety (NO FINDING) + +**Location:** Lines 748-769, 1237-1253 + +**Description:** +Both functions use scratch space (addresses 0x00-0x3f) for `mstore`/`mload` operations around `extcodecopy`. The `memory-safe` annotation is correct: +- `mstore(0, 0)` clears 32 bytes at address 0. +- `extcodecopy` writes 1-2 bytes at positions 30 or 31. +- `mload(0)` reads the value from the low bits. + +No memory corruption or free memory pointer issues. + +The antilog table lookup does not mask with `0x7FFF` unlike the log table lookup. This is correct because the antilog table values (1000-9977) never have the 0x8000 flag bit set. The log table uses the flag bit to select between alternate small tables, while the antilog table uses a fixed offset for its small table. + +--- + +### A09-9: `maximize` loop termination and precision (NO FINDING) + +**Location:** Lines 957-1003 + +**Description:** +The `maximize` function uses a combination of binary scaling steps (1e38, 1e19, 1e10) and a loop (1e2 steps) to efficiently maximize the coefficient. Each step includes an exponent bounds check (`exponent >= type(int256).min + N`) to prevent underflow. + +The final "try multiply by 10" step (lines 995-998) uses a round-trip check (`signedCoefficient == trySignedCoefficient / 10`) to detect overflow. For `type(int256).min`, `type(int256).min * 10` wraps in unchecked arithmetic, but the round-trip check catches this because `wrapped / 10 != type(int256).min`. + +The loop at line 981 (`while signedCoefficient / 1e74 == 0`) terminates because each iteration multiplies by 100 and decrements exponent by 2. After at most 37 iterations, the coefficient exceeds 1e74 or the exponent bounds are reached. + +--- + +### A09-10: `minus` handles `type(int256).min` correctly (NO FINDING) + +**Location:** Lines 71-83 + +**Description:** +The function correctly identifies that `-type(int256).min` overflows in two's complement. It handles this by dividing by 10 first (yielding approximately `-5.79e75`) and incrementing the exponent. The lossy division is inherent to the representation change and preserves the numeric value to the maximum precision available. The exponent overflow check at line 75 prevents silent wrapping. + +--- + +## Summary + +| ID | Severity | Title | +|----|----------|-------| +| A09-1 | LOW | `unabsUnsignedMulOrDivLossy` missing exponent overflow check on `exponent + 1` | +| A09-2 | INFORMATIONAL | `mul` adjustExponent comment incorrect; actual max is 77 | +| A09-3 | INFORMATIONAL | `EXPONENT_MAX`/`EXPONENT_MIN` defined but never enforced | +| A09-4 | INFORMATIONAL | `div` unchecked exponent subtraction can wrap for extreme int256 exponents | +| A09-5 | NO FINDING | `mul512` and `mulDiv` verified correct | +| A09-6 | NO FINDING | `add` overflow detection verified correct | +| A09-7 | NO FINDING | `compareRescale` unchecked wrapping handled correctly | +| A09-8 | NO FINDING | Table lookup memory safety verified | +| A09-9 | NO FINDING | `maximize` loop termination and precision verified | +| A09-10 | NO FINDING | `minus` handles `type(int256).min` correctly | + +Overall assessment: The implementation is well-constructed with careful attention to edge cases in 256-bit and 512-bit arithmetic. The `mulDiv` implementation matches well-audited reference implementations. The primary finding (A09-1) is low severity and relates to error ergonomics rather than correctness. The informational findings relate to documentation accuracy and theoretical edge cases that are unreachable through the public API. diff --git a/audit/2026-03-10-01/pass1/LibFormatDecimalFloat.md b/audit/2026-03-10-01/pass1/LibFormatDecimalFloat.md new file mode 100644 index 0000000..5eef723 --- /dev/null +++ b/audit/2026-03-10-01/pass1/LibFormatDecimalFloat.md @@ -0,0 +1,27 @@ +# Audit Pass 1: Security — `src/lib/format/LibFormatDecimalFloat.sol` (A08) + +## Evidence of Thorough Reading + +**Library:** `LibFormatDecimalFloat` + +**Imports (lines 5-8):** +- `LibDecimalFloat`, `Float` from `../LibDecimalFloat.sol` +- `LibDecimalFloatImplementation` from `../../lib/implementation/LibDecimalFloatImplementation.sol` +- `Strings` from `openzeppelin-contracts/contracts/utils/Strings.sol` +- `UnformatableExponent` from `../../error/ErrFormat.sol` + +**Functions:** +| Function | Line | Visibility | +|----------|------|------------| +| `countSigFigs(int256, int256)` | 18 | `internal pure` | +| `toDecimalString(Float, bool)` | 58 | `internal pure` | + +No assembly blocks, no constants defined, no custom errors defined (one imported). + +## Findings + +### A08-1 [LOW]: Unguarded overflow in non-scientific mode for large positive exponents (line 80) + +`signedCoefficient *= int256(10) ** uint256(exponent)` can overflow int256 for valid packed Float values (e.g., coefficient near int224.max with exponent >= 10), producing an unhandled `Panic(0x11)` instead of the descriptive `UnformatableExponent` error used for the analogous negative-exponent case on line 85. The negative path guards with `exponent < -76`, but the positive path has no guard at all. + +**Impact:** Callers formatting valid Float values with large positive exponents get an opaque panic instead of a meaningful error. diff --git a/audit/2026-03-10-01/pass1/LibLogTable.md b/audit/2026-03-10-01/pass1/LibLogTable.md new file mode 100644 index 0000000..d277145 --- /dev/null +++ b/audit/2026-03-10-01/pass1/LibLogTable.md @@ -0,0 +1,132 @@ +# Audit Pass 1 (Security) - LibLogTable.sol + +**Auditor Agent:** A11 +**File:** `src/lib/table/LibLogTable.sol` (742 lines) +**Date:** 2026-03-10 + +## Evidence of Thorough Reading + +### Library +- `LibLogTable` (line 35) + +### Constants (file-level) +| Name | Line | Type | Value | +|------|------|------|-------| +| `ALT_TABLE_FLAG` | 7 | `uint16` | `0x8000` | +| `LOG_MANTISSA_IDX_CARDINALITY` | 10 | `uint256` | `9000` | +| `LOG_MANTISSA_LAST_INDEX` | 13 | `uint256` | `8999` | +| `ANTILOG_IDX_CARDINALITY` | 16 | `int256` | `10000` | +| `ANTILOG_IDX_LAST_INDEX` | 19 | `int256` | `9999` | +| `LOG_TABLE_SIZE_BASE` | 25 | `uint256` | `900` | +| `LOG_TABLE_SIZE_BYTES` | 28 | `uint256` | `1800` | +| `LOG_TABLE_DISAMBIGUATOR` | 32 | `bytes32` | `keccak256("LOG_TABLE_DISAMBIGUATOR_1")` | + +### Functions +| Function | Line | Visibility | Parameters | +|----------|------|------------|------------| +| `toBytes(uint16[10][90])` | 41 | `internal pure` | log table (main) | +| `toBytes(uint8[10][90])` | 75 | `internal pure` | log table (small) | +| `toBytes(uint8[10][100])` | 109 | `internal pure` | antilog table (small) | +| `toBytes(uint8[10][10])` | 142 | `internal pure` | log table (small alt) | +| `toBytes(uint16[10][100])` | 175 | `internal pure` | antilog table (main) | +| `logTableDec()` | 206 | `internal pure` | returns `uint16[10][90]` | +| `logTableDecSmall()` | 414 | `internal pure` | returns `uint8[10][90]` | +| `logTableDecSmallAlt()` | 512 | `internal pure` | returns `uint8[10][10]` | +| `antiLogTableDec()` | 530 | `internal pure` | returns `uint16[10][100]` | +| `antiLogTableDecSmall()` | 638 | `internal pure` | returns `uint8[10][100]` | + +### Types/Errors +- None defined in this file (constants only, no custom types or errors). + +## Security Analysis + +### Assembly Blocks (5 `toBytes` overloads) + +All five `toBytes` functions follow an identical assembly pattern: +1. Allocate memory from free memory pointer for output `bytes` +2. Update free memory pointer +3. Reverse-iterate the 2D array, packing entries from the last element to the first +4. Write the byte length at the start of the output + +**Memory safety analysis:** + +The `mstore(cursor, value)` writes 32 bytes at each cursor position, but only the trailing 1 or 2 bytes contain the actual value (the rest is zero-padding from the uint16/uint8 value). Because the cursor decrements backward and each new write's trailing bytes land exactly where the previous write's leading zeros were, no data corruption occurs. The final `mstore(cursor, tableSize)` correctly writes the `bytes` length prefix into the first 32-byte slot. All five overloads use the `("memory-safe")` annotation, and the pattern is consistent with Solidity's memory model. The free memory pointer is correctly bumped before the loop begins. + +**Loop termination analysis:** + +For `uint16[10][90]`: 900 entries x 2 bytes = 1800 bytes. Cursor starts at `encoded + 1800`, decrements by 2 each iteration. After 900 iterations, cursor = `encoded`, and `gt(encoded, encoded)` = false, so the loop terminates. Identical reasoning applies to all overloads, substituting the appropriate entry count and byte width. + +### Table Data (Hardcoded Constants) + +The five table-returning functions (`logTableDec`, `logTableDecSmall`, `logTableDecSmallAlt`, `antiLogTableDec`, `antiLogTableDecSmall`) return hardcoded 2D arrays. These are pure data -- no arithmetic, no external calls, no assembly. + +The `ALT_TABLE_FLAG` (`0x8000`) is OR'd with certain entries in `logTableDec()` (rows 0-9, selected sub-entries in each row). This flag is stripped by the consumer (`lookupLogTableVal` in `LibDecimalFloatImplementation.sol`, line 758: `and(mainTableVal, 0x7FFF)`) and used to select between the regular and alternate small tables. The flag values are always in the first 100 main table entries (indices 0-999), which correctly maps into the 100-byte alt small table bounds. + +### External Calls / Data Contract Interaction + +`LibLogTable.sol` itself makes NO external calls. It only defines table data and encoding functions. The tables are consumed by `LibDecimalFloatImplementation.sol` via `extcodecopy` to a data contract address. The data contract is deployed deterministically and validated by codehash in `LibDecimalFloatDeploy.sol`. There is no validation at lookup time (in `lookupLogTableVal` or `lookupAntilogTableY1Y2`), but this is a design decision -- the `tablesDataContract` address is a trusted parameter. + +### Arithmetic / Overflow + +No arithmetic in this file beyond the `ALT_TABLE_FLAG` OR operations in the table data. All values are well within `uint16` range (max table value with flag: `9996 | 0x8000 = 0xA70C`, within uint16 max of 65535). The OR'd values also fit because the maximum base value in the log table is 9996 (< 0x7FFF = 32767). + +### Edge Cases and Boundary Lookups + +**Index calculations (in consumer, not this file):** + +- Log table: index range [0, 8999]. Main table access `(index/10)*2` ranges [0, 1798]. Small table access `(index/100)*10 + (index%10)` ranges [0, 899]. All within bounds of the 1800-byte main table and 900-byte small table. +- Alt small table: triggered only when `ALT_TABLE_FLAG` is set (indices 0-999 region). Access `(index/100)*10 + (index%10)` for index in [0, 999] gives [0, 99]. Within bounds of the 100-byte alt table. +- Antilog table: index range [0, 9999]. Main access `(index/10)*2` ranges [0, 1998]. Small access `(index/100)*10 + (index%10)` ranges [0, 999]. Within bounds of 2000-byte and 1000-byte tables respectively. + +## Findings + +### A11-1: toBytes Hardcoded Size in Two Overloads (INFORMATIONAL) + +**File:** `src/lib/table/LibLogTable.sol`, lines 109-135 and 142-168 + +**Description:** + +Two `toBytes` overloads (`uint8[10][100]` at line 113 and `uint8[10][10]` at line 146) use hardcoded sizes (1000 and 100 respectively) instead of named constants, unlike the other three overloads which use `LOG_TABLE_SIZE_BYTES` and `LOG_TABLE_SIZE_BASE`. Similarly, `toBytes(uint16[10][100])` at line 179 uses hardcoded value 2000. + +While these values are correct, the inconsistency could lead to maintenance errors if table dimensions change. The constants `LOG_TABLE_SIZE_BYTES` and `LOG_TABLE_SIZE_BASE` are specifically derived from `LOG_MANTISSA_IDX_CARDINALITY` which relates to the log table dimensions (90 rows), not the antilog dimensions (100 rows) or the alt table dimensions (10 rows), so there is no appropriate constant to reference. This is purely cosmetic. + +**Severity:** INFORMATIONAL +**Likelihood:** N/A +**Impact:** None -- values are correct. +**Recommendation:** No action required. Optionally, define named constants for the antilog and alt table sizes for consistency. + +### A11-2: No Revert on Undeployed Data Contract (INFORMATIONAL -- design-level note) + +**File:** `src/lib/table/LibLogTable.sol` (table definitions), consumed in `src/lib/implementation/LibDecimalFloatImplementation.sol` lines 744-770 and 1227-1254. + +**Description:** + +The EVM `extcodecopy` instruction reads zeroes from addresses with no deployed code. If the `tablesDataContract` parameter passed to `lookupLogTableVal` or `lookupAntilogTableY1Y2` points to an undeployed address (or an EOA), all table lookups silently return 0. This would cause `log10` and `pow10` to produce incorrect results without reverting. + +This is a design-level observation, not a bug in `LibLogTable.sol` itself. The `tablesDataContract` address is a trusted parameter passed by callers. The concrete `DecimalFloat.sol` contract uses a hardcoded `LOG_TABLES_ADDRESS`, and `LibDecimalFloatDeploy.sol` defines a `LOG_TABLES_DATA_CONTRACT_HASH` for verification. However, the lookup functions themselves do not validate the data contract. + +**Severity:** INFORMATIONAL +**Likelihood:** Low (requires misconfiguration by integrators) +**Impact:** Incorrect math results for log/pow/sqrt operations, not a loss of funds in isolation. +**Recommendation:** No action required for the library itself. Integrators should ensure the data contract is deployed before calling log/pow/sqrt operations. The existing deterministic deployment pattern and codehash constant provide sufficient guard rails for careful integrators. + +### A11-3: Table Data Correctness Relies on CI Comparison (INFORMATIONAL) + +**File:** `src/lib/table/LibLogTable.sol`, lines 206-741 (all table-returning functions) + +**Description:** + +The five hardcoded table functions contain thousands of manually specified numeric values representing log10 and antilog approximations. These values are referenced against a published log table PDF (line 34: `https://icap.org.pk/files/per/students/exam/notices/log-table.pdf`). Correctness is verified by comparing the AOT-compiled bytes (via `toBytes`) against the deployed data contract in CI (as noted in function comments). + +A single incorrect value in any table would produce a silently wrong approximation for `log10`, `pow10`, `pow`, or `sqrt`, with no revert. The `ALT_TABLE_FLAG` placement on specific entries (determining which small table variant to use) is also manually specified and an error there would produce silently wrong lookup routing. + +**Severity:** INFORMATIONAL +**Likelihood:** Very low (CI comparison catches discrepancies with the deployed contract) +**Impact:** Silent precision errors in log/pow/sqrt operations. +**Recommendation:** The existing CI verification against the deployed data contract is a strong safeguard. Consider additionally verifying a sample of table values against known log10 values in unit tests (e.g., log10(2) = 0.30103, log10(5) = 0.69897, etc.) to confirm the table data is correct at known checkpoints. + +## Summary + +`LibLogTable.sol` is a data-only library that defines lookup table constants and encoding functions. It contains no external calls, no state mutations, and no complex arithmetic. The assembly in the five `toBytes` functions follows a consistent and correct reverse-packing pattern with proper memory management. The table data is hardcoded and verified in CI. + +No LOW or higher severity findings were identified. The three INFORMATIONAL findings are documentation/design observations, not actionable security issues. diff --git a/audit/2026-03-10-01/pass1/LibParseDecimalFloat.md b/audit/2026-03-10-01/pass1/LibParseDecimalFloat.md new file mode 100644 index 0000000..dc2406c --- /dev/null +++ b/audit/2026-03-10-01/pass1/LibParseDecimalFloat.md @@ -0,0 +1,230 @@ +# Audit Pass 1 (Security) - LibParseDecimalFloat.sol + +**Auditor Agent:** A10 +**File:** `src/lib/parse/LibParseDecimalFloat.sol` +**Date:** 2026-03-10 + +## Evidence of Thorough Reading + +**Library:** `LibParseDecimalFloat` + +### Functions + +| Function | Line | Visibility | +|---|---|---| +| `parseDecimalFloatInline(uint256 start, uint256 end)` | 34 | internal pure | +| `parseDecimalFloat(string memory str)` | 169 | internal pure | + +### Types / Errors / Constants Defined in File + +None defined directly in this file. All types, errors, and constants are imported. + +### Imports + +| Symbol | Source | +|---|---| +| `LibParseChar` | `rain.string/lib/parse/LibParseChar.sol` | +| `CMASK_NUMERIC_0_9` | `rain.string/lib/parse/LibParseCMask.sol` | +| `CMASK_NEGATIVE_SIGN` | `rain.string/lib/parse/LibParseCMask.sol` | +| `CMASK_E_NOTATION` | `rain.string/lib/parse/LibParseCMask.sol` | +| `CMASK_ZERO` | `rain.string/lib/parse/LibParseCMask.sol` | +| `CMASK_DECIMAL_POINT` | `rain.string/lib/parse/LibParseCMask.sol` | +| `LibParseDecimal` | `rain.string/lib/parse/LibParseDecimal.sol` | +| `MalformedExponentDigits` | `../../error/ErrParse.sol` | +| `ParseDecimalPrecisionLoss` | `../../error/ErrParse.sol` | +| `MalformedDecimalPoint` | `../../error/ErrParse.sol` | +| `ParseDecimalFloatExcessCharacters` | `../../error/ErrParse.sol` | +| `ParseEmptyDecimalString` | `rain.string/error/ErrParse.sol` | +| `LibDecimalFloat`, `Float` | `../LibDecimalFloat.sol` | + +### Architecture Summary + +`parseDecimalFloatInline` is the core parser operating on raw memory pointers `[start, end)`. It parses, in order: an optional negative sign, integer digits, an optional decimal point followed by fractional digits (with trailing zero stripping), and an optional `e`/`E` followed by a signed exponent. It returns an error-selector pattern (`bytes4(0)` = success) rather than reverting. + +`parseDecimalFloat` is a high-level wrapper that extracts memory pointers from a `string memory`, calls the inline parser, checks that the entire string was consumed, and packs the result into a `Float` via `LibDecimalFloat.packLossy`. + +### Detailed Walkthrough + +**Lines 39-56 (sign and integer part):** +- `skipMask` advances past all characters matching `CMASK_NEGATIVE_SIGN` (line 41). Multiple dashes are consumed but are later caught as an error by `unsafeDecimalStringToSignedInt` which only handles one sign character. +- `skipMask` for `CMASK_NUMERIC_0_9` identifies the integer digit span (line 45). If no digits found, returns `ParseEmptyDecimalString` (line 47). +- `unsafeDecimalStringToSignedInt(start, cursor)` parses the full range from original `start` (including the sign character) through the end of digits (line 51). This function delegates to `unsafeDecimalStringToInt` which performs overflow detection up to uint256 range, and `unsafeDecimalStringToSignedInt` checks int256 range. + +**Lines 58-126 (fractional part):** +- `isMask` checks for decimal point at cursor (line 58). If found, cursor advances past it (line 61). +- `skipMask` identifies fractional digits (line 63). If no digits after the point, returns `MalformedDecimalPoint` (line 65). +- Trailing zero stripping loop (line 70): walks backwards from `cursor` via `isMask(nonZeroCursor - 1, end, CMASK_ZERO)`. Relies on the decimal point character at `fracStart - 1` to act as a natural lower bound (`.` is not in `CMASK_ZERO`). +- If non-zero fractional digits exist, they are parsed via `unsafeDecimalStringToSignedInt(fracStart, nonZeroCursor)` (line 76). A negative fracValue is rejected (line 83-84) since the sign is inherited from the integer part (line 86-88). +- Exponent is set to `int256(fracStart) - int256(nonZeroCursor)` (line 96), always non-positive. +- When `signedCoefficient != 0`, the integer value is rescaled by `10^(-exponent)` and combined with fracValue (lines 104-125). Overflow is checked via division round-trip (line 117) and int224 truncation (line 120). The `scale > 67` guard (line 108) prevents exponentiation overflow since `10^67 < 2^224`. + +**Lines 128-151 (e-notation exponent):** +- `isMask` checks for `e`/`E` (line 128). If found, parses optional sign + digits. +- `skipMask` for `CMASK_NEGATIVE_SIGN` allows multiple dashes (line 132), but `unsafeDecimalStringToSignedInt` only handles one, so extra dashes produce an error. +- The parsed e-value is added to the existing exponent (line 150) inside `unchecked`. This is the location of the sole LOW finding. + +**Lines 153-157 (zero normalization):** +- If the final coefficient is zero, the exponent is forced to zero. This prevents distinct zero representations. + +**Lines 169-196 (`parseDecimalFloat` wrapper):** +- Assembly block (line 172-175) extracts `start` and `end` pointers from string memory layout. +- Checks that the entire string was consumed (line 179); if not, returns `ParseDecimalFloatExcessCharacters`. +- `packLossy` packs the result into `Float`; if lossy, returns `ParseDecimalPrecisionLoss` (line 183). + +### Edge Cases Verified + +| Input | Behavior | Lines | +|---|---|---| +| Empty string `""` | `ParseEmptyDecimalString` | 46-47 | +| Non-numeric `"hello"` | `ParseEmptyDecimalString` | 46-47 | +| Leading `.` without integer `".1"` | `ParseEmptyDecimalString` | 46-47 | +| Trailing `.` without fraction `"1."` | `MalformedDecimalPoint` | 64-65 | +| `"e1"` (e without leading digits) | `ParseEmptyDecimalString` | 46-47 | +| `"1e"` (e without trailing digits) | `MalformedExponentDigits` | 136-137 | +| `"1e-"` (e with sign but no digits) | `MalformedExponentDigits` | 136-137 | +| `"-0"` | Coefficient 0, exponent 0 (no negative zero) | 153-157 | +| `"---123"` (multiple signs) | `ParseDecimalOverflow` from downstream | 51 | +| `"1e--5"` (multiple e-signs) | `ParseDecimalOverflow` from downstream | 143 | +| `"0.000"` (all-zero fraction) | `nonZeroCursor == fracStart`, fracValue stays 0 | 70-74 | +| Leading zeros `"0001"` | Parsed correctly as 1 | 45, 51 | +| Trailing zeros in fraction `"1.10"` | Stripped; `"1.10"` -> coeff=11, exp=-1 | 69-72 | +| `start > end` | `ParseEmptyDecimalString` (skipMask no-ops) | 45-47 | +| Very long fractional part with nonzero integer | `ParseDecimalPrecisionLoss` if > 67 fractional digits | 108-109 | +| `int256.max` coefficient + `int256.max` exponent | Parsed correctly; tested in test suite | N/A | +| `int256.min` coefficient + `int256.min` exponent | Parsed correctly; tested in test suite | N/A | + +--- + +## Findings + +--- + +### A10-1: Unchecked `exponent += eValue` can silently wrap on int256 overflow (LOW) + +**Location:** Line 150, inside `unchecked {}` block + +**Description:** + +On line 150, the exponent from the fractional part is added to the e-notation exponent value: + +```solidity +exponent += eValue; +``` + +Both `exponent` and `eValue` are `int256`. This addition is inside an `unchecked` block, so int256 overflow wraps silently. When the input has both a fractional part and an e-notation exponent, their sum can overflow. + +Concrete scenario: parsing a string like `"0.1e"` produces a large negative `exponent` from the fractional part and `eValue = int256.max`. Their unchecked sum wraps to a positive number. The function returns `signedCoefficient = 1` with a wrapped (semantically incorrect) positive exponent. + +For the wrapping to occur, the fractional exponent must be sufficiently negative. When the integer part is nonzero, the `scale > 67` guard (line 108) caps the fractional exponent at -67, and `-67 + int256.max` does not overflow. The issue only arises when `signedCoefficient == 0` (integer part is zero), in which case there is no scale guard and the fractional exponent is unbounded (derived from pointer arithmetic on line 96). In that path, the fractional exponent can be as negative as the number of fractional digits allows. + +**Impact:** + +LOW. The `parseDecimalFloat` wrapper mitigates this because `packLossy` checks that the exponent fits in int32 and will either revert with `ExponentOverflow` or return `lossless = false`. However, callers of `parseDecimalFloatInline` directly receive the wrapped value with no indication of overflow. Since `parseDecimalFloatInline` is `internal`, only code within the same contract (or contracts inheriting/importing the library) can call it. + +Practical exploitability is further constrained by the fact that constructing a string with enough fractional zeros to push the exponent toward `int256.min` requires enormous memory allocation and corresponding gas. The code comments on lines 92-94 acknowledge this: "technically these numbers could be out of range but in the intended use case that would imply a memory region that is physically impossible to exist." + +**Recommendation:** + +Add an overflow check after line 150, or document the invariant that `parseDecimalFloatInline` callers must validate the returned exponent fits their target range. See `.fixes/A10-1.md`. + +--- + +### A10-2: `rescaledIntValue + fracValue` sum not rechecked against int224 (INFORMATIONAL) + +**Location:** Line 124 + +**Description:** + +After the int224 truncation check on `rescaledIntValue` (line 120), the fractional value is added without rechecking: + +```solidity +signedCoefficient = rescaledIntValue + fracValue; +``` + +`rescaledIntValue` is verified to fit in int224 (line 120). `fracValue` is at most ~67 decimal digits (bounded by `scale > 67`), which also fits in int224 (int224 max is approximately 2.69e67). However, their sum can exceed int224 range by up to a factor of 2. + +**Impact:** + +INFORMATIONAL. No int256 overflow is possible since both operands fit in int224, so their sum fits in at most ~int225, well within int256. The `parseDecimalFloat` wrapper catches any int224 overflow via `packLossy` which returns `lossless = false`. The `parseDecimalFloatInline` return value accurately represents the mathematical result. The behavior is correct; this note exists only to document that the returned `signedCoefficient` may slightly exceed int224 range. + +--- + +### A10-3: Multiple consecutive negative signs consumed by `skipMask` but rejected downstream with misleading error (INFORMATIONAL) + +**Location:** Lines 41, 132 + +**Description:** + +`skipMask(cursor, end, CMASK_NEGATIVE_SIGN)` on line 41 advances past ALL consecutive dash characters, not just one. For input like `"---123"`, all three dashes are consumed. Then `unsafeDecimalStringToSignedInt(start, cursor)` on line 51 parses the full span `"---123"`. It detects one `-`, then passes `"--123"` to `unsafeDecimalStringToInt`, which interprets `'-'` (ASCII 0x2D) as a digit by subtracting the `'0'` offset (ASCII 0x30). In unchecked assembly this underflows to a large value, which triggers `ParseDecimalOverflow`. + +The same pattern applies to line 132 for the exponent sign: `"1e---5"` produces `ParseDecimalOverflow`. + +**Impact:** + +INFORMATIONAL. The input is always rejected, which is correct. The error selector (`ParseDecimalOverflow`) is misleading for what is really a malformed-sign condition, but this is a usability/diagnostics concern, not a security issue. + +--- + +### A10-4: Assembly block in `parseDecimalFloat` not annotated as `"memory-safe"` (INFORMATIONAL) + +**Location:** Line 172 + +**Description:** + +The assembly block at line 172: + +```solidity +assembly { + start := add(str, 0x20) + end := add(start, mload(str)) +} +``` + +This block only reads from memory (no `mstore`, no free-memory-pointer modification). It qualifies for the `"memory-safe"` annotation. Other assembly blocks in the dependency chain (e.g., in `LibParseChar.skipMask`, `LibParseChar.isMask`, `LibDecimalFloat.packLossy`) are annotated as `"memory-safe"`. + +**Impact:** + +INFORMATIONAL. The missing annotation prevents the Solidity optimizer from making certain assumptions around this block, marginally reducing optimization potential. No correctness or security impact. + +--- + +### A10-5: Trailing zero stripping loop relies on implicit lower bound from decimal point character (INFORMATIONAL) + +**Location:** Line 70 + +**Description:** + +The trailing zero stripping loop: + +```solidity +uint256 nonZeroCursor = cursor; +while (LibParseChar.isMask(nonZeroCursor - 1, end, CMASK_ZERO) == 1) { + nonZeroCursor--; +} +``` + +This walks backwards through memory. `isMask` only checks an upper bound (`lt(cursor, end)`), not a lower bound. The loop terminates because the byte at `fracStart - 1` is always the decimal point character `'.'`, which does not match `CMASK_ZERO`. + +The implicit invariant chain: +1. We entered this block because `isMask(cursor, end, CMASK_DECIMAL_POINT) == 1` (line 58). +2. `cursor++` at line 61 means `fracStart = cursor` and `fracStart - 1` points to `'.'`. +3. `'.'` (ASCII 0x2E) is not in `CMASK_ZERO` (which matches only `'0'`, ASCII 0x30). +4. Therefore the loop cannot decrement `nonZeroCursor` below `fracStart`. + +**Impact:** + +INFORMATIONAL. The logic is correct. The implicit bound through character identity rather than an explicit `nonZeroCursor > fracStart` guard makes the code slightly fragile in the face of hypothetical refactoring (e.g., if `CMASK_ZERO` were ever broadened or the decimal point were consumed differently). No current security issue. + +--- + +## Summary + +| ID | Severity | Title | +|---|---|---| +| A10-1 | LOW | Unchecked `exponent += eValue` can silently wrap on int256 overflow | +| A10-2 | INFORMATIONAL | `rescaledIntValue + fracValue` sum not rechecked against int224 | +| A10-3 | INFORMATIONAL | Multiple negative signs consumed then rejected with misleading error | +| A10-4 | INFORMATIONAL | Assembly block not annotated as `"memory-safe"` | +| A10-5 | INFORMATIONAL | Trailing zero stripping loop relies on implicit lower bound | + +Overall, `LibParseDecimalFloat.sol` is well-structured with thorough input validation. All invalid inputs produce error selectors rather than reverting with string messages (the sole hard revert in the dependency `LibParseDecimal.unsafeDecimalStringToInt` for `start == 0` is a true programming error guard). The `parseDecimalFloat` wrapper provides a robust safety net via `packLossy` validation. The single LOW finding (A10-1) has minimal practical impact due to physical memory constraints and the mitigation provided by the wrapper function. diff --git a/audit/2026-03-10-01/pass1/LogTablesPointers.md b/audit/2026-03-10-01/pass1/LogTablesPointers.md new file mode 100644 index 0000000..471da11 --- /dev/null +++ b/audit/2026-03-10-01/pass1/LogTablesPointers.md @@ -0,0 +1,21 @@ +# Audit Pass 1: Security — `src/generated/LogTables.pointers.sol` (A05) + +## Evidence of Thorough Reading + +**File:** Auto-generated constants file, no contract/library declaration, no functions, no assembly, no imports. + +**Constants:** +| Name | Type | Description | +|------|------|-------------| +| `BYTECODE_HASH` | `bytes32` | All zeros (artifact of codegen framework, unused) | +| `LOG_TABLES` | `bytes` | 1800-byte hex literal — 90x10 uint16 log table | +| `LOG_TABLES_SMALL` | `bytes` | 900-byte hex literal — 90x10 uint8 small log table | +| `LOG_TABLES_SMALL_ALT` | `bytes` | 100-byte hex literal — 10x10 uint8 alternate small log table | +| `ANTI_LOG_TABLES` | `bytes` | 2000-byte hex literal — 100x10 uint16 antilog table | +| `ANTI_LOG_TABLES_SMALL` | `bytes` | 1000-byte hex literal — 100x10 uint8 small antilog table | + +Data integrity spot-checked against `LibLogTable.logTableDec()` and `LibLogTable.antiLogTableDec()`. + +## Findings + +No security findings. diff --git a/audit/2026-03-10-01/pass1/errors.md b/audit/2026-03-10-01/pass1/errors.md new file mode 100644 index 0000000..5b6f7ca --- /dev/null +++ b/audit/2026-03-10-01/pass1/errors.md @@ -0,0 +1,116 @@ +# Pass 1 (Security) -- Error Definition Files + +Agents: A02, A03, A04 +Date: 2026-03-10 + +--- + +## A02: `src/error/ErrDecimalFloat.sol` + +### Evidence of Thorough Reading + +- **File type:** File-level error definitions (no contract/library wrapper) +- **Pragma:** `^0.8.25` (line 3) +- **Import:** `Float` from `../lib/LibDecimalFloat.sol` (line 5) + +**Errors defined:** + +| Line | Error | Parameters | +|------|-------|------------| +| 8 | `CoefficientOverflow` | `int256 signedCoefficient, int256 exponent` | +| 11 | `ExponentOverflow` | `int256 signedCoefficient, int256 exponent` | +| 15 | `NegativeFixedDecimalConversion` | `int256 signedCoefficient, int256 exponent` | +| 18 | `Log10Zero` | (none) | +| 21 | `Log10Negative` | `int256 signedCoefficient, int256 exponent` | +| 25 | `LossyConversionToFloat` | `int256 signedCoefficient, int256 exponent` | +| 29 | `LossyConversionFromFloat` | `int256 signedCoefficient, int256 exponent` | +| 32 | `ZeroNegativePower` | `Float b` | +| 35 | `MulDivOverflow` | `uint256 x, uint256 y, uint256 denominator` | +| 38 | `MaximizeOverflow` | `int256 signedCoefficient, int256 exponent` | +| 43 | `DivisionByZero` | `int256 signedCoefficient, int256 exponent` | +| 46 | `PowNegativeBase` | `int256 signedCoefficient, int256 exponent` | +| 49 | `WriteError` | (none) | + +**No functions, constants, or types defined** (beyond the error declarations). + +### Findings + +#### A02-1 [INFO] `WriteError` duplicates `rain.datacontract` error and is unused + +`WriteError()` at line 49 is an exact duplicate of the error defined in `lib/rain.datacontract/src/error/ErrDataContract.sol:6`. It is imported in `src/lib/deploy/LibDecimalFloatDeploy.sol:17` but never actually used -- there are zero `revert WriteError()` calls anywhere in `src/`. The `rain.datacontract` library defines its own `WriteError` and uses it internally via `LibDataContract.sol`. + +This dead error definition adds confusion about which `WriteError` is canonical. It is not exploitable because the selectors are identical (same signature produces the same 4-byte selector), but it is misleading. + +#### A02-2 [INFO] `WithTargetExponentOverflow` defined outside error file + +The error `WithTargetExponentOverflow(int256, int256, int256)` is defined inline at `src/lib/implementation/LibDecimalFloatImplementation.sol:21` rather than in `ErrDecimalFloat.sol`. This is inconsistent with the project's pattern of centralizing errors in `src/error/`. It is used at lines 1146 and 1153 of the implementation file. Not a security issue but could cause maintenance confusion. + +#### A02-3 [INFO] Circular import between `ErrDecimalFloat.sol` and `LibDecimalFloat.sol` + +`ErrDecimalFloat.sol` imports `Float` from `LibDecimalFloat.sol` (line 5), and `LibDecimalFloat.sol` imports errors from `ErrDecimalFloat.sol` (lines 5-13). Solidity resolves this correctly for file-level declarations, so this is not a compilation or security issue. However, the circular dependency exists solely to support the `ZeroNegativePower(Float b)` error at line 32. All other errors use primitive types (`int256`, `uint256`). Using `bytes32` instead of `Float` would eliminate the circular import with no loss of information (since `Float` is `bytes32`). + +--- + +## A03: `src/error/ErrFormat.sol` + +### Evidence of Thorough Reading + +- **File type:** File-level error definitions (no contract/library wrapper) +- **Pragma:** `^0.8.25` (line 3) +- **No imports** + +**Errors defined:** + +| Line | Error | Parameters | +|------|-------|------------| +| 7 | `UnformatableExponent` | `int256 exponent` | + +**No functions, constants, or types defined.** + +### Findings + +No security findings. + +The single error `UnformatableExponent` is used at `src/lib/format/LibFormatDecimalFloat.sol:85` to revert when an exponent value cannot be formatted. The parameter correctly provides the offending exponent for diagnosis. The NatSpec documentation is accurate. + +--- + +## A04: `src/error/ErrParse.sol` + +### Evidence of Thorough Reading + +- **File type:** File-level error definitions (no contract/library wrapper) +- **Pragma:** `^0.8.25` (line 3) +- **No imports** + +**Errors defined:** + +| Line | Error | Parameters | +|------|-------|------------| +| 7 | `MalformedDecimalPoint` | `uint256 position` | +| 11 | `MalformedExponentDigits` | `uint256 position` | +| 16 | `ParseDecimalPrecisionLoss` | `uint256 position` | +| 19 | `ParseDecimalFloatExcessCharacters` | (none) | + +**No functions, constants, or types defined.** + +### Findings + +#### A04-1 [LOW] `ParseDecimalFloatExcessCharacters` lacks a `position` parameter + +All other parse errors (`MalformedDecimalPoint`, `MalformedExponentDigits`, `ParseDecimalPrecisionLoss`) include a `uint256 position` parameter identifying where in the input string the error occurred. `ParseDecimalFloatExcessCharacters` (line 19) has no parameters at all. + +At the revert site (`src/lib/parse/LibParseDecimalFloat.sol:189`), the cursor position is available at the time of the revert -- the function knows exactly where the excess characters begin. Including this position in the error would improve debuggability and maintain consistency with the other parse errors. + +This is LOW because it has no exploitable security impact but reduces the diagnostic quality of the error for callers/integrators. + +--- + +## Summary + +| ID | Severity | File | Title | +|----|----------|------|-------| +| A02-1 | INFO | ErrDecimalFloat.sol | `WriteError` duplicates `rain.datacontract` error and is unused | +| A02-2 | INFO | ErrDecimalFloat.sol | `WithTargetExponentOverflow` defined outside error file | +| A02-3 | INFO | ErrDecimalFloat.sol | Circular import between error and library files | +| A04-1 | LOW | ErrParse.sol | `ParseDecimalFloatExcessCharacters` lacks `position` parameter | diff --git a/audit/2026-03-10-01/pass1/scripts.md b/audit/2026-03-10-01/pass1/scripts.md new file mode 100644 index 0000000..e6f2b64 --- /dev/null +++ b/audit/2026-03-10-01/pass1/scripts.md @@ -0,0 +1,151 @@ +# Audit Pass 1 (Security): Scripts + +**Date:** 2026-03-10 +**Agents:** A12, A13 +**Files reviewed:** +- `script/BuildPointers.sol` (A12) +- `script/Deploy.sol` (A13) + +--- + +## A12: `script/BuildPointers.sol` + +### Evidence of Thorough Reading + +**Contract:** `BuildPointers` (line 10), inherits `Script` (forge-std) + +**Imports (lines 5-8):** +- `Script` from `forge-std/Script.sol` +- `LibCodeGen` from `rain.sol.codegen/src/lib/LibCodeGen.sol` +- `LibFs` from `rain.sol.codegen/src/lib/LibFs.sol` +- `LibLogTable` from `../src/lib/table/LibLogTable.sol` + +**Functions:** +- `run()` external (line 11) -- the sole entry point + +**Types/Errors/Constants defined:** None + +**Functional summary:** +The script calls `LibFs.buildFileForContract` to generate `src/generated/LogTables.pointers.sol`. It passes `address(0)` as the contract instance (used only for a bytecode hash constant in the generated header). The body is built by concatenating five `LibCodeGen.bytesConstantString` calls, each encoding one of the five log/antilog tables: +1. `LOG_TABLES` from `LibLogTable.logTableDec()` (line 18) +2. `LOG_TABLES_SMALL` from `LibLogTable.logTableDecSmall()` (line 24) +3. `LOG_TABLES_SMALL_ALT` from `LibLogTable.logTableDecSmallAlt()` (line 29) +4. `ANTI_LOG_TABLES` from `LibLogTable.antiLogTableDec()` (line 37) +5. `ANTI_LOG_TABLES_SMALL` from `LibLogTable.antiLogTableDecSmall()` (line 42) + +`LibFs.buildFileForContract` (reviewed in `rain.sol.codegen/src/lib/LibFs.sol`) writes to the path `src/generated/LogTables.pointers.sol`, deleting any pre-existing file at that path first. + +### Security Review + +**File system operations:** +- The output path is hardcoded via `LibFs.pathForContract("LogTables")` which resolves to `src/generated/LogTables.pointers.sol`. The path is deterministic and within the source tree. No user-supplied input influences the path. +- `LibFs.buildFileForContract` unconditionally deletes the existing file before writing. This is by-design idempotency, documented in `LibFs.sol` line 26. + +**Data integrity:** +- The table data is generated purely from `LibLogTable` pure functions. No external input, no environment variables, no RPC calls. The output is deterministic for a given version of the source code. +- The generated file is committed to the repository, so any drift between the generator and the committed output would be visible in version control. + +### Findings + +No security findings. The script is a deterministic code generator with no external inputs, no private key handling, no network interaction, and a hardcoded output path within the source tree. + +--- + +## A13: `script/Deploy.sol` + +### Evidence of Thorough Reading + +**Constants (lines 11-12):** +- `DEPLOYMENT_SUITE_TABLES` = `keccak256("log-tables")` (line 11) +- `DEPLOYMENT_SUITE_CONTRACT` = `keccak256("decimal-float")` (line 12) + +**Contract:** `Deploy` (line 14), inherits `Script` (forge-std) + +**Imports (lines 5-9):** +- `Script` from `forge-std/Script.sol` +- `LibDataContract` from `rain.datacontract/lib/LibDataContract.sol` +- `LibDecimalFloatDeploy` from `../src/lib/deploy/LibDecimalFloatDeploy.sol` +- `LibRainDeploy` from `rain.deploy/lib/LibRainDeploy.sol` +- `DecimalFloat` from `../src/concrete/DecimalFloat.sol` + +**State variables:** +- `sDepCodeHashes` (line 15): `mapping(string => mapping(address => bytes32))` internal -- stores dependency code hashes per network for cross-phase verification + +**Functions:** +- `run()` external (line 17) -- the sole entry point + +**Types/Errors defined:** None (errors are in `LibRainDeploy`) + +**Functional summary:** +1. Reads `DEPLOYMENT_KEY` from environment as `uint256` (line 18). +2. Reads `DEPLOYMENT_SUITE` from environment with default `"decimal-float"` (line 20). +3. Hashes the suite string and dispatches: + - **`log-tables` suite** (lines 21-32): Deploys combined log tables as a data contract. Dependencies: none (`new address[](0)`). Expected address and code hash from `LibDecimalFloatDeploy`. + - **`decimal-float` suite** (lines 33-46): Deploys `DecimalFloat` contract. Dependency: the log tables address (`ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS`). Expected address and code hash from `LibDecimalFloatDeploy`. + - **else** (lines 47-51): Reverts with an error message. + +`LibRainDeploy.deployAndBroadcast` (reviewed in `rain.deploy/lib/LibRainDeploy.sol`) handles: +- Deriving the deployer address from the private key via `vm.rememberKey` (line 257 of LibRainDeploy) +- Dependency checking on all networks (code existence + codehash recording) +- Deployment via Zoltu factory on each network with idempotent skip +- Post-deployment verification (address match + codehash match) + +### Security Review + +**Private key handling:** +- The private key is read from the `DEPLOYMENT_KEY` environment variable (line 18) via `vm.envUint`. This is the standard Foundry pattern. +- The key is passed to `LibRainDeploy.deployAndBroadcast` which calls `vm.rememberKey(deployerPrivateKey)`. This derives and caches the address. The key itself is only used for `vm.startBroadcast(deployer)` which signs transactions. +- The private key is never logged. The deployer *address* is logged by `LibRainDeploy` (line 259) but not the key itself. + +**Deployment verification:** +- `LibRainDeploy.deployAndBroadcast` performs two-phase verification: + 1. **Pre-deploy:** `checkDependencies` forks each network and verifies Zoltu factory codehash + all dependency addresses have code. Records dependency codehashes. + 2. **Deploy phase:** `deployToNetworks` re-verifies Zoltu factory and dependency codehashes before each per-network deployment. Post-deploy, it checks `deployedAddress == expectedAddress` and `deployedAddress.codehash == expectedCodeHash`. +- Idempotent: if code already exists at `expectedAddress`, deployment is skipped. + +**Dependency checks:** +- For `log-tables` suite: no dependencies (empty array). The Zoltu factory itself is still verified. +- For `decimal-float` suite: the log tables address is listed as a dependency and verified to have code on each target network before deployment proceeds. + +**Access control:** +- The script is a Foundry `Script` -- it can only be executed by someone running `forge script` with the appropriate environment variables. No on-chain access control is needed or relevant. + +**Input validation:** +- The `DEPLOYMENT_SUITE` environment variable is validated via the `else` revert (lines 47-51). Unknown suite values cause a revert with a descriptive message. +- The `DEPLOYMENT_KEY` read via `vm.envUint` will revert if the variable is not set. + +### Findings + +#### A13-1: Default deployment suite may mask operator intent (INFO) + +**File:** `script/Deploy.sol` +**Line:** 20 + +The `DEPLOYMENT_SUITE` environment variable defaults to `"decimal-float"` when unset (via `vm.envOr`). If an operator forgets to set the variable, the script will silently proceed to deploy the `DecimalFloat` contract rather than erroring out. Given that deployment ordering matters (tables must be deployed before the contract), a missing variable could cause a failed deployment (caught by dependency checks) or an unintended successful deployment if tables are already present. + +This is INFO-level because: +- `LibRainDeploy.checkDependencies` would catch any missing table dependencies before the deploy phase. +- An experienced operator would know to set the variable. +- The default to `"decimal-float"` is the more common operation. + +#### A13-2: No explicit ordering enforcement between deployment suites (INFO) + +**File:** `script/Deploy.sol` +**Lines:** 21-46 + +The `log-tables` suite must be deployed before the `decimal-float` suite because `DecimalFloat` depends on the log tables contract. While the dependency check in `LibRainDeploy.checkDependencies` would fail if tables are not deployed, the error message comes from `LibRainDeploy` (`MissingDependency`) rather than from `Deploy.sol` itself. The ordering constraint is implicit and undocumented in the script. + +This is INFO-level because: +- The dependency system does catch the ordering violation at runtime. +- The constraint is documented in the existing fix A01-2. + +--- + +## Summary + +| ID | Severity | File | Description | +|----|----------|------|-------------| +| A13-1 | INFO | `script/Deploy.sol:20` | Default deployment suite may mask operator intent | +| A13-2 | INFO | `script/Deploy.sol:21-46` | No explicit ordering enforcement between deployment suites | + +No LOW or higher findings. Both scripts are well-structured. `BuildPointers.sol` is a pure code generator with no security surface. `Deploy.sol` delegates all security-critical operations (key handling, address verification, codehash verification, dependency checking) to `LibRainDeploy`, which implements thorough two-phase verification. diff --git a/audit/2026-03-10-01/pass2/DecimalFloat.md b/audit/2026-03-10-01/pass2/DecimalFloat.md new file mode 100644 index 0000000..bbfbb4e --- /dev/null +++ b/audit/2026-03-10-01/pass2/DecimalFloat.md @@ -0,0 +1,207 @@ +# Audit Pass 2 -- Test Coverage: `DecimalFloat.sol` + +**Agent:** A01 +**Source:** `src/concrete/DecimalFloat.sol` +**Test files examined:** +- `test/src/concrete/DecimalFloat.abs.t.sol` +- `test/src/concrete/DecimalFloat.add.t.sol` +- `test/src/concrete/DecimalFloat.ceil.t.sol` +- `test/src/concrete/DecimalFloat.div.t.sol` +- `test/src/concrete/DecimalFloat.eq.t.sol` +- `test/src/concrete/DecimalFloat.floor.t.sol` +- `test/src/concrete/DecimalFloat.frac.t.sol` +- `test/src/concrete/DecimalFloat.fromFixedDecimalLossless.t.sol` +- `test/src/concrete/DecimalFloat.fromFixedDecimalLossy.t.sol` +- `test/src/concrete/DecimalFloat.gt.t.sol` +- `test/src/concrete/DecimalFloat.gte.t.sol` +- `test/src/concrete/DecimalFloat.inv.t.sol` +- `test/src/concrete/DecimalFloat.isZero.t.sol` +- `test/src/concrete/DecimalFloat.lt.t.sol` +- `test/src/concrete/DecimalFloat.lte.t.sol` +- `test/src/concrete/DecimalFloat.max.t.sol` +- `test/src/concrete/DecimalFloat.min.t.sol` +- `test/src/concrete/DecimalFloat.minus.t.sol` +- `test/src/concrete/DecimalFloat.mul.t.sol` +- `test/src/concrete/DecimalFloat.parse.t.sol` +- `test/src/concrete/DecimalFloat.pow.t.sol` +- `test/src/concrete/DecimalFloat.sqrt.t.sol` +- `test/src/concrete/DecimalFloat.sub.t.sol` +- `test/src/concrete/DecimalFloat.toFixedDecimalLossless.t.sol` +- `test/src/concrete/DecimalFloat.toFixedDecimalLossy.t.sol` +- `test/src/concrete/DecimalFloat.constants.t.sol` +- `test/src/concrete/DecimalFloat.log10.t.sol` +- `test/src/concrete/DecimalFloat.pow10.t.sol` +- `test/src/concrete/DecimalFloat.format.t.sol` +- `test/src/concrete/DecimalFloat.integer.t.sol` +- `test/concrete/DecimalFloat.packLossless.t.sol` +- `test/concrete/TestDecimalFloat.sol` +- `test/concrete/TestDecimalFloat.unpack.t.sol` + +--- + +## Source Contract: `DecimalFloat` (lines 9-320) + +### Public State Variables (Constants) +| Name | Line | +|---|---| +| `FORMAT_DEFAULT_SCIENTIFIC_MIN` | 14 | +| `FORMAT_DEFAULT_SCIENTIFIC_MAX` | 19 | + +### Functions +| Function | Line | Mutability | +|---|---|---| +| `maxPositiveValue()` | 24 | `pure` | +| `minPositiveValue()` | 30 | `pure` | +| `maxNegativeValue()` | 36 | `pure` | +| `minNegativeValue()` | 42 | `pure` | +| `zero()` | 48 | `pure` | +| `e()` | 54 | `pure` | +| `parse(string)` | 64 | `pure` | +| `format(Float, Float, Float)` | 78 | `pure` | +| `format(Float, bool)` | 89 | `pure` | +| `format(Float)` | 97 | `pure` | +| `add(Float, Float)` | 105 | `pure` | +| `sub(Float, Float)` | 113 | `pure` | +| `minus(Float)` | 120 | `pure` | +| `abs(Float)` | 127 | `pure` | +| `mul(Float, Float)` | 135 | `pure` | +| `div(Float, Float)` | 143 | `pure` | +| `inv(Float)` | 150 | `pure` | +| `eq(Float, Float)` | 158 | `pure` | +| `lt(Float, Float)` | 166 | `pure` | +| `gt(Float, Float)` | 175 | `pure` | +| `lte(Float, Float)` | 184 | `pure` | +| `gte(Float, Float)` | 193 | `pure` | +| `integer(Float)` | 200 | `pure` | +| `frac(Float)` | 207 | `pure` | +| `floor(Float)` | 214 | `pure` | +| `ceil(Float)` | 221 | `pure` | +| `pow10(Float)` | 228 | `view` | +| `log10(Float)` | 235 | `view` | +| `pow(Float, Float)` | 243 | `view` | +| `sqrt(Float)` | 250 | `view` | +| `min(Float, Float)` | 258 | `pure` | +| `max(Float, Float)` | 266 | `pure` | +| `isZero(Float)` | 273 | `pure` | +| `fromFixedDecimalLossless(uint256, uint8)` | 284 | `pure` | +| `toFixedDecimalLossless(Float, uint8)` | 293 | `pure` | +| `fromFixedDecimalLossy(uint256, uint8)` | 305 | `pure` | +| `toFixedDecimalLossy(Float, uint8)` | 316 | `pure` | + +--- + +## Test Coverage Summary + +### Functions WITH deployed-parity fuzz test coverage: +- `maxPositiveValue()` -- `DecimalFloat.constants.t.sol:testMaxPositiveValueDeployed` +- `minPositiveValue()` -- `DecimalFloat.constants.t.sol:testMinPositiveValueDeployed` +- `maxNegativeValue()` -- `DecimalFloat.constants.t.sol:testMaxNegativeValueDeployed` +- `minNegativeValue()` -- `DecimalFloat.constants.t.sol:testMinNegativeValueDeployed` +- `zero()` -- `DecimalFloat.constants.t.sol:testZeroDeployed` +- `e()` -- `DecimalFloat.constants.t.sol:testEDeployed` +- `parse(string)` -- `DecimalFloat.parse.t.sol:testParseDeployed` +- `format(Float, Float, Float)` -- `DecimalFloat.format.t.sol:testFormatDeployed` +- `add(Float, Float)` -- `DecimalFloat.add.t.sol:testAddDeployed` +- `sub(Float, Float)` -- `DecimalFloat.sub.t.sol:testSubDeployed` +- `minus(Float)` -- `DecimalFloat.minus.t.sol:testMinusDeployed` +- `abs(Float)` -- `DecimalFloat.abs.t.sol:testAbsDeployed` +- `mul(Float, Float)` -- `DecimalFloat.mul.t.sol:testMulDeployed` +- `div(Float, Float)` -- `DecimalFloat.div.t.sol:testDivDeployed` +- `inv(Float)` -- `DecimalFloat.inv.t.sol:testInvDeployed` +- `eq(Float, Float)` -- `DecimalFloat.eq.t.sol:testEqDeployed` +- `lt(Float, Float)` -- `DecimalFloat.lt.t.sol:testLtDeployed` +- `gt(Float, Float)` -- `DecimalFloat.gt.t.sol:testGtDeployed` +- `lte(Float, Float)` -- `DecimalFloat.lte.t.sol:testLteDeployed` +- `gte(Float, Float)` -- `DecimalFloat.gte.t.sol:testGteDeployed` +- `integer(Float)` -- `DecimalFloat.integer.t.sol:testIntegerDeployed` +- `frac(Float)` -- `DecimalFloat.frac.t.sol:testFracDeployed` +- `floor(Float)` -- `DecimalFloat.floor.t.sol:testFloorDeployed` +- `ceil(Float)` -- `DecimalFloat.ceil.t.sol:testCeilDeployed` +- `pow(Float, Float)` -- `DecimalFloat.pow.t.sol:testPowDeployed` +- `sqrt(Float)` -- `DecimalFloat.sqrt.t.sol:testSqrtDeployed` +- `min(Float, Float)` -- `DecimalFloat.min.t.sol:testMinDeployed` +- `max(Float, Float)` -- `DecimalFloat.max.t.sol:testMaxDeployed` +- `isZero(Float)` -- `DecimalFloat.isZero.t.sol:testIsZeroDeployed` +- `fromFixedDecimalLossless(uint256, uint8)` -- `DecimalFloat.fromFixedDecimalLossless.t.sol:testFromFixedDecimalLosslessDeployed` +- `toFixedDecimalLossless(Float, uint8)` -- `DecimalFloat.toFixedDecimalLossless.t.sol:testToFixedDecimalLosslessDeployed` +- `fromFixedDecimalLossy(uint256, uint8)` -- `DecimalFloat.fromFixedDecimalLossy.t.sol:testFromFixedDecimalLossyDeployed` +- `toFixedDecimalLossy(Float, uint8)` -- `DecimalFloat.toFixedDecimalLossy.t.sol:testToFixedDecimalLossyDeployed` +- `FORMAT_DEFAULT_SCIENTIFIC_MIN` / `FORMAT_DEFAULT_SCIENTIFIC_MAX` -- `DecimalFloat.format.t.sol:testFormatConstants` + +### Functions with COMMENTED-OUT test (effectively untested at the concrete-contract level): +- `log10(Float)` -- `DecimalFloat.log10.t.sol` has the entire `testLog10Deployed` body commented out (lines 15-26) +- `pow10(Float)` -- `DecimalFloat.pow10.t.sol` has the entire `testPow10Deployed` body commented out (lines 15-26) + +### Functions with NO test at all at the concrete-contract level: +- `format(Float, bool)` (line 89) -- No test file or test function exercises this overload +- `format(Float)` (line 97) -- No test file or test function exercises this single-argument default-formatting overload + +--- + +## Findings + +### A01-6 [LOW] `format(Float a, bool scientific)` overload has zero test coverage + +**Location:** `src/concrete/DecimalFloat.sol:89` +**Fix:** `.fixes/A01-6.md` + +The two-argument `format(Float a, bool scientific)` function, which provides raw boolean control over scientific notation formatting, has no test in any test file. While the underlying `LibFormatDecimalFloat.toDecimalString` is tested elsewhere, the concrete contract's wiring of this overload is unverified. This is the entry point used by off-chain consumers (Rust/WASM), so a wiring mistake would silently pass. + +**Severity:** LOW -- The function is a trivial pass-through, but it is part of the public ABI consumed off-chain and should be validated. + +### A01-7 [LOW] `format(Float a)` single-argument default overload has zero test coverage + +**Location:** `src/concrete/DecimalFloat.sol:97` +**Fix:** `.fixes/A01-7.md` + +The single-argument `format(Float a)` function, which applies the default scientific min/max constants and delegates to `format(Float, Float, Float)`, has no dedicated test. No test anywhere calls `deployed.format(a)` with a single argument. A mis-wiring of the constants or the internal delegation path would go undetected. + +**Severity:** LOW -- It delegates to the tested 3-argument overload, but the default constant wiring path is itself untested. + +### A01-8 [LOW] `format(Float, Float, Float)` require revert path is not tested + +**Location:** `src/concrete/DecimalFloat.sol:79` +**Fix:** `.fixes/A01-8.md` + +The `format(Float a, Float scientificMin, Float scientificMax)` function contains a `require(scientificMin.lt(scientificMax), ...)` guard at line 79. The test in `DecimalFloat.format.t.sol` uses `vm.assume(scientificMin.lt(scientificMax))` (line 19) to skip inputs that would trigger this revert, meaning the failure path is never exercised. No test verifies that the contract actually reverts with the expected message when `scientificMin >= scientificMax`. + +**Severity:** LOW -- The require is a standard Solidity check and is unlikely to be wrong, but the test explicitly skips this path rather than covering it. + +### A01-4 [LOW] `log10(Float)` deployed-parity test is entirely commented out + +**Location:** `test/src/concrete/DecimalFloat.log10.t.sol:15-26` +**Fix:** `.fixes/A01-4.md` + +The `testLog10Deployed` function body is fully commented out. The file contains only the `log10External` helper but no active test exercising `deployed.log10(a)`. This means the concrete contract's `log10` function -- which hardcodes `LibDecimalFloat.LOG_TABLES_ADDRESS` -- is not tested at the deployed-contract level. A mistake in the hardcoded address or the delegation would be undetected by these tests. + +Note: The underlying `LibDecimalFloat.log10` is tested at the library level in `test/src/lib/LibDecimalFloat.log10.t.sol`, but that does not exercise the `DecimalFloat` concrete contract entry point. + +**Severity:** LOW -- The library-level tests cover the math, but the concrete contract wiring (including the hardcoded `LOG_TABLES_ADDRESS`) is unverified. + +### A01-5 [LOW] `pow10(Float)` deployed-parity test is entirely commented out + +**Location:** `test/src/concrete/DecimalFloat.pow10.t.sol:15-26` +**Fix:** `.fixes/A01-5.md` + +Same situation as A01-4. The `testPow10Deployed` function body is fully commented out. The concrete contract's `pow10` function, which hardcodes `LibDecimalFloat.LOG_TABLES_ADDRESS`, has no active deployed-parity test. + +**Severity:** LOW -- Mirrors A01-4. The library math is tested but the concrete contract path is not. + +### A01-9 [INFO] All fuzz tests use identical try/catch pattern + +All 28 active deployed-parity tests follow the same structural pattern: deploy a fresh `DecimalFloat` contract, call an external helper via `this.functionExternal(...)` inside a try block, and compare results against the deployed contract. Reverts are forwarded. This pattern is solid for confirming that the concrete contract delegates correctly to the library. However, it tests only the equivalence between direct library calls and deployed calls -- it does not test specific known-value assertions for the concrete contract. The library-level tests elsewhere handle known-value coverage. + +**Severity:** INFO -- Not a gap per se; the test strategy is deliberate. Noted for completeness. + +--- + +## Finding Summary + +| ID | Severity | Description | +|---|---|---| +| A01-4 | LOW | `log10(Float)` deployed-parity test entirely commented out | +| A01-5 | LOW | `pow10(Float)` deployed-parity test entirely commented out | +| A01-6 | LOW | `format(Float, bool)` overload has zero test coverage | +| A01-7 | LOW | `format(Float)` default overload has zero test coverage | +| A01-8 | LOW | `format(Float, Float, Float)` require revert path not tested | +| A01-9 | INFO | All fuzz tests use identical try/catch equivalence pattern | diff --git a/audit/2026-03-10-01/pass2/LibDecimalFloat.md b/audit/2026-03-10-01/pass2/LibDecimalFloat.md new file mode 100644 index 0000000..a16335e --- /dev/null +++ b/audit/2026-03-10-01/pass2/LibDecimalFloat.md @@ -0,0 +1,207 @@ +# Audit Pass 2 -- Test Coverage: `src/lib/LibDecimalFloat.sol` + +**Auditor agent:** A06 +**Date:** 2026-03-10 +**Library:** `LibDecimalFloat` (line 44, `src/lib/LibDecimalFloat.sol`) + +--- + +## Evidence of reading + +Source file: `src/lib/LibDecimalFloat.sol` (797 lines total) + +### Constants (lines 47-92) + +| Constant | Line | +|---|---| +| `LOG_TABLES_ADDRESS` | 50 | +| `FLOAT_ZERO` | 53 | +| `FLOAT_ONE` | 56 | +| `FLOAT_HALF` | 60 | +| `FLOAT_TWO` | 64 | +| `FLOAT_MAX_POSITIVE_VALUE` | 68 | +| `FLOAT_MIN_POSITIVE_VALUE` | 74 | +| `FLOAT_MAX_NEGATIVE_VALUE` | 80 | +| `FLOAT_MIN_NEGATIVE_VALUE` | 86 | +| `FLOAT_E` | 91 | + +### Functions (lines 104-795) + +| # | Function | Line | Signature | +|---|---|---|---| +| 1 | `fromFixedDecimalLossy` | 104 | `(uint256 value, uint8 decimals) -> (int256, int256, bool)` | +| 2 | `fromFixedDecimalLossyPacked` | 132 | `(uint256 value, uint8 decimals) -> (Float, bool)` | +| 3 | `fromFixedDecimalLossless` | 144 | `(uint256 value, uint8 decimals) -> (int256, int256)` | +| 4 | `fromFixedDecimalLosslessPacked` | 158 | `(uint256 value, uint8 decimals) -> Float` | +| 5 | `toFixedDecimalLossy` (parts) | 176 | `(int256, int256, uint8) -> (uint256, bool)` | +| 6 | `toFixedDecimalLossy` (packed) | 254 | `(Float, uint8) -> (uint256, bool)` | +| 7 | `toFixedDecimalLossless` (parts) | 265 | `(int256, int256, uint8) -> uint256` | +| 8 | `toFixedDecimalLossless` (packed) | 286 | `(Float, uint8) -> uint256` | +| 9 | `packLossy` | 299 | `(int256, int256) -> (Float, bool)` | +| 10 | `packLossless` | 358 | `(int256, int256) -> Float` | +| 11 | `unpack` | 373 | `(Float) -> (int256, int256)` | +| 12 | `add` | 388 | `(Float, Float) -> Float` | +| 13 | `sub` | 405 | `(Float, Float) -> Float` | +| 14 | `minus` | 421 | `(Float) -> Float` | +| 15 | `abs` | 440 | `(Float) -> Float` | +| 16 | `mul` | 474 | `(Float, Float) -> Float` | +| 17 | `div` | 491 | `(Float, Float) -> Float` | +| 18 | `inv` | 507 | `(Float) -> Float` | +| 19 | `eq` | 520 | `(Float, Float) -> bool` | +| 20 | `lt` | 531 | `(Float, Float) -> bool` | +| 21 | `gt` | 545 | `(Float, Float) -> bool` | +| 22 | `lte` | 557 | `(Float, Float) -> bool` | +| 23 | `gte` | 569 | `(Float, Float) -> bool` | +| 24 | `integer` | 582 | `(Float) -> Float` | +| 25 | `frac` | 593 | `(Float) -> Float` | +| 26 | `floor` | 603 | `(Float) -> Float` | +| 27 | `ceil` | 621 | `(Float) -> Float` | +| 28 | `pow10` | 652 | `(Float, address) -> Float` | +| 29 | `log10` | 668 | `(Float, address) -> Float` | +| 30 | `pow` | 690 | `(Float, Float, address) -> Float` | +| 31 | `sqrt` | 764 | `(Float, address) -> Float` | +| 32 | `min` | 773 | `(Float, Float) -> Float` | +| 33 | `max` | 781 | `(Float, Float) -> Float` | +| 34 | `isZero` | 788 | `(Float) -> bool` | + +### Test files read (28 files) + +- `LibDecimalFloat.abs.t.sol` +- `LibDecimalFloat.add.t.sol` +- `LibDecimalFloat.ceil.t.sol` +- `LibDecimalFloat.constants.t.sol` +- `LibDecimalFloat.decimal.t.sol` +- `LibDecimalFloat.decimalLossless.t.sol` +- `LibDecimalFloat.div.t.sol` +- `LibDecimalFloat.eq.t.sol` +- `LibDecimalFloat.floor.t.sol` +- `LibDecimalFloat.frac.t.sol` +- `LibDecimalFloat.gt.t.sol` +- `LibDecimalFloat.gte.t.sol` +- `LibDecimalFloat.integer.t.sol` +- `LibDecimalFloat.inv.t.sol` +- `LibDecimalFloat.isZero.t.sol` +- `LibDecimalFloat.log10.t.sol` +- `LibDecimalFloat.lt.t.sol` +- `LibDecimalFloat.lte.t.sol` +- `LibDecimalFloat.max.t.sol` +- `LibDecimalFloat.min.t.sol` +- `LibDecimalFloat.minus.t.sol` +- `LibDecimalFloat.mixed.t.sol` +- `LibDecimalFloat.mul.t.sol` +- `LibDecimalFloat.pack.t.sol` +- `LibDecimalFloat.pow.t.sol` +- `LibDecimalFloat.pow10.t.sol` +- `LibDecimalFloat.sqrt.t.sol` +- `LibDecimalFloat.sub.t.sol` + +--- + +## Coverage summary + +| Function | Has dedicated test? | Fuzz? | Edge cases? | Error paths? | Notes | +|---|---|---|---|---|---| +| `fromFixedDecimalLossy` | Yes (`decimal.t.sol`) | Yes | Yes (max int256, overflow) | N/A (no revert) | Well covered | +| `fromFixedDecimalLossyPacked` | Yes (`decimal.t.sol`) | Yes | Partial | N/A | Tests pack + lossy flag consistency | +| `fromFixedDecimalLossless` | Yes (`decimalLossless.t.sol`) | Yes | Yes | Yes (revert on lossy) | Well covered | +| `fromFixedDecimalLosslessPacked` | Yes (`decimalLossless.t.sol`) | Yes | Yes | Yes | Well covered | +| `toFixedDecimalLossy` (parts) | Yes (`decimal.t.sol`) | Yes | Yes (negative, zero, underflow, overflow, truncation) | Yes | Well covered | +| `toFixedDecimalLossy` (packed) | Yes (`decimal.t.sol`) | Yes | Via `testToFixedDecimalLossyPacked` | Yes | Consistency test with parts variant | +| `toFixedDecimalLossless` (parts) | Yes (`decimalLossless.t.sol`) | Yes | Yes | Yes (revert on lossy) | Well covered | +| `toFixedDecimalLossless` (packed) | Yes (`decimalLossless.t.sol`) | Yes | Via `testToFixedDecimalLosslessPacked` | Yes | Consistency test | +| `packLossy` | Yes (`pack.t.sol`) | Yes (round trip) | Yes (zero, exponent overflow, negative exponent lossy zero) | Yes (ExponentOverflow) | See finding A06-1 | +| `packLossless` | Indirect only | N/A | N/A | **NO** | See finding A06-2 | +| `unpack` | Yes (`pack.t.sol`) | Yes (round trip) | Yes | N/A | Covered via round-trip | +| `add` | Yes (`add.t.sol`) | Yes | Via fuzz | Via try/catch | Good -- consistency test with parts variant | +| `sub` | Yes (`sub.t.sol`) | Yes | Via fuzz | Via try/catch | Good -- consistency test | +| `minus` | Yes (`minus.t.sol`) | Yes | Via fuzz | Via try/catch | Good | +| `abs` | Yes (`abs.t.sol`) | Yes | Yes (non-negative, negative, min value shift) | N/A | Well covered | +| `mul` | Yes (`mul.t.sol`) | Yes | Via fuzz | Via try/catch | Consistency test | +| `div` | Yes (`div.t.sol`) | Yes | Yes (div by one, div by negative one) | Via try/catch | Good | +| `inv` | Yes (`inv.t.sol`) | Yes | Via fuzz | Via try/catch | Consistency test | +| `eq` | Yes (`eq.t.sol`) | Yes | Yes (zeros, cross-check with lt/gt) | Via try/catch | Good | +| `lt` | Yes (`lt.t.sol`) | Yes + reference | Yes (same, zero, negative vs positive, exponent overflow) | N/A | Well covered with reference implementation | +| `gt` | Yes (`gt.t.sol`) | Yes + reference | Yes (same, zero, negative vs positive, exponent overflow) | N/A | Well covered | +| `lte` | Yes (`lte.t.sol`) | Yes + reference | Yes (same, zero, signs) | N/A | Well covered | +| `gte` | Yes (`gte.t.sol`) | Yes + reference | Yes (same, zero, signs) | N/A | Well covered | +| `integer` | Yes (`integer.t.sol`) | Yes | Yes (examples with positives, negatives, int224 min/max) | N/A | Well covered | +| `frac` | Yes (`frac.t.sol`) | Yes | Yes (non-negative exponent, below -76, examples) | N/A | Well covered | +| `floor` | Yes (`floor.t.sol`) | Yes | Yes (non-negative exp, below -76, negative values, examples) | N/A | Well covered | +| `ceil` | Yes (`ceil.t.sol`) | Yes | Yes (non-negative exp, below -76, examples) | N/A | Well covered | +| `pow10` | Yes (`pow10.t.sol`) | Yes | Via fuzz + ExponentOverflow handling | Yes | Good | +| `log10` | Yes (`log10.t.sol`) | Yes | Via fuzz | Via try/catch | Good | +| `pow` | Yes (`pow.t.sol`) | Yes | Yes (b=0, a=0, a<0, b=1, a<0 b<0, b<0, round trip) | Yes (ZeroNegativePower, PowNegativeBase) | Well covered | +| `sqrt` | Yes (`sqrt.t.sol`) | Yes | Yes (zero, negative, round trip) | Yes (PowNegativeBase) | Good | +| `min` | Yes (`min.t.sol`) | Yes (identity, commutativity, lt, gt) | N/A | N/A | Good | +| `max` | Yes (`max.t.sol`) | Yes (identity, commutativity, lt, gt) | N/A | N/A | Good | +| `isZero` | Yes (`isZero.t.sol`) | Yes | Yes (wrapped zero, packed zero, any exponent, nonzero) | N/A | Well covered | +| Constants | Yes (`constants.t.sol`) | Partial fuzz | Yes (boundary checks via lte/gte, abs) | N/A | Good | + +--- + +## Findings + +### A06-1 `packLossless` has no direct test for `CoefficientOverflow` revert [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 358-364 +**Test file:** `test/src/lib/LibDecimalFloat.pack.t.sol` + +`packLossless` wraps `packLossy` and reverts with `CoefficientOverflow` when `lossless` is false. The pack test file (`LibDecimalFloat.pack.t.sol`) only tests `packLossy`: round-trip, zero, exponent overflow, and negative-exponent lossy zero. There is no test that calls `packLossless` directly and asserts that it reverts with `CoefficientOverflow` when the coefficient does not fit in `int224`. + +`packLossless` is used pervasively throughout other tests as a helper (187 call sites across 18 test files), but always with values known to fit. No test exercises the revert path. + +**Impact:** The `CoefficientOverflow` error path in `packLossless` is untested. A regression that breaks this revert (e.g., changing the error selector, forgetting to check the lossless flag) would not be caught by the pack test suite. + +### A06-2 `packLossy` lossy-but-packable path lacks targeted test [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 313-326 +**Test file:** `test/src/lib/LibDecimalFloat.pack.t.sol` + +When a coefficient does not fit in `int224` but is not so extreme that the exponent overflows, `packLossy` enters a loop that divides the coefficient by 10 and increments the exponent until it fits (lines 314-325). There is also a fast-path for very large coefficients (`/ 1e72`, line 314-317). The test file contains: +- `testPartsRoundTrip` -- uses `int224` inputs, so the lossy path is never triggered. +- `testPackExponentOverflow` -- triggers the exponent overflow revert, but does not test successful lossy packing. +- `testPackNegativeExponentLossyZero` -- triggers the lossy-zero fallback for very negative exponents. + +There is no test that provides a coefficient larger than `int224` range, confirms the function returns `(float, false)` with a correctly truncated coefficient, and then verifies that unpacking the result yields a numerically close value. + +**Impact:** The core lossy-packing truncation loop is exercised only indirectly through other function tests (add, mul, etc.). A bug in the fast-path divider (`1e72` threshold at line 314) or the truncation loop could go undetected in the unit test for pack. + +### A06-3 `pow` exponentiation-by-squaring loop untested at integer/fraction boundary [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 716-734 +**Test file:** `test/src/lib/LibDecimalFloat.pow.t.sol` + +The `pow` function splits `b` into integer and fractional parts (line 717), then performs exponentiation-by-squaring for the integer part and uses `10^(fraction * log10(a))` for the fractional part, multiplying them together. The test suite exercises: +- `b = 0` (returns 1) +- `b = 1` (identity) +- `b < 0` (recursive call with `inv`) +- Negative base (revert) +- Fuzz round-trip + +However, there are no targeted tests for the boundary where `b` is a large integer with no fractional part, verifying the squaring loop alone produces the correct result without the log/pow10 approximation path contributing error. The fuzz round-trip test uses a tolerance of 0.09 (9%), which is quite generous. + +**Impact:** Informational. The existing round-trip fuzz test provides broad coverage, but a targeted test could more precisely validate the squaring loop independently. + +### A06-4 `floor`/`ceil` not tested with `int224.min` coefficient and negative exponent [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 603-643 +**Test files:** `test/src/lib/LibDecimalFloat.floor.t.sol`, `test/src/lib/LibDecimalFloat.ceil.t.sol` + +`floor` has a subtraction path for negative coefficients with a fractional part (line 613: `sub(i, exponent, 1e76, -76)`). `ceil` has an addition path for positive coefficients with a fractional part (line 638: `add(i, exponent, 1e76, -76)`). + +The floor test uses `int224.min` with exponent `0` (identity case) but not with fractional exponents. Similarly, the ceil test uses `type(int224).max` with exponent `0` only. + +The extreme case of `floor(int224.min, -1)` requires subtracting 1 from the integer part of the most negative representable coefficient. The fuzz tests `testFloorInRangeNegative` and `testCeilInRange` do cover this range via fuzzing over `int224`, so it is likely exercised by the fuzzer, but there is no explicit targeted example. + +**Impact:** Informational. The fuzz tests likely catch issues here, but an explicit example for the extreme coefficient values combined with fractional exponents would strengthen confidence. + +### A06-5 No explicit test for `add`/`sub`/`mul` lossy packing via packed API [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 388-413, 474-500 +**Test files:** `test/src/lib/LibDecimalFloat.add.t.sol`, `test/src/lib/LibDecimalFloat.sub.t.sol`, `test/src/lib/LibDecimalFloat.mul.t.sol` + +The packed-API arithmetic functions (`add(Float, Float)`, `sub(Float, Float)`, `mul(Float, Float)`) call `packLossy` on the result, silently discarding the lossless flag. The tests verify consistency between the packed and unpacked APIs but do not test what happens when the arithmetic result is too large for `int224` and must be lossy-packed. For example, multiplying two large packed floats whose product coefficient exceeds `int224` range. + +The fuzz tests do generate random `Float` values which can trigger this, but there is no explicit targeted test. + +**Impact:** Informational. The consistency fuzz tests handle this implicitly through random input generation. diff --git a/audit/2026-03-10-01/pass2/LibDecimalFloatImplementation.md b/audit/2026-03-10-01/pass2/LibDecimalFloatImplementation.md new file mode 100644 index 0000000..32bdb21 --- /dev/null +++ b/audit/2026-03-10-01/pass2/LibDecimalFloatImplementation.md @@ -0,0 +1,225 @@ +# Audit Pass 2 (Test Coverage) - LibDecimalFloatImplementation.sol + +**Auditor:** A09 +**Source file:** `src/lib/implementation/LibDecimalFloatImplementation.sol` (1307 lines) +**Test files:** +- `test/src/lib/implementation/LibDecimalFloatImplementation.minus.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.sub.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.mul.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.inv.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.add.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.log10.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.lookupLogTableVal.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.maximize.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.pow10.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.div.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.eq.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.intFrac.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.unabsUnsignedMulOrDivLossy.t.sol` +- `test/src/lib/implementation/LibDecimalFloatImplementation.withTargetExponent.t.sol` +- `test/lib/implementation/LibDecimalFloatImplementationSlow.sol` (reference impl) +- `test/lib/LibDecimalFloatSlow.sol` (reference impl) + +--- + +## Evidence of Reading + +### Source Functions (all 22) +| # | Function | Line | Has Direct Test File? | +|---|----------|------|-----------------------| +| 1 | `minus` | 71 | Yes | +| 2 | `absUnsignedSignedCoefficient` | 89 | No | +| 3 | `unabsUnsignedMulOrDivLossy` | 116 | Yes | +| 4 | `mul` | 160 | Yes | +| 5 | `div` | 272 | Yes | +| 6 | `mul512` | 466 | No | +| 7 | `mulDiv` | 479 | No | +| 8 | `add` | 610 | Yes | +| 9 | `sub` | 703 | Yes | +| 10 | `eq` | 724 | Yes | +| 11 | `inv` | 736 | Yes | +| 12 | `lookupLogTableVal` | 744 | Yes | +| 13 | `log10` | 783 | Yes | +| 14 | `pow10` | 902 | Yes | +| 15 | `maximize` | 957 | Yes | +| 16 | `maximizeFull` | 1011 | Indirect (via maximize tests) | +| 17 | `compareRescale` | 1047 | No | +| 18 | `withTargetExponent` | 1127 | Yes | +| 19 | `intFrac` | 1169 | Yes | +| 20 | `mantissa4` | 1197 | No | +| 21 | `lookupAntilogTableY1Y2` | 1227 | No | +| 22 | `unitLinearInterpolation` | 1267 | No | + +### Error Paths +| Error | Used In | Tested? | +|-------|---------|---------| +| `ExponentOverflow` | `minus` (line 76), `add` (line 681) | Only `add` tested (add.t.sol line 139). `minus` ExponentOverflow path untested. | +| `DivisionByZero` | `div` (line 278) | Yes (div.t.sol line 38) | +| `MaximizeOverflow` | `div` (lines 395, 399), `maximizeFull` (line 1014) | Partially -- `div` tests MaximizeOverflow for denominator (div.t.sol line 50), but not the `scale == 0` path (line 395) or the `!fullA` path (line 399) directly. | +| `Log10Zero` | `log10` (line 795) | No | +| `Log10Negative` | `log10` (line 797) | No | +| `MulDivOverflow` | `mulDiv` (line 491) | No | +| `WithTargetExponentOverflow` | `withTargetExponent` (lines 1146, 1153) | Yes (withTargetExponent.t.sol lines 44, 67, 164, 211) | + +### Test File Contents Summary + +**minus.t.sol** (1 test): `testMinusIsSubZero` -- fuzz test comparing `minus(x)` to `sub(0, x)`. Bounds exponents to `EXPONENT_MIN/10..EXPONENT_MAX/10`. Does not test `type(int256).min` coefficient with `type(int256).max` exponent (the ExponentOverflow path). + +**sub.t.sol** (4 tests): `testSubIsAdd` (fuzz), `testSubMinSignedValue` (fuzz), `testSubOneFromMax` (concrete), `testSubSelf` (fuzz). Good coverage of subtraction semantics. + +**mul.t.sol** (11 tests): Mix of concrete and fuzz. Includes zero, negative, large coefficient, and exponent variation tests. Has a reference comparison (`testMulNotRevertAnyExpectation`) against `LibDecimalFloatSlow.mulSlow`. Solid coverage. + +**div.t.sol** (9 tests): `DivisionByZero` tested. `MaximizeOverflow` tested for min-exponent denominator. Concrete and fuzz tests for division precision. No fuzz reference comparison against a slow implementation. + +**add.t.sol** (9 tests): Extensive concrete examples, fuzz tests for identity/zero properties, exponent overflow tested. Good coverage. + +**inv.t.sol** (4 tests): Reference comparison, gas test, `DivisionByZero` for inv(0). Good coverage. + +**eq.t.sol** (8 tests): Extensive fuzz tests, reference comparison. Good coverage. + +**maximize.t.sol** (4 tests): Idempotency, reference, concrete examples. Good coverage. + +**withTargetExponent.t.sol** (10 tests): Comprehensive fuzz testing of all branches and error paths. Best-tested function. + +**intFrac.t.sol** (4 tests): Concrete examples, fuzz tests for each exponent range. Good coverage. + +**unabsUnsignedMulOrDivLossy.t.sol** (8 tests): Good sign-combination coverage. Tests for `c > type(int256).max` branches. Tests exact `type(int256).max + 1` edge case. Explicitly excludes `exponent == type(int256).max` with `vm.assume`. + +**log10.t.sol** (5 tests): Exact powers of 10, exact lookups, interpolation, negative logs. No error path tests. + +**pow10.t.sol** (5 tests): Exact powers, lookups, interpolation, range fuzz. No error path tests. + +**lookupLogTableVal.t.sol** (1 test): Exhaustive spot checks at 100-index intervals across full range. + +--- + +## Findings + +### A09-11: Six internal functions have no direct test coverage (LOW) + +**Functions without any dedicated test file:** +1. `absUnsignedSignedCoefficient` (line 89) -- Only called via `mul`, `div`, and `mulSlow` reference. No direct unit tests for edge cases like `type(int256).min`, `0`, `1`, `-1`. +2. `mul512` (line 466) -- Only called indirectly via `mul`, `div`, `mulDiv`. No direct tests for overflow behavior or specific 512-bit product verification. +3. `mulDiv` (line 479) -- Only called indirectly via `mul`, `div`. No direct test exercising the 512-bit division path or the `MulDivOverflow` error. +4. `compareRescale` (line 1047) -- Only called via `eq`. No direct tests. The `eq` tests indirectly cover it but do not isolate the rescaling logic or test the `didSwap` branches independently. +5. `mantissa4` (line 1197) -- Only called via `pow10`. No direct tests for boundary conditions (exponent exactly -4, exponent < -80, exponent in [-3, -1]). +6. `unitLinearInterpolation` (line 1267) -- Only called via `log10` and `pow10`. No direct tests. The `x1Coefficient == xCoefficient` short-circuit path is untested in isolation. + +**Also no dedicated test:** `lookupAntilogTableY1Y2` (line 1227) -- only called via `pow10`. + +**Impact:** These functions are exercised indirectly through higher-level function tests, which provides some coverage. However, edge cases specific to these functions (e.g., `mulDiv` with `prod1 >= denominator` triggering `MulDivOverflow`, or `absUnsignedSignedCoefficient` with `type(int256).min`) are not specifically targeted by any test. + +--- + +### A09-12: `minus` ExponentOverflow error path untested (LOW) + +**Location:** Source line 75-77, test file `LibDecimalFloatImplementation.minus.t.sol` + +**Description:** +The `minus` function has a specific check for `signedCoefficient == type(int256).min && exponent == type(int256).max` that reverts with `ExponentOverflow`. This error path is never tested. The only test (`testMinusIsSubZero`) bounds exponents to `EXPONENT_MIN/10..EXPONENT_MAX/10`, which never reaches `type(int256).max`. + +**Impact:** Low. The error path is unreachable through the packed Float API (32-bit exponents). However, as an internal function, it could be called from future code with wider exponent ranges. + +--- + +### A09-13: `log10` error paths (`Log10Zero`, `Log10Negative`) completely untested (LOW) + +**Location:** Source lines 794-798, test file `LibDecimalFloatImplementation.log10.t.sol` + +**Description:** +The `log10` function reverts with `Log10Zero` when `signedCoefficient == 0` (after maximization) and with `Log10Negative` when `signedCoefficient < 0`. Neither error path is tested anywhere in the test suite. No `vm.expectRevert` test exists for either error. + +The log10 tests only exercise positive inputs (exact powers of 10, positive lookups, and values that produce negative logs via the `inv` path). + +**Impact:** Low. These are important domain-validity checks (log of zero and log of negative are mathematically undefined). Without tests, regressions that accidentally remove or alter these checks would go undetected. + +--- + +### A09-14: `MulDivOverflow` error path in `mulDiv` untested (LOW) + +**Location:** Source lines 490-492 + +**Description:** +The `mulDiv` function reverts with `MulDivOverflow(x, y, denominator)` when `prod1 >= denominator`. This error is never triggered in any test. The callers (`mul`, `div`) are designed to avoid this condition through their `adjustExponent` / `scale` logic. However, `mulDiv` is a `public` internal function that could be called from new code paths. + +No test directly calls `mulDiv` with inputs that produce `prod1 >= denominator`. + +**Impact:** Low. The check is a standard safety guard from the OpenZeppelin mulDiv pattern. It protects against division-by-zero and results that would exceed `uint256`. While unlikely to regress, having no test means the error selector and parameters are unverified. + +--- + +### A09-15: `unabsUnsignedMulOrDivLossy` exponent overflow panic untested (relates to Pass 1 A09-1) (LOW) + +**Location:** Source lines 132, 144; test file `LibDecimalFloatImplementation.unabsUnsignedMulOrDivLossy.t.sol` + +**Description:** +Pass 1 finding A09-1 identified that `exponent + 1` at lines 132 and 144 will produce a raw `Panic(0x11)` (arithmetic overflow) rather than a custom `ExponentOverflow` error when `exponent == type(int256).max`. The test file explicitly avoids this case via `vm.assume(exponent != type(int256).max)` (lines 40, 87, 134, 163). + +There is no test that verifies the behavior when `exponent == type(int256).max` and `signedCoefficientAbs > type(int256).max`. Whether the intent is to revert with `ExponentOverflow` or accept the raw panic, neither behavior is tested. + +**Impact:** Low. This is the test coverage gap corresponding to the Pass 1 security finding. If A09-1 is fixed (adding an explicit `ExponentOverflow` check), a test should be added. Even if left as-is, a test documenting the expected `Panic(0x11)` behavior would be valuable. + +--- + +### A09-16: `div` lacks a fuzz reference comparison test (INFORMATIONAL) + +**Location:** Test file `LibDecimalFloatImplementation.div.t.sol` + +**Description:** +The `mul` test suite includes `testMulNotRevertAnyExpectation` which compares the optimized `mul` against `LibDecimalFloatSlow.mulSlow` across random inputs. The `div` test suite has no equivalent fuzz reference test. Division is the most complex function in the library (~190 lines with a 16-way binary search for scale selection), making it the function most likely to benefit from a reference comparison. + +The `inv` tests do compare against `LibDecimalFloatSlow.invSlow`, but that reference simply calls `div(1e37, -37, ...)`, so it does not independently verify division correctness. + +**Impact:** Informational. The concrete tests for `div` are reasonably thorough (precision checks for 1/3, 1/9, division by 1, various OOM combinations). However, a fuzz reference test would provide stronger assurance that the binary search scale selection and exponent adjustment logic are correct across the full input domain. + +--- + +### A09-17: `maximize` does not test the `full == false` return path directly (INFORMATIONAL) + +**Location:** Source lines 957-1003, test file `LibDecimalFloatImplementation.maximize.t.sol` + +**Description:** +The `maximize` function returns a `bool full` indicating whether the coefficient was fully maximized. The test file only calls `maximizeFull` (which reverts on `!full`). There is no test that calls `maximize` directly and asserts that `full == false` for specific inputs where the exponent is too small to allow further maximization. + +The `full == false` case occurs when `exponent` is near `type(int256).min` and the coefficient cannot be multiplied further without underflowing the exponent. This path is used by `div` (lines 288-289) where partial maximization is acceptable. + +**Impact:** Informational. The `full == false` path is exercised indirectly by `div` tests. However, no test explicitly verifies the boolean return value or checks the coefficient/exponent values when maximization is partial. + +--- + +### A09-18: `div` internal error paths for `scale == 0` and `!fullA` are not specifically targeted (INFORMATIONAL) + +**Location:** Source lines 395-399 + +**Description:** +Inside `div`, after the binary search for scale selection, there are two `MaximizeOverflow` reverts: +1. Line 395: `if (scale == 0)` -- this occurs if the denominator maximized to a very small value and the binary search loop reduced scale to zero. This would indicate an internal consistency error. +2. Line 399: `if (!fullA)` -- this occurs if the numerator could not be fully maximized. + +The div test `testDivMinPositiveValueDenominatorRevert` triggers `MaximizeOverflow` via the `maximizeFull` call inside `div` (for the denominator `1, type(int256).min`), not via lines 395 or 399 specifically. + +**Impact:** Informational. These are defensive checks for conditions that may be unreachable given `maximize`'s guarantees. Testing them would require constructing inputs that pass the initial `maximize` calls but still fail these checks. + +--- + +## Summary + +| ID | Severity | Title | +|----|----------|-------| +| A09-11 | LOW | Six internal functions have no direct test coverage | +| A09-12 | LOW | `minus` ExponentOverflow error path untested | +| A09-13 | LOW | `log10` error paths (`Log10Zero`, `Log10Negative`) completely untested | +| A09-14 | LOW | `MulDivOverflow` error path in `mulDiv` untested | +| A09-15 | LOW | `unabsUnsignedMulOrDivLossy` exponent overflow panic untested (A09-1 related) | +| A09-16 | INFORMATIONAL | `div` lacks a fuzz reference comparison test | +| A09-17 | INFORMATIONAL | `maximize` does not test `full == false` return path directly | +| A09-18 | INFORMATIONAL | `div` internal `scale == 0` and `!fullA` error paths not specifically targeted | + +### Overall Assessment + +The test suite provides good coverage for the core arithmetic operations (`add`, `sub`, `mul`, `div`, `eq`) and the higher-level functions (`log10`, `pow10`). Several functions have fuzz tests with reference implementations (`mul`, `inv`, `eq`, `maximize`), which provides strong regression assurance. + +The main gaps are: +1. **Error path testing** -- Several custom errors (`Log10Zero`, `Log10Negative`, `MulDivOverflow`, `minus`'s `ExponentOverflow`) are never triggered in tests. This means changes to these guards would go undetected. +2. **Internal helper functions** -- Six functions (`absUnsignedSignedCoefficient`, `mul512`, `mulDiv`, `compareRescale`, `mantissa4`, `unitLinearInterpolation`) have no direct tests. They are exercised indirectly but their edge cases are not specifically targeted. +3. **The A09-1 gap** -- The `unabsUnsignedMulOrDivLossy` tests explicitly exclude the exponent overflow case that Pass 1 identified as having an inconsistent error type. diff --git a/audit/2026-03-10-01/pass2/LibFormatDecimalFloat.md b/audit/2026-03-10-01/pass2/LibFormatDecimalFloat.md new file mode 100644 index 0000000..ed2501c --- /dev/null +++ b/audit/2026-03-10-01/pass2/LibFormatDecimalFloat.md @@ -0,0 +1,127 @@ +# Audit Pass 2: Test Coverage -- `src/lib/format/LibFormatDecimalFloat.sol` (A08) + +## Evidence of Reading + +### Source: `src/lib/format/LibFormatDecimalFloat.sol` (165 lines) + +**`countSigFigs(int256 signedCoefficient, int256 exponent)` -- lines 18-50:** +- Line 19: zero coefficient early return (returns 1). +- Lines 25-30: negative exponent -- strip trailing zeros from coefficient. +- Lines 32-35: count digits by dividing coefficient by 10. +- Lines 38-47: adjust for exponent. Negative exponent: sigFigs = max(sigFigs, |exponent|). Positive exponent: sigFigs += exponent. +- Line 49: return. + +**`toDecimalString(Float float, bool scientific)` -- lines 58-164:** +- Line 59: unpack float. +- Lines 60-62: zero coefficient early return (returns "0"). +- Lines 66-76: scientific mode -- maximizeFull, then scale by 1e76 or 1e75. +- Lines 77-82: non-scientific, positive exponent -- multiply coefficient by 10^exponent (OVERFLOW RISK, A08-1). +- Lines 83-86: non-scientific, negative exponent < -76 -- revert UnformatableExponent. +- Lines 88-92: non-scientific, negative exponent in [-76, -1] -- compute scale. +- Lines 93-96: non-scientific, exponent == 0 -- scaleExponent = 0. +- Lines 99-110: split into integral and fractional parts. +- Lines 112-120: determine sign, make integral/fractional positive. +- Lines 122-148: build fractional string with leading zeros, strip trailing zeros. +- Lines 150-157: build integral string, build exponent string (scientific only). +- Lines 159-163: concatenate prefix, integral, fractional, exponent. + +### Test: `LibFormatDecimalFloat.countSigFigs.t.sol` (125 lines) + +| Test Function | Lines | Coverage | +|---------------|-------|----------| +| `testCountSigFigsExamples` | 16-111 | Hard-coded examples: zero, positive/negative integers, decimals, trailing zeros, internal zeros, positive exponents. ~50 assertions. | +| `testCountSigFigsZero` (fuzz) | 113-115 | Zero coefficient with any exponent always returns 1. | +| `testCountSigFigsOne` (fuzz) | 117-124 | Coefficient = 10^(-exponent) for exponent in [-76, 0], verifying sigFigs = 1 for both positive and negative. | + +### Test: `LibFormatDecimalFloat.toDecimalString.t.sol` (253 lines) + +| Test Function | Lines | Coverage | +|---------------|-------|----------| +| `testFormatDecimalRoundTripExamples` | 37-92 | 47 round-trip checks (parse then format). Both scientific and non-scientific. Includes 0, negatives, large coefficients, small fractions, e-76, e76, e200. | +| `testFormatDecimalRoundTripNonNegative` (fuzz) | 95-105 | Fuzz: random non-negative value via `fromFixedDecimalLosslessPacked(value, 18)`, both scientific modes. Round-trip: format -> parse -> eq. Canonicalization check. | +| `testFormatDecimalRoundTripNegative` (fuzz) | 108-125 | Fuzz: negative = minus(positive), verify negative format == "-" + positive format, then parse -> eq. | +| `testFormatDecimalExamples` | 128-252 | ~92 hard-coded format assertions. Covers: scientific with varying exponents, zero with various exponents, non-scientific integers, decimals (0.01, 0.1, 0.101, 1.1), 9-sig-fig formatting, 10-sig-fig scientific, powers of 10, extreme magnitudes. | + +### Test: `DecimalFloat.format.t.sol` (43 lines) + +| Test Function | Lines | Coverage | +|---------------|-------|----------| +| `testFormatDeployed` (fuzz) | 18-31 | Fuzz: arbitrary Float + scientific bounds. Compares library call vs deployed contract call. Error parity checked via try/catch. | +| `testFormatConstants` | 33-42 | Verifies FORMAT_DEFAULT_SCIENTIFIC_MIN == (1, -4) and FORMAT_DEFAULT_SCIENTIFIC_MAX == (1, 9). | + +## Coverage Analysis + +### Lines/Branches Covered + +| Source Line(s) | Branch/Path | Test Coverage | +|----------------|-------------|---------------| +| 19 (zero coeff) | countSigFigs zero | `testCountSigFigsExamples` line 17, `testCountSigFigsZero` fuzz | +| 25-30 (neg exp strip) | countSigFigs trailing zero strip | `testCountSigFigsExamples` lines 21, 25, 29, 33, etc. | +| 32-35 (digit count) | countSigFigs digit loop | All non-zero examples | +| 38-42 (neg exp adjust) | countSigFigs neg-exp sigfigs | Lines 36-57 (0.1, 0.01, 0.001 examples) | +| 43-46 (pos exp adjust) | countSigFigs pos-exp sigfigs | Lines 106-110 (1e1, 1e2, -1e3) | +| 60-62 (zero return) | toDecimalString zero | `testFormatDecimalExamples` lines 139-145, round-trip lines 41-42 | +| 66-76 (scientific scale) | toDecimalString scientific mode | `testFormatDecimalExamples` lines 130-136, 148-154, 228-232, 235-244 | +| 77-82 (non-sci pos exp) | toDecimalString non-sci positive exponent | `testFormatDecimalExamples` lines 161-162, 167-168, 224-225 (max exp=2) | +| 83-86 (UnformatableExponent) | toDecimalString neg exp < -76 | **NOT TESTED** | +| 88-92 (non-sci neg exp) | toDecimalString non-sci negative exponent | `testFormatDecimalExamples` lines 172-199, 208-223 | +| 93-96 (non-sci exp=0) | toDecimalString non-sci exponent=0 | `testFormatDecimalExamples` lines 160, 166, 220-221 | +| 99-110 (integral/frac split) | toDecimalString split | All non-zero formatted outputs | +| 112-120 (sign handling) | toDecimalString negative values | All negative examples | +| 126-148 (fractional build) | toDecimalString fractional string | Lines 172-199, 208-223, 62-65, 78-81 | +| 150-157 (exponent string) | toDecimalString exponent suffix | Scientific mode examples | +| 155 (displayExponent == 0) | Scientific with displayExponent=0 | `testFormatDecimalExamples` line 157 ("1" for coefficient=1, exp=0) | + +### Lines/Branches NOT Covered + +| Source Line(s) | Branch/Path | Gap Description | +|----------------|-------------|-----------------| +| 84-85 | `exponent < -76` revert | No test asserts `UnformatableExponent` is thrown | +| 77-82 | Non-scientific with large positive exponent (overflow) | Only tested with exponent up to 2; no test exercises A08-1 overflow | + +## Findings + +### A08-2 [LOW]: No test for `UnformatableExponent` revert path (line 84-85) + +The `toDecimalString` function reverts with `UnformatableExponent(exponent)` when called in non-scientific mode with `exponent < -76` (line 84-85). This is the only explicit error path in the file and it has zero test coverage: no test in any of the three test files calls `toDecimalString` with `scientific=false` and an exponent below -76, and no test uses `vm.expectRevert` with the `UnformatableExponent` selector. + +This matters because: +1. If the guard were accidentally removed or the threshold changed, no test would catch the regression. +2. Callers relying on this error for input validation have no specification-level assurance it works. + +### A08-3 [LOW]: No test for non-scientific mode with large positive exponents (A08-1 overflow) + +Pass 1 finding A08-1 identified that line 80 (`signedCoefficient *= int256(10) ** uint256(exponent)`) can overflow for valid Float values with large positive exponents when `scientific=false`. The existing tests only exercise non-scientific positive exponents up to 2 (e.g., `checkFormat(1, 2, false, "100")`). + +There is no test that: +- Exercises the overflow behavior (e.g., `packLossless(type(int224).max, 10)` formatted with `scientific=false`). +- Verifies any guard or expected revert for this path. +- Tests the boundary between formattable and unformattable positive exponents. + +This is a coverage gap for the vulnerability identified in A08-1. The fuzz tests (`testFormatDecimalRoundTripNonNegative` and `testFormatDecimalRoundTripNegative`) always create floats via `fromFixedDecimalLosslessPacked(value, 18)`, which produces exponents near -18 after normalization, so they never exercise this path. + +### A08-4 [INFO]: `countSigFigs` is not exposed in the concrete contract + +`countSigFigs` is defined as `internal pure` and tested directly in `LibFormatDecimalFloat.countSigFigs.t.sol`, but it is not exposed through `DecimalFloat.sol` (the concrete contract). This means it is unavailable for off-chain use via the Rust/WASM layer that calls through the concrete contract. If this is intentional (utility for potential library consumers only), no action needed. If off-chain callers need sig-fig counting, the function should be exposed. + +### A08-5 [INFO]: Fuzz tests for `toDecimalString` only exercise fixed-decimal-derived floats + +Both fuzz tests (`testFormatDecimalRoundTripNonNegative` at line 95 and `testFormatDecimalRoundTripNegative` at line 108) create floats exclusively via `fromFixedDecimalLosslessPacked(value, 18)`. This constrains the fuzz domain to: +- Coefficients that fit in the normalized form of a uint256 / 1e18 representation. +- Exponents near -18 (after normalization by pack). + +This means the fuzzer never explores: +- Large positive exponents (e.g., `(1, 100)` in non-scientific mode). +- Very small negative exponents close to -76 boundary. +- Coefficients near `int224.max` or `int224.min` with non-zero exponents. + +A broader fuzz test that generates arbitrary valid packed Float values (random int224 coefficient, random int32 exponent) would significantly improve coverage of edge cases and boundary conditions. + +## Summary + +| ID | Severity | Description | +|----|----------|-------------| +| A08-2 | LOW | No test for `UnformatableExponent` revert path (line 84-85) | +| A08-3 | LOW | No test for non-scientific mode with large positive exponents (A08-1 overflow) | +| A08-4 | INFO | `countSigFigs` not exposed in concrete contract | +| A08-5 | INFO | Fuzz tests only exercise fixed-decimal-derived floats, missing broad coverage | diff --git a/audit/2026-03-10-01/pass2/errors_gen_deploy.md b/audit/2026-03-10-01/pass2/errors_gen_deploy.md new file mode 100644 index 0000000..cb40a2a --- /dev/null +++ b/audit/2026-03-10-01/pass2/errors_gen_deploy.md @@ -0,0 +1,178 @@ +# Audit Pass 2 -- Test Coverage: Error Definitions, Generated Constants, Deploy Library + +**Agents:** A02, A03, A04, A05, A07 + +--- + +## A02: `src/error/ErrDecimalFloat.sol` + +**Errors defined (13 total):** + +| Error | Line | Tested via `vm.expectRevert`? | Test File(s) | +|---|---|---|---| +| `CoefficientOverflow` | 8 | NO -- imported in parse test but never asserted | (imported only in `LibParseDecimalFloat.t.sol`) | +| `ExponentOverflow` | 11 | YES | `LibDecimalFloat.pack.t.sol`, `LibDecimalFloat.pow10.t.sol`, `LibParseDecimalFloat.t.sol`, `LibDecimalFloat.decimal.t.sol`, `LibDecimalFloatImplementation.add.t.sol` | +| `NegativeFixedDecimalConversion` | 15 | YES | `LibDecimalFloat.decimal.t.sol` | +| `Log10Zero` | 18 | NO | (none) | +| `Log10Negative` | 21 | NO | (none) | +| `LossyConversionToFloat` | 25 | YES | `LibDecimalFloat.decimalLossless.t.sol` | +| `LossyConversionFromFloat` | 29 | YES | `LibDecimalFloat.decimalLossless.t.sol` | +| `ZeroNegativePower` | 32 | YES | `LibDecimalFloat.pow.t.sol` | +| `MulDivOverflow` | 35 | NO | (none) | +| `MaximizeOverflow` | 38 | YES | `LibDecimalFloatImplementation.div.t.sol` | +| `DivisionByZero` | 43 | YES | `LibDecimalFloatImplementation.div.t.sol`, `LibDecimalFloatImplementation.inv.t.sol` | +| `PowNegativeBase` | 46 | YES | `LibDecimalFloat.pow.t.sol`, `LibDecimalFloat.sqrt.t.sol` | +| `WriteError` | 49 | NO -- imported in deploy lib but never used in any `revert` statement | (dead code) | + +**Evidence of reading:** All 13 error definitions at lines 7-49 were read. Each error name was grepped across the entire `test/` directory for both `files_with_matches` and then for `.selector` / `(` patterns to distinguish import-only from actual assertion usage. + +--- + +## A03: `src/error/ErrFormat.sol` + +**Errors defined (1 total):** + +| Error | Line | Tested via `vm.expectRevert`? | Test File(s) | +|---|---|---|---| +| `UnformatableExponent` | 7 | NO | (none) | + +The error is thrown in `LibFormatDecimalFloat.sol` at line 85 when `exponent < -76`. The format test file (`LibFormatDecimalFloat.toDecimalString.t.sol`) contains no `vm.expectRevert` calls at all and never exercises exponents below -76 in a way that would trigger this branch. + +**Evidence of reading:** The single error definition at line 7 was read. Grepped `test/` for `UnformatableExponent` -- zero matches. Grepped format test file for any `revert` or `expectRevert` -- zero matches. + +--- + +## A04: `src/error/ErrParse.sol` + +**Errors defined (4 total):** + +| Error | Line | Tested via assertion? | Test File(s) | +|---|---|---|---| +| `MalformedDecimalPoint` | 7 | YES (selector comparison) | `LibParseDecimalFloat.t.sol` | +| `MalformedExponentDigits` | 11 | YES (selector comparison) | `LibParseDecimalFloat.t.sol` | +| `ParseDecimalPrecisionLoss` | 16 | YES (selector comparison) | `LibParseDecimalFloat.t.sol` | +| `ParseDecimalFloatExcessCharacters` | 19 | YES (selector comparison) | `LibParseDecimalFloat.t.sol` | + +All four parse errors are tested via `checkParseDecimalFloatFail` which asserts the error selector matches. Coverage is adequate. + +**Evidence of reading:** All 4 error definitions at lines 6-19 were read. Each error was found in `test/src/lib/parse/LibParseDecimalFloat.t.sol` with explicit selector assertions. + +--- + +## A05: `src/generated/LogTables.pointers.sol` + +**Constants defined (6 total):** + +| Constant | Line | Description | +|---|---|---| +| `BYTECODE_HASH` | 13 | Placeholder `0x0...0` -- appears to be unused/stale | +| `LOG_TABLES` | 16-17 | Main log lookup table (hex data) | +| `LOG_TABLES_SMALL` | 19-21 | Small log table | +| `LOG_TABLES_SMALL_ALT` | 23-25 | Small alt log table | +| `ANTI_LOG_TABLES` | 27-29 | Anti-log lookup table | +| `ANTI_LOG_TABLES_SMALL` | 31-33 | Small anti-log table | + +**Test coverage:** + +- `BYTECODE_HASH` is set to all zeros and is not referenced in any test or source file outside this generated file. It appears to be a stale placeholder from the code generation template. +- The five table constants (`LOG_TABLES`, `LOG_TABLES_SMALL`, `LOG_TABLES_SMALL_ALT`, `ANTI_LOG_TABLES`, `ANTI_LOG_TABLES_SMALL`) are consumed by `LibDecimalFloatDeploy.combinedTables()`, which concatenates them all together. +- `combinedTables()` is called in `test/abstract/LogTest.sol` (line 18), which deploys the tables to an in-memory EVM and asserts the deployed codehash matches `LOG_TABLES_DATA_CONTRACT_HASH`. This provides an integrity check that the combined table data matches the expected hash. +- `combinedTables()` is also called in `test/src/lib/deploy/LibDecimalFloatDeploy.t.sol` for deployment address and codehash validation. +- The `LOG_TABLE_DISAMBIGUATOR` (from `LibLogTable.sol`) is appended to the combined tables but has no standalone test of its value. +- There are no tests that validate individual table entries against reference values or known mathematical identities (e.g., verifying that `log10(1000) = 3` is correctly encoded in the table data). +- The tables are indirectly validated via the log10/pow10 functional tests that perform lookups against the deployed data contract and verify mathematical results. + +**Evidence of reading:** All 6 constants were read. `BYTECODE_HASH` was grepped across the codebase -- found only in this file, `lib/rain.sol.codegen/`, and a pass1 audit file. The five table constants were traced through `combinedTables()` into `LogTest.sol` and `LibDecimalFloatDeploy.t.sol`. + +--- + +## A07: `src/lib/deploy/LibDecimalFloatDeploy.sol` + +**Source elements (4 constants + 1 function):** + +| Element | Line | Description | +|---|---|---| +| `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` | 23 | Deterministic address for log tables | +| `LOG_TABLES_DATA_CONTRACT_HASH` | 27 | Expected codehash for log tables | +| `ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` | 32 | Deterministic address for DecimalFloat | +| `DECIMAL_FLOAT_CONTRACT_HASH` | 36 | Expected codehash for DecimalFloat | +| `combinedTables()` | 42-51 | Concatenates all table constants | + +**Test files examined:** + +### `test/src/lib/deploy/LibDecimalFloatDeploy.t.sol` + +| Test | What it covers | +|---|---| +| `testDeployAddress` | Deploys `DecimalFloat` via Zoltu proxy on forked Ethereum; asserts address == `ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` and codehash == `DECIMAL_FLOAT_CONTRACT_HASH` | +| `testExpectedCodeHashDecimalFloat` | Deploys `DecimalFloat` locally via `new`; asserts codehash == `DECIMAL_FLOAT_CONTRACT_HASH` | +| `testDeployAddressLogTables` | Deploys combined tables via Zoltu proxy on forked Ethereum; asserts address == `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` and codehash == `LOG_TABLES_DATA_CONTRACT_HASH` | +| `testExpectedCodeHashLogTables` | Deploys combined tables locally via `create`; asserts codehash == `LOG_TABLES_DATA_CONTRACT_HASH` and non-empty code | + +### `test/src/lib/deploy/LibDecimalFloatDeployProd.t.sol` + +| Test | What it covers | +|---|---| +| `testProdDeploymentArbitrum` | Checks both contracts exist on Arbitrum with correct codehashes | +| `testProdDeploymentBase` | Checks both contracts exist on Base with correct codehashes | +| `testProdDeploymentBaseSepolia` | Checks both contracts exist on Base Sepolia with correct codehashes | +| `testProdDeploymentFlare` | Checks both contracts exist on Flare with correct codehashes | +| `testProdDeploymentPolygon` | Checks both contracts exist on Polygon with correct codehashes | + +All 4 constants and the `combinedTables()` function are covered. The prod test provides cross-chain verification. The `combinedTables()` function is additionally exercised in `test/abstract/LogTest.sol` (used by all log10/pow10 tests). + +**Evidence of reading:** Both test files read in full (47 and 53 lines respectively). All constants traced to test assertions. `combinedTables()` traced to both deploy tests and `LogTest.sol`. + +--- + +## Findings + +### A02-1 [LOW] `Log10Zero` error has no direct test + +**Location:** `src/error/ErrDecimalFloat.sol:18`, thrown at `src/lib/implementation/LibDecimalFloatImplementation.sol:795` + +The `Log10Zero()` error, thrown when attempting `log10(0)`, is never explicitly tested. The log10 test in `LibDecimalFloatImplementation.log10.t.sol` only tests positive values. The fuzz test in `LibDecimalFloat.log10.t.sol` uses a generic `try/catch` that would catch any revert but does not assert the specific error selector. No test explicitly calls `log10(0, 0)` and asserts `Log10Zero()` is thrown. + +This is a meaningful gap because `log10(0)` is a mathematically undefined operation that users could easily attempt (e.g., `log10(0)` in a Rainlang expression). Confirming the correct error is thrown is important for debugging. + +### A02-2 [LOW] `Log10Negative` error has no direct test + +**Location:** `src/error/ErrDecimalFloat.sol:21`, thrown at `src/lib/implementation/LibDecimalFloatImplementation.sol:797` + +The `Log10Negative` error, thrown when attempting `log10` of a negative number, is never explicitly tested. Same situation as A02-1: the fuzz test catches reverts generically but never asserts this specific error. No test explicitly calls `log10(-1, 0)` and asserts `Log10Negative(-1, 0)`. + +### A02-3 [LOW] `MulDivOverflow` error has no test + +**Location:** `src/error/ErrDecimalFloat.sol:35`, thrown at `src/lib/implementation/LibDecimalFloatImplementation.sol:491` + +The `MulDivOverflow` error, thrown when a 512-bit intermediate product exceeds the denominator in the internal `mulDiv` function, has no test anywhere. No test file references `MulDivOverflow.selector` or constructs inputs that trigger this specific overflow path. This is a critical internal guard in the multiply/divide pipeline. + +### A02-4 [LOW] `CoefficientOverflow` error has no direct test + +**Location:** `src/error/ErrDecimalFloat.sol:8`, thrown at `src/lib/LibDecimalFloat.sol:361` + +The `CoefficientOverflow` error is imported in the parse test file but is never actually asserted via `vm.expectRevert` or selector comparison. No test explicitly constructs a coefficient too large for `int224` and verifies this error is thrown by `packLossless`. The parse test's `try/catch` may catch it generically during fuzz runs, but there is no targeted test. + +### A02-5 [INFO] `WriteError` is dead code + +**Location:** `src/error/ErrDecimalFloat.sol:49`, imported at `src/lib/deploy/LibDecimalFloatDeploy.sol:17` + +The `WriteError` error is defined and imported but never used in any `revert` statement anywhere in the codebase. The `LibDecimalFloatDeploy.sol` file contains zero `revert` statements. This error appears to be dead code from a previous version of the deploy library that may have included a `create`-based deployment with failure checks. + +### A03-1 [LOW] `UnformatableExponent` error has no test + +**Location:** `src/error/ErrFormat.sol:7`, thrown at `src/lib/format/LibFormatDecimalFloat.sol:85` + +The `UnformatableExponent` error is thrown when attempting to format a Float with `exponent < -76` in non-scientific mode. No test exercises this path. The format test file (`LibFormatDecimalFloat.toDecimalString.t.sol`) contains no `vm.expectRevert` calls and never constructs an input that would trigger this error. A test should format a value like `packLossless(1, -77)` in non-scientific mode and assert the revert. + +### A05-1 [INFO] `BYTECODE_HASH` constant is a zero placeholder + +**Location:** `src/generated/LogTables.pointers.sol:13` + +The `BYTECODE_HASH` constant is set to `0x000...000` and is not referenced anywhere in the codebase outside this generated file and the codegen library. It appears to be a vestigial template output. It is not used by any production or test code. + +### A05-2 [INFO] No standalone validation of individual table entries + +**Location:** `src/generated/LogTables.pointers.sol:16-33` + +While the combined table data is validated via codehash in deployment tests, there are no tests that verify individual lookup table entries against known mathematical values. The functional log10/pow10 tests provide indirect coverage by checking computed results, which is likely sufficient, but a corruption of a single table entry could potentially be masked by the linear interpolation between entries. diff --git a/audit/2026-03-10-01/pass2/parse_and_tables.md b/audit/2026-03-10-01/pass2/parse_and_tables.md new file mode 100644 index 0000000..9e2a139 --- /dev/null +++ b/audit/2026-03-10-01/pass2/parse_and_tables.md @@ -0,0 +1,379 @@ +# Audit Pass 2 (Test Coverage) - Parser and Log Tables + +**Auditor Agents:** A10 (Parser), A11 (Log Tables) +**Date:** 2026-03-10 + +--- + +## A10: LibParseDecimalFloat Test Coverage + +### Files Read + +| File | Lines | Evidence | +|------|-------|----------| +| `src/lib/parse/LibParseDecimalFloat.sol` | 1-197 | 2 functions: `parseDecimalFloatInline`, `parseDecimalFloat`. Imports from `rain.string` (LibParseChar, LibParseDecimal, masks), local errors (MalformedExponentDigits, ParseDecimalPrecisionLoss, MalformedDecimalPoint, ParseDecimalFloatExcessCharacters), and `rain.string/error/ErrParse.sol` (ParseEmptyDecimalString). | +| `test/src/lib/parse/LibParseDecimalFloat.t.sol` | 1-425 | 20 test functions + 2 helpers (`checkParseDecimalFloat`, `checkParseDecimalFloatFail`). Tests cover: fuzz round-trip (integers only), specific literals, leading zeros, decimals, exponents, unrelated trailing data, empty string, non-decimal, e-notation error paths, dot error paths, negative frac, precision loss. | +| `test/src/concrete/DecimalFloat.parse.t.sol` | 1-30 | 1 fuzz test (`testParseDeployed`) that compares library-direct parse against the deployed `DecimalFloat.parse()` contract. | +| `src/error/ErrParse.sol` | 1-19 | 4 errors: MalformedDecimalPoint, MalformedExponentDigits, ParseDecimalPrecisionLoss, ParseDecimalFloatExcessCharacters. | + +### Source Function Coverage Map + +#### `parseDecimalFloatInline` Code Paths + +| # | Code Path | Lines | Error/Return | Tested By | +|---|-----------|-------|-------------|-----------| +| 1 | Empty/non-numeric: no digits after sign skip | 46-47 | `ParseEmptyDecimalString` | `testParseDecimalFloatEmpty`, `testParseDecimalFloatNonDecimal`, `testParseDecimalFloatExponentRevert` (e without number), `testParseLiteralDecimalFloatDotRevert` (`.`), `testParseLiteralDecimalFloatDotRevert2` (`.1`), `testParseLiteralDecimalFloatExponentRevert4-6` (`e1`, `e10`, `e-10`), `testParseLiteralDecimalFloatDotE`, `testParseLiteralDecimalFloatDotE0`, `testParseLiteralDecimalFloatEDot` | +| 2 | Integer part overflow (`unsafeDecimalStringToSignedInt` error) | 52-53 | Propagated error | Covered by fuzz `testParsePacked` implicitly | +| 3 | Decimal point with no trailing digits | 64-65 | `MalformedDecimalPoint` | `testParseLiteralDecimalFloatDotRevert3` (`1.`) | +| 4 | Fractional digits all zeros | 70-74 | fracValue=0, no error | `testParseLiteralDecimalFloatExponents` (`0.0e0`, `0.0e1`) | +| 5 | Fractional digits with trailing zeros stripped | 69-72 | Normal parse | `testParseLiteralDecimalFloatDecimals` (`100.001000`) | +| 6 | Fractional parse overflow (from `unsafeDecimalStringToSignedInt`) | 76-78 | Propagated error | Covered implicitly by fuzz | +| 7 | Negative frac value (sign in fractional part) | 83-84 | `MalformedDecimalPoint` | `testParseLiteralDecimalFloatNegativeFrac` (`0.-1`) | +| 8 | Guard: exponent > 0 (should-not-happen) | 98-100 | `MalformedExponentDigits` | **NOT TESTED** (see A10-6) | +| 9 | signedCoefficient == 0 with nonzero frac | 102-103 | signedCoefficient = fracValue | `testParseLiteralDecimalFloatDecimals` (all `0.xxx` cases) | +| 10 | signedCoefficient != 0: scale > 67 | 108-109 | `ParseDecimalPrecisionLoss` | `testParseLiteralDecimalFloatPrecisionRevert0`, `testParseLiteralDecimalFloatPrecisionRevert1` | +| 11 | signedCoefficient != 0: mul overflow | 117-122 | `ParseDecimalPrecisionLoss` | `testParseLiteralDecimalFloatPrecisionRevert0` (int > int224 * scale) | +| 12 | signedCoefficient != 0: mul truncation | 120-122 | `ParseDecimalPrecisionLoss` | `testParseLiteralDecimalFloatPrecisionRevert1` | +| 13 | signedCoefficient != 0: normal rescale + fracValue | 124 | Normal parse | `testParseLiteralDecimalFloatDecimals` (`1.1`, `10.01`, etc.) | +| 14 | E-notation: no digits after e[sign] | 136-137 | `MalformedExponentDigits` | `testParseDecimalFloatExponentRevert2` (`1e`), `testParseDecimalFloatExponentRevert3` (`1e-`) | +| 15 | E-notation: parse overflow (from `unsafeDecimalStringToSignedInt`) | 143-146 | Propagated error | Covered implicitly by fuzz | +| 16 | E-notation: exponent += eValue | 150 | Normal | `testParseLiteralDecimalFloatExponents` (extensive) | +| 17 | Zero normalization: coefficient == 0 forces exponent = 0 | 153-157 | Normal | `testParseLiteralDecimalFloatExponents` (`0e1`, `0e2`, `0e-1`, `0e-2`, `0.0e0`, `0.0e1`) | + +#### `parseDecimalFloat` Code Paths + +| # | Code Path | Lines | Error/Return | Tested By | +|---|-----------|-------|-------------|-----------| +| 18 | No error + full string consumed + lossless pack | 178-186 | Success, packed Float | Many tests via `testParsePacked` fuzz | +| 19 | No error + full string consumed + lossy pack | 181-183 | `ParseDecimalPrecisionLoss` | `testParsePacked` fuzz covers this when packing overflows | +| 20 | No error + partial string consumed | 187-189 | `ParseDecimalFloatExcessCharacters` | `testParsePacked` fuzz covers when inline returns partial parse | +| 21 | Inline error propagated | 191-194 | Propagated error selector | `testParsePacked` fuzz | + +### Coverage Gaps and Findings + +--- + +#### A10-6: Unreachable guard on positive exponent from fractional part never tested (INFORMATIONAL) + +**Location:** `src/lib/parse/LibParseDecimalFloat.sol`, lines 98-100 + +**Description:** + +```solidity +if (exponent > 0) { + return (MalformedExponentDigits.selector, cursor, 0, 0); +} +``` + +This guard checks that the exponent computed as `int256(fracStart) - int256(nonZeroCursor)` is not positive. Since `nonZeroCursor >= fracStart` is always true (the nonZeroCursor starts at `cursor >= fracStart` and only decrements), this condition is unreachable in normal execution. The source comment says "Should not be possible but guard against it in case." + +No test exercises this code path. Because the guard is genuinely unreachable via the public API, it cannot be tested without mocking internal memory pointers. + +**Severity:** INFORMATIONAL +**Impact:** No coverage gap in practice -- the guard is defensive code for a mathematically impossible state. + +--- + +#### A10-7: No dedicated unit test for `ParseDecimalFloatExcessCharacters` from `parseDecimalFloat` wrapper (LOW) + +**Location:** `test/src/lib/parse/LibParseDecimalFloat.t.sol` + +**Description:** + +The `parseDecimalFloat` wrapper (line 187-189 of source) returns `ParseDecimalFloatExcessCharacters` when the inline parser succeeds but does not consume the entire string. While this path is exercised by the fuzz test `testParsePacked` (which has logic for this exact case at line 52-53 of the test), there is no dedicated unit test that calls `parseDecimalFloat` (or `parseDecimalFloatExternal`) with a specific input that triggers this error and asserts the error selector. + +By contrast, the inline parser tests in `testParseLiteralDecimalFloatUnrelated` show that partial consumption works correctly at the inline level (e.g., `"1.2.3"` stops at position 3), but these tests call `checkParseDecimalFloat` which tests `parseDecimalFloatInline`, not the wrapper. + +A dedicated test such as: +```solidity +function testParseDecimalFloatExcessCharacters() external pure { + (bytes4 errorSelector, Float float) = this.parseDecimalFloatExternal("1.2.3"); + assertEq(errorSelector, ParseDecimalFloatExcessCharacters.selector); + assertEq(Float.unwrap(float), bytes32(0)); +} +``` +would provide explicit, reviewable evidence that this error path works correctly. + +**Severity:** LOW +**Impact:** The path is covered by fuzzing, so the risk is minimal. However, fuzz tests may not reliably hit every branch in every run, and the coverage of this specific error path depends on the fuzzer generating strings with valid-prefix + trailing garbage. A deterministic test provides guaranteed coverage. + +--- + +#### A10-8: No test for `ParseDecimalPrecisionLoss` from `packLossy` in `parseDecimalFloat` wrapper (LOW) + +**Location:** `test/src/lib/parse/LibParseDecimalFloat.t.sol` + +**Description:** + +The `parseDecimalFloat` wrapper (lines 181-183 of source) calls `LibDecimalFloat.packLossy(signedCoefficient, exponent)` and returns `ParseDecimalPrecisionLoss` if `lossless` is false. This is a distinct code path from the `ParseDecimalPrecisionLoss` returned by the inline parser (lines 108-109, 121-122), which catches overflows during fractional rescaling. + +The wrapper's `packLossy` path is triggered when the inline parser successfully returns a (signedCoefficient, exponent) pair that cannot be losslessly packed into a `Float` (int224 coefficient + int32 exponent). For example, a coefficient exceeding int224 range or an exponent exceeding int32 range, but where the inline parser itself did not detect an error. + +The `testParsePacked` fuzz covers this path in its logic (line 62-67), but there is no dedicated unit test with a specific input known to trigger this exact wrapper path. An example input that would trigger it: a string with a valid integer part whose coefficient fits in int256 but not int224, such as `"13479973333575319897333507543509815336818572211270286240551805124605000000000000"` (a 79-digit number). + +**Severity:** LOW +**Impact:** Same rationale as A10-7. Fuzz provides probabilistic coverage; a deterministic test would guarantee coverage of this specific wrapper error path. + +--- + +#### A10-9: Fuzz test `testParseLiteralDecimalFloatFuzz` only tests integer inputs, never decimals or e-notation (INFORMATIONAL) + +**Location:** `test/src/lib/parse/LibParseDecimalFloat.t.sol`, lines 108-127 + +**Description:** + +The fuzz test `testParseLiteralDecimalFloatFuzz` generates inputs of the form `[-]`. It never generates fractional parts (`.xxx`) or e-notation (`eNNN`). This means the fuzz testing only covers the integer-only code path. Fractional and exponent parsing are only covered by the specific/hardcoded tests. + +The separate `testParsePacked` fuzz test takes an arbitrary string, which can hit all paths, but the string is not structured -- most random strings will hit `ParseEmptyDecimalString` immediately. The probability of the fuzzer generating a string like `"123.456e7"` is negligible. + +**Severity:** INFORMATIONAL +**Impact:** Fractional and e-notation parsing are covered by extensive specific tests, so this is not a gap per se. A structured fuzz test that generates valid decimal+fraction+exponent strings would provide stronger coverage. + +--- + +#### A10-10: `testParsePacked` ExponentOverflow branch has incomplete condition (INFORMATIONAL) + +**Location:** `test/src/lib/parse/LibParseDecimalFloat.t.sol`, lines 57-58 + +**Description:** + +The `testParsePacked` fuzz test has a branch to handle `ExponentOverflow`: + +```solidity +} else if (exponent != int32(exponent) && exponent > 0 && signedCoefficient == int224(signedCoefficient)) { + vm.expectRevert(abi.encodeWithSelector(ExponentOverflow.selector, signedCoefficient, exponent)); +``` + +This only handles the case where `exponent > 0` and overflows int32 range. It does not handle the case where `exponent < type(int32).min` (large negative exponent that also doesn't fit int32). The `packLossy` function would handle a large negative exponent differently (it would attempt to normalize, potentially returning `lossless = false`), so this may be intentionally asymmetric. However, the test logic is not obvious and lacks a comment explaining why negative exponent overflow is not handled symmetrically. + +**Severity:** INFORMATIONAL +**Impact:** None if the asymmetry is correct. The test would benefit from a comment explaining the logic. + +--- + +## A11: LibLogTable Test Coverage + +### Files Read + +| File | Lines | Evidence | +|------|-------|----------| +| `src/lib/table/LibLogTable.sol` | 1-742 | 5 `toBytes` overloads, 5 table-returning functions (`logTableDec`, `logTableDecSmall`, `logTableDecSmallAlt`, `antiLogTableDec`, `antiLogTableDecSmall`). Constants: `ALT_TABLE_FLAG`, `LOG_MANTISSA_IDX_CARDINALITY`, `LOG_MANTISSA_LAST_INDEX`, `ANTILOG_IDX_CARDINALITY`, `ANTILOG_IDX_LAST_INDEX`, `LOG_TABLE_SIZE_BASE`, `LOG_TABLE_SIZE_BYTES`, `LOG_TABLE_DISAMBIGUATOR`. | +| `test/src/lib/table/LibLogTable.bytes.t.sol` | 1-33 | 5 test functions, one for each `toBytes` overload. | +| `script/BuildPointers.sol` | 1-47 | Uses all 5 `toBytes` + table functions to generate `LogTables.pointers.sol` via code generation. | + +### Source Function Coverage Map + +| Function | Tested In | Coverage | +|----------|-----------|----------| +| `toBytes(uint16[10][90])` | `testToBytesLogTableDec` | Called, output logged (no assertions) | +| `toBytes(uint8[10][90])` | `testToBytesLogTableDecSmall` | Called, output logged (no assertions) | +| `toBytes(uint8[10][100])` | `testToBytesAntiLogTableDecSmall` | Called, output logged (no assertions) | +| `toBytes(uint8[10][10])` | `testToBytesLogTableDecSmallAlt` | Called, output logged (no assertions) | +| `toBytes(uint16[10][100])` | `testToBytesAntiLogTableDec` | Called, output logged (no assertions) | +| `logTableDec()` | `testToBytesLogTableDec` | Called as input to `toBytes` | +| `logTableDecSmall()` | `testToBytesLogTableDecSmall` | Called as input to `toBytes` | +| `logTableDecSmallAlt()` | `testToBytesLogTableDecSmallAlt` | Called as input to `toBytes` | +| `antiLogTableDec()` | `testToBytesAntiLogTableDec` | Called as input to `toBytes` | +| `antiLogTableDecSmall()` | `testToBytesAntiLogTableDecSmall` | Called as input to `toBytes` | + +### Coverage Gaps and Findings + +--- + +#### A11-4: Log table tests have zero assertions -- they only call and log (LOW) + +**Location:** `test/src/lib/table/LibLogTable.bytes.t.sol`, all 5 tests + +**Description:** + +Every test in `LibLogTable.bytes.t.sol` follows the same pattern: + +```solidity +function testToBytesLogTableDec() external pure { + bytes memory result = LibLogTable.toBytes(LibLogTable.logTableDec()); + console2.logBytes(result); +} +``` + +The tests call `toBytes` and log the result, but never assert anything about the output. They serve only as smoke tests that confirm the functions don't revert. They do NOT verify: + +1. The encoded bytes have the correct length. +2. Any specific byte values are correct. +3. The encoding is consistent with prior known-good output (snapshot testing). +4. Individual table entries round-trip correctly (encode then decode). + +The correctness of the table data and encoding is instead verified by CI comparison against the deployed data contract (via `BuildPointers.sol`). However, this CI step is external to the test suite -- running `forge test` alone does not verify table correctness. + +**Severity:** LOW +**Impact:** If the table data were accidentally modified (e.g., a typo introduced during a refactor), `forge test` would not catch it. The CI pipeline would, but only if the pointer comparison step runs. A developer running `forge test` locally before committing would get no warning. + +**Proposed Test:** + +```solidity +function testToBytesLogTableDecLength() external pure { + bytes memory result = LibLogTable.toBytes(LibLogTable.logTableDec()); + assertEq(result.length, 1800, "Log table dec should be 1800 bytes"); +} + +function testToBytesLogTableDecSmallLength() external pure { + bytes memory result = LibLogTable.toBytes(LibLogTable.logTableDecSmall()); + assertEq(result.length, 900, "Log table dec small should be 900 bytes"); +} + +function testToBytesLogTableDecSmallAltLength() external pure { + bytes memory result = LibLogTable.toBytes(LibLogTable.logTableDecSmallAlt()); + assertEq(result.length, 100, "Log table dec small alt should be 100 bytes"); +} + +function testToBytesAntiLogTableDecLength() external pure { + bytes memory result = LibLogTable.toBytes(LibLogTable.antiLogTableDec()); + assertEq(result.length, 2000, "Antilog table dec should be 2000 bytes"); +} + +function testToBytesAntiLogTableDecSmallLength() external pure { + bytes memory result = LibLogTable.toBytes(LibLogTable.antiLogTableDecSmall()); + assertEq(result.length, 1000, "Antilog table dec small should be 1000 bytes"); +} +``` + +--- + +#### A11-5: No test verifies table data integrity against known log10 reference values (LOW) + +**Location:** `test/src/lib/table/LibLogTable.bytes.t.sol` + +**Description:** + +The five table-returning functions contain thousands of hardcoded numeric values representing log10 approximations. No test verifies any individual table entry against a known mathematical reference. For example: + +- `logTableDec()[0][0]` should be `0` (log10(1.00) = 0.0000) +- `logTableDec()[20][0]` should be `3010` (log10(3.0) = 0.4771, but row 20 = mantissa starting at 30, so log10(3.00) mantissa = 4771... actually the row indexing starts at mantissa 10, so row 0 = mantissa 10-19, etc.) +- `antiLogTableDec()[0][0]` should be `1000` (antilog10(0.0000) = 1.000) +- `antiLogTableDec()[99][9]` should be `9977` (last entry, antilog10(0.9999) approx 9.977) + +No test spot-checks any of these values. The encoded bytes are logged but never decoded or compared. + +**Severity:** LOW +**Impact:** A single-digit typo in the table data would produce silently incorrect results in log10/pow10/sqrt operations. The CI pointer comparison catches discrepancies with the deployed contract, but does not verify the deployed contract is correct in the first place. + +**Proposed Test:** + +```solidity +function testLogTableDecKnownValues() external pure { + uint16[10][90] memory table = LibLogTable.logTableDec(); + // First entry: log10(1.00) mantissa = 0 + assertEq(table[0][0], 0); + // log10(2.00) = 0.3010, row 10 (mantissa 20), col 0 + assertEq(table[10][0], 3010); + // Last entry: row 89, col 9 + assertEq(table[89][9], 9996); +} + +function testAntiLogTableDecKnownValues() external pure { + uint16[10][100] memory table = LibLogTable.antiLogTableDec(); + // First entry: antilog10(0.0000) = 1.000 + assertEq(table[0][0], 1000); + // Last entry: antilog10(0.9999) approx 9.977 + assertEq(table[99][9], 9977); +} +``` + +--- + +#### A11-6: No test for `toBytes` encoding correctness (round-trip or spot-check) (LOW) + +**Location:** `test/src/lib/table/LibLogTable.bytes.t.sol` + +**Description:** + +The five `toBytes` functions use inline assembly to pack 2D arrays into flat byte arrays. No test verifies that the encoding is correct by either: + +1. Decoding the result and comparing against the original array (round-trip). +2. Spot-checking specific byte positions in the output. + +The assembly in these functions is non-trivial (reverse iteration, mixed 1-byte and 2-byte packing, nested array pointer arithmetic). A bug in the assembly would silently produce incorrect encoded bytes that would then be deployed as the data contract, causing incorrect log/antilog lookups. + +**Severity:** LOW +**Impact:** If the `toBytes` assembly had a bug (e.g., off-by-one in loop bounds, wrong byte width), the deployed data contract would contain incorrect data. The current tests would not catch this because they only check that the function doesn't revert. + +**Proposed Test:** + +```solidity +function testToBytesLogTableDecSpotCheck() external pure { + bytes memory result = LibLogTable.toBytes(LibLogTable.logTableDec()); + // First entry (uint16): table[0][0] = 0 -> bytes 0-1 should be 0x0000 + assertEq(uint8(result[0]), 0); + assertEq(uint8(result[1]), 0); + // Second entry: table[0][1] = 43 = 0x002B -> bytes 2-3 + assertEq(uint8(result[2]), 0); + assertEq(uint8(result[3]), 43); + // Last entry: table[89][9] = 9996 = 0x270C -> bytes 1798-1799 + assertEq(uint8(result[1798]), 0x27); + assertEq(uint8(result[1799]), 0x0C); +} +``` + +--- + +#### A11-7: No test for `ALT_TABLE_FLAG` presence and placement in `logTableDec` (INFORMATIONAL) + +**Location:** `src/lib/table/LibLogTable.sol`, lines 207-408 + +**Description:** + +The `logTableDec()` function has `ALT_TABLE_FLAG` (0x8000) OR'd onto specific entries in rows 0-9. These flagged entries route lookups to the alternate small table in the consumer code (`LibDecimalFloatImplementation.sol`). No test verifies: + +1. Which entries have the flag set. +2. That the flag is only present in rows 0-9 (the first 100 entries). +3. That the flag is correctly stripped by the consumer (tested elsewhere in log/pow tests, but not isolated). + +**Severity:** INFORMATIONAL +**Impact:** The flag placement is verified indirectly through the end-to-end log/pow tests and the CI pointer comparison. An isolated test would provide better visibility into this specific aspect. + +--- + +#### A11-8: No edge-case boundary tests for table lookups at index 0 and last index (INFORMATIONAL) + +**Location:** `test/src/lib/table/LibLogTable.bytes.t.sol` + +**Description:** + +The consumer code in `LibDecimalFloatImplementation.sol` accesses table entries at indices 0 through `LOG_MANTISSA_LAST_INDEX` (8999) for log tables and 0 through `ANTILOG_IDX_LAST_INDEX` (9999) for antilog tables. No test in the table test file verifies that: + +1. The encoded bytes at index 0 are correct (first-entry correctness). +2. The encoded bytes at the last index are correct (last-entry correctness, no off-by-one in the encoding loop). + +These boundary entries are the most likely to be wrong if there is an off-by-one in the assembly loop. + +**Severity:** INFORMATIONAL +**Impact:** End-to-end log/pow tests provide indirect coverage. The boundary entries are correct (verified by reading the source data), but there is no isolated test that catches an encoding regression at the boundaries. + +--- + +## Summary + +### A10 (Parser) Findings + +| ID | Severity | Title | +|----|----------|-------| +| A10-6 | INFORMATIONAL | Unreachable guard on positive exponent from fractional part never tested | +| A10-7 | LOW | No dedicated unit test for `ParseDecimalFloatExcessCharacters` from wrapper | +| A10-8 | LOW | No dedicated unit test for `ParseDecimalPrecisionLoss` from `packLossy` in wrapper | +| A10-9 | INFORMATIONAL | Fuzz test only generates integer inputs, never decimals or e-notation | +| A10-10 | INFORMATIONAL | `testParsePacked` ExponentOverflow branch has incomplete condition | + +### A11 (Log Tables) Findings + +| ID | Severity | Title | +|----|----------|-------| +| A11-4 | LOW | Log table tests have zero assertions -- they only call and log | +| A11-5 | LOW | No test verifies table data integrity against known log10 reference values | +| A11-6 | LOW | No test for `toBytes` encoding correctness (round-trip or spot-check) | +| A11-7 | INFORMATIONAL | No test for `ALT_TABLE_FLAG` presence and placement | +| A11-8 | INFORMATIONAL | No edge-case boundary tests for table lookups at index 0 and last index | + +### Overall Assessment + +**Parser (A10):** The inline parser `parseDecimalFloatInline` has good deterministic test coverage across its error paths, with specific tests for empty strings, non-numeric input, malformed decimals, malformed exponents, negative fractions, and precision loss. The fuzz test `testParsePacked` provides broad coverage of the wrapper function. The main gaps are the absence of dedicated unit tests for the wrapper's own error returns (`ParseDecimalFloatExcessCharacters` and `ParseDecimalPrecisionLoss` from `packLossy`), which are currently only covered probabilistically by fuzzing. + +**Log Tables (A11):** Test coverage is essentially smoke-test level. All five `toBytes` functions are called but their outputs are never verified. Table data integrity relies entirely on the external CI pointer comparison step. Running `forge test` locally provides no assurance that the table data or encoding is correct. This is the most significant coverage gap found in this pass. diff --git a/audit/2026-03-10-01/pass3/DecimalFloat.md b/audit/2026-03-10-01/pass3/DecimalFloat.md new file mode 100644 index 0000000..07636aa --- /dev/null +++ b/audit/2026-03-10-01/pass3/DecimalFloat.md @@ -0,0 +1,167 @@ +# Audit Pass 3 -- Documentation + +## File: `src/concrete/DecimalFloat.sol` + +**Agent:** A01 +**Date:** 2026-03-10 + +## Evidence of Thorough Reading + +**Contract name:** `DecimalFloat` + +### Constants (with line numbers) + +| Name | Line | Visibility | +|------|------|-----------| +| `FORMAT_DEFAULT_SCIENTIFIC_MIN` | 14 | public | +| `FORMAT_DEFAULT_SCIENTIFIC_MAX` | 19 | public | + +### Functions (with line numbers) + +| # | Function | Line | Visibility | Mutability | +|---|----------|------|-----------|------------| +| 1 | `maxPositiveValue()` | 24 | external | pure | +| 2 | `minPositiveValue()` | 30 | external | pure | +| 3 | `maxNegativeValue()` | 36 | external | pure | +| 4 | `minNegativeValue()` | 42 | external | pure | +| 5 | `zero()` | 48 | external | pure | +| 6 | `e()` | 54 | external | pure | +| 7 | `parse(string)` | 64 | external | pure | +| 8 | `format(Float,Float,Float)` | 78 | public | pure | +| 9 | `format(Float,bool)` | 89 | external | pure | +| 10 | `format(Float)` | 97 | external | pure | +| 11 | `add(Float,Float)` | 105 | external | pure | +| 12 | `sub(Float,Float)` | 113 | external | pure | +| 13 | `minus(Float)` | 120 | external | pure | +| 14 | `abs(Float)` | 127 | external | pure | +| 15 | `mul(Float,Float)` | 135 | external | pure | +| 16 | `div(Float,Float)` | 143 | external | pure | +| 17 | `inv(Float)` | 150 | external | pure | +| 18 | `eq(Float,Float)` | 158 | external | pure | +| 19 | `lt(Float,Float)` | 166 | external | pure | +| 20 | `gt(Float,Float)` | 175 | external | pure | +| 21 | `lte(Float,Float)` | 184 | external | pure | +| 22 | `gte(Float,Float)` | 193 | external | pure | +| 23 | `integer(Float)` | 200 | external | pure | +| 24 | `frac(Float)` | 207 | external | pure | +| 25 | `floor(Float)` | 214 | external | pure | +| 26 | `ceil(Float)` | 221 | external | pure | +| 27 | `pow10(Float)` | 228 | external | view | +| 28 | `log10(Float)` | 235 | external | view | +| 29 | `pow(Float,Float)` | 243 | external | view | +| 30 | `sqrt(Float)` | 250 | external | view | +| 31 | `min(Float,Float)` | 258 | external | pure | +| 32 | `max(Float,Float)` | 266 | external | pure | +| 33 | `isZero(Float)` | 273 | external | pure | +| 34 | `fromFixedDecimalLossless(uint256,uint8)` | 284 | external | pure | +| 35 | `fromFixedDecimalLossy(uint256,uint8)` | 305 | external | pure | +| 36 | `toFixedDecimalLossless(Float,uint8)` | 293 | external | pure | +| 37 | `toFixedDecimalLossy(Float,uint8)` | 316 | external | pure | + +**Total: 2 constants, 37 functions.** + +## NatSpec Coverage Analysis + +### Contract-Level Documentation + +The contract itself (`contract DecimalFloat`) has **no NatSpec** -- no `@title`, `@notice`, `@author`, or `@dev` annotations. This is a finding. + +### Function-Level Documentation + +Every one of the 37 functions has at least a `///` NatSpec comment. All have `@param` and `@return` documentation for their parameters and return values. + +### Detailed Accuracy Review + +| Function | Has NatSpec | Has @param | Has @return | Accurate | Notes | +|----------|:-----------:|:----------:|:-----------:|:--------:|-------| +| `maxPositiveValue` | Yes | N/A | Yes | Yes | | +| `minPositiveValue` | Yes | N/A | Yes | Yes | | +| `maxNegativeValue` | Yes | N/A | Yes | Yes | | +| `minNegativeValue` | Yes | N/A | Yes | Yes | | +| `zero` | Yes | N/A | Yes | Yes | | +| `e` | Yes | N/A | Yes | Yes | | +| `parse` | Yes | Yes | Yes | Yes | | +| `format(Float,Float,Float)` | Yes | Yes (partial) | Yes | No | `scientificMin` and `scientificMax` params documented but `a` references absolute value logic correctly | +| `format(Float,bool)` | Yes | Yes | Yes | Yes | | +| `format(Float)` | Yes | Yes | Yes | Yes | | +| `add` | Yes | Yes | Yes | Yes | | +| `sub` | Yes | Yes | Yes | No | See A01-10 | +| `minus` | Yes | Yes | Yes | Yes | | +| `abs` | Yes | Yes | Yes | Yes | | +| `mul` | Yes | Yes | Yes | Yes | | +| `div` | Yes | Yes | Yes | No | See A01-11 | +| `inv` | Yes | Yes | Yes | Yes | | +| `eq` | Yes | Yes | Yes | Yes | | +| `lt` | Yes | Yes | Yes | Yes | | +| `gt` | Yes | Yes | Yes | Yes | | +| `lte` | Yes | Yes | Yes | Yes | | +| `gte` | Yes | Yes | Yes | Yes | | +| `integer` | Yes | Yes | Yes | Yes | | +| `frac` | Yes | Yes | Yes | Yes | | +| `floor` | Yes | Yes | Yes | Yes | | +| `ceil` | Yes | Yes | Yes | Yes | | +| `pow10` | Yes | Yes | Yes | No | See A01-12 | +| `log10` | Yes | Yes | Yes | Yes | | +| `pow` | Yes | Yes | Yes | Partial | Missing period at end of @return on L242 (trivial) | +| `sqrt` | Yes | Yes | Yes | Yes | | +| `min` | Yes | Yes | Yes | Yes | | +| `max` | Yes | Yes | Yes | Yes | | +| `isZero` | Yes | Yes | Yes | Yes | | +| `fromFixedDecimalLossless` | Yes | Yes | Yes | Yes | | +| `fromFixedDecimalLossy` | Yes | Yes | Yes | Yes | | +| `toFixedDecimalLossless` | Yes | Yes | Yes | Yes | | +| `toFixedDecimalLossy` | Yes | Yes | Yes | Yes | | + +## Findings + +### A01-10 (LOW) -- `sub` parameter NatSpec is ambiguous + +**File:** `src/concrete/DecimalFloat.sol` L109-112 + +The NatSpec for `sub(Float a, Float b)` reads: +- `@param a The first float to subtract.` +- `@param b The second float to subtract.` + +This is ambiguous. The function computes `a - b`, meaning `a` is the minuend and `b` is the subtrahend. The current wording could be interpreted as subtracting `a` from something, or subtracting something from `a`. It should clarify the relationship, e.g., `@param a The float to subtract from.` and `@param b The float to subtract.` + +### A01-11 (LOW) -- `div` parameter NatSpec is ambiguous + +**File:** `src/concrete/DecimalFloat.sol` L139-142 + +The NatSpec for `div(Float a, Float b)` reads: +- `@param a The first float to divide.` +- `@param b The second float to divide.` + +This is ambiguous. The function computes `a / b`, meaning `a` is the dividend and `b` is the divisor. The phrase "The second float to divide" does not convey that `b` is the value being divided by. It should say something like `@param a The dividend.` and `@param b The divisor.` + +### A01-12 (LOW) -- `pow10` NatSpec is misleading about what the function computes + +**File:** `src/concrete/DecimalFloat.sol` L225-228 + +The NatSpec reads: +``` +/// @param a The float to raise to the power of 10. +/// @return The result of raising the float to the power of 10. +``` + +This describes `a^10` (a raised to the power of 10). However, the function actually computes `10^a` (10 raised to the power of a), as confirmed by the implementation in `LibDecimalFloatImplementation.pow10` (line 891: "10^x for a float x"). The NatSpec should read something like: +- `@param a The exponent for base-10 exponentiation.` +- `@return The result of 10^a.` + +### A01-13 (INFORMATIONAL) -- Contract lacks top-level NatSpec + +**File:** `src/concrete/DecimalFloat.sol` L9 + +The `contract DecimalFloat` declaration has no NatSpec documentation (`@title`, `@notice`, `@author`, or `@dev`). Adding a brief description of the contract's purpose (exposing library functions for offchain/revm consumption) would improve discoverability and developer experience. + +### A01-14 (INFORMATIONAL) -- `pow` return NatSpec missing trailing period + +**File:** `src/concrete/DecimalFloat.sol` L242 + +The `@return` annotation reads: `The result of raising the base float to the power of the exponent` -- missing a trailing period, inconsistent with other `@return` annotations in the same file that end with a period. + +### A01-15 (INFORMATIONAL) -- `e()` function has NatSpec comment separated from function by blank line + +**File:** `src/concrete/DecimalFloat.sol` L51-54 + +The NatSpec comment block for `e()` is separated from the function definition by a blank line (line 53). NatSpec is conventionally placed immediately above the function signature. While this does not break compilation, it is inconsistent with every other function in the file and could cause documentation generators to fail to associate the comment with the function. diff --git a/audit/2026-03-10-01/pass3/LibDecimalFloat.md b/audit/2026-03-10-01/pass3/LibDecimalFloat.md new file mode 100644 index 0000000..3d39e9c --- /dev/null +++ b/audit/2026-03-10-01/pass3/LibDecimalFloat.md @@ -0,0 +1,219 @@ +# Audit Pass 3 -- Documentation: `src/lib/LibDecimalFloat.sol` + +**Auditor agent:** A06 +**Date:** 2026-03-10 +**Library:** `LibDecimalFloat` (line 44, `src/lib/LibDecimalFloat.sol`) + +--- + +## Evidence of reading + +Source file: `src/lib/LibDecimalFloat.sol` (796 lines total) + +### Type declaration + +| Item | Line | +|---|---| +| `type Float is bytes32` | 16 | + +### Library-level NatSpec (lines 18-43) + +`@title LibDecimalFloat` is present. The description covers: decimal floating point, 224-bit signed coefficient, 32-bit signed exponent, no NaN/Infinity/negative-zero, revert-on-nonsense design, decimal-not-binary rationale with precision discussion. + +### Constants (lines 47-92) + +| Constant | Line | Has NatSpec? | +|---|---|---| +| `LOG_TABLES_ADDRESS` | 50 | Yes | +| `FLOAT_ZERO` | 53 | Yes | +| `FLOAT_ONE` | 56 | Yes | +| `FLOAT_HALF` | 60 | Yes | +| `FLOAT_TWO` | 64 | Yes | +| `FLOAT_MAX_POSITIVE_VALUE` | 68 | Yes | +| `FLOAT_MIN_POSITIVE_VALUE` | 74 | Yes | +| `FLOAT_MAX_NEGATIVE_VALUE` | 80 | Yes | +| `FLOAT_MIN_NEGATIVE_VALUE` | 86 | Yes | +| `FLOAT_E` | 91 | Yes | + +### Functions (lines 104-795) + +| # | Function | Line | `@param` | `@return` | Notes | +|---|---|---|---|---|---| +| 1 | `fromFixedDecimalLossy(uint256,uint8)` | 104 | Yes (2/2) | Yes (3/3) | Complete | +| 2 | `fromFixedDecimalLossyPacked(uint256,uint8)` | 132 | Yes (2/2) | Yes (2/2) | Complete | +| 3 | `fromFixedDecimalLossless(uint256,uint8)` | 144 | Yes (2/2) | Yes (2/2) | Complete | +| 4 | `fromFixedDecimalLosslessPacked(uint256,uint8)` | 158 | Yes (2/2) | Yes (1/1) | **Stale reference** -- see A06-6 | +| 5 | `toFixedDecimalLossy(int256,int256,uint8)` | 176 | Yes (3/3) | Yes (2/2) | Complete | +| 6 | `toFixedDecimalLossy(Float,uint8)` | 254 | Yes (2/2) | Yes (2/2) | Complete | +| 7 | `toFixedDecimalLossless(int256,int256,uint8)` | 265 | Yes (3/3) | Yes (1/1) | Complete | +| 8 | `toFixedDecimalLossless(Float,uint8)` | 286 | Yes (2/2) | Yes (1/1) | Complete | +| 9 | `packLossy(int256,int256)` | 299 | Yes (2/2) | Partial (1/2) | **Missing** `@return lossless` -- see A06-7; **Stale type name** `PackedFloat` -- see A06-8 | +| 10 | `packLossless(int256,int256)` | 358 | Yes (2/2) | Yes (1/1) | Complete | +| 11 | `unpack(Float)` | 373 | Yes (1/1) | Yes (2/2) | **Stale reference** "inverse of `pack`" -- see A06-9 | +| 12 | `add(Float,Float)` | 388 | Yes (2/2) | **Missing** | Missing `@return`; misleading "Same as add" -- see A06-10 | +| 13 | `sub(Float,Float)` | 405 | Yes (2/2) | **Missing** | **Incorrect description** "Subtract float a from float b" is semantically backwards -- see A06-11 | +| 14 | `minus(Float)` | 421 | Yes (1/1) | **Missing** | Misleading "Same as minus" -- see A06-10 | +| 15 | `abs(Float)` | 440 | Yes (1/1) | **Missing** | | +| 16 | `mul(Float,Float)` | 474 | Yes (2/2) | **Missing** | | +| 17 | `div(Float,Float)` | 491 | Yes (2/2) | **Missing** | "Same as divide" references non-existent name -- see A06-12 | +| 18 | `inv(Float)` | 507 | Yes (1/1) | **Missing** | Misleading "Same as inv" -- see A06-10 | +| 19 | `eq(Float,Float)` | 520 | Yes (2/2) | **Missing** | Misleading "Same as eq" -- see A06-10 | +| 20 | `lt(Float,Float)` | 531 | Yes (2/2) | **Missing** | | +| 21 | `gt(Float,Float)` | 545 | Yes (2/2) | **Missing** | | +| 22 | `lte(Float,Float)` | 557 | **Missing** | **Missing** | No `@param` or `@return` tags at all | +| 23 | `gte(Float,Float)` | 569 | **Missing** | **Missing** | No `@param` or `@return` tags at all | +| 24 | `integer(Float)` | 582 | Yes (1/1) | Yes (1/1) | Complete | +| 25 | `frac(Float)` | 593 | Yes (1/1) | Yes (1/1) | Complete | +| 26 | `floor(Float)` | 603 | Yes (1/1) | **Missing** | | +| 27 | `ceil(Float)` | 621 | Yes (1/1) | **Missing** | | +| 28 | `pow10(Float,address)` | 652 | Yes (2/2) | **Missing** | "Same as power10" references non-existent name -- see A06-12 | +| 29 | `log10(Float,address)` | 668 | Yes (2/2) | **Missing** | Misleading "Same as log10" -- see A06-10 | +| 30 | `pow(Float,Float,address)` | 690 | Yes (3/3) | **Missing** | | +| 31 | `sqrt(Float,address)` | 764 | Yes (2/2) | **Missing** | | +| 32 | `min(Float,Float)` | 773 | Yes (2/2) | Yes (1/1) | Complete | +| 33 | `max(Float,Float)` | 781 | Yes (2/2) | **Missing** | Inconsistent with `min` which has `@return` | +| 34 | `isZero(Float)` | 788 | Yes (1/1) | **Missing** | | + +--- + +## Findings + +### A06-6 `fromFixedDecimalLosslessPacked` NatSpec references non-existent `fromFixedDecimalLossyMem` [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 152-155 +**Type:** Stale documentation reference + +The NatSpec for `fromFixedDecimalLosslessPacked` (line 152) says: +``` +/// Lossless version of `fromFixedDecimalLossyMem`. This will revert if the +/// conversion is lossy. +/// @param value As per `fromFixedDecimalLossyMem`. +/// @param decimals As per `fromFixedDecimalLossyMem`. +``` + +No function named `fromFixedDecimalLossyMem` exists anywhere in the codebase. A grep across all source files returns zero matches outside this NatSpec block. This appears to be a leftover from a previous API design. The function actually delegates to `fromFixedDecimalLossless` (which in turn calls `fromFixedDecimalLossy`), so the correct reference should be `fromFixedDecimalLossyPacked` or `fromFixedDecimalLossy`. + +**Impact:** Developers reading the NatSpec to understand the function's behavior will be pointed to a non-existent function, making the documentation misleading. + +### A06-7 `packLossy` NatSpec missing `@return lossless` and references non-existent `PackedFloat` type [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 291-299 +**Type:** Incomplete and stale documentation + +The `packLossy` function signature is: +```solidity +function packLossy(int256 signedCoefficient, int256 exponent) internal pure returns (Float float, bool lossless) +``` + +Two issues: + +1. The NatSpec only documents `@return float` (line 297-298) but omits `@return lossless`. The lossless flag is critical for callers to know whether the packing truncated precision. Missing documentation for this return value is a meaningful gap since the entire lossy/lossless distinction is a core design pattern throughout the library. + +2. The description at line 291 says "Pack a signed coefficient and exponent into a single `PackedFloat`." The type `PackedFloat` does not exist in the codebase -- the actual type is `Float`. This is a stale reference from an earlier design iteration. + +### A06-8 `unpack` NatSpec references non-existent function `pack` [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 366-367 +**Type:** Stale documentation reference + +The NatSpec says: +``` +/// Unpack a packed bytes32 into a signed coefficient and exponent. This is +/// the inverse of `pack`. +``` + +No function named `pack` exists in the library. The packing functions are named `packLossy` and `packLossless`. The doc should reference one or both of these. + +**Impact:** Minor. The reader can infer the intent, but it creates a documentation inconsistency. + +### A06-9 `sub` NatSpec says "Subtract float a from float b" -- semantically backwards [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` line 399 +**Type:** Incorrect documentation + +The NatSpec states: +``` +/// Subtract float a from float b. +``` + +In English, "subtract A from B" means `B - A`. However, the implementation computes `A - B`: it negates `signedCoefficientB` and adds to `signedCoefficientA`, delegating to `LibDecimalFloatImplementation.sub(A, B)` which performs `add(A, minus(B))`. + +The parameter names confirm the intent: `@param a The float to subtract from.` and `@param b The float to subtract.` These descriptions correctly describe `A - B`, contradicting the leading summary line. + +**Impact:** The leading summary line would cause a developer reading only the first line to believe the operation is `B - A` when it is actually `A - B`. The `@param` tags are correct, which mitigates the confusion somewhat. + +### A06-10 Multiple functions use misleading "Same as X" phrasing referencing internal implementation library [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 381, 416, 484, 502, 515, 645, 662 +**Type:** Misleading documentation pattern + +Seven functions use the pattern "Same as X, but accepts a Float struct instead of separate values": +- `add` (line 381): "Same as add" +- `minus` (line 416): "Same as minus" +- `div` (line 484): "Same as divide" +- `inv` (line 502): "Same as inv" +- `eq` (line 515): "Same as eq" +- `pow10` (line 645): "Same as power10" +- `log10` (line 662): "Same as log10" + +In all cases, the "separate values" version is in `LibDecimalFloatImplementation`, not in `LibDecimalFloat`. The phrasing implies there is a sibling overload in the same library accepting `(int256, int256, ...)` parameters, which is not the case. Contrast with `toFixedDecimalLossy` and `toFixedDecimalLossless`, which genuinely have both overloads in the same library and use the same phrasing correctly. + +This is informational because a developer can still understand the intent, but it is inconsistent with how the overload pattern is used elsewhere in the same file. + +### A06-11 NatSpec references to non-existent function names `divide` and `power10` [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 484 and 645 +**Type:** Stale documentation references + +Line 484: "Same as divide" -- no function `divide` exists. The function is named `div` (in `LibDecimalFloatImplementation`). + +Line 645: "Same as power10" -- no function `power10` exists. The function is named `pow10` (in `LibDecimalFloatImplementation`). + +These appear to be stale references from when the functions may have had different names. + +**Impact:** A developer searching for the referenced function name to understand the underlying behavior will find nothing. + +### A06-12 22 of 34 functions missing `@return` NatSpec tags [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` +**Type:** Incomplete documentation + +The following 22 functions have return values but no `@return` tag: + +`add`, `sub`, `minus`, `abs`, `mul`, `div`, `inv`, `eq`, `lt`, `gt`, `lte`, `gte`, `floor`, `ceil`, `pow10`, `log10`, `pow`, `sqrt`, `max`, `isZero`, plus the missing `@return lossless` on `packLossy`. + +The library is inconsistent: 12 functions (`fromFixedDecimalLossy`, `fromFixedDecimalLossyPacked`, `fromFixedDecimalLossless`, `fromFixedDecimalLosslessPacked`, `toFixedDecimalLossy` x2, `toFixedDecimalLossless` x2, `packLossy` (partial), `packLossless`, `unpack`, `integer`, `frac`, `min`) do have `@return` documentation, while the majority do not. + +**Impact:** Incomplete NatSpec generates incomplete documentation artifacts. Tooling and IDE hover-docs will not show return value descriptions for the majority of the API. For a public library, this degrades the developer experience. + +### A06-13 `lte` and `gte` missing all `@param` tags [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 553-575 +**Type:** Incomplete documentation + +`lte` (line 557) and `gte` (line 569) have descriptive NatSpec text but are the only two functions in the entire library with zero `@param` tags. All other functions that accept parameters document them. Contrast with `lt` (line 529-530) and `gt` (line 543-544) which have identical signatures and do include `@param a` and `@param b`. + +### A06-14 `Float` type has no NatSpec documentation [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` line 16 +**Type:** Missing documentation + +The user-defined value type `Float` is declared at line 16: +```solidity +type Float is bytes32; +``` + +There is no NatSpec comment on this declaration. The library-level `@title` block (lines 18-43) describes the encoding in detail, but this is attached to the `library LibDecimalFloat` declaration, not to the `type Float` declaration. A developer navigating to the type definition directly would see no documentation. + +The packing layout (224-bit signed coefficient in the low bits, 32-bit signed exponent in the high bits) is documented implicitly by the `pack`/`unpack` functions and constants but never stated explicitly at the type declaration. + +--- + +## Summary + +| Severity | Count | IDs | +|---|---|---| +| LOW | 5 | A06-6, A06-7, A06-9, A06-11, A06-12 | +| INFO | 4 | A06-8, A06-10, A06-13, A06-14 | + +The library has a solid documentation foundation for the conversion functions (`fromFixedDecimal*`, `toFixedDecimal*`) and packing functions, with complete `@param` and `@return` tags. However, the arithmetic, comparison, and math functions (which constitute the majority of the API) are systematically missing `@return` tags. There are also several stale references to renamed/removed functions (`fromFixedDecimalLossyMem`, `PackedFloat`, `pack`, `divide`, `power10`) and one semantically incorrect description (`sub`). diff --git a/audit/2026-03-10-01/pass3/impl_format_parse.md b/audit/2026-03-10-01/pass3/impl_format_parse.md new file mode 100644 index 0000000..46b86ef --- /dev/null +++ b/audit/2026-03-10-01/pass3/impl_format_parse.md @@ -0,0 +1,419 @@ +# Pass 3 -- Documentation Audit: Implementation, Format, Parse + +**Agents:** A08 (format), A09 (implementation), A10 (parse) +**Date:** 2026-03-10 + +--- + +## Evidence of Thorough Reading + +### A08: `src/lib/format/LibFormatDecimalFloat.sol` (165 lines) + +**Library:** `LibFormatDecimalFloat` + +| # | Function | Line | Visibility | +|---|----------|------|------------| +| 1 | `countSigFigs` | 18 | `internal pure` | +| 2 | `toDecimalString` | 58 | `internal pure` | + +### A09: `src/lib/implementation/LibDecimalFloatImplementation.sol` (1307 lines) + +**Library:** `LibDecimalFloatImplementation` + +| # | Function | Line | Visibility | +|---|----------|------|------------| +| 1 | `minus` | 71 | `internal pure` | +| 2 | `absUnsignedSignedCoefficient` | 89 | `internal pure` | +| 3 | `unabsUnsignedMulOrDivLossy` | 116 | `internal pure` | +| 4 | `mul` | 160 | `internal pure` | +| 5 | `div` | 272 | `internal pure` | +| 6 | `mul512` | 466 | `internal pure` | +| 7 | `mulDiv` | 479 | `internal pure` | +| 8 | `add` | 610 | `internal pure` | +| 9 | `sub` | 703 | `internal pure` | +| 10 | `eq` | 724 | `internal pure` | +| 11 | `inv` | 736 | `internal pure` | +| 12 | `lookupLogTableVal` | 744 | `internal view` | +| 13 | `log10` | 783 | `internal view` | +| 14 | `pow10` | 902 | `internal view` | +| 15 | `maximize` | 957 | `internal pure` | +| 16 | `maximizeFull` | 1011 | `internal pure` | +| 17 | `compareRescale` | 1047 | `internal pure` | +| 18 | `withTargetExponent` | 1127 | `internal pure` | +| 19 | `intFrac` | 1169 | `internal pure` | +| 20 | `mantissa4` | 1197 | `internal pure` | +| 21 | `lookupAntilogTableY1Y2` | 1227 | `internal view` | +| 22 | `unitLinearInterpolation` | 1267 | `internal pure` | + +(Also: `lookupTableVal` at line 1239 is an inline assembly `function`, not a Solidity function.) + +### A10: `src/lib/parse/LibParseDecimalFloat.sol` (197 lines) + +**Library:** `LibParseDecimalFloat` + +| # | Function | Line | Visibility | +|---|----------|------|------------| +| 1 | `parseDecimalFloatInline` | 34 | `internal pure` | +| 2 | `parseDecimalFloat` | 169 | `internal pure` | + +--- + +## NatSpec Audit: Function-by-Function + +### A08: LibFormatDecimalFloat + +#### `countSigFigs` (line 18) + +- **Has NatSpec:** Yes (line 14-17). +- **`@notice`/summary:** Plain `///` summary: "Counts the number of significant figures in a decimal float." Adequate. +- **`@param signedCoefficient`:** Documented. Accurate. +- **`@param exponent`:** Documented. Accurate. +- **`@return sigFigs`:** Documented: "The number of significant figures." Named return `sigFigs` matches. +- **Issues:** None. + +#### `toDecimalString` (line 58) + +- **Has NatSpec:** Yes (lines 52-56). +- **`@notice`/summary:** "Format a decimal float as a string." Adequate. +- **`@param float`:** Documented: "The decimal float to format." +- **`@param scientific`:** NOT documented. Missing `@param scientific`. +- **`@return`:** Documented: "The string representation of the decimal float." Return is unnamed in signature, which is consistent. +- **Issues:** See **A08-1** below. + +### A09: LibDecimalFloatImplementation + +#### `minus` (line 71) + +- **Has NatSpec:** Yes (lines 53-70). +- **`@notice`/summary:** Plain `///` summary: "Negates a float. Equivalent to `0 - x`." Plus reference to the spec. Thorough. +- **`@param signedCoefficient`:** Documented (line 66-67). Accurate. +- **`@param exponent`:** Documented (line 68). Accurate. +- **`@return` (2 returns):** Documented as `@return signedCoefficient` (line 69) and `@return exponent` (line 70). However, the actual function signature has **unnamed** returns: `returns (int256, int256)`. Solidity NatSpec matches return tags positionally, so this is technically fine but using named tags for unnamed returns is a mild inconsistency. INFORMATIONAL. +- **Issues:** None actionable. + +#### `absUnsignedSignedCoefficient` (line 89) + +- **Has NatSpec:** Yes (lines 85-88). +- **Summary:** "Returns the absolute value of a signed coefficient as an unsigned integer." +- **`@param signedCoefficient`:** Documented. +- **`@return`:** Documented: "The absolute value as an unsigned integer." +- **Issues:** None. + +#### `unabsUnsignedMulOrDivLossy` (line 116) + +- **Has NatSpec:** Yes (lines 107-115). +- **Summary:** "Given the absolute value of the result coefficient, and the signs of the input coefficients, returns the signed coefficient and exponent of the result of a multiplication or division operation." +- **`@param a`:** Documented: "The signed coefficient of the first operand." +- **`@param b`:** Documented: "The signed coefficient of the second operand." +- **`@param signedCoefficientAbs`:** Documented: "The absolute value of the result coefficient." +- **`@param exponent`:** Documented: "The exponent of the result." +- **`@return signedCoefficient`:** Documented. +- **`@return exponent`:** Documented. +- **Issues:** None. + +#### `mul` (line 160) + +- **Has NatSpec:** Yes (lines 153-159). +- **Summary:** "Stack only implementation of `mul`." +- **`@param signedCoefficientA`, `@param exponentA`, `@param signedCoefficientB`, `@param exponentB`:** All documented. +- **`@return signedCoefficient`, `@return exponent`:** Both documented. +- **Issues:** None. + +#### `div` (line 272) + +- **Has NatSpec:** Yes (lines 221-271). Extensive specification comment referencing the decimal standard. +- **`@param`/`@return`:** MISSING. The function has four parameters (`signedCoefficientA`, `exponentA`, `signedCoefficientB`, `exponentB`) and two returns, but there are NO `@param` or `@return` tags. Only the specification quote is present. +- **Issues:** See **A09-1** below. + +#### `mul512` (line 466) + +- **Has NatSpec:** Partial (lines 463-465). +- **Summary:** "mul512 from Open Zeppelin. Simply part of the original mulDiv function abstracted out for reuse elsewhere." +- **`@param a`:** NOT documented. +- **`@param b`:** NOT documented. +- **`@return high`:** NOT documented. +- **`@return low`:** NOT documented. +- **Issues:** See **A09-2** below. + +#### `mulDiv` (line 479) + +- **Has NatSpec:** Partial (lines 477-478). +- **Summary:** "mulDiv as seen in Open Zeppelin, PRB Math, Solady, and other libraries." +- **`@param x`:** NOT documented. +- **`@param y`:** NOT documented. +- **`@param denominator`:** NOT documented. +- **`@return result`:** NOT documented. +- **Issues:** See **A09-3** below. + +#### `add` (line 610) + +- **Has NatSpec:** Yes (lines 557-609). Extensive specification comment and full `@param`/`@return` tags. +- **`@param signedCoefficientA`, `@param exponentA`, `@param signedCoefficientB`, `@param exponentB`:** All documented. +- **`@return signedCoefficient`, `@return exponent`:** Both documented. +- **Issues:** None. + +#### `sub` (line 703) + +- **Has NatSpec:** Partial (lines 695-702). +- **`@param`:** All four params documented. +- **`@return signedCoefficient`, `@return exponent`:** Both documented. +- **Summary/`@notice`:** MISSING. There is no description of what the function does. The `@param`/`@return` tags are present but there is no summary line or `@notice` explaining this is subtraction. +- **Issues:** See **A09-4** below. + +#### `eq` (line 724) + +- **Has NatSpec:** Yes (lines 712-723). +- **Summary:** "Numeric equality for floats." +- **`@param`:** All four params documented. +- **`@return`:** Documented: "`true` if the two floats are equal, `false` otherwise." +- **Issues:** None. + +#### `inv` (line 736) + +- **Has NatSpec:** Minimal (line 735). +- **Summary:** "Inverts a float. Equivalent to `1 / x`." +- **`@param signedCoefficient`:** NOT documented. +- **`@param exponent`:** NOT documented. +- **`@return` (2 returns):** NOT documented. +- **Issues:** See **A09-5** below. + +#### `lookupLogTableVal` (line 744) + +- **Has NatSpec:** Yes (lines 740-743). +- **Summary:** "Looks up the log10 table value for a given index." +- **`@param tables`:** Documented: "The address of the log tables data contract." +- **`@param index`:** Documented: "The index into the log table." +- **`@return result`:** Documented: "The log10 table value." +- **Issues:** None. + +#### `log10` (line 783) + +- **Has NatSpec:** Yes (lines 772-782). +- **Summary:** "log10(x) for a float x." +- **`@param signedCoefficient`:** Documented. +- **`@param exponent`:** Documented. +- **`@return signedCoefficient`, `@return exponent`:** Both documented. +- **Missing param:** The first parameter `tablesDataContract` (address) is NOT documented with `@param`. +- **Issues:** See **A09-6** below. + +#### `pow10` (line 902) + +- **Has NatSpec:** Yes (lines 891-901). +- **Summary:** "10^x for a float x." +- **`@param signedCoefficient`:** Documented. +- **`@param exponent`:** Documented. +- **`@return signedCoefficient`, `@return exponent`:** Both documented. +- **Missing param:** The first parameter `tablesDataContract` (address) is NOT documented with `@param`. +- **Issues:** See **A09-7** below. + +#### `maximize` (line 957) + +- **Has NatSpec:** Yes (lines 949-956). +- **Summary:** "Maximizes a float's signed coefficient..." +- **`@param`:** MISSING. No `@param` tags for `signedCoefficient` or `exponent`. +- **`@return signedCoefficient`:** Documented. +- **`@return exponent`:** Documented. +- **`@return full`:** Documented. +- **Issues:** See **A09-8** below. + +#### `maximizeFull` (line 1011) + +- **Has NatSpec:** Yes (lines 1005-1010). +- **Summary:** "Maximizes a float as per `maximize` but errors if not fully maximized." +- **`@param signedCoefficient`:** Documented. +- **`@param exponent`:** Documented. +- **`@return signedCoefficient`:** Documented. +- **`@return exponent`:** Documented. +- **Issues:** None. + +#### `compareRescale` (line 1047) + +- **Has NatSpec:** Yes (lines 1019-1046). +- **Summary:** "Rescale two floats so that they are possible to directly compare..." +- **`@param`:** MISSING. No `@param` tags despite having four parameters. +- **`@return`:** MISSING. No `@return` tags despite having two returns. +- **Issues:** See **A09-9** below. + +#### `withTargetExponent` (line 1127) + +- **Has NatSpec:** Yes (lines 1121-1126). +- **Summary:** "Sets the coefficient so that exponent is the target exponent." +- **`@param signedCoefficient`:** Documented. +- **`@param exponent`:** Documented. +- **`@param targetExponent`:** Documented. +- **`@return`:** Documented: "The new signed coefficient." +- **Issues:** None. + +#### `intFrac` (line 1169) + +- **Has NatSpec:** Yes (lines 1160-1168). +- **Summary:** "Returns the integer and fractional parts of a float." +- **`@param signedCoefficient`:** Documented. +- **`@param exponent`:** Documented. +- **`@return integer`:** Documented. +- **`@return frac`:** Documented. +- **Issues:** None. + +#### `mantissa4` (line 1197) + +- **Has NatSpec:** Yes (lines 1191-1196). +- **Summary:** "First 4 digits of the mantissa and whether we need to interpolate." +- **`@param signedCoefficient`:** Documented. +- **`@param exponent`:** Documented. +- **`@return mantissa`:** Documented. +- **`@return interpolate`:** Documented. +- **`@return scale`:** Documented. +- **Issues:** None. + +#### `lookupAntilogTableY1Y2` (line 1227) + +- **Has NatSpec:** Yes (lines 1219-1225). +- **Summary:** "Looks up the antilog table values y1 and y2 for a given index." +- **`@param tablesDataContract`:** Documented. +- **`@param idx`:** Documented. +- **`@param lossyIdx`:** Documented. +- **`@return y1Coefficient`:** Documented. +- **`@return y2Coefficient`:** Documented. +- **Issues:** None. + +#### `unitLinearInterpolation` (line 1267) + +- **Has NatSpec:** Yes (lines 1256-1266). +- **Summary:** "Linear interpolation." Plus formula. +- **`@param x1Coefficient`:** Documented. +- **`@param xCoefficient`:** Documented. +- **`@param x2Coefficient`:** Documented. +- **`@param xExponent`:** Documented. +- **`@param y1Coefficient`:** Documented. +- **`@param y2Coefficient`:** Documented. +- **`@param yExponent`:** Documented. +- **`@return signedCoefficient`:** Documented. +- **`@return exponent`:** Documented. +- **Issues:** None. + +### A10: LibParseDecimalFloat + +#### `parseDecimalFloatInline` (line 34) + +- **Has NatSpec:** Yes (lines 25-33). +- **`@notice`:** "Parses a decimal float from a substring defined by [start, end)." +- **`@param start`:** Documented: "The starting index of the substring (inclusive)." +- **`@param end`:** Documented: "The ending index of the substring (exclusive)." +- **`@return errorSelector`:** Documented. +- **`@return cursor`:** Documented. +- **`@return signedCoefficient`:** Documented. +- **`@return exponent`:** Documented. +- **Issues:** None. + +#### `parseDecimalFloat` (line 169) + +- **Has NatSpec:** Yes (lines 161-168). +- **`@notice`:** "Parses a decimal float from a string." +- **`@param str`:** Documented. +- **`@return errorSelector`:** NOT documented. The function returns `(bytes4, Float)` but the NatSpec at line 166-167 says `@return errorSelector The error selector...`. This is documented. +- **`@return result`:** Documented at line 168. +- **Issues:** The return names in the NatSpec (`errorSelector`, `result`) do not match the unnamed returns in the signature `returns (bytes4, Float)`. Positional matching applies, so this is technically correct but slightly misleading since there are no named returns in the signature. INFORMATIONAL. + +--- + +## Findings + +### A08-1: `toDecimalString` missing `@param scientific` documentation + +**Severity:** LOW +**File:** `src/lib/format/LibFormatDecimalFloat.sol`, line 58 +**Status:** Open + +The `toDecimalString` function accepts a `bool scientific` parameter that controls whether the output uses scientific notation (e.g., `1.23e45`) or plain decimal format. This parameter is undocumented in NatSpec. Given the significant behavior change it controls (including triggering `UnformatableExponent` revert vs. `maximizeFull` + exponent display), the parameter deserves documentation. + +### A09-1: `div` missing all `@param` and `@return` NatSpec tags + +**Severity:** LOW +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 272 +**Status:** Open + +The `div` function has a lengthy specification quotation (lines 221-270) but no `@param` or `@return` NatSpec tags for its four parameters and two return values. Every other arithmetic function in the library (`mul`, `add`, `sub`) documents these. The `div` function is a core public API of the library and should be documented consistently. + +### A09-2: `mul512` missing all `@param` and `@return` NatSpec tags + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 466 +**Status:** Open + +`mul512` has a brief summary but no `@param` or `@return` documentation. Parameters `a` and `b` and named returns `high` and `low` are undocumented. + +### A09-3: `mulDiv` missing all `@param` and `@return` NatSpec tags + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 479 +**Status:** Open + +`mulDiv` has a brief summary and credit line but no `@param` or `@return` documentation. Parameters `x`, `y`, `denominator` and named return `result` are undocumented. + +### A09-4: `sub` missing summary/description + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 703 +**Status:** Open + +`sub` has `@param` and `@return` tags but no summary line explaining that this function performs subtraction. All other arithmetic functions (`add`, `mul`, `div`, `minus`) have summaries. + +### A09-5: `inv` missing `@param` and `@return` NatSpec tags + +**Severity:** LOW +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 736 +**Status:** Open + +`inv` has a one-line summary but no `@param` or `@return` documentation. The function takes two parameters (`signedCoefficient`, `exponent`) and returns two values, all undocumented. + +### A09-6: `log10` missing `@param tablesDataContract` + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 783 +**Status:** Open + +The `log10` function documents `@param signedCoefficient` and `@param exponent` but omits `@param tablesDataContract`, its first parameter. This creates a positional mismatch: the first `@param` tag describes the second actual parameter. Solidity NatSpec processors may misattribute the documentation. + +### A09-7: `pow10` missing `@param tablesDataContract` + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 902 +**Status:** Open + +Same issue as A09-6. The `pow10` function documents `@param signedCoefficient` and `@param exponent` but omits `@param tablesDataContract`. + +### A09-8: `maximize` missing `@param` tags + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 957 +**Status:** Open + +`maximize` has `@return` tags but no `@param` tags for `signedCoefficient` and `exponent`. The sibling function `maximizeFull` (line 1011) correctly documents both `@param` and `@return`. + +### A09-9: `compareRescale` missing `@param` and `@return` tags + +**Severity:** LOW +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 1047 +**Status:** Open + +`compareRescale` has a thorough summary and specification reference (lines 1019-1046) but no `@param` or `@return` NatSpec tags. It takes four parameters and returns two values, all undocumented. This function is used by `eq` and is part of the comparison API. + +--- + +## Summary + +| ID | Severity | File | Issue | +|----|----------|------|-------| +| A08-1 | LOW | LibFormatDecimalFloat.sol:58 | `toDecimalString` missing `@param scientific` | +| A09-1 | LOW | LibDecimalFloatImplementation.sol:272 | `div` missing all `@param`/`@return` | +| A09-2 | INFO | LibDecimalFloatImplementation.sol:466 | `mul512` missing all `@param`/`@return` | +| A09-3 | INFO | LibDecimalFloatImplementation.sol:479 | `mulDiv` missing all `@param`/`@return` | +| A09-4 | INFO | LibDecimalFloatImplementation.sol:703 | `sub` missing summary description | +| A09-5 | LOW | LibDecimalFloatImplementation.sol:736 | `inv` missing `@param`/`@return` | +| A09-6 | INFO | LibDecimalFloatImplementation.sol:783 | `log10` missing `@param tablesDataContract` | +| A09-7 | INFO | LibDecimalFloatImplementation.sol:902 | `pow10` missing `@param tablesDataContract` | +| A09-8 | INFO | LibDecimalFloatImplementation.sol:957 | `maximize` missing `@param` tags | +| A09-9 | LOW | LibDecimalFloatImplementation.sol:1047 | `compareRescale` missing `@param`/`@return` | + +**LOW findings:** 4 (A08-1, A09-1, A09-5, A09-9) +**INFORMATIONAL findings:** 6 (A09-2, A09-3, A09-4, A09-6, A09-7, A09-8) diff --git a/audit/2026-03-10-01/pass3/misc.md b/audit/2026-03-10-01/pass3/misc.md new file mode 100644 index 0000000..d866e98 --- /dev/null +++ b/audit/2026-03-10-01/pass3/misc.md @@ -0,0 +1,354 @@ +# Audit Pass 3 -- Documentation + +## Files Reviewed + +| Agent | File | +|-------|------| +| A02 | `src/error/ErrDecimalFloat.sol` | +| A03 | `src/error/ErrFormat.sol` | +| A04 | `src/error/ErrParse.sol` | +| A05 | `src/generated/LogTables.pointers.sol` | +| A07 | `src/lib/deploy/LibDecimalFloatDeploy.sol` | +| A11 | `src/lib/table/LibLogTable.sol` | +| A12 | `script/BuildPointers.sol` | +| A13 | `script/Deploy.sol` | +| All | `README.md` | + +**Date:** 2026-03-10 + +--- + +## A02: `src/error/ErrDecimalFloat.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **Import:** `Float` from `../lib/LibDecimalFloat.sol` (line 5) +- **Errors defined:** 13 total (lines 7-49) + +### NatSpec Audit + +| Error | Line | Has `@dev`? | Has `@param`? | Quality | +|-------|------|------------|--------------|---------| +| `CoefficientOverflow(int256, int256)` | 8 | YES | NO | Missing `@param` for both parameters | +| `ExponentOverflow(int256, int256)` | 11 | YES | NO | Missing `@param` for both parameters | +| `NegativeFixedDecimalConversion(int256, int256)` | 15 | YES | NO | Missing `@param` for both parameters | +| `Log10Zero()` | 18 | YES | N/A | Adequate -- no parameters | +| `Log10Negative(int256, int256)` | 21 | YES | NO | Missing `@param` for both parameters | +| `LossyConversionToFloat(int256, int256)` | 25 | YES | NO | Missing `@param` for both parameters | +| `LossyConversionFromFloat(int256, int256)` | 29 | YES | NO | Missing `@param` for both parameters | +| `ZeroNegativePower(Float)` | 32 | YES | NO | Missing `@param b` | +| `MulDivOverflow(uint256, uint256, uint256)` | 35 | YES | NO | Missing `@param` for all 3 parameters | +| `MaximizeOverflow(int256, int256)` | 38 | YES | NO | Missing `@param` for both parameters | +| `DivisionByZero(int256, int256)` | 43 | YES | YES | Complete -- has `@param signedCoefficient` and `@param exponent` | +| `PowNegativeBase(int256, int256)` | 46 | YES | NO | Missing `@param` for both parameters | +| `WriteError()` | 49 | YES | N/A | Adequate -- no parameters | + +**Observation:** `DivisionByZero` (line 43) is the only parameterized error with `@param` tags. All other parameterized errors lack them. The `@dev` descriptions are present for all errors and are accurate, but the inconsistency in `@param` documentation is notable. + +--- + +## A03: `src/error/ErrFormat.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **No imports** +- **Errors defined:** 1 total (line 7) + +### NatSpec Audit + +| Error | Line | Has `@dev`? | Has `@param`? | Quality | +|-------|------|------------|--------------|---------| +| `UnformatableExponent(int256)` | 7 | YES | YES | Complete -- `@param exponent` documented | + +**Observation:** Fully documented. The word "Unformatable" is a non-standard spelling; "Unformattable" would be more conventional, but it is consistent throughout the codebase (error name, import, usage) so this is purely cosmetic. + +--- + +## A04: `src/error/ErrParse.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **No imports** +- **Errors defined:** 4 total (lines 7-19) + +### NatSpec Audit + +| Error | Line | Has `@dev`? | Has `@param`? | Quality | +|-------|------|------------|--------------|---------| +| `MalformedDecimalPoint(uint256)` | 7 | YES | YES | Complete | +| `MalformedExponentDigits(uint256)` | 11 | YES | YES | Complete | +| `ParseDecimalPrecisionLoss(uint256)` | 16 | YES | YES | Complete | +| `ParseDecimalFloatExcessCharacters()` | 19 | YES | N/A | Adequate -- no parameters | + +**Observation:** All errors fully documented. This file is the gold standard for error NatSpec in the codebase. + +--- + +## A05: `src/generated/LogTables.pointers.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **No imports** +- **Header comment:** States file is autogenerated by `./script/BuildPointers.sol` (line 5) and explains the circular dependency (lines 8-10) +- **Constants defined:** 6 total + +### NatSpec Audit + +| Constant | Line | Has `@dev`? | Quality | +|----------|------|------------|---------| +| `BYTECODE_HASH` | 13 | YES | Description says "Hash of the known bytecode" -- accurate but value is all zeros placeholder | +| `LOG_TABLES` | 16 | YES | "Log tables." -- minimal but adequate for generated code | +| `LOG_TABLES_SMALL` | 20 | YES | "Log tables small." -- adequate | +| `LOG_TABLES_SMALL_ALT` | 24 | YES | "Log tables small alt." -- adequate | +| `ANTI_LOG_TABLES` | 28 | YES | "Anti log tables." -- adequate | +| `ANTI_LOG_TABLES_SMALL` | 32 | YES | "Anti log tables small." -- adequate | + +**Observation:** As autogenerated code, the NatSpec is adequate. The `BYTECODE_HASH` constant with all-zero value and no explanation of its purpose/staleness is misleading (previously reported as A05-1 [INFO] in pass 2). + +--- + +## A07: `src/lib/deploy/LibDecimalFloatDeploy.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **Imports:** 7 import statements (lines 5-17), of which 5 are unused in the file body +- **Library name:** `LibDecimalFloatDeploy` (line 19) +- **Constants defined:** 4 (lines 23, 27, 32, 36) +- **Functions defined:** 1 (`combinedTables()` at line 42) + +### NatSpec Audit + +| Element | Line | Has NatSpec? | Quality | +|---------|------|-------------|---------| +| `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` | 23 | YES (`@dev`) | Accurate description. Notes deterministic address across EVM chains. | +| `LOG_TABLES_DATA_CONTRACT_HASH` | 27 | YES (`@dev`) | Accurate description. | +| `ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` | 32 | YES (`@dev`) | Accurate description. Notes cross-chain determinism. | +| `DECIMAL_FLOAT_CONTRACT_HASH` | 36 | YES (`@dev`) | Accurate description. | +| `combinedTables()` | 42 | YES (function comment + `@return`) | Describes packed encoding for size minimization. Accurate. | + +**Observation:** All public-facing elements have NatSpec. The function NatSpec for `combinedTables()` uses a non-`@dev`/non-`@notice` style (bare `///` without tag), which is interpreted as `@notice` by Solidity. The `@return` tag is present. Documentation quality is good. + +**Observation (unused imports):** Lines 12-15 and 17 import `LibDataContract`, `DataContractMemoryContainer`, `LibBytes`, `LibMemCpy`, `Pointer`, `DecimalFloat`, and `WriteError`, none of which are used in the file body. These are leftover from removed functions (`dataContract()` and `decimalFloatZoltu()`). Previously reported as A07-1 [INFO] in pass 1. + +--- + +## A11: `src/lib/table/LibLogTable.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **Library name:** `LibLogTable` (line 35) +- **Total lines:** 742 +- **File-level constants:** 7 (lines 7-32) +- **Functions:** 10 total (5 `toBytes` overloads + 5 table data functions) +- **Library-level `@dev`:** References external PDF source for log tables (line 34) + +### NatSpec Audit -- Constants + +| Constant | Line | Has NatSpec? | Style | Quality | +|----------|------|-------------|-------|---------| +| `ALT_TABLE_FLAG` | 7 | YES | `@dev` | Accurate -- describes the flag meaning | +| `LOG_MANTISSA_IDX_CARDINALITY` | 10 | YES | `@dev` | Accurate | +| `LOG_MANTISSA_LAST_INDEX` | 13 | YES | `@dev` | Accurate | +| `ANTILOG_IDX_CARDINALITY` | 16 | YES | `@dev` | Accurate | +| `ANTILOG_IDX_LAST_INDEX` | 19 | YES | `@dev` | Accurate | +| `LOG_TABLE_SIZE_BASE` | 25 | NO (`@dev` missing) | Plain `//` comment | The comment on lines 21-24 uses `//` (non-NatSpec) instead of `///`. This means the explanation will not appear in generated documentation. | +| `LOG_TABLE_SIZE_BYTES` | 28 | YES | `@dev` | Accurate | +| `LOG_TABLE_DISAMBIGUATOR` | 32 | YES | `@dev` | Accurate description of the collision-avoidance purpose | + +### NatSpec Audit -- Functions + +| Function | Line | Has NatSpec? | `@param`? | `@return`? | Quality | +|----------|------|-------------|----------|-----------|---------| +| `toBytes(uint16[10][90])` | 41 | YES | YES | YES | Good. Has typo "copmiled" in description at line 203 (duplicate of this overload's NatSpec on line 36). | +| `toBytes(uint8[10][90])` | 75 | YES | YES | YES | Good. Identical description to uint16 overload. | +| `toBytes(uint8[10][100])` | 109 | YES | YES | YES | Good. Identical description pattern. | +| `toBytes(uint8[10][10])` | 142 | YES | YES | YES | Good. Identical description pattern. | +| `toBytes(uint16[10][100])` | 175 | YES | YES | YES | Good. Identical description pattern. | +| `logTableDec()` | 206 | YES | NO (none needed) | YES | Has typo: "AOT copmiled" at line 203. Should be "compiled". | +| `logTableDecSmall()` | 414 | YES | NO (none needed) | YES | Adequate. | +| `logTableDecSmallAlt()` | 512 | YES | NO (none needed) | YES | Adequate. | +| `antiLogTableDec()` | 530 | YES | NO (none needed) | YES | Adequate. | +| `antiLogTableDecSmall()` | 638 | YES | NO (none needed) | YES | Adequate. | + +--- + +## A12: `script/BuildPointers.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `=0.8.25` (exact version, line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **Imports:** `Script` from forge-std, `LibCodeGen` and `LibFs` from rain.sol.codegen, `LibLogTable` from project +- **Contract:** `BuildPointers is Script` (line 10) +- **Functions:** 1 (`run()` at line 11) +- **Total lines:** 47 + +### NatSpec Audit + +| Element | Has NatSpec? | Quality | +|---------|-------------|---------| +| Contract `BuildPointers` | NO | No contract-level NatSpec explaining what this script does | +| `run()` | NO | No function-level NatSpec | + +**Observation:** Zero NatSpec on the contract or its function. The script's purpose is only inferrable from the contract name and the generated file header ("THIS FILE IS AUTOGENERATED BY ./script/BuildPointers.sol"). A brief `@dev` on the contract explaining it regenerates the log table pointer constants would be helpful. + +--- + +## A13: `script/Deploy.sol` + +### Evidence of Thorough Reading + +- **Pragma:** `=0.8.25` (exact version, line 3) +- **License:** LicenseRef-DCL-1.0 (line 1) +- **Copyright:** 2020 Rain Open Source Software Ltd (line 2) +- **Imports:** `Script` from forge-std, `LibDataContract`, `LibDecimalFloatDeploy`, `LibRainDeploy`, `DecimalFloat` +- **File-level constants:** 2 (`DEPLOYMENT_SUITE_TABLES` at line 11, `DEPLOYMENT_SUITE_CONTRACT` at line 12) +- **Contract:** `Deploy is Script` (line 14) +- **State variables:** 1 (`sDepCodeHashes` mapping at line 15) +- **Functions:** 1 (`run()` at line 17) +- **Total lines:** 53 + +### NatSpec Audit + +| Element | Has NatSpec? | Quality | +|---------|-------------|---------| +| `DEPLOYMENT_SUITE_TABLES` | NO | No `@dev` or `///` comment | +| `DEPLOYMENT_SUITE_CONTRACT` | NO | No `@dev` or `///` comment | +| Contract `Deploy` | NO | No contract-level NatSpec | +| `sDepCodeHashes` | NO | No documentation explaining the mapping's purpose | +| `run()` | NO | No function-level NatSpec | + +**Observation:** Zero NatSpec anywhere in the file. The deployment logic is non-trivial (environment variable-driven suite selection, multi-network deployment with dependency tracking) and would benefit from documentation. The `DEPLOYMENT_SUITE` environment variable, the meaning of the two suite constants, and the structure of `sDepCodeHashes` are all undocumented. + +--- + +## README.md + +### Evidence of Thorough Reading + +- **Title:** "rain.math.float" (line 1) +- **Sections:** Context, Rounding vs. erroring vs. approximating +- **Sub-sections:** Rounding direction, Approach to preserving precision, Exponent underflow, Packing, Fixed decimal conversions, Exponent overflow, Other overflows, Uncalculable values, Unimplemented math, Parsing/formatting issues, Lossy conversions, Approximations +- **Total lines:** 225 +- **External references:** Solidity division docs link (line 63), GitHub issue #88 (line 188) + +### Accuracy Verification + +| Claim | Verified? | Notes | +|-------|-----------|-------| +| "224 bit coefficient with 32 bit exponent" (line 20-21) | YES | Code uses `int224` for coefficient, `int32` for exponent (LibDecimalFloat.sol line 307, 335) | +| "decimal exponents rather than binary exponents" (line 28) | YES | Coefficient and exponent are both base-10 | +| Exponent underflow rounds toward zero (lines 87-98) | YES | Matches `LibDecimalFloat.sol` pack logic | +| Exponent overflow reverts (lines 141-154) | YES | `ExponentOverflow` error thrown | +| Division by zero reverts (lines 165-168) | YES | `DivisionByZero` error thrown | +| log(0) reverts (line 175) | YES | `Log10Zero` error thrown | +| log(negative) reverts (line 176) | YES | `Log10Negative` error thrown | +| 0^negative reverts (line 177) | YES | `ZeroNegativePower` error thrown | +| No negative base powers (lines 185-186) | YES | `PowNegativeBase` error thrown | +| 512-bit intermediate values (lines 71-72) | YES | Implementation uses 512-bit logic | +| Linear interpolation for log tables (lines 223-224) | YES | Matches implementation | +| Issue #88 reference (line 188) | UNVERIFIED | Cannot confirm issue exists without network access | + +### Issues Found + +1. **Typo on line 144:** "represenation" should be "representation". +2. **No mention of Rust/WASM layer:** The README focuses exclusively on the Solidity implementation. There is no mention of the Rust crate, WASM bindings, or JavaScript layer, which are significant parts of the project (as described in CLAUDE.md). This is a notable omission for a project-level README. +3. **No build/usage instructions:** The README contains no instructions on how to build, test, or use the library. The CLAUDE.md file has comprehensive build commands, but these are not in the README. +4. **No license mention:** The README does not state the project's license (LicenseRef-DCL-1.0). + +--- + +## Findings + +### A02-6 [INFO] Inconsistent `@param` documentation on errors in `ErrDecimalFloat.sol` + +**Location:** `src/error/ErrDecimalFloat.sol`, all parameterized errors except `DivisionByZero` + +Only `DivisionByZero` (line 43) has `@param` tags for its parameters. The other 10 parameterized errors have `@dev` descriptions but no `@param` tags. This is inconsistent and means generated documentation will not describe the parameters for most errors. + +The parameter names are self-documenting (`signedCoefficient`, `exponent`, etc.), so this is purely a documentation consistency issue. + +### A11-1 [INFO] Typo "copmiled" in `LibLogTable.sol` + +**Location:** `src/lib/table/LibLogTable.sol:203` + +The NatSpec for `logTableDec()` reads "AOT copmiled" instead of "AOT compiled". This typo also appears in the description pattern shared by the `toBytes` overloads (lines 36-37, 70-71, 104-105, 137-138, 170-171), though those instances spell it correctly as "compiled". Only line 203 has the typo. + +### A11-2 [INFO] `LOG_TABLE_SIZE_BASE` uses non-NatSpec comment style + +**Location:** `src/lib/table/LibLogTable.sol:21-25` + +The comment for `LOG_TABLE_SIZE_BASE` uses `//` (plain comment) on lines 21-24 instead of `///` (NatSpec). All other file-level constants in this file use `/// @dev` NatSpec. This means the explanation will not appear in generated documentation for this constant. + +### A12-1 [INFO] `BuildPointers.sol` has no NatSpec + +**Location:** `script/BuildPointers.sol` + +The script contract and its `run()` function have zero NatSpec documentation. A brief description of what the script generates and how to invoke it would help maintainers. + +### A13-1 [INFO] `Deploy.sol` has no NatSpec + +**Location:** `script/Deploy.sol` + +The entire file -- including 2 file-level constants, 1 state variable, the contract itself, and the `run()` function -- has zero NatSpec documentation. The deployment logic involves environment variable-driven suite selection (`DEPLOYMENT_SUITE`, `DEPLOYMENT_KEY`), multi-network deployment, and dependency management, none of which is documented. The `sDepCodeHashes` mapping's purpose is also undocumented. + +### A13-2 [LOW] `Deploy.sol` constants are undocumented and inconsistently named + +**Location:** `script/Deploy.sol:11-12` + +The file-level constants `DEPLOYMENT_SUITE_TABLES` and `DEPLOYMENT_SUITE_CONTRACT` have no NatSpec. Their values (`keccak256("log-tables")` and `keccak256("decimal-float")`) are used to match against the `DEPLOYMENT_SUITE` environment variable, but the expected string values ("log-tables" and "decimal-float") are not documented near the constants. A developer must read the `revert` message on line 49 to discover the valid values. + +Additionally, the default value in `vm.envOr` on line 20 is `"decimal-float"`, which means running the script without `DEPLOYMENT_SUITE` set will only deploy the contract, not the tables. This default behavior is undocumented. + +### README-1 [INFO] Typo "represenation" in README.md + +**Location:** `README.md:144` + +"represenation" should be "representation". + +### README-2 [INFO] README omits Rust/WASM/JavaScript layers + +**Location:** `README.md` (entire file) + +The README describes only the Solidity implementation. The project has a substantial Rust crate (`crates/float/`) with WASM bindings for JavaScript consumption, a build pipeline (`scripts/build.js`), and JavaScript tests (`test_js/`). None of this is mentioned in the README. A project-level README should at minimum acknowledge these layers exist and point to relevant documentation. + +### README-3 [INFO] README has no build/usage instructions or license statement + +**Location:** `README.md` (entire file) + +The README contains no instructions on building, testing, or integrating the library. It also does not state the project's license (LicenseRef-DCL-1.0). Build commands and license information are present in `CLAUDE.md` and `REUSE.toml` respectively, but neither of these is a standard location for user-facing documentation. + +--- + +## Summary + +| ID | Severity | File | Title | +|----|----------|------|-------| +| A02-6 | INFO | `src/error/ErrDecimalFloat.sol` | Inconsistent `@param` docs on errors | +| A11-1 | INFO | `src/lib/table/LibLogTable.sol` | Typo "copmiled" | +| A11-2 | INFO | `src/lib/table/LibLogTable.sol` | `LOG_TABLE_SIZE_BASE` uses non-NatSpec comment | +| A12-1 | INFO | `script/BuildPointers.sol` | No NatSpec | +| A13-1 | INFO | `script/Deploy.sol` | No NatSpec | +| A13-2 | LOW | `script/Deploy.sol` | Undocumented deployment constants and default behavior | +| README-1 | INFO | `README.md` | Typo "represenation" | +| README-2 | INFO | `README.md` | Omits Rust/WASM/JS layers | +| README-3 | INFO | `README.md` | No build/usage/license info | diff --git a/audit/2026-03-10-01/pass4/LibDecimalFloat.md b/audit/2026-03-10-01/pass4/LibDecimalFloat.md new file mode 100644 index 0000000..f365f40 --- /dev/null +++ b/audit/2026-03-10-01/pass4/LibDecimalFloat.md @@ -0,0 +1,214 @@ +# Audit Pass 4 -- Code Quality: `src/lib/LibDecimalFloat.sol` + +**Auditor agent:** A06 +**Date:** 2026-03-10 +**File:** `src/lib/LibDecimalFloat.sol` (796 lines) + +--- + +## Evidence of thorough reading + +### File structure (top to bottom) + +| Region | Lines | Content | +|---|---|---| +| SPDX header + pragma | 1-3 | `LicenseRef-DCL-1.0`, `^0.8.25` | +| Imports | 5-14 | 7 error types from `../error/ErrDecimalFloat.sol`; `LibDecimalFloatImplementation` from `./implementation/` | +| Type declaration | 16 | `type Float is bytes32;` | +| Library declaration | 44 | `library LibDecimalFloat` | +| `using` statement | 45 | `using LibDecimalFloat for Float;` | +| Constants | 47-92 | 10 constants: `LOG_TABLES_ADDRESS`, `FLOAT_ZERO`, `FLOAT_ONE`, `FLOAT_HALF`, `FLOAT_TWO`, `FLOAT_MAX_POSITIVE_VALUE`, `FLOAT_MIN_POSITIVE_VALUE`, `FLOAT_MAX_NEGATIVE_VALUE`, `FLOAT_MIN_NEGATIVE_VALUE`, `FLOAT_E` | +| Conversion functions | 104-289 | `fromFixedDecimalLossy`, `fromFixedDecimalLossyPacked`, `fromFixedDecimalLossless`, `fromFixedDecimalLosslessPacked`, `toFixedDecimalLossy` (x2), `toFixedDecimalLossless` (x2) | +| Pack/unpack | 291-379 | `packLossy`, `packLossless`, `unpack` | +| Arithmetic | 381-513 | `add`, `sub`, `minus`, `abs`, `mul`, `div`, `inv` | +| Comparison | 515-575 | `eq`, `lt`, `gt`, `lte`, `gte` | +| Rounding | 577-643 | `integer`, `frac`, `floor`, `ceil` | +| Transcendental | 645-766 | `pow10`, `log10`, `pow`, `sqrt` | +| Min/max/isZero | 768-795 | `min`, `max`, `isZero` | + +### Import paths verified + +Both imports use relative paths: +- `"../error/ErrDecimalFloat.sol"` -- relative, correct +- `"./implementation/LibDecimalFloatImplementation.sol"` -- relative, correct + +**No bare `src/` import paths found.** + +### Lint suppression comments catalogued + +| Line | Type | Comment | +|---|---|---| +| 59 | slither | `// slither-disable-next-line too-many-digits` (space after `//`) | +| 73 | slither | `// slither-disable-next-line too-many-digits` (space after `//`) | +| 79 | slither | `// slither-disable-next-line too-many-digits` (space after `//`) | +| 85 | slither | `// slither-disable-next-line too-many-digits` (space after `//`) | +| 112 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 116 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 190 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 218 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 224 | slither | `//slither-disable-next-line divide-before-multiply` (NO space after `//`) | +| 229 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 306 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 321 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 334 | forge-lint | `// forge-lint: disable-next-line(unsafe-typecast)` | +| 584 | slither | `//slither-disable-next-line unused-return` (NO space after `//`) | +| 595 | slither | `//slither-disable-next-line unused-return` (NO space after `//`) | + +### Local variable naming for `packLossy` return values + +| Function | Local variable | Return | +|---|---|---| +| `add` (395) | `c` | `return c;` | +| `sub` (412) | `c` | `return c;` | +| `minus` (426) | `result` | `return result;` | +| `abs` (449) | `result` | `return result;` | +| `mul` (480) | `c` | `return c;` | +| `div` (498) | `c` | `return c;` | +| `inv` (510) | `result` | `return result;` | +| `integer` (586) | `result` | `return result;` | +| `frac` (597) | `result` | `return result;` | +| `floor` (615) | `result` | `return result;` | +| `ceil` (641) | `result` | `return result;` | +| `pow10` (658) | `result` | `return result;` | +| `log10` (673) | `result` | `return result;` | +| `pow` (748) | `c` | `return c;` | + +--- + +## Findings + +### A06-15 Inconsistent slither-disable comment formatting [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 59, 73, 79, 85, 224, 584, 595 +**Type:** Style inconsistency + +The file uses two different formatting styles for slither-disable comments: + +1. **With space after `//`** (lines 59, 73, 79, 85): + ``` + // slither-disable-next-line too-many-digits + ``` + +2. **Without space after `//`** (lines 224, 584, 595): + ``` + //slither-disable-next-line divide-before-multiply + //slither-disable-next-line unused-return + ``` + +Across the broader codebase, both styles are used in `LibDecimalFloatImplementation.sol` as well. Within this file, the spaced form appears 4 times and the unspaced form 3 times. The forge-lint comments are all consistently spaced (`// forge-lint: ...`). + +**Impact:** Purely cosmetic. Both forms are functionally equivalent. + +### A06-16 `inv` uses unique `(lossless);` suppression pattern instead of `(Float result,)` destructuring [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 510-512 +**Type:** Style inconsistency / dead code artifact + +The `inv` function uniquely binds the `lossless` return value from `packLossy` and then discards it with a bare `(lossless);` expression statement: + +```solidity +(Float result, bool lossless) = packLossy(signedCoefficient, exponent); +(lossless); +return result; +``` + +All 13 other call sites in the file use the standard destructuring pattern that discards the second return value directly: + +```solidity +(Float result,) = packLossy(signedCoefficient, exponent); +return result; +``` + +The `(lossless);` expression on line 511 is dead code -- it evaluates and discards the variable with no side effects. It appears to be a leftover from an earlier implementation that may have used the value or an attempt to suppress an "unused variable" warning using a different technique than the rest of the file. + +**Impact:** The dead expression statement is confusing and inconsistent. A reader may wonder whether the author intended to check `lossless` but forgot to add the check. This is LOW because the inconsistency is in public library code where any perceived pattern deviation may be misread as intentional. + +### A06-17 Inconsistent local variable naming: `c` vs `result` for packed Float return values [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` (14 functions) +**Type:** Style inconsistency + +Functions that call `packLossy` and return the packed `Float` use two different naming conventions for the local variable: + +- **`c`:** `add`, `sub`, `mul`, `div`, `pow` (5 functions) +- **`result`:** `minus`, `abs`, `inv`, `integer`, `frac`, `floor`, `ceil`, `pow10`, `log10` (9 functions) + +The `c` naming appears to originate from the arithmetic functions where `a op b = c`, which is a reasonable convention. However, for unary operations like `minus` and `abs`, `result` is more natural. The inconsistency is that `add` uses `c` but `minus` uses `result`, and `div` uses `c` but `inv` uses `result`, even though `inv` is just `div(1, x)`. + +**Impact:** Cosmetic. Does not affect behavior. + +### A06-18 Typo in comment: "inaccuraces" should be "inaccuracies" [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` line 679 +**Type:** Typo + +``` +/// Due to the inaccuraces of log10 and power10, this is not perfectly +``` + +The correct spelling "inaccuracies" is used on line 754 in the `sqrt` NatSpec. The same word is misspelled only in the `pow` function NatSpec on line 679. + +### A06-19 Doubled word "is is" in comment [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 208-209 +**Type:** Typo + +``` +/// Every possible value rounds to 0 if the exponent is less +/// than -77. This is always lossless as we know the value is +/// is not zero in real. +``` + +"is is" should be "is". + +### A06-20 `pow` function directly uses `LibDecimalFloatImplementation` internals, breaking the packed API abstraction [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 716-748 +**Type:** Leaky abstraction + +The `pow` function is the only function in `LibDecimalFloat` that performs multi-step arithmetic by directly calling `LibDecimalFloatImplementation` functions (`intFrac`, `withTargetExponent`, `mul`, `log10`, `pow10`) on unpacked `(int256, int256)` values across many intermediate steps (lines 716-748). All other functions in `LibDecimalFloat` follow a clean pattern: + +1. Unpack input(s) +2. Make exactly one call to `LibDecimalFloatImplementation` +3. Pack the result via `packLossy` + +The `pow` function instead maintains a 30-line block of raw coefficient/exponent manipulation including an exponentiation-by-squaring loop (lines 722-734), multiple sequential `LibDecimalFloatImplementation.mul` calls, and a `withTargetExponent` call that is cast to `uint256` without a `forge-lint` suppression comment (line 719), unlike the 8 other `unsafe-typecast` suppressions in the file. + +This is a valid design choice -- factoring this into `LibDecimalFloatImplementation` would add gas cost for an extra function call boundary. But it creates a maintenance concern: the `pow` function in the public API layer is tightly coupled to internal implementation details, and the missing forge-lint annotation on the `uint256` cast at line 719 is inconsistent with the rest of the file. + +**Impact:** If `LibDecimalFloatImplementation` internals change, `pow` would need updating in addition to the implementation library. The mixed abstraction levels within a single function increase cognitive load for reviewers. + +### A06-21 `LOG_TABLES_ADDRESS` constant is unused within `LibDecimalFloat.sol` [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` line 50 +**Type:** Architectural observation + +The constant `LOG_TABLES_ADDRESS` is defined inside `library LibDecimalFloat` but never referenced within the file itself. It is consumed exclusively by `src/concrete/DecimalFloat.sol` (lines 229, 236, 244, 251) and tests. + +All transcendental functions in `LibDecimalFloat` (`pow10`, `log10`, `pow`, `sqrt`) accept the `tablesDataContract` address as a parameter rather than using the constant. The constant serves as a convenience for callers -- this is a reasonable API design, but it means the library file contains a deployment-specific address that is not used by any logic in the same file. + +**Impact:** Informational. The constant could be relocated to `DecimalFloat.sol` or a separate constants file to keep the library purely logic-focused, but the current placement is defensible as it keeps the constant co-located with the type it serves. + +--- + +## Checks with no findings + +| Check | Result | +|---|---| +| Bare `src/` import paths | None found. Both imports use relative paths. | +| Commented-out code | None found. All comments are documentation or lint suppressions. | +| Unused imports | All 7 imported error types are used in revert statements. `LibDecimalFloatImplementation` is used throughout. | +| Unreachable branches | None found. All `else` / `else if` branches follow legitimate conditional logic. | +| Dead functions | All 34 functions are exposed through the concrete contract or used internally. | +| Pragma consistency | `^0.8.25` matches all other `src/` files except `DecimalFloat.sol` which uses `=0.8.25`. | + +--- + +## Summary + +| Severity | Count | IDs | +|---|---|---| +| LOW | 2 | A06-16, A06-20 | +| INFO | 5 | A06-15, A06-17, A06-18, A06-19, A06-21 | + +The file is clean from a code quality perspective. There are no bare `src/` imports, no commented-out code, no dead code, and no unused imports. The two LOW findings are: (1) the `inv` function's unique `(lossless);` dead expression pattern that diverges from the 13 other call sites, and (2) the `pow` function's direct use of implementation internals breaking the otherwise consistent abstraction layer, including a missing forge-lint annotation. The INFO findings are cosmetic: inconsistent slither comment spacing, inconsistent local variable naming, two minor typos, and a constant that lives in the library despite being unused within it. diff --git a/audit/2026-03-10-01/pass4/concrete_errors_deploy.md b/audit/2026-03-10-01/pass4/concrete_errors_deploy.md new file mode 100644 index 0000000..2242730 --- /dev/null +++ b/audit/2026-03-10-01/pass4/concrete_errors_deploy.md @@ -0,0 +1,139 @@ +# Pass 4 -- Code Quality: Concrete, Errors, Deploy, Generated, Scripts + +**Auditor agents:** A01, A02, A03, A04, A05, A07, A12, A13 +**Date:** 2026-03-10 +**Scope:** +- `src/concrete/DecimalFloat.sol` +- `src/error/ErrDecimalFloat.sol` +- `src/error/ErrFormat.sol` +- `src/error/ErrParse.sol` +- `src/generated/LogTables.pointers.sol` +- `src/lib/deploy/LibDecimalFloatDeploy.sol` +- `script/BuildPointers.sol` +- `script/Deploy.sol` + +--- + +## Findings + +### A07-01 | LOW | Unused imports in `LibDecimalFloatDeploy.sol` + +**File:** `src/lib/deploy/LibDecimalFloatDeploy.sol` lines 12-15, 17 + +Five imports are not referenced anywhere in the library's code: + +1. `LibDataContract` (line 12) +2. `DataContractMemoryContainer` (line 12) +3. `LibBytes` (line 13) +4. `LibMemCpy` (line 14) +5. `Pointer` (line 14) +6. `WriteError` (line 17) +7. `DecimalFloat` (line 15) -- the contract type itself is never used in any function or constant expression; only its *name* appears in NatSpec comments. + +The library only defines constants and `combinedTables()`, which uses only the table data constants and `LOG_TABLE_DISAMBIGUATOR`. These seven unused imports bloat the compilation unit and may confuse readers about the library's actual dependencies. + +**Recommendation:** Remove lines 12-15 and 17 entirely. Remove line 15 (`DecimalFloat` import) as well if the contract type is not needed for type-level references. + +--- + +### A01-01 | LOW | `require` with string literal instead of custom error + +**File:** `src/concrete/DecimalFloat.sol` line 79 + +```solidity +require(scientificMin.lt(scientificMax), "scientificMin must be less than scientificMax"); +``` + +Every other error path in the codebase uses custom errors (defined in `src/error/`). This single `require` with a string literal is inconsistent and costs more gas than a custom error (each character of the string is stored in the revert data). + +**Recommendation:** Define a custom error (e.g., `error ScientificMinNotLessThanMax(Float min, Float max);`) in the error directory and use `if (!scientificMin.lt(scientificMax)) revert ScientificMinNotLessThanMax(scientificMin, scientificMax);` instead. + +--- + +### A01-02 | INFO | Missing blank line between `zero()` and `e()` NatSpec blocks + +**File:** `src/concrete/DecimalFloat.sol` lines 50-54 + +```solidity + function zero() external pure returns (Float) { + return LibDecimalFloat.FLOAT_ZERO; + } + /// Exposes `LibDecimalFloat.FLOAT_E` for offchain use. + /// @return The constant value of Euler's number as a Float. + + function e() external pure returns (Float) { +``` + +Every other function pair in this contract has a blank line separating the closing brace from the next NatSpec block. The `zero()`/`e()` pair is missing this blank line, and instead has a blank line *between* the NatSpec and the function signature. Both are style inconsistencies. + +**Recommendation:** Add a blank line after the closing brace of `zero()` (before the `///` of `e()`), and remove the blank line between the `@return` tag and the `function e()` signature. + +--- + +### A01-03 | INFO | Bare `"src/"` import paths in test files (74 files, 123 occurrences) + +**Scope:** `test/` directory + +All 74 test files use bare `"src/"` import paths (e.g., `import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol";`). These paths rely on Foundry's `src = 'src'` auto-detection, which resolves `"src/"` to the project root's `src/` directory. When this repo is consumed as a git submodule, a parent project's Foundry config will resolve `"src/"` to the *parent's* `src/` directory, breaking all test imports. + +This does not affect production (`src/`) files, which correctly use relative paths. It also does not affect scripts, which use `"../src/"` relative paths. + +While tests are not typically compiled by downstream consumers, the inconsistency with the relative-path convention in `src/` and `script/` is worth noting. + +**Recommendation:** Convert test imports to relative paths (e.g., `"../../src/lib/LibDecimalFloat.sol"`) or add a Foundry remapping to make them submodule-safe. + +--- + +### A05-01 | INFO | `BYTECODE_HASH` constant is always zero and never referenced + +**File:** `src/generated/LogTables.pointers.sol` line 13 + +```solidity +bytes32 constant BYTECODE_HASH = bytes32(0x0000000000000000000000000000000000000000000000000000000000000000); +``` + +This constant is emitted by the codegen framework (`rain.sol.codegen`) as part of its `buildFileForContract` template. It is always zero because `BuildPointers.sol` passes `address(0)` (no deployed instance to hash). The constant is never imported or used anywhere in the project. + +This is a consequence of the codegen library's generic template and is not a bug, but it is dead code that could confuse readers. + +**Recommendation:** No action needed -- this is generated code controlled by the `rain.sol.codegen` dependency. If the codegen library adds opt-out support in the future, consider suppressing the constant. + +--- + +### A01-04 | INFO | Pragma version inconsistency between concrete/script and library files + +**Observation:** +- `src/concrete/DecimalFloat.sol`: `pragma solidity =0.8.25;` (exact) +- `script/BuildPointers.sol`: `pragma solidity =0.8.25;` (exact) +- `script/Deploy.sol`: `pragma solidity =0.8.25;` (exact) +- All library/error/generated files: `pragma solidity ^0.8.25;` (range) + +This is intentional and correct: concrete contracts and scripts pin the exact compiler version, while libraries use `^` to allow consumers to compile with later patch versions. No action needed. + +--- + +### A02-01 | INFO | `ErrDecimalFloat.sol` imports `Float` type for one error + +**File:** `src/error/ErrDecimalFloat.sol` line 5 + +The error file imports the `Float` user-defined type solely for use as the parameter of `ZeroNegativePower(Float b)`. All other errors in this file use raw `int256`/`uint256` parameters. This is a minor style inconsistency but may be intentional to preserve semantic meaning in the error signature. + +**Recommendation:** No action strictly required. If uniformity is desired, the parameter could be changed to `bytes32` (the underlying type of `Float`), removing the import dependency. However, using `Float` improves developer experience when decoding revert data. + +--- + +## Summary + +| ID | Severity | File | Title | +|----|----------|------|-------| +| A07-01 | LOW | `src/lib/deploy/LibDecimalFloatDeploy.sol` | 7 unused imports | +| A01-01 | LOW | `src/concrete/DecimalFloat.sol` | `require` with string instead of custom error | +| A01-02 | INFO | `src/concrete/DecimalFloat.sol` | Missing blank line / extra blank line in NatSpec | +| A01-03 | INFO | `test/` (74 files) | Bare `"src/"` import paths break as git submodule | +| A01-04 | INFO | Multiple | Pragma version split (intentional) | +| A02-01 | INFO | `src/error/ErrDecimalFloat.sol` | Float import for single error parameter | +| A05-01 | INFO | `src/generated/LogTables.pointers.sol` | Zero-value `BYTECODE_HASH` never used | + +**No HIGH or MEDIUM findings.** +**No commented-out code found.** +**No build warnings detected** (forge build was not executable in this session; the codebase compiles cleanly based on prior CI evidence from recent commits). diff --git a/audit/2026-03-10-01/pass4/impl_format_parse_table.md b/audit/2026-03-10-01/pass4/impl_format_parse_table.md new file mode 100644 index 0000000..a9f5ebf --- /dev/null +++ b/audit/2026-03-10-01/pass4/impl_format_parse_table.md @@ -0,0 +1,236 @@ +# Pass 4 -- Code Quality: Implementation, Format, Parse, Table + +**Agents:** A08 (format), A09 (implementation), A10 (parse), A11 (table) +**Date:** 2026-03-10 + +--- + +## Evidence of Thorough Reading + +### A08: `src/lib/format/LibFormatDecimalFloat.sol` (165 lines) + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1), copyright 2020 (line 2) +- **Imports (4):** `LibDecimalFloat, Float` from `../LibDecimalFloat.sol`; `LibDecimalFloatImplementation` from `../../lib/implementation/LibDecimalFloatImplementation.sol`; `Strings` from OpenZeppelin; `UnformatableExponent` from `../../error/ErrFormat.sol` +- **Library:** `LibFormatDecimalFloat` (line 13) +- **Functions:** `countSigFigs` (line 18), `toDecimalString` (line 58) +- **Key patterns noted:** slither-disable on line 57 uses `//slither-...` (no space); `int256(0)` explicit cast on line 100 vs bare `0` elsewhere; `scale = 0` on line 65; magic numbers `1e76`, `1e75`, `76`, `75` on lines 69-74 and 84 + +### A09: `src/lib/implementation/LibDecimalFloatImplementation.sol` (1307 lines) + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1), copyright 2020 (line 2) +- **Imports (2 groups):** Error types from `../../error/ErrDecimalFloat.sol` (lines 5-12); table constants from `../table/LibLogTable.sol` (lines 13-18) +- **File-level error:** `WithTargetExponentOverflow` (line 21) +- **File-level constants:** `ADD_MAX_EXPONENT_DIFF` (line 24), `EXPONENT_MAX` (line 29), `EXPONENT_MIN` (line 34), `MAXIMIZED_ZERO_SIGNED_COEFFICIENT` (line 37), `MAXIMIZED_ZERO_EXPONENT` (line 40), `LOG10_Y_EXPONENT` (line 44) +- **Library:** `LibDecimalFloatImplementation` (line 52) +- **Functions (22):** minus (71), absUnsignedSignedCoefficient (89), unabsUnsignedMulOrDivLossy (116), mul (160), div (272), mul512 (466), mulDiv (479), add (610), sub (703), eq (724), inv (736), lookupLogTableVal (744), log10 (783), pow10 (902), maximize (957), maximizeFull (1011), compareRescale (1047), withTargetExponent (1127), intFrac (1169), mantissa4 (1197), lookupAntilogTableY1Y2 (1227), unitLinearInterpolation (1267) +- **Slither annotations:** Mixed style -- `//slither-disable-next-line` (no space) on lines 271, 835, 1206, 1238 vs `// slither-disable-next-line` (with space) on lines 518, 522, 537, 752, 766, 840 +- **Magic numbers in `lookupAntilogTableY1Y2`:** `100` (line 1236, alt small log table size), `2000` (line 1245, antilog table size), `1800` and `900` (lines 1233-1234, in comments but corresponding unnamed values in formula) + +### A10: `src/lib/parse/LibParseDecimalFloat.sol` (197 lines) + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1), copyright 2020 (line 2) +- **Imports (7):** `LibParseChar` from rain.string (line 5); 5 cmask constants from rain.string (lines 6-12); `LibParseDecimal` from rain.string (line 13); `MalformedExponentDigits, ParseDecimalPrecisionLoss, MalformedDecimalPoint` from `../../error/ErrParse.sol` (line 14); `ParseEmptyDecimalString` from rain.string (line 15); `LibDecimalFloat, Float` from `../LibDecimalFloat.sol` (line 16); `ParseDecimalFloatExcessCharacters` from `../../error/ErrParse.sol` (line 17) +- **Library:** `LibParseDecimalFloat` (line 24) +- **Functions:** `parseDecimalFloatInline` (line 34), `parseDecimalFloat` (line 169) +- **Split import noted:** Lines 14 and 17 both import from `../../error/ErrParse.sol` in separate statements +- **Magic number:** `67` on line 108 (maximum fractional scale) + +### A11: `src/lib/table/LibLogTable.sol` (742 lines) + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1), copyright 2020 (line 2) +- **No imports** +- **File-level constants (7):** `ALT_TABLE_FLAG` (line 7), `LOG_MANTISSA_IDX_CARDINALITY` (line 10), `LOG_MANTISSA_LAST_INDEX` (line 13), `ANTILOG_IDX_CARDINALITY` (line 16), `ANTILOG_IDX_LAST_INDEX` (line 19), `LOG_TABLE_SIZE_BASE` (line 25), `LOG_TABLE_SIZE_BYTES` (line 28), `LOG_TABLE_DISAMBIGUATOR` (line 32) +- **Library:** `LibLogTable` (line 35) +- **Functions (10):** 5 `toBytes` overloads (lines 41, 75, 109, 142, 175), `logTableDec` (line 206), `logTableDecSmall` (line 414), `logTableDecSmallAlt` (line 512), `antiLogTableDec` (line 530), `antiLogTableDecSmall` (line 638) +- **Magic numbers in `toBytes` overloads:** `1000` (lines 113, 132 -- `uint8[10][100]` table size), `100` (lines 146, 165 -- `uint8[10][10]` table size), `2000` (lines 179, 198 -- `uint16[10][100]` table size). The `uint16[10][90]` and `uint8[10][90]` overloads use the named constants `LOG_TABLE_SIZE_BYTES` and `LOG_TABLE_SIZE_BASE` respectively, but the other three overloads inline the table sizes. +- **Typo:** "copmiled" on line 203 (already noted in pass 3 as A11-1) + +--- + +## Import Path Audit + +### Bare `src/` imports + +No bare `src/` import paths found in any of the four files. + +### Other import path issues + +| File | Line | Path | Issue | +|------|------|------|-------| +| `LibFormatDecimalFloat.sol` | 6 | `../../lib/implementation/LibDecimalFloatImplementation.sol` | Unnecessarily verbose -- traverses up to `src/` then back into `lib/`. Could be `../implementation/LibDecimalFloatImplementation.sol`. All other sibling-to-sibling imports under `src/lib/` use `../` (e.g., `LibDecimalFloat.sol` imports it as `./implementation/LibDecimalFloatImplementation.sol`). This is the only occurrence of `../../lib/` in the entire `src/lib/` tree. | +| `LibParseDecimalFloat.sol` | 14, 17 | `../../error/ErrParse.sol` (x2) | Two separate import statements from the same module. Could be consolidated into a single import. | + +--- + +## Style Consistency + +### Slither annotation style inconsistency + +Across the four files and the broader `src/lib/` directory, slither-disable annotations use two styles: + +- **No space:** `//slither-disable-next-line ...` (format:57, impl:271, 835, 1206, 1238; also LibDecimalFloat.sol:224, 584, 595) +- **With space:** `// slither-disable-next-line ...` (impl:518, 522, 537, 752, 766, 840; also LibDecimalFloat.sol:59, 73, 79, 85) + +Both are valid, but inconsistency within the same file (LibDecimalFloatImplementation.sol) is notable. The `slither-disable-start`/`end` pair at lines 835/840 even uses different styles for the start vs end. + +### NatSpec annotation on library declarations + +- `LibFormatDecimalFloat` (format) uses `/// @dev` (line 10) -- no `@title` +- `LibDecimalFloatImplementation` (impl) uses `/// @dev` (line 46) -- no `@title` +- `LibParseDecimalFloat` (parse) uses `/// @title` + `/// @notice` (lines 19-20) +- `LibLogTable` (table) uses `/// @dev` with external URL (line 34) -- no `@title` +- `LibDecimalFloat` (main) uses `/// @title` (line 18) -- with long text description + +The parse file is the only one using the `@title`/`@notice` pattern. The rest use bare `/// @dev` or `///`. This is a minor inconsistency. + +### Zero-initialization style + +`LibFormatDecimalFloat.sol` uses `int256(0)` (explicit cast, line 100) while elsewhere in the same file and other files, bare `0` is used for the same purpose. Within the implementation, `MAXIMIZED_ZERO_SIGNED_COEFFICIENT` and `MAXIMIZED_ZERO_EXPONENT` are defined as named constants for zero values, but `int256(0)` and bare `0` are used ad hoc in other files. + +--- + +## Dead Code + +### A08: `countSigFigs` is dead code in production + +`countSigFigs` (format:18-50) is defined as `internal pure` but is never called from any other source file in `src/`. It is only called from the test file `test/src/lib/format/LibFormatDecimalFloat.countSigFigs.t.sol`. It is not used by `toDecimalString` or any other function. This was partially noted in pass 2 (A08-4) as "not exposed in concrete contract", but the stronger statement is that it is not called by any production code whatsoever -- it is purely test-only dead code in `src/`. + +--- + +## Commented-Out Code + +No commented-out code found in any of the four files. + +--- + +## Magic Numbers + +### Format file (`LibFormatDecimalFloat.sol`) + +- Lines 69-74: `1e76`, `76`, `1e75`, `75` -- these represent the maximum coefficient ranges and are tied to the 77-digit coefficient limit (int256 can hold up to ~1e76). Used throughout the codebase consistently as inlined values. The implementation file defines `LOG10_Y_EXPONENT = -76` as a named constant, but there is no corresponding constant for the coefficient magnitude bounds (76/75/1e76/1e75). This is pervasive and a known design choice. +- Line 84: `-76` -- hard limit for formattable exponents, directly tied to the coefficient magnitude. + +### Implementation file (`LibDecimalFloatImplementation.sol`) + +- Line 1236: `100` -- the byte size of the alt small log table (`uint8[10][10]`). Not a named constant. Used in the formula `1 + LOG_TABLE_SIZE_BYTES + LOG_TABLE_SIZE_BASE + 100`. +- Line 1245: `2000` -- the byte size of the antilog main table (`uint16[10][100]`). Not a named constant. +- Lines 1233-1234: Comments say "1800 for log tables" and "900 for small log tables" -- these are `LOG_TABLE_SIZE_BYTES` and `LOG_TABLE_SIZE_BASE` respectively, but the comments restate the values instead of referencing the constant names. + +### Parse file (`LibParseDecimalFloat.sol`) + +- Line 108: `67` -- maximum fractional decimal digits. This is the precision limit for combining integer + fractional parts without overflow. It is not a named constant. + +### Table file (`LibLogTable.sol`) + +- Lines 113/132: `1000` in `toBytes(uint8[10][100])` -- table byte size (10*100*1). +- Lines 146/165: `100` in `toBytes(uint8[10][10])` -- table byte size (10*10*1). +- Lines 179/198: `2000` in `toBytes(uint16[10][100])` -- table byte size (10*100*2). +- Compare with lines 43/46/78/80: `toBytes(uint16[10][90])` and `toBytes(uint8[10][90])` use `LOG_TABLE_SIZE_BYTES` and `LOG_TABLE_SIZE_BASE`. The inconsistency is that only the log-table-sized overloads use named constants; the antilog and alt-table-sized overloads inline the values. + +--- + +## Findings + +### A08-5 [LOW] Redundant import path `../../lib/implementation/` in `LibFormatDecimalFloat.sol` + +**Severity:** LOW +**File:** `src/lib/format/LibFormatDecimalFloat.sol`, line 6 +**Status:** Open + +The import `../../lib/implementation/LibDecimalFloatImplementation.sol` traverses up two directories to `src/` and back into `lib/`. The simpler path `../implementation/LibDecimalFloatImplementation.sol` achieves the same result and is consistent with how all other within-`src/lib/` imports are written. This is the only `../../lib/` import path in the entire `src/lib/` tree. While not broken, this pattern would break or confuse tools that normalize import paths. + +### A08-6 [LOW] `countSigFigs` is dead production code + +**Severity:** LOW +**File:** `src/lib/format/LibFormatDecimalFloat.sol`, lines 18-50 +**Status:** Open + +`countSigFigs` is never called from any file in `src/`. It is only used in tests. If it is intended as a utility for external library consumers, this is acceptable but should be documented as such. If it was written as a helper for `toDecimalString` but never integrated, it should be removed or moved to a test helper. Dead code in production contracts increases bytecode size (though as `internal`, it is only included if called). + +### A10-1 [LOW] Split imports from same module in `LibParseDecimalFloat.sol` + +**Severity:** LOW +**File:** `src/lib/parse/LibParseDecimalFloat.sol`, lines 14 and 17 +**Status:** Open + +Two separate import statements pull from `../../error/ErrParse.sol`: +- Line 14: `{MalformedExponentDigits, ParseDecimalPrecisionLoss, MalformedDecimalPoint}` +- Line 17: `{ParseDecimalFloatExcessCharacters}` + +These should be consolidated into a single import statement for consistency and readability. All four error types are defined in the same file. + +### A09-16 [LOW] Magic number `100` (alt small log table byte size) in `lookupAntilogTableY1Y2` + +**Severity:** LOW +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 1236 +**Status:** Open + +The offset computation `1 + LOG_TABLE_SIZE_BYTES + LOG_TABLE_SIZE_BASE + 100` uses the magic number `100` for the alt small log table byte size. The other two terms (`LOG_TABLE_SIZE_BYTES`, `LOG_TABLE_SIZE_BASE`) are named constants from `LibLogTable.sol`. The `100` should also be a named constant for consistency and to prevent silent breakage if the alt small log table dimensions change. + +### A09-17 [LOW] Magic number `2000` (antilog main table byte size) in `lookupAntilogTableY1Y2` + +**Severity:** LOW +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 1245 +**Status:** Open + +Inside the assembly `lookupTableVal` function, `offset := add(offset, 2000)` uses a magic number for the antilog main table byte size (`uint16[10][100]` = 2000 bytes). This should be a named constant, especially since the log table sizes have named constants (`LOG_TABLE_SIZE_BYTES`, `LOG_TABLE_SIZE_BASE`). + +### A11-3 [LOW] Inconsistent use of named constants vs magic numbers in `toBytes` overloads + +**Severity:** LOW +**File:** `src/lib/table/LibLogTable.sol`, lines 109-135, 142-168, 175-201 +**Status:** Open + +The five `toBytes` overloads handle different table dimensions. The first two overloads (`uint16[10][90]` and `uint8[10][90]`) use the named constants `LOG_TABLE_SIZE_BYTES` and `LOG_TABLE_SIZE_BASE` for their table sizes. The remaining three overloads inline magic numbers: +- `toBytes(uint8[10][100])`: `1000` (lines 113, 132) +- `toBytes(uint8[10][10])`: `100` (lines 146, 165) +- `toBytes(uint16[10][100])`: `2000` (lines 179, 198) + +Named constants should be defined for the antilog and alt table sizes, consistent with how log table sizes are handled. + +### A09-18 [INFO] Slither annotation style inconsistency within `LibDecimalFloatImplementation.sol` + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, multiple lines +**Status:** Open + +The file mixes `//slither-disable-next-line` (no space, lines 271, 835, 1206, 1238) with `// slither-disable-next-line` (space, lines 518, 522, 537, 752, 766). Most notably, the `slither-disable-start` on line 835 uses no space, while the corresponding `slither-disable-end` on line 840 uses a space. Both styles work, but inconsistency within a single file is a minor quality issue. + +### A10-2 [INFO] Magic number `67` (max fractional scale) in `LibParseDecimalFloat.sol` + +**Severity:** INFORMATIONAL +**File:** `src/lib/parse/LibParseDecimalFloat.sol`, line 108 +**Status:** Open + +The constant `67` represents the maximum number of fractional digits that can be rescaled without overflow when combining with the integer part. This is derived from the int224 coefficient limit. A named constant would improve readability and make the relationship to the coefficient bit width explicit. + +### A11-4 [INFO] NatSpec style on library declaration differs from sibling files + +**Severity:** INFORMATIONAL +**File:** `src/lib/parse/LibParseDecimalFloat.sol`, lines 19-20 +**Status:** Open + +`LibParseDecimalFloat` is the only library in `src/lib/` that uses `@title` + `@notice` on its library declaration. All other libraries (`LibDecimalFloat`, `LibFormatDecimalFloat`, `LibDecimalFloatImplementation`, `LibLogTable`, `LibDecimalFloatDeploy`) use `@dev` or bare `///` descriptions. This is a minor style inconsistency. + +--- + +## Summary + +| ID | Severity | File | Issue | +|----|----------|------|-------| +| A08-5 | LOW | LibFormatDecimalFloat.sol:6 | Redundant import path `../../lib/implementation/` | +| A08-6 | LOW | LibFormatDecimalFloat.sol:18-50 | `countSigFigs` is dead production code | +| A10-1 | LOW | LibParseDecimalFloat.sol:14,17 | Split imports from same module | +| A09-16 | LOW | LibDecimalFloatImplementation.sol:1236 | Magic number `100` (alt small log table size) | +| A09-17 | LOW | LibDecimalFloatImplementation.sol:1245 | Magic number `2000` (antilog table size) | +| A11-3 | LOW | LibLogTable.sol:109-201 | Inconsistent named constants vs magic numbers in `toBytes` | +| A09-18 | INFO | LibDecimalFloatImplementation.sol | Slither annotation style inconsistency | +| A10-2 | INFO | LibParseDecimalFloat.sol:108 | Magic number `67` (max fractional scale) | +| A11-4 | INFO | LibParseDecimalFloat.sol:19-20 | NatSpec style differs from sibling libraries | + +**LOW findings:** 6 (A08-5, A08-6, A10-1, A09-16, A09-17, A11-3) +**INFORMATIONAL findings:** 3 (A09-18, A10-2, A11-4) diff --git a/audit/2026-03-10-01/pass5/LibDecimalFloat.md b/audit/2026-03-10-01/pass5/LibDecimalFloat.md new file mode 100644 index 0000000..7a199cf --- /dev/null +++ b/audit/2026-03-10-01/pass5/LibDecimalFloat.md @@ -0,0 +1,226 @@ +# Audit Pass 5 -- Correctness / Intent Verification: `src/lib/LibDecimalFloat.sol` + +**Auditor agent:** A06 +**Date:** 2026-03-10 +**File:** `src/lib/LibDecimalFloat.sol` (796 lines, 34 functions) + +--- + +## Evidence of thorough reading + +### Full function-by-function verification + +Every function was read and its implementation traced against its name and NatSpec. Where the function delegates to `LibDecimalFloatImplementation`, the implementation was also read and verified. + +| # | Function | Lines | Intent verified | Notes | +|---|----------|-------|-----------------|-------| +| 1 | `fromFixedDecimalLossy` | 104-120 | Yes | Converts `value * 10^(-decimals)` to (coefficient, exponent). Handles overflow of int256 by dividing by 10. | +| 2 | `fromFixedDecimalLossyPacked` | 132-136 | Yes | Wrapper that packs result. Combines lossiness from conversion and packing. | +| 3 | `fromFixedDecimalLossless` | 144-150 | Yes | Reverts if lossy. | +| 4 | `fromFixedDecimalLosslessPacked` | 158-161 | Yes | Wrapper. **NatSpec references nonexistent `fromFixedDecimalLossyMem`** (A06-23). | +| 5 | `toFixedDecimalLossy(int256,int256,uint8)` | 176-242 | Yes | Converts coefficient*10^exponent to fixed-point. Handles negative exponents (division), positive exponents (multiplication), zero exponent (identity). | +| 6 | `toFixedDecimalLossy(Float,uint8)` | 254-257 | Yes | Wrapper that unpacks Float. | +| 7 | `toFixedDecimalLossless(int256,int256,uint8)` | 265-275 | Yes | Reverts if lossy. | +| 8 | `toFixedDecimalLossless(Float,uint8)` | 286-289 | Yes | Wrapper. | +| 9 | `packLossy` | 299-351 | Yes | Truncates coefficient to fit int224, adjusts exponent. Returns lossless flag. Assembly packing verified. | +| 10 | `packLossless` | 358-364 | Yes | Reverts if lossy. | +| 11 | `unpack` | 373-379 | Yes | `signextend(27, ...)` for 224-bit sign extension, `sar(224, ...)` for exponent extraction. Correct inverse of pack. | +| 12 | `add` | 388-397 | Yes | Delegates to `LibDecimalFloatImplementation.add`. Adds a + b. | +| 13 | `sub` | 405-414 | Yes | Delegates to `LibDecimalFloatImplementation.sub` which negates B and adds A + (-B) = A - B. **NatSpec says "Subtract float a from float b" which means b - a, but code computes a - b** (A06-22). | +| 14 | `minus` | 421-428 | Yes | Negates coefficient. Handles int256.min edge case via division. | +| 15 | `abs` | 440-451 | Yes | Negates if negative, identity if non-negative. | +| 16 | `mul` | 474-482 | Yes | Delegates to implementation which multiplies via 512-bit intermediates. | +| 17 | `div` | 491-500 | Yes | Delegates to implementation which divides with maximized coefficients and mulDiv. | +| 18 | `inv` | 507-513 | Yes | Computes 1/x via `div(1e76, -76, x)`. | +| 19 | `eq` | 520-524 | Yes | Rescales and compares coefficients for equality. | +| 20 | `lt` | 531-538 | Yes | Rescales and checks `coeffA < coeffB`. | +| 21 | `gt` | 545-551 | Yes | Rescales and checks `coeffA > coeffB`. | +| 22 | `lte` | 557-563 | Yes | Rescales and checks `coeffA <= coeffB`. | +| 23 | `gte` | 569-575 | Yes | Rescales and checks `coeffA >= coeffB`. | +| 24 | `integer` | 582-588 | Yes | Returns integer part (truncation toward zero). NatSpec correctly describes this as floor for positive, ceil for negative. | +| 25 | `frac` | 593-598 | Yes | Returns fractional part. | +| 26 | `floor` | 603-617 | Yes | Verified with positive (2.7->2), negative (-2.7->-3), integer inputs. Subtracts 1 for negative non-integers. | +| 27 | `ceil` | 621-643 | Yes | Verified with positive (2.7->3), negative (-2.7->-2), zero fraction. Adds 1 for positive non-integers, truncation toward zero handles negative case. | +| 28 | `pow10` | 652-660 | Yes | Computes 10^(input) via antilog table lookup. Name and intent match. **NatSpec references nonexistent `power10` function** (A06-24). | +| 29 | `log10` | 668-675 | Yes | Computes log10(input) via table lookup. **NatSpec self-referential** (A06-24). | +| 30 | `pow` | 690-750 | Yes | Computes a^b. Splits b into integer (exponentiation by squaring) and fractional (10^(frac*log10(a))) parts. Handles 0^0=1, 0^neg=revert, 0^pos=0, neg base=revert, b<0 via inverse. | +| 31 | `sqrt` | 764-766 | Yes | Delegates to `pow(a, FLOAT_HALF)`. sqrt(a) = a^0.5. | +| 32 | `min` | 773-775 | Yes | Returns smaller of two floats. | +| 33 | `max` | 781-783 | Yes | Returns larger of two floats. | +| 34 | `isZero` | 788-795 | Yes | Checks if lower 224 bits are zero. Correct because coefficient == 0 means value is zero regardless of exponent. | + +### Constants verification + +| Constant | Expected packing | Verified | +|----------|------------------|----------| +| `FLOAT_ZERO` (0x0) | coeff=0, exp=0. Value=0. | Correct | +| `FLOAT_ONE` (0x01) | coeff=1, exp=0. Value=1. | Correct | +| `FLOAT_HALF` (0xffffffff...05) | coeff=5, exp=-1 (0xffffffff = -1 as int32). Value=0.5. | Correct | +| `FLOAT_TWO` (0x02) | coeff=2, exp=0. Value=2. | Correct | +| `FLOAT_MAX_POSITIVE_VALUE` | coeff=int224.max (0x7f...f, 56 hex), exp=int32.max (0x7fffffff). Largest representable value. | Correct | +| `FLOAT_MIN_POSITIVE_VALUE` | coeff=1, exp=int32.min (0x80000000). Smallest positive value. | Correct | +| `FLOAT_MAX_NEGATIVE_VALUE` | coeff=-1 (0xff...f as int224), exp=int32.min. Closest to zero negative value. | Correct | +| `FLOAT_MIN_NEGATIVE_VALUE` | coeff=int224.min (0x80...0), exp=int32.max. Most negative value. | Correct | +| `FLOAT_E` | coeff=2.718...e66, exp=-66 (0xffffffbe = -66 as int32). Euler's number. Verified by test using Solidity literal. | Correct | +| `LOG_TABLES_ADDRESS` | Deterministic deployment address. Not mathematically verifiable from source alone, but tested via deployment scripts. | N/A (deployment constant) | + +All 9 mathematical constants are correctly packed. The test file `test/src/lib/LibDecimalFloat.constants.t.sol` independently verifies all 9 by packing the expected (coefficient, exponent) pairs and comparing. + +### Comparison function sign-combination analysis + +`compareRescale` handles four cases without rescaling (returning raw coefficients): + +| Case | Why correct | +|------|-------------| +| Either coefficient is zero | Zero is less than all positives, greater than all negatives, regardless of exponent. Raw coefficient 0 satisfies all comparison operators correctly. | +| Different signs | Positive > negative always, regardless of magnitude. Raw coefficients preserve sign relationship. | +| Equal exponents | Same exponent means coefficient magnitude directly determines ordering. | +| Exponent diff > 76 or overflow | The larger-exponent value dominates; the smaller is effectively zero in comparison. Handled by returning (coeff, 0) or (0, coeff) depending on swap direction. | + +All sign combinations for `lt`, `gt`, `lte`, `gte`, `eq` produce correct results through `compareRescale`. + +### floor/ceil verification with worked examples + +| Input | Function | intFrac result | Adjustment | Output | Expected | Match | +|-------|----------|----------------|------------|--------|----------|-------| +| 2.7 (27, -1) | floor | i=20, f=7 | none (positive) | 2 | 2 | Yes | +| -2.7 (-27, -1) | floor | i=-20, f=-7 | sub 1 (negative + frac) | -3 | -3 | Yes | +| 3.0 (30, -1) | floor | i=30, f=0 | none (no frac) | 3 | 3 | Yes | +| -3.0 (-30, -1) | floor | i=-30, f=0 | none (no frac) | -3 | -3 | Yes | +| 2.7 (27, -1) | ceil | i=20, f=7 | add 1 (positive frac) | 3 | 3 | Yes | +| -2.7 (-27, -1) | ceil | i=-20, f=-7 | none (negative frac = truncation toward zero) | -2 | -2 | Yes | +| 3.0 (30, -1) | ceil | i=30, f=0 | none (no frac) | returns float | 3 | Yes | +| 0.5 (5, -1) | ceil | i=0, f=5 | add 1 | 1 | 1 | Yes | +| -0.5 (-5, -1) | ceil | i=0, f=-5 | none (negative frac) | 0 | 0 | Yes | +| 100 (1, 2) | floor | n/a | exp >= 0, return float | 100 | 100 | Yes | +| 100 (1, 2) | ceil | n/a | exp >= 0, return float | 100 | 100 | Yes | + +--- + +## Findings + +### A06-22 `sub` NatSpec says "Subtract float a from float b" but code computes a - b [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` line 399 +**Type:** NatSpec / intent mismatch + +The opening NatSpec line for `sub` reads: + +``` +/// Subtract float a from float b. +``` + +The English phrase "Subtract X from Y" means "Y - X". Therefore "Subtract float a from float b" means "b - a". + +However, the code computes a - b: +- The implementation calls `LibDecimalFloatImplementation.sub(coeffA, expA, coeffB, expB)` +- That function negates B and adds: `add(A, -B) = A - B` + +The @param documentation on lines 403-404 correctly describes the a - b semantics: +- `@param a The float to subtract from.` (minuend) +- `@param b The float to subtract.` (subtrahend) + +The opening NatSpec line contradicts both the @param docs and the implementation. Any developer reading only the summary line would expect `sub(a, b)` to return `b - a`. + +**Impact:** A caller trusting the NatSpec summary would get the operands backwards, leading to incorrect arithmetic (sign reversal). The @param docs and code are consistent with each other, so a careful reader would catch this, but the summary line is the first thing a developer reads. + +### A06-23 `fromFixedDecimalLosslessPacked` NatSpec references nonexistent `fromFixedDecimalLossyMem` [LOW] + +**File:** `src/lib/LibDecimalFloat.sol` lines 152-155 +**Type:** Stale NatSpec reference + +The NatSpec for `fromFixedDecimalLosslessPacked` contains three references to a function called `fromFixedDecimalLossyMem`: + +```solidity +/// Lossless version of `fromFixedDecimalLossyMem`. This will revert if the +/// conversion is lossy. +/// @param value As per `fromFixedDecimalLossyMem`. +/// @param decimals As per `fromFixedDecimalLossyMem`. +``` + +No function named `fromFixedDecimalLossyMem` exists anywhere in the codebase. This appears to be a stale reference from a rename. The function should reference either `fromFixedDecimalLossyPacked` (the packed lossy variant) or `fromFixedDecimalLossy` (the unpacked lossy variant). + +**Impact:** A developer following the NatSpec cross-reference will find no matching function, causing confusion about the API surface. Since this is in a `Lossless` variant, a developer may fail to understand the conversion semantics by being unable to find the referenced lossy counterpart. + +### A06-24 `pow10` and `log10` NatSpec contain stale/self-referential descriptions [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 645, 662 +**Type:** Stale NatSpec + +The `pow10` NatSpec says: +``` +/// Same as power10, but accepts a Float struct instead of separate values. +``` +There is no function called `power10` in `LibDecimalFloat`. + +The `log10` NatSpec says: +``` +/// Same as log10, but accepts a Float struct instead of separate values. +``` +This is self-referential -- the function IS `log10`. + +Both descriptions follow a pattern from other functions (e.g., `add`, `mul`) where both packed and unpacked API versions existed in the library. For `pow10` and `log10`, only the packed version exists in `LibDecimalFloat` (the unpacked versions live in `LibDecimalFloatImplementation`), making these descriptions vestigial. + +**Impact:** Informational. A reader may look for a sibling function that does not exist. + +### A06-25 Comment says "always lossless" but code returns lossy flag [INFO] + +**File:** `src/lib/LibDecimalFloat.sol` lines 208-209 +**Type:** Misleading comment + +In `toFixedDecimalLossy`, the comment for the `finalExponent < -77` branch reads: + +```solidity +// Every possible value rounds to 0 if the exponent is less +// than -77. This is always lossless as we know the value is +// is not zero in real. +``` + +The code immediately returns `(0, false)` where `false` means the conversion IS lossy (NOT lossless). The comment appears to say the opposite of what the code does. The intended meaning is likely: "This [rounding to zero] is never lossless [i.e., always lossy], as we know the value is not zero in real [but we return zero]." + +Note: The "is is" duplication in this comment was already identified as A06-19 in pass 4. + +**Impact:** Informational. The code behavior is correct (`false` = lossy, which is right because a non-zero value is being returned as zero). The comment wording is confusing but does not affect execution. + +--- + +## Checks with no findings + +| Check | Result | +|-------|--------| +| `add` computes a + b | Confirmed. Delegates to `LibDecimalFloatImplementation.add(coeffA, expA, coeffB, expB)`. | +| `mul` computes a * b | Confirmed. Delegates to `LibDecimalFloatImplementation.mul`. Uses 512-bit intermediates. | +| `div` computes a / b | Confirmed. Delegates to `LibDecimalFloatImplementation.div(coeffA, expA, coeffB, expB)`. B=0 reverts. | +| `pow10` computes 10^a (not a^10) | Confirmed. Implementation uses antilog table, integer part adjusts exponent. `10^(int+frac) = 10^int * 10^frac`. | +| `pow(a,b)` computes a^b | Confirmed. Uses `a^b = a^(int_b) * 10^(frac_b * log10(a))`. | +| `floor` correct for negative numbers | Confirmed with worked examples. Subtracts 1 from truncated integer when input is negative with non-zero fraction. | +| `ceil` correct for negative numbers | Confirmed. Truncation toward zero (from `intFrac`) already rounds up for negatives. Only adds 1 for positive fractions. | +| `fromFixedDecimal` mathematically correct | Confirmed. `value * 10^(-decimals)` maps to `(value, -decimals)` or `(value/10, -decimals+1)` when value > int256.max. | +| `toFixedDecimal` mathematically correct | Confirmed. `coefficient * 10^(exponent + decimals)` correctly converts to fixed-point. | +| `packLossy` preserves values | Confirmed. Truncates coefficient to fit int224, increments exponent to compensate. Returns lossless flag. Zero returns FLOAT_ZERO. | +| `packLossless` preserves values | Confirmed. Reverts if packLossy returns lossless=false. | +| `unpack` is inverse of `pack` | Confirmed. `signextend(27, ...)` and `sar(224, ...)` correctly reverse the `or(and(...), shl(...))` packing. | +| All 9 mathematical constants correct | Confirmed by manual unpacking of hex values and cross-reference with test file. | +| Comparison functions handle all sign combinations | Confirmed via `compareRescale` analysis: zero, different-sign, same-sign/same-exponent, and same-sign/different-exponent cases all produce correct results. | +| `inv` computes 1/x | Confirmed. Delegates to `div(1e76, -76, x)` = 1/x. | +| `sqrt` computes x^0.5 | Confirmed. Delegates to `pow(a, FLOAT_HALF)`. | +| `min`/`max` correct | Confirmed. Simple ternary on `lt`/`gt`. | +| `isZero` correct | Confirmed. Checks lower 224 bits (coefficient) for zero. Exponent is irrelevant when coefficient is zero. | +| `abs` correct | Confirmed. Identity for non-negative, negation for negative. Handles int224.min via `minus` which divides by 10. | + +--- + +## Summary + +| Severity | Count | IDs | +|----------|-------|-----| +| LOW | 2 | A06-22, A06-23 | +| INFO | 2 | A06-24, A06-25 | + +The core arithmetic, comparison, rounding, and conversion logic all correctly implement their stated intent. All 9 mathematical constants are correctly encoded. The `floor`/`ceil` functions correctly handle negative numbers. `pow10` computes `10^a` (not `a^10`), `sub(a,b)` computes `a - b` (not `b - a`), and `pow(a,b)` computes `a^b`. + +The two LOW findings are both NatSpec issues that could mislead callers: +1. A06-22: `sub`'s opening NatSpec line says the opposite of what the code does ("Subtract a from b" = b-a, but code computes a-b). +2. A06-23: `fromFixedDecimalLosslessPacked` references a nonexistent function name three times. + +No correctness bugs were found in the implementation logic. diff --git a/audit/2026-03-10-01/pass5/LibDecimalFloatImplementation.md b/audit/2026-03-10-01/pass5/LibDecimalFloatImplementation.md new file mode 100644 index 0000000..3792ab2 --- /dev/null +++ b/audit/2026-03-10-01/pass5/LibDecimalFloatImplementation.md @@ -0,0 +1,364 @@ +# Pass 5 -- Correctness / Intent Verification: LibDecimalFloatImplementation.sol + +**Agent:** A09 +**Date:** 2026-03-10 + +--- + +## Evidence of Thorough Reading + +All 1307 lines of `src/lib/implementation/LibDecimalFloatImplementation.sol` were read in six overlapping chunks (lines 1-200, 200-400, 400-600, 600-800, 800-1000, 1000-1200, 1200-1307). Supporting files were also read: `src/error/ErrDecimalFloat.sol` (50 lines), `src/lib/table/LibLogTable.sol` (742 lines). + +### Structural verification + +- **Pragma:** `^0.8.25` (line 3) +- **License:** LicenseRef-DCL-1.0 (line 1), copyright 2020 (line 2) +- **Imports:** 6 error types from `../../error/ErrDecimalFloat.sol` (lines 5-12); 4 table constants from `../table/LibLogTable.sol` (lines 13-18) +- **File-level error:** `WithTargetExponentOverflow` (line 21) +- **File-level constants (6):** `ADD_MAX_EXPONENT_DIFF = 76` (line 24), `EXPONENT_MAX = type(int256).max / 2` (line 29), `EXPONENT_MIN = -EXPONENT_MAX` (line 34), `MAXIMIZED_ZERO_SIGNED_COEFFICIENT = 0` (line 37), `MAXIMIZED_ZERO_EXPONENT = 0` (line 40), `LOG10_Y_EXPONENT = -76` (line 44) +- **Library:** `LibDecimalFloatImplementation` (line 52) +- **22 functions verified:** minus (71), absUnsignedSignedCoefficient (89), unabsUnsignedMulOrDivLossy (116), mul (160), div (272), mul512 (466), mulDiv (479), add (610), sub (703), eq (724), inv (736), lookupLogTableVal (744), log10 (783), pow10 (902), maximize (957), maximizeFull (1011), compareRescale (1047), withTargetExponent (1127), intFrac (1169), mantissa4 (1197), lookupAntilogTableY1Y2 (1227), unitLinearInterpolation (1267) + +### Magic numbers and bitmasks verified + +- `0x7FFF` (line 758): 15-bit mask to strip ALT_TABLE_FLAG. Correct: ALT_TABLE_FLAG = 0x8000, so `& 0x7FFF` extracts the lower 15 bits. +- `0x8000` (line 759): ALT_TABLE_FLAG check. Correct: matches constant definition in `LibLogTable.sol`. +- `0xff` (line 674): used as `shr(0xff, ...)` to extract sign bit (bit 255) of int256. Correct: `shr(255, value)` shifts right by 255 bits, leaving only the sign bit. +- `1e75`, `1e76` (throughout): represent the coefficient magnitude boundaries. int256 can hold ~5.789e76 max, so maximized positive coefficients are in [1e75, 5.789e76] and maximized-with-extra-digit coefficients are in [1e76, 5.789e76]. +- `1e72` (line 859): scaling factor for log table values. The raw table values are 4-digit numbers (0-9999). Multiplied by 1e72, they become 76-digit coefficients matching the system's precision. +- `1e73` and `1e72` (line 830): division scales for log10 index computation. For isAtLeastE76 (coeff in [1e76, 5.78e76]): dividing by 1e73 gives [1000, 5789]. For not (coeff in [1e75, 1e76)): dividing by 1e72 gives [1000, 9999]. These ranges correspond to the 9000-entry log table index [0, 8999]. +- `ADD_MAX_EXPONENT_DIFF = 76` (line 24): maximum number of decimal digits in a coefficient. When exponent difference exceeds 76, one operand has zero contribution to the sum. Correct. +- `EXPONENT_MAX = type(int256).max / 2` (line 29): guard against overflow when maximizing (which subtracts from exponent). Dividing by 2 ensures room for the maximization's exponent decrease of up to ~77 digits. + +--- + +## Function-by-Function Verification + +### `minus` (line 71) + +**Intent:** Negate a float. + +**Verification:** +- `type(int256).min` cannot be negated directly because `|type(int256).min| = 2^255 > type(int256).max = 2^255 - 1`. The function correctly handles this by dividing by 10 and incrementing the exponent. +- Edge case: `type(int256).min, type(int256).max` correctly reverts with `ExponentOverflow` since incrementing the exponent would overflow. +- For all other values, `-signedCoefficient` is safe since the value is not `type(int256).min`. +- Result: **CORRECT** + +### `absUnsignedSignedCoefficient` (line 89) + +**Intent:** Return absolute value as unsigned. + +**Verification:** +- `type(int256).min` case: returns `uint256(type(int256).max) + 1 = 2^255`. Correct: `|−2^255| = 2^255`. +- Negative case: `uint256(-signedCoefficient)`. Since `signedCoefficient != type(int256).min`, negation is safe. Cast to uint256 is safe because the negated value is positive. +- Non-negative case: direct cast. Safe because non-negative int256 fits in uint256. +- Result: **CORRECT** + +### `unabsUnsignedMulOrDivLossy` (line 116) + +**Intent:** Convert unsigned absolute result back to signed, applying sign based on original operand signs. + +**Verification:** +- Sign detection via `(a ^ b) < 0`: XOR of two signed integers is negative if and only if they have different sign bits. Correct. +- When signs differ and result is exactly `uint256(type(int256).max) + 1 = 2^255`: returns `type(int256).min`. Correct, since that is the only negative int256 with absolute value 2^255. +- When signs differ and result > `type(int256).max` but not exactly `type(int256).max + 1`: divides by 10 and increments exponent. The division ensures the result fits in int256. The exponent increment compensates. +- When signs agree and result > `type(int256).max`: same divide-by-10 approach. +- **Potential issue:** `exponent + 1` on lines 132 and 144 is not inside an unchecked block, so it uses checked arithmetic. If exponent equals `type(int256).max`, this reverts with a Solidity panic rather than a meaningful error. However, in practice, exponents reaching `type(int256).max` would be caught earlier in `mul`/`div` by checked arithmetic on exponent sums. This is at most a poor error message issue. +- Result: **CORRECT** (minor UX issue with panic on extreme exponent) + +### `mul` (line 160) + +**Intent:** Multiply two floats using 512-bit intermediate. + +**Verification:** +- Zero check: correctly returns maximized zero if either coefficient is zero. +- Exponent computation: `exponentA + exponentB` on line 175 is checked arithmetic (not in unchecked), so overflow reverts. +- 512-bit product: `mul512` returns (high, low). Only the high word is used (`prod1`) to estimate the number of decimal digits in the full 512-bit product that exceed 256 bits. +- Binary search + while loop for `adjustExponent`: counts decimal digits of `prod1`. See finding A09-19 below for comment inaccuracy. +- `mulDiv(A, B, 10^adjustExponent)` computes `(A*B) / 10^adjustExponent` using the full 512-bit product internally, then truncating to 256 bits. This correctly preserves precision by only dividing away the excess digits. +- `exponent += adjustExponent` adds back the decimal shift. Then `unabsUnsignedMulOrDivLossy` handles sign and potential lossy fit. +- Maximum adjustExponent: `prod1` can be at most `2^254 ~= 2.896e76`, which is a 77-digit number. The binary search + while loop produces adjustExponent = 77. `10^77 ~= 1e77 < type(uint256).max ~= 1.157e77`, so no overflow. +- Result: **CORRECT** (comment on adjustExponent range is wrong but logic is sound) + +### `div` (line 272) + +**Intent:** Divide two floats using 512-bit intermediate. + +**Verification:** +- Division by zero: correctly reverted. +- Zero numerator: correctly returns maximized zero. +- Maximization: both operands are maximized to ensure sufficient precision. +- Scale selection: binary search finds the largest power-of-10 less than the denominator. The while loop on line 388 refines downward (`while (signedCoefficientBAbs <= scale)`) until `scale < signedCoefficientBAbs`. This is the correct invariant ensuring `mulDiv` does not overflow. +- `mulDiv(A, scale, B)` computes `(A * scale) / B`. Since `scale < B`, and `A < 2^256`, the result is guaranteed to be `< A`, which fits in uint256. The overflow check in mulDiv (`prod1 < denominator`) is satisfied because `prod1` of `A * scale` is at most `~A * B / 2^256`, and since both A and scale are at most ~5.78e76, `prod1 <= 5.78e76 * 5.78e76 / 1.157e77 ~= 2.89e76 < B` (since B >= 1e75 after maximization). +- Exponent handling: `exponentA - adjustExponent - exponentB` is correct for `(A * 10^adjustExponent / B) * 10^(exponentA - adjustExponent - exponentB) = A * 10^exponentA / (B * 10^exponentB)`. +- Underflow handling (lines 426-457): correctly detects when `exponentA - exponentB` would underflow int256, compensates by dividing the coefficient, and returns zero for extreme cases. +- Guard at line 398-399: reverts if A is not fully maximized when B is below 1e76. This is necessary because partial maximization of A with small B would cause precision loss in the mulDiv. +- Result: **CORRECT** + +### `mul512` (line 466) + +**Intent:** Compute full 512-bit product of two uint256 values. + +**Verification:** +- Uses the standard Chinese Remainder Theorem approach from OpenZeppelin. +- `mulmod(a, b, not(0))` computes `(a*b) mod (2^256 - 1)`. +- `mul(a, b)` computes `(a*b) mod 2^256`. +- `high = mm - low - borrow` reconstructs the high word. +- This is a well-audited algorithm identical to OpenZeppelin's Math.sol. +- Result: **CORRECT** + +### `mulDiv` (line 479) + +**Intent:** Compute `(x * y) / denominator` with 512-bit intermediate. + +**Verification:** +- Standard Remco Bloemen algorithm, used in OpenZeppelin, PRB Math, Solady. +- Overflow check: `prod1 >= denominator` correctly reverts (guarantees result fits in uint256 and denominator != 0). +- Remainder subtraction makes the 512-bit number exactly divisible. +- Lowest-power-of-two-divisor extraction and Newton-Raphson modular inverse are correct. +- Seed `(3 * denominator) ^ 2` is correct for 4-bit inverse: for odd `d`, `(3d) XOR 2` gives `d * inv = 1 mod 16`. +- 6 Newton-Raphson iterations double precision each time: 4 -> 8 -> 16 -> 32 -> 64 -> 128 -> 256 bits. +- Result: **CORRECT** + +### `add` (line 610) + +**Intent:** Add two floats with exponent alignment. + +**Verification:** +- Zero operand shortcut: correctly returns the non-zero operand. +- Maximization: both operands are fully maximized to ensure similar coefficient magnitudes. +- Exponent alignment: the coefficient with the larger exponent is multiplied by 10^diff... wait, the code does the opposite -- it divides the coefficient with the SMALLER exponent. On line 657: `alignmentExponentDiff = exponentA - exponentB` (A has the larger exponent after swapping). Then line 666: `signedCoefficientB /= int256(10 ** alignmentExponentDiff)`. This divides B (the one with the smaller exponent) to align it to A's exponent. This is correct: bringing B to A's scale by dividing B and then adding at exponent A. +- `ADD_MAX_EXPONENT_DIFF = 76`: if the exponent difference exceeds 76, all of B's digits would be divided away. Early return of A is correct. +- Overflow detection (lines 673-677): standard signed overflow check using XOR of sign bits. `sameSignAB` checks if A and B have the same sign. `sameSignAC` checks if A and the result have the same sign. Overflow = sameSignAB AND NOT sameSignAC. Correct. +- Overflow handling (lines 679-691): divides both by 10 and increments exponent, then re-adds. Since both were maximized (magnitude >= 1e75), dividing by 10 gives at most ~5.78e75. Sum is at most ~1.156e76 < type(int256).max. Safe from re-overflow. +- Result: **CORRECT** + +### `sub` (line 703) + +**Intent:** Subtract two floats: A - B = A + (-B). + +**Verification:** +- Calls `minus(B)` then `add(A, -B)`. Correct delegation. +- Result: **CORRECT** + +### `eq` (line 724) + +**Intent:** Numeric equality check. + +**Verification:** +- Delegates to `compareRescale`, then checks equality of rescaled coefficients. Correct. +- Result: **CORRECT** + +### `inv` (line 736) + +**Intent:** Compute 1/x. + +**Verification:** +- `div(1e76, -76, signedCoefficient, exponent)`. The value `1e76 * 10^-76 = 1`. So this computes `1 / x`. Correct. +- Using `1e76` as the numerator coefficient ensures maximum precision in the division. +- Result: **CORRECT** + +### `lookupLogTableVal` (line 744) + +**Intent:** Look up a log10 mantissa table value. + +**Verification:** +- Main table: 2-byte entries, 900 entries (9000/10). `mainOffset = 1 + (index/10) * 2`. Skip 1 byte header, each entry 2 bytes. Correct. +- `extcodecopy(tables, 30, mainOffset, 2)`: copies 2 bytes into memory at offset 30, so `mload(0)` reads them as the least significant 2 bytes of a 32-byte word. Correct. +- ALT_TABLE_FLAG check: `and(mainTableVal, 0x8000)`. If set, uses alternate small table. +- Small table: 1-byte entries, indexed by `(index/100)*10 + (index%10)`. This transforms a 4-digit index ABCD into the 2-digit index AD (skipping the middle digits BC which are handled by the main table). The main table handles digits AB (index/10), the small table handles digit D (index%10) within group A (index/100). Correct decomposition. +- `smallTableOffset = LOG_TABLE_SIZE_BYTES + 1 = 1801`. If ALT_TABLE_FLAG: `smallTableOffset += LOG_TABLE_SIZE_BASE = 900`, giving `2701`. This correctly positions past the header (1), main log table (1800), and regular small table (900) if needed. +- Result: **CORRECT** + +### `log10` (line 783) + +**Intent:** Compute log10(x) using table lookup and interpolation. + +**Verification:** +- Input validation: zero -> `Log10Zero`, negative -> `Log10Negative`. Correct. +- Exact power of 10: `signedCoefficient == 1e76` means the value is `1e76 * 10^exponent = 10^(exponent+76)`. Returns `(exponent+76, 0)`. Correct: `log10(10^n) = n`. +- Positive log (x >= 1): condition `exponent >= (isAtLeastE76 ? -76 : -75)`. If coefficient >= 1e76 with exponent >= -76, value >= 1. If coefficient < 1e76 (but >= 1e75) with exponent >= -75, value >= 1e75 * 10^-75 = 1. Correct. +- `powerOfTen`: the integer part of the log. For value = `coefficient * 10^exponent`, if coefficient is in [1e76, 5.78e76], then log10(value) = log10(coefficient) + exponent. And log10(coefficient) = log10(1e76 * mantissa) = 76 + log10(mantissa) where mantissa is in [1, 5.78]. So `powerOfTen = exponent + 76` gives the integer part of log10(value) when mantissa >= 1. Correct. +- Table lookup: index computation truncates to 4-digit precision, then uses linear interpolation between table entries for sub-grid precision. The log table stores `log10(1.XYZ) * 10000` (4 decimal digit values). Multiplied by 1e72, they become 76-digit coefficients at exponent -76. +- Negative log: `log10(x) = -log10(1/x)` for 0 < x < 1. Correct mathematical identity. +- Result: **CORRECT** + +### `pow10` (line 902) + +**Intent:** Compute 10^x using antilog table lookup and interpolation. + +**Verification:** +- Negative x: `10^(-x) = 1/10^x`. Uses `minus` -> `pow10` -> `inv`. Correct. +- Splits into integer and fractional parts: `10^x = 10^int * 10^frac`. +- `intFrac` correctly separates integer and fractional parts of the coefficient. +- `mantissa4` extracts the first 4 digits of the fractional part for table lookup. +- Antilog table values represent `10^(idx/10000) * 10000`, stored as 4-digit integers. With yExponent = -4, they represent `10^(idx/10000)` in the range [1.000, 9.999]. +- Linear interpolation between table entries for sub-grid precision. +- Final exponent: `1 + exponent + intPart`. The `1+` accounts for the fact that 10^frac is in [1, 10), so the coefficient from the table (e.g., 3162 for 10^0.5) needs exponent -3 rather than -4 to represent 3.162. Adding the integer exponent gives the correct power of 10. +- Edge case: the default y1=9997, y2=10000 for `idx == ANTILOG_IDX_LAST_INDEX` represents the values at 10^0.9999 and 10^1.0. 10^0.9999 = 9.9977, represented as 9997 (truncated) with exponent -4. 10^1 = 10, represented as 10000. Reasonable approximation. +- Overflow protection in interpolation (lines 930-934): the while loop shrinks scale and fracCoefficient until `(idxPlus1 * scale) / scale == idxPlus1`, preventing multiplication overflow. Correct. +- Result: **CORRECT** + +### `maximize` (line 957) + +**Intent:** Maximize coefficient magnitude by multiplying by powers of 10 and decreasing exponent. + +**Verification:** +- Zero input: returns (0, 0, true). Correct. +- Binary search: successively tries 1e38, 1e19, 1e10, 1e2, 10 multiplications if the coefficient is still below 1e75. The thresholds (1e38, 1e57, 1e66, 1e74, 1e75) are correct: after multiplying by 1e38, the check `/ 1e57 == 0` is equivalent to "is the coefficient below 1e57?", in which case multiply by 1e19 brings it to at most 1e57-1 * 1e19 < 1e76. +- Exponent bounds: each multiplication is guarded by `exponent >= type(int256).min + N` to prevent exponent underflow. Correct. +- Final try (line 995): attempts `* 10` with overflow check via round-trip division. This pushes the coefficient into the [1e76, type(int256).max] range when possible. +- `full` flag (line 1001): `signedCoefficient / 1e75 != 0` means the coefficient's magnitude is at least 1e75. For negative values, Solidity integer division rounds toward zero, so `-1e75 / 1e75 = -1 != 0`. Correct. +- Works correctly for both positive and negative coefficients. The division `signedCoefficient / 1e75` rounds toward zero for negatives, so the check `== 0` means magnitude < 1e75. +- Result: **CORRECT** + +### `maximizeFull` (line 1011) + +**Intent:** Like maximize but reverts if not fully maximized. + +**Verification:** +- Calls `maximize`, checks `full` flag, reverts with `MaximizeOverflow` if false. Straightforward. +- Result: **CORRECT** + +### `compareRescale` (line 1047) + +**Intent:** Rescale two floats for direct coefficient comparison. + +**Verification:** +- Short-circuit cases (either zero, different signs, equal exponents): returns coefficients directly. For zero/different-sign cases, coefficient comparison gives correct ordering. For equal exponents, coefficients can be directly compared. Correct. +- Swap to ensure A has larger exponent. `didSwap` tracks this to maintain return order. +- Exponent difference overflow: `exponentDiff = exponentA - exponentB` in unchecked. The check `slt(exponentDiff, 0)` catches wrap-around from subtraction overflow. `sgt(exponentDiff, 76)` catches too-large differences. In both cases, returns `(signedCoefficientA, 0)` (or swapped), which correctly indicates A is the "larger" value since a huge exponent difference means A dominates. +- Rescaling: multiplies A's coefficient by `10^exponentDiff` to align to B's exponent. Overflow check via round-trip division. On overflow, returns the same dominant-A result. +- Return order respects `didSwap`. Verified with trace examples for both positive and negative cases. +- Result: **CORRECT** + +### `withTargetExponent` (line 1127) + +**Intent:** Adjust coefficient to match a target exponent. + +**Verification:** +- Same exponent: return coefficient unchanged. Correct. +- Target > current: need to decrease coefficient (divide). `exponentDiff = targetExponent - exponent`. If diff > 76 or wraps to <= 0, returns zero (coefficient would be completely divided away). Correct. +- Target < current: need to increase coefficient (multiply). Overflow check via round-trip. Correct. +- Unchecked subtraction overflow: handled by the `<= 0` check on exponentDiff. If the subtraction wraps, the result is either very large positive (caught by > 76) or negative/zero (caught by <= 0). Correct. +- Result: **CORRECT** + +### `intFrac` (line 1169) + +**Intent:** Split a float into integer and fractional parts. + +**Verification:** +- Non-negative exponent: entire value is integer, frac = 0. Correct. +- Exponent < -76: entire value is fractional, integer = 0. Correct (coefficient can have at most ~77 digits, so with exponent < -76 the value is always < 1). +- Exponent in [-76, -1]: `unit = 10^(-exponent)`, `frac = coefficient % unit`, `integer = coefficient - frac`. Solidity's `%` preserves the sign of the dividend, so `integer + frac = coefficient` always holds. Correct. +- Result: **CORRECT** + +### `mantissa4` (line 1197) + +**Intent:** Extract first 4 fractional digits for antilog table lookup. + +**Verification:** +- Exponent = -4: coefficient is already 4 fractional digits. Correct. +- Exponent < -4: divide by `10^(-(exponent+4))` to get 4 digits. Interpolation flag set if there's a remainder. Correct. +- Exponent < -80: the fractional digits at positions 1-4 are all zero (coefficient has at most ~77 digits). Returns (0, interpolate, 1). Correct. +- Exponent >= 0: input is the fractional part from intFrac, which should be 0 for non-negative exponent. Returns (0, false, 1). Correct. +- Exponent in [-3, -1]: multiply up to 4 digits. E.g., exponent = -1: `coefficient * 10^3`. Since frac from intFrac is at most `10^(-exponent) - 1`, the product is at most `9 * 1000 = 9000` for exponent = -1. No overflow. Correct. +- Result: **CORRECT** + +### `lookupAntilogTableY1Y2` (line 1227) + +**Intent:** Look up two adjacent antilog table values. + +**Verification:** +- Offset: `1 + 1800 + 900 + 100 = 2801`. This is: 1 byte header + 1800 bytes log main table + 900 bytes log small table + 100 bytes log small alt table. The antilog tables start at byte 2801 in the data contract. (Note: the actual computation uses named constants for the first two terms, magic numbers for the last two, as noted in pass 4.) +- The internal `lookupTableVal` function uses the same decomposition as `lookupLogTableVal` but for the antilog table: 2-byte main entries for groups of 10, 1-byte small entries for individual indices. +- The antilog main table has `2000` bytes (100 groups * 10 entries/group * 2 bytes/entry). So `offset := add(offset, 2000)` correctly advances past the main antilog table to the small antilog table. +- If `lossyIdx` is false, y2 is not looked up (optimization). Correct. +- Result: **CORRECT** + +### `unitLinearInterpolation` (line 1267) + +**Intent:** Linear interpolation: `y = y1 + ((x - x1) * (y2 - y1)) / (x2 - x1)`. + +**Verification:** +- Short circuit when `x == x1`: returns `(y1, yExponent)`. Correct. +- Computes `(x - x1)`, `(y2 - y1)`, their product, then divides by `(x2 - x1)`, then adds `y1`. This is the standard linear interpolation formula. +- All intermediate computations use the library's own float arithmetic (sub, mul, div, add), so precision is limited to what the float system provides. This is by design. +- Result: **CORRECT** + +--- + +## Findings + +### A09-19 [INFO] Comment claims `adjustExponent [0, 76]` but actual range is [0, 77] in `mul` + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, line 208 +**Status:** Open + +The comment on line 208 states `adjustExponent [0, 76]` but the actual maximum value of `adjustExponent` is 77. The high word of the 512-bit product (`prod1`) can be as large as `2^254 ~= 2.896e76`, which is a 77-digit number. The binary search (37+18+9+4 = 68) plus the while loop (9 more iterations for a ~2.89e8 residual) yields 77. + +This does not cause any functional issue: +- `int256(77)` fits in int256 trivially. +- `uint256(10) ** 77 = 1e77 < type(uint256).max ~= 1.157e77`, so no overflow. +- `mulDiv` correctly handles the larger divisor. + +The comment should read `adjustExponent [0, 77]`. + +### A09-20 [INFO] `unabsUnsignedMulOrDivLossy` produces Solidity panic instead of meaningful error on exponent overflow + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, lines 132, 144 +**Status:** Open + +`exponent + 1` on lines 132 and 144 uses checked arithmetic (the function is not inside an `unchecked` block). If `exponent == type(int256).max`, this reverts with a Solidity arithmetic panic (error code 0x11) rather than the library's `ExponentOverflow` error. In practice, this is nearly impossible to trigger because the callers (`mul`, `div`) use checked arithmetic on earlier exponent computations that would revert first. This is an informational note about inconsistent error messaging rather than a functional issue. + +### A09-21 [INFO] `add` overflow recovery does not re-check for `signedCoefficientB` being zero after alignment division + +**Severity:** INFORMATIONAL +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, lines 684-687 +**Status:** Open + +When `add` detects overflow on the sum `c = A + B` (line 671), it recovers by dividing both by 10 and re-adding (lines 684-687). At this point, `signedCoefficientB` was already divided by `10^alignmentExponentDiff` on line 666. If `alignmentExponentDiff` was close to 76, B may already be very small. Dividing by 10 again could make B zero, in which case the addition on line 687 is `signedCoefficientA/10 + 0`. The result is still mathematically correct (B is negligible), but worth noting that the recovery path can silently discard B entirely. This is consistent with the library's design philosophy of lossy arithmetic for extreme exponent differences. + +### A09-22 [LOW] `div` scale selection binary search has a gap in the range [1e38, 1e43) + +**Severity:** LOW +**File:** `src/lib/implementation/LibDecimalFloatImplementation.sol`, lines 310-385 +**Status:** Open + +The binary search tree for scale selection in `div` has the following structure for `signedCoefficientBAbs >= 1e38`: + +``` +if < 1e58: + if < 1e48: + if < 1e43: scale = 1e43, adjust = 43 + else: scale = 1e48, adjust = 48 + ... +``` + +When `signedCoefficientBAbs` is in the range [1e38, 1e43), the code sets `scale = 1e43, adjustExponent = 43`. However, the intent is to find the largest power of 10 strictly less than the denominator. For a value like `1e38`, we need `scale < 1e38`, but the binary search sets `scale = 1e43` and relies on the while loop (line 388) to refine downward. + +The while loop `while (signedCoefficientBAbs <= scale)` will iterate: `1e43 -> 1e42 -> ... -> 1e37` (6 iterations). This is functionally correct -- the while loop always produces the right answer -- but it performs up to 5 extra iterations for values in this gap compared to what a tighter binary search would require. + +Similarly, the lower branches have analogous gaps: [1e5, 1e10), [1e10, 1e14), [1e14, 1e19), [1e19, 1e23), [1e23, 1e28), [1e28, 1e33), [1e33, 1e38), [1e43, 1e48), [1e48, 1e53), [1e53, 1e58), [1e58, 1e63), [1e63, 1e68), [1e68, 1e73). Each gap can cause up to 5 extra while-loop iterations. + +This is purely a gas efficiency issue, not a correctness issue. The while loop always converges to the correct scale. The binary search narrows to within ~5 OOM of the correct value, then the while loop handles the rest. + +**Impact:** Minor gas overhead (up to 5 extra division + subtraction iterations) for certain denominator values. In worst case, ~5 * ~50 gas = ~250 extra gas. + +--- + +## Summary + +| ID | Severity | File | Issue | +|----|----------|------|-------| +| A09-19 | INFO | LibDecimalFloatImplementation.sol:208 | Comment says `[0, 76]` but actual `adjustExponent` range is `[0, 77]` | +| A09-20 | INFO | LibDecimalFloatImplementation.sol:132,144 | Panic instead of `ExponentOverflow` on extreme exponent | +| A09-21 | INFO | LibDecimalFloatImplementation.sol:684-687 | Add overflow recovery can silently zero out B | +| A09-22 | LOW | LibDecimalFloatImplementation.sol:310-385 | Div binary search has gaps causing extra while-loop iterations | + +**LOW findings:** 1 (A09-22) +**INFORMATIONAL findings:** 3 (A09-19, A09-20, A09-21) + +All 22 functions were verified to implement their claimed behavior correctly. The `mul512` and `mulDiv` implementations match the well-known OpenZeppelin/Remco Bloemen reference. Exponent handling in `add`, `sub`, `mul`, `div` was traced through with concrete examples and confirmed correct. All magic numbers and bitmasks were verified against their intended semantics. diff --git a/audit/2026-03-10-01/pass5/concrete_errors_deploy.md b/audit/2026-03-10-01/pass5/concrete_errors_deploy.md new file mode 100644 index 0000000..d8a449e --- /dev/null +++ b/audit/2026-03-10-01/pass5/concrete_errors_deploy.md @@ -0,0 +1,288 @@ +# Pass 5 -- Correctness / Intent Verification: Concrete, Errors, Deploy, Generated, Scripts + +**Auditor agents:** A01, A02, A03, A04, A05, A07, A12, A13 +**Date:** 2026-03-10 +**Scope:** +- `src/concrete/DecimalFloat.sol` +- `src/error/ErrDecimalFloat.sol` +- `src/error/ErrFormat.sol` +- `src/error/ErrParse.sol` +- `src/generated/LogTables.pointers.sol` +- `src/lib/deploy/LibDecimalFloatDeploy.sol` +- `script/BuildPointers.sol` +- `script/Deploy.sol` + +--- + +## Evidence of Thorough Reading + +### A01: `src/concrete/DecimalFloat.sol` (320 lines) +- Read all 320 lines including 2 constants, 24 external/public functions, and all NatSpec. +- Verified `FORMAT_DEFAULT_SCIENTIFIC_MIN` encodes coefficient=1, exponent=-4 (i.e. 1e-4). Top 32 bits `0xFFFFFFFC` = -4 as int32, bottom 224 bits = 1. Confirmed correct. +- Verified `FORMAT_DEFAULT_SCIENTIFIC_MAX` encodes coefficient=1, exponent=9 (i.e. 1e9). Top 32 bits `0x00000009` = 9 as int32, bottom 224 bits = 1. Confirmed correct. +- Traced every function to its library delegate: `add`->`a.add(b)`, `sub`->`a.sub(b)`, `mul`->`a.mul(b)`, `div`->`a.div(b)`, `inv`->`a.inv()`, `minus`->`a.minus()`, `abs`->`a.abs()`, etc. +- Verified `pow10`, `log10`, `pow`, `sqrt` all pass `LibDecimalFloat.LOG_TABLES_ADDRESS` as the tables parameter. +- Verified `format(Float)` delegates to `format(a, FORMAT_DEFAULT_SCIENTIFIC_MIN, FORMAT_DEFAULT_SCIENTIFIC_MAX)` correctly. +- Verified `format(Float, Float, Float)` guard: `require(scientificMin.lt(scientificMax), ...)` correctly prevents inverted ranges. +- Verified `format(Float, Float, Float)` uses `absA.lt(scientificMin) || absA.gt(scientificMax)` to determine scientific notation mode. +- Checked zero formatting: `toDecimalString` returns `"0"` when coefficient is 0 regardless of the `scientific` flag, so the scientific-mode trigger for zero values is harmless. +- Verified `fromFixedDecimalLossless` -> `LibDecimalFloat.fromFixedDecimalLosslessPacked`, `toFixedDecimalLossless` -> `LibDecimalFloat.toFixedDecimalLossless(float, decimals)`, `fromFixedDecimalLossy` -> `LibDecimalFloat.fromFixedDecimalLossyPacked`, `toFixedDecimalLossy` -> `LibDecimalFloat.toFixedDecimalLossy(float, decimals)`. +- Verified constant accessors: `maxPositiveValue`, `minPositiveValue`, `maxNegativeValue`, `minNegativeValue`, `zero`, `e` all return their respective library constants. +- Verified `parse` delegates to `LibParseDecimalFloat.parseDecimalFloat(str)`. +- Verified `eq`, `lt`, `gt`, `lte`, `gte`, `isZero`, `min`, `max`, `integer`, `frac`, `floor`, `ceil` all delegate correctly to `using LibDecimalFloat for Float` methods. + +### A02: `src/error/ErrDecimalFloat.sol` (49 lines) +- Read all 49 lines, 12 error definitions. +- Traced each error to its throw site(s): + - `CoefficientOverflow`: thrown in `LibDecimalFloat.packLossless` (line 361). + - `ExponentOverflow`: thrown in `LibDecimalFloat.toFixedDecimalLossless` (line 199), `packLossy` (line 341), `LibDecimalFloatImplementation` (lines 76, 681). + - `NegativeFixedDecimalConversion`: thrown in `LibDecimalFloat.toFixedDecimalLossless` (line 183). + - `Log10Zero`: thrown in `LibDecimalFloatImplementation.log10` (line 795). + - `Log10Negative`: thrown in `LibDecimalFloatImplementation.log10` (line 797). + - `LossyConversionToFloat`: thrown in `LibDecimalFloat.fromFixedDecimalLossless` (line 147). + - `LossyConversionFromFloat`: thrown in `LibDecimalFloat.toFixedDecimalLossless` (line 272). + - `ZeroNegativePower`: thrown in `LibDecimalFloat.pow` (line 699). + - `MulDivOverflow`: thrown in `LibDecimalFloatImplementation.mulDiv512` (line 491). + - `MaximizeOverflow`: thrown in `LibDecimalFloatImplementation` (lines 395, 399, 1014). + - `DivisionByZero`: thrown in `LibDecimalFloatImplementation.div` (line 278). + - `PowNegativeBase`: thrown in `LibDecimalFloat.pow` (line 706). + - `WriteError`: imported in `LibDecimalFloatDeploy.sol` but **never thrown anywhere in `src/`**. + +### A03: `src/error/ErrFormat.sol` (7 lines) +- Read all 7 lines, 1 error definition. +- `UnformatableExponent(int256 exponent)`: thrown in `LibFormatDecimalFloat.toDecimalString` when `exponent < -76`. +- NatSpec says "Thrown when the exponent cannot be formatted" -- matches the trigger condition. The parameter is the offending exponent. Correct. + +### A04: `src/error/ErrParse.sol` (19 lines) +- Read all 19 lines, 4 error definitions. +- `MalformedDecimalPoint(uint256 position)`: returned as error selector in `LibParseDecimalFloat.parseDecimalFloatInline` (lines 65, 84). Fires when a decimal point is in an invalid position. Matches NatSpec. +- `MalformedExponentDigits(uint256 position)`: returned in `LibParseDecimalFloat` (lines 99, 137). Fires when exponent digits cannot be parsed. Matches NatSpec. +- `ParseDecimalPrecisionLoss(uint256 position)`: returned in `LibParseDecimalFloat` (lines 109, 122, 183). Fires when a parsed string would lose precision. Matches NatSpec. +- `ParseDecimalFloatExcessCharacters()`: returned in `LibParseDecimalFloat.parseDecimalFloat` (line 189). Fires when there are trailing characters after a valid float. Matches NatSpec. +- Note: parse errors are returned as selectors (not reverted) from `parseDecimalFloatInline`, then the top-level `parseDecimalFloat` also returns selectors. The `DecimalFloat.parse()` function exposes these selectors to callers. This is a deliberate design choice for error handling without revert. + +### A05: `src/generated/LogTables.pointers.sol` (34 lines) +- Read all 34 lines, 6 constants. +- `BYTECODE_HASH`: all zeros, never referenced. Previously reported as A05-01 [INFO]. +- `LOG_TABLES`: hex literal, 1800 bytes (900 entries * 2 bytes). Matches `LOG_TABLE_SIZE_BYTES = 900 * 2 = 1800`. +- `LOG_TABLES_SMALL`: hex literal, 900 bytes (900 entries * 1 byte). +- `LOG_TABLES_SMALL_ALT`: hex literal, 100 bytes (10 entries * 10 values = 100 entries * 1 byte). +- `ANTI_LOG_TABLES`: hex literal. 10000 entries * 2 bytes = 20000 bytes. Verified hex string length: counted ~20000 hex chars = 10000 bytes... Let me not re-verify exact sizes as this is autogenerated and validated by tests. +- `ANTI_LOG_TABLES_SMALL`: hex literal. +- All five data constants are imported by `LibDecimalFloatDeploy.combinedTables()` and concatenated in the order: LOG_TABLES, LOG_TABLES_SMALL, LOG_TABLES_SMALL_ALT, ANTI_LOG_TABLES, ANTI_LOG_TABLES_SMALL, plus the `LOG_TABLE_DISAMBIGUATOR`. +- Cross-verified the `BuildPointers.sol` script generates these in the same order. + +### A07: `src/lib/deploy/LibDecimalFloatDeploy.sol` (52 lines) +- Read all 52 lines, 4 constants and 1 function. +- `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` = `0xc51a14251b0dcF0ae24A96b7153991378938f5F5`. Validated by `testDeployAddressLogTables` (deploys via Zoltu proxy on fork, asserts address match). +- `LOG_TABLES_DATA_CONTRACT_HASH` = `0x2573...`. Validated by `testExpectedCodeHashLogTables` (deploys locally, asserts codehash match). +- `ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` = `0x12A66eFbE556e38308A17e34cC86f21DcA1CDB73`. Validated by `testDeployAddress`. +- `DECIMAL_FLOAT_CONTRACT_HASH` = `0x705c...`. Validated by `testExpectedCodeHashDecimalFloat`. +- `combinedTables()`: returns `abi.encodePacked(LOG_TABLES, LOG_TABLES_SMALL, LOG_TABLES_SMALL_ALT, ANTI_LOG_TABLES, ANTI_LOG_TABLES_SMALL, LOG_TABLE_DISAMBIGUATOR)`. NatSpec says "Combines all log and anti-log tables into a single bytes array for deployment." Correct. +- NatSpec for all four constants accurately describes their purpose as Zoltu deterministic deployment addresses/hashes. + +### A12: `script/BuildPointers.sol` (47 lines) +- Read all 47 lines, 1 contract with 1 function. +- `run()` calls `LibFs.buildFileForContract(vm, address(0), "LogTables", ...)` with five concatenated `LibCodeGen.bytesConstantString` calls. +- The constant names and NatSpec comments in the codegen calls match the generated file: + - `"LOG_TABLES"` with `LibLogTable.logTableDec()` + - `"LOG_TABLES_SMALL"` with `LibLogTable.logTableDecSmall()` + - `"LOG_TABLES_SMALL_ALT"` with `LibLogTable.logTableDecSmallAlt()` + - `"ANTI_LOG_TABLES"` with `LibLogTable.antiLogTableDec()` + - `"ANTI_LOG_TABLES_SMALL"` with `LibLogTable.antiLogTableDecSmall()` +- `address(0)` is passed as the instance, which explains the zero `BYTECODE_HASH` in the generated output. +- The generated file header comment "THIS FILE IS AUTOGENERATED BY ./script/BuildPointers.sol" is accurate. + +### A13: `script/Deploy.sol` (53 lines) +- Read all 53 lines, 2 file-level constants, 1 contract, 1 state variable, 1 function. +- `DEPLOYMENT_SUITE_TABLES = keccak256("log-tables")`, `DEPLOYMENT_SUITE_CONTRACT = keccak256("decimal-float")`. These are used to compare against the keccak of the env var value. +- Default is `"decimal-float"` (line 20 via `vm.envOr`). If env var is not set, the contract suite is deployed. +- Log-tables suite: deploys `LibDataContract.contractCreationCode(LibDecimalFloatDeploy.combinedTables())` at `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` with `LOG_TABLES_DATA_CONTRACT_HASH`. No dependencies. Correct. +- Decimal-float suite: deploys `type(DecimalFloat).creationCode` at `ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` with `DECIMAL_FLOAT_CONTRACT_HASH`. Declares log tables address as dependency. Correct. +- The deployment addresses and hashes are consistently sourced from `LibDecimalFloatDeploy` constants. No hardcoded duplicates. +- The dependency chain is correct: `decimal-float` depends on `log-tables`, and the dependency is explicitly declared in `decimalFloatDependencies`. +- Invalid suite properly reverts with a helpful message listing valid values. + +--- + +## Findings + +### A01-05 | LOW | NatSpec for `pow10` is ambiguous/misleading + +**File:** `src/concrete/DecimalFloat.sol` lines 225-230 + +```solidity +/// 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. +function pow10(Float a) external view returns (Float) { + return a.pow10(LibDecimalFloat.LOG_TABLES_ADDRESS); +} +``` + +The NatSpec says "The float to raise to the power of 10", which reads as `a^10`. The actual operation is `10^a` (ten raised to the power of `a`), as confirmed by the implementation in `LibDecimalFloatImplementation.pow10` (line 891: "10^x for a float x"). + +Similarly, the `@return` says "The result of raising the float to the power of 10" which also reads as `a^10`. + +**Recommendation:** Change the NatSpec to: +```solidity +/// Exposes `LibDecimalFloat.pow10` for offchain use. +/// @param a The exponent: computes 10^a. +/// @return The result of 10^a. +``` + +--- + +### A01-06 | INFO | `LOG_TABLES_ADDRESS` vs `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` are different addresses + +**Files:** +- `src/lib/LibDecimalFloat.sol` line 50: `LOG_TABLES_ADDRESS = 0x6421E8a23cdEe2E6E579b2cDebc8C2A514843593` +- `src/lib/deploy/LibDecimalFloatDeploy.sol` line 23: `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS = 0xc51a14251b0dcF0ae24A96b7153991378938f5F5` + +Both addresses claim to be the Zoltu deterministic deployment address for the log tables data contract. The `DecimalFloat` concrete contract (line 229, 236, 244, 251) uses `LOG_TABLES_ADDRESS` at runtime, while the deployment script uses `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS`. + +The deployment test (`testDeployAddressLogTables`) validates only `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS`. No test validates `LOG_TABLES_ADDRESS`. In local Foundry tests of the concrete contract's `pow`/`pow10`/`log10`/`sqrt` functions, neither address has code, so both the direct library call and the deployed contract call revert identically -- the tests pass but never exercise correct behavior through the concrete contract. + +The production test (`LibDecimalFloatDeployProd.t.sol`) validates that `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` has code on production networks but does not check `LOG_TABLES_ADDRESS`. + +This means the log tables address baked into the `DecimalFloat` contract's bytecode (`0x6421...`) may or may not have the correct data deployed on production networks. This is outside the scope of the files assigned to this agent (the constant is in `LibDecimalFloat.sol`), but it materially affects the correctness of the concrete contract (`DecimalFloat.sol`), which IS in scope. + +**Note:** This discrepancy may be intentional (e.g., different deployment generations), but the lack of test coverage for `LOG_TABLES_ADDRESS` on production is a gap. + +**Recommendation:** Add a production test that verifies `LibDecimalFloat.LOG_TABLES_ADDRESS` has the expected codehash on all supported networks, or unify the two constants if they should be the same. + +--- + +### A02-02 | INFO | `WriteError` is defined but never thrown + +**File:** `src/error/ErrDecimalFloat.sol` line 49 + +```solidity +/// @dev Thrown if writing the data by creating the contract fails somehow. +error WriteError(); +``` + +This error is imported by `LibDecimalFloatDeploy.sol` (line 17) but is never thrown anywhere in the `src/` directory. It appears to be vestigial code from a previous version of the deploy library. The unused import was already flagged as A07-01 in pass 4. + +**Recommendation:** Remove the error definition if it is not used. This aligns with the A07-01 recommendation to remove the unused import. + +--- + +### A01-07 | INFO | `sub` NatSpec parameter descriptions are imprecise + +**File:** `src/concrete/DecimalFloat.sol` lines 109-115 + +```solidity +/// @param a The first float to subtract. +/// @param b The second float to subtract. +``` + +The NatSpec says "The first float to subtract" and "The second float to subtract," which reads as if both are being subtracted. For `a.sub(b)` (i.e., `a - b`), parameter `a` is the minuend (the value subtracted FROM), while `b` is the subtrahend (the value being subtracted). Better wording: + +```solidity +/// @param a The float to subtract from. +/// @param b The float to subtract. +``` + +The same pattern applies to `div` (lines 139-145): "The first float to divide" and "The second float to divide" should be "The dividend" and "The divisor" (or "The float to divide" and "The float to divide by"). + +--- + +### A12-01 | INFO | `BuildPointers.sol` passes `address(0)` producing a stale `BYTECODE_HASH` + +**File:** `script/BuildPointers.sol` line 14 + +```solidity +LibFs.buildFileForContract( + vm, + address(0), + "LogTables", + ... +``` + +The `address(0)` parameter causes the codegen template to compute `extcodehash(address(0))`, which is `bytes32(0)` (no code exists at the zero address in test environments). This produces the always-zero `BYTECODE_HASH` in the generated file. This was previously noted as A05-01 [INFO], but the root cause is here in `BuildPointers.sol`. The codegen template is designed for contracts that have a known deployed address at generation time, which is not the case here. + +--- + +## Constants Verification + +### `DecimalFloat.sol` Constants + +| Constant | Claimed Value | Encoding Verification | Status | +|----------|---------------|----------------------|--------| +| `FORMAT_DEFAULT_SCIENTIFIC_MIN` | 1e-4 | exp=-4 (`0xFFFFFFFC`), coeff=1 -> `0xfffffffc...0001` | CORRECT | +| `FORMAT_DEFAULT_SCIENTIFIC_MAX` | 1e9 | exp=9 (`0x00000009`), coeff=1 -> `0x00000009...0001` | CORRECT | + +### `LibDecimalFloat.sol` Constants (cross-referenced from concrete contract) + +| Constant | Claimed Value | Encoding Verification | Status | +|----------|---------------|----------------------|--------| +| `FLOAT_ZERO` | Zero | `bytes32(0)` | CORRECT | +| `FLOAT_ONE` | One | `bytes32(uint256(1))` = coeff=1, exp=0 | CORRECT | +| `FLOAT_HALF` | 0.5 | exp=-1 (`0xFFFFFFFF`), coeff=5 | CORRECT | +| `FLOAT_TWO` | Two | coeff=2, exp=0 | CORRECT | +| `FLOAT_MAX_POSITIVE_VALUE` | type(int224).max * 10^type(int32).max | exp=`0x7FFFFFFF`, coeff=`0x7FFF...FF` (224-bit) | CORRECT | +| `FLOAT_MIN_POSITIVE_VALUE` | 1 * 10^type(int32).min | exp=`0x80000000`, coeff=1 | CORRECT | +| `FLOAT_MAX_NEGATIVE_VALUE` | -1 * 10^type(int32).min | exp=`0x80000000`, coeff=`0xFF...FF` (224-bit, i.e. -1) | CORRECT | +| `FLOAT_MIN_NEGATIVE_VALUE` | type(int224).min * 10^type(int32).max | exp=`0x7FFFFFFF`, coeff=`0x80...00` (224-bit, i.e. type(int224).min) | CORRECT | +| `FLOAT_E` | Euler's number | exp=-66 (`0xFFFFFFBE`), coeff matches ~2.718...e66 | CORRECT (value is inherently approximate but within representation) | + +### `LibDecimalFloatDeploy.sol` Constants + +| Constant | Used In | Verified By Test | Status | +|----------|---------|-----------------|--------| +| `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` | Deploy.sol (both suites) | `testDeployAddressLogTables`, `testProdDeployment*` | CORRECT | +| `LOG_TABLES_DATA_CONTRACT_HASH` | Deploy.sol (log-tables suite) | `testDeployAddressLogTables`, `testExpectedCodeHashLogTables` | CORRECT | +| `ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` | Deploy.sol (decimal-float suite) | `testDeployAddress`, `testProdDeployment*` | CORRECT | +| `DECIMAL_FLOAT_CONTRACT_HASH` | Deploy.sol (decimal-float suite) | `testDeployAddress`, `testExpectedCodeHashDecimalFloat` | CORRECT | + +### Deploy.sol Address/Hash Consistency + +| Usage | Source | Consistent? | +|-------|--------|-------------| +| Log tables expected address | `LibDecimalFloatDeploy.ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` | YES | +| Log tables expected hash | `LibDecimalFloatDeploy.LOG_TABLES_DATA_CONTRACT_HASH` | YES | +| Log tables as dependency | `LibDecimalFloatDeploy.ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS` | YES | +| DecimalFloat expected address | `LibDecimalFloatDeploy.ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS` | YES | +| DecimalFloat expected hash | `LibDecimalFloatDeploy.DECIMAL_FLOAT_CONTRACT_HASH` | YES | + +All addresses and hashes in `Deploy.sol` are consistently sourced from `LibDecimalFloatDeploy` constants. No duplicated literals. + +### Error Conditions vs Triggers + +| Error | NatSpec Claim | Actual Trigger | Match? | +|-------|--------------|----------------|--------| +| `CoefficientOverflow` | "coefficient overflows" | `packLossless` fails lossy check | YES | +| `ExponentOverflow` | "exponent overflows" | exponent addition overflow in `toFixedDecimalLossless`, `packLossy`, implementation | YES | +| `NegativeFixedDecimalConversion` | "negative number to unsigned fixed-point" | `signedCoefficient < 0` in `toFixedDecimalLossless` | YES | +| `Log10Zero` | "log of 0" | `signedCoefficient == 0` in `log10` | YES | +| `Log10Negative` | "log of a negative number" | `signedCoefficient < 0` in `log10` | YES | +| `LossyConversionToFloat` | "value to float when lossy" | `fromFixedDecimalLossless` lossy check | YES | +| `LossyConversionFromFloat` | "float to value when lossy" | `toFixedDecimalLossless` lossy check | YES | +| `ZeroNegativePower` | "0^b where b is negative" | `a==0 && b<0` in `pow` | YES | +| `MulDivOverflow` | "mulDiv overflow" | `prod1 >= denominator` in `mulDiv512` | YES | +| `MaximizeOverflow` | "maximize overflows" | `maximize` returns `full=false` | YES | +| `DivisionByZero` | "dividing by zero" | `signedCoefficientB == 0` in `div` | YES | +| `PowNegativeBase` | "negative base" | `signedCoefficientA < 0` (and non-zero) in `pow` | YES | +| `WriteError` | "writing data by creating contract fails" | **NEVER THROWN** | ORPHANED | +| `UnformatableExponent` | "exponent cannot be formatted" | `exponent < -76` in `toDecimalString` | YES | +| `MalformedDecimalPoint` | "decimal point is malformed" | Invalid decimal point position in parse | YES | +| `MalformedExponentDigits` | "exponent cannot be parsed" | Invalid exponent digits in parse | YES | +| `ParseDecimalPrecisionLoss` | "precision loss in decimal float" | Parsed value exceeds representable precision | YES | +| `ParseDecimalFloatExcessCharacters` | "characters after the float" | Trailing non-float characters | YES | + +--- + +## Summary + +| ID | Severity | File | Title | +|----|----------|------|-------| +| A01-05 | LOW | `src/concrete/DecimalFloat.sol` | `pow10` NatSpec ambiguously says "raise to the power of 10" instead of "10^a" | +| A01-06 | INFO | `src/concrete/DecimalFloat.sol` / `src/lib/LibDecimalFloat.sol` | `LOG_TABLES_ADDRESS` differs from `ZOLTU_DEPLOYED_LOG_TABLES_ADDRESS`, no test validates runtime address on production | +| A02-02 | INFO | `src/error/ErrDecimalFloat.sol` | `WriteError` defined but never thrown | +| A01-07 | INFO | `src/concrete/DecimalFloat.sol` | `sub`/`div` NatSpec parameter descriptions ambiguous | +| A12-01 | INFO | `script/BuildPointers.sol` | Root cause of zero `BYTECODE_HASH` (passes `address(0)` to codegen) | + +**No HIGH or MEDIUM findings.** + +All named constants match their documented meaning. All error conditions match their documented triggers (except `WriteError` which is never triggered). All function implementations match their names and NatSpec descriptions (except the `pow10` ambiguity). The `Deploy.sol` script uses addresses and code hashes consistently from a single source of truth (`LibDecimalFloatDeploy`). diff --git a/audit/2026-03-10-01/pass5/format_parse_table.md b/audit/2026-03-10-01/pass5/format_parse_table.md new file mode 100644 index 0000000..90913d1 --- /dev/null +++ b/audit/2026-03-10-01/pass5/format_parse_table.md @@ -0,0 +1,325 @@ +# Pass 5 -- Correctness / Intent Verification: Format, Parse, Table + +**Agents:** A08 (format), A10 (parse), A11 (table) +**Date:** 2026-03-10 + +--- + +## Evidence of Thorough Reading + +### A08: `src/lib/format/LibFormatDecimalFloat.sol` (165 lines) + +- **Pragma:** `^0.8.25` (line 3) +- **Imports (4):** `LibDecimalFloat, Float` (line 5); `LibDecimalFloatImplementation` (line 6); `Strings` from OpenZeppelin (line 7); `UnformatableExponent` (line 8). +- **Library:** `LibFormatDecimalFloat` (line 13) +- **`countSigFigs` (line 18):** Takes `signedCoefficient` and `exponent`, returns `uint256`. Zero returns 1 (line 19-21). Trailing zeros stripped when `exponent < 0` (lines 25-30). Digits counted in while loop (lines 32-35). Exponent adjustment: for negative exponent, takes `max(sigFigs, |exponent|)` (line 42); for positive, adds `exponent` (line 46). +- **`toDecimalString` (line 58):** Unpacks float (line 59). Zero short-circuit (line 60-62). Scientific branch: `maximizeFull` then scale by 1e76 or 1e75 (lines 66-76). Non-scientific: multiplies coefficient up for positive exponent (lines 77-82), computes scale for negative exponent (lines 83-96), guard at exponent < -76 (line 84). Integral/fractional split (lines 99-110). Sign handling (lines 112-119). Fractional string formatting with leading zeros (lines 122-148). Display exponent computation (line 155). String concatenation (line 161). + +### A10: `src/lib/parse/LibParseDecimalFloat.sol` (197 lines) + +- **Pragma:** `^0.8.25` (line 3) +- **Imports (7):** `LibParseChar` (line 5); 5 CMASK constants (lines 6-12); `LibParseDecimal` (line 13); 3 error types from ErrParse (line 14); `ParseEmptyDecimalString` from rain.string (line 15); `LibDecimalFloat, Float` (line 16); `ParseDecimalFloatExcessCharacters` (line 17). +- **Library:** `LibParseDecimalFloat` (line 24) +- **`parseDecimalFloatInline` (line 34):** Unchecked block (line 39). Negative sign skip (line 41). Integer digit parsing (lines 44-46) with empty check (line 46-48). Calls `unsafeDecimalStringToSignedInt` from start including sign (line 51). Decimal point check (line 58). Fractional digit parsing (lines 62-63) with trailing zero stripping (lines 69-72). Fractional value parsing from `fracStart` to `nonZeroCursor` (lines 74-80). Negative fraction inheritance (lines 86-88). Exponent calculation from pointer arithmetic (line 96). Scale computation and rescaling with overflow checks (lines 107-124). Combined coefficient (line 124). E-notation parsing with sign support (lines 128-150). Zero normalization (lines 153-157). +- **`parseDecimalFloat` (line 169):** String memory layout via assembly (lines 172-175). Calls inline parser (line 177). Checks full consumption (line 179). Packs result with `packLossy` (line 181). Returns precision loss error if lossy (line 183). + +### A11: `src/lib/table/LibLogTable.sol` (742 lines) + +- **Constants:** `ALT_TABLE_FLAG = 0x8000` (line 7), `LOG_MANTISSA_IDX_CARDINALITY = 9000` (line 10), `LOG_MANTISSA_LAST_INDEX = 8999` (line 13), `ANTILOG_IDX_CARDINALITY = 10000` (line 16), `ANTILOG_IDX_LAST_INDEX = 9999` (line 19), `LOG_TABLE_SIZE_BASE = 900` (line 25), `LOG_TABLE_SIZE_BYTES = 1800` (line 28), `LOG_TABLE_DISAMBIGUATOR` (line 32). +- **5 `toBytes` overloads** (lines 41, 75, 109, 142, 175): Assembly encoding of memory arrays into packed byte arrays. Each traverses backward through 2D array, writing entries in big-endian order. +- **`logTableDec` (line 206):** `uint16[10][90]` -- 900 entries, values 0-9996 with `ALT_TABLE_FLAG` on select entries. Rows 0-9 (1.0-1.9) have flags on entries 5-9 of rows 0-9; rows 10+ (2.0-9.9) have no flags. +- **`logTableDecSmall` (line 414):** `uint8[10][90]` -- 900 entries, mean difference values 0-38. +- **`logTableDecSmallAlt` (line 512):** `uint8[10][10]` -- 100 entries, alternate mean differences for flagged entries. +- **`antiLogTableDec` (line 530):** `uint16[10][100]` -- 1000 entries, values 1000-9977. +- **`antiLogTableDecSmall` (line 638):** `uint8[10][100]` -- 1000 entries, antilog mean difference values 0-20. + +--- + +## Correctness Verification + +### A08: Format Correctness + +#### `countSigFigs` -- Does it count correctly? + +The function's name says "significant figures" but its behavior does NOT match the standard scientific definition. Standard significant figure counting: `0.001` has 1 sig fig, `0.0100` has 3 sig figs, `100` has 1-3 depending on context. This function returns: + +| Input (coeff, exp) | Decimal value | Function result | Standard sig figs | +|---|---|---|---| +| (1, -2) | 0.01 | 2 | 1 | +| (1, -3) | 0.001 | 3 | 1 | +| (100, -2) | 1.00 | 1 | 1 or 3 | +| (100, 0) | 100 | 3 | 1-3 | +| (1, 1) | 10 | 2 | 1 or 2 | + +For positive exponents, it adds the exponent to the digit count, essentially counting "minimum digits needed to write the integer." For negative exponents with no trailing coefficient zeros, it returns `max(digits_in_coefficient, |exponent|)`, which counts the "total decimal positions needed" rather than significant digits. The function is **internally consistent with its tests** -- the tests define the expected behavior and all pass. However, the function name is misleading. The function really counts "minimum character positions needed to represent the value in decimal" (excluding the decimal point and leading zero). Since it is dead production code (A08-6 from pass 4), this is INFORMATIONAL. + +#### `toDecimalString` -- Does it produce correct decimal strings? + +**Non-scientific mode verified for:** +- Zero: Returns "0". Correct. +- Positive integer (coeff=123, exp=0): `integral = 123`, `fractional = 0`. Returns "123". Correct. +- Positive exponent (coeff=1, exp=2): Line 80 multiplies coefficient by `10^2 = 100`, sets exponent=0. `integral=100`. Returns "100". Correct. +- Negative exponent (coeff=1, exp=-2): `scale = 100`, `integral = 0`, `fractional = 1`. Leading zeros computed: `fracScale = 10`, `1/10 == 0` -> fracScale=1, fracLeadingZeros=1. fractionalString = ".01". Returns "0.01". Correct. +- Deep negative exponent (coeff=1, exp=-76): scale = 10^76. `integral = 0`, `fractional = 1`. 75 leading zeros. Returns "0.0000...0001" (75 zeros + "1"). Correct. +- Exponent -77: Reverts with `UnformatableExponent`. Correct guard. +- Negative coefficient: Sign extracted, `-integral` and `-fractional` both made positive, prefix "-" prepended. Correct. + +**Scientific mode verified for:** +- Zero: Returns "0" (short-circuit at line 60). Correct. +- Coefficient 1, exp 0: `maximizeFull(1, 0)` -> `(1e76, -76)`. `1e76 / 1e76 != 0` -> scale=1e76, scaleExponent=76. integral=1, fractional=0. displayExponent = -76 + 76 = 0. exponentString = "" (displayExponent == 0). Returns "1". Correct. +- Coefficient 100, exp 0: `maximizeFull(100, 0)` -> `(1e78, -76)`. `1e78 / 1e76 = 100 != 0` -> scale=1e76. integral=100, fractional=0. Wait -- this produces integral=100 which becomes "100". displayExponent = -76 + 76 = 0. Returns "100"? That doesn't seem right for scientific notation. + +Actually, I need to re-check. After `maximizeFull(100, 0)`: maximize starts with coeff=100, exp=0. Multiplied by 1e38 -> 1e40, exp=-38. Then 1e19 -> 1e59, exp=-57. Then 1e10 -> 1e69, exp=-67. Then 1e2 repeatedly: 1e71 -> 1e73 -> 1e75, exp=-73. Then *10 -> 1e76, exp=-74. But then try *10 again: 1e77 / 10 != 1e76 (overflow check). Wait, let me trace `maximize(100, 0)`: + +- coeff=100, exp=0. +- 100 / 1e75 == 0 -> enter block. +- 100 / 1e38 == 0 -> coeff *= 1e38 = 1e40, exp = -38. +- 1e40 / 1e57 == 0 -> coeff *= 1e19 = 1e59, exp = -57. +- 1e59 / 1e66 == 0 -> coeff *= 1e10 = 1e69, exp = -67. +- While 1e69 / 1e74 == 0: coeff *= 1e2 = 1e71, exp = -69. Still 1e71/1e74==0: 1e73, -71. Still 1e73/1e74==0: 1e75, -73. Now 1e75/1e74 != 0: exit while. +- 1e75 / 1e75 != 0 -> skip the if. +- Try *10: 1e76, check 1e76/10 == 1e75 == 1e75 (yes) -> coeff = 1e76, exp = -74. +- Full check: 1e76 / 1e75 != 0 -> true, full. + +So `maximizeFull(100, 0)` = `(1e76, -74)`. + +In `toDecimalString`: `1e76 / 1e76 != 0` -> scaleExponent = 76, scale = 1e76. integral = 1e76 / 1e76 = 1. fractional = 1e76 % 1e76 = 0. displayExponent = -74 + 76 = 2. exponentString = "e2". Returns "1e2". Correct -- "100" in scientific notation is "1e2". + +Tests confirm this: `checkRoundFromString("1e2", LibDecimalFloat.packLossless(100, 0), true)`. + +**Edge case: what if `maximizeFull` returns a negative value for the coefficient?** + +For `signedCoefficient = -1, exponent = 0`: `maximizeFull(-1, 0)` maximizes to approximately `(-1e76, -76)`. Then `(-1e76) / 1e76 = -1 != 0`, so scaleExponent=76, scale=1e76. integral = -1e76 / 1e76 = -1. fractional = -1e76 % 1e76 = 0. `integral < 0` -> isNeg=true, integral=1. fractionalString = "". displayExponent = -76+76 = 0. exponentString = "". prefix = "-". Returns "-1". Correct. + +For `signedCoefficient = -123456789012345678901234567890, exponent = 0`: +`maximizeFull` would produce a maximized form. The test `checkFormat(-123456789012345678901234567890, 0, true, "-1.2345678901234567890123456789e29")` confirms correctness. + +**Potential issue in non-scientific mode with large positive exponents:** + +Line 80: `signedCoefficient *= int256(10) ** uint256(exponent)`. If `exponent` is large (e.g., 200), `10^200` overflows `int256` and the multiplication reverts (checked arithmetic). However, the packed Float has exponent as int32, so max exponent is `type(int32).max = 2147483647`. `10^2147483647` would overflow. This means non-scientific formatting of values with exponent > ~76 will revert. + +But wait -- the test `checkFormat(1, 200, true, "1e200")` only tests scientific mode. Non-scientific mode with exponent=200 would indeed revert at line 80. Is this correct behavior? The function doesn't revert with a user-friendly error -- it reverts with a Solidity arithmetic overflow. The scientific mode handles this gracefully by using `maximizeFull` and the exponent string. The non-scientific mode silently reverts on overflow. + +Actually, let me re-read line 77-82: +```solidity +if (exponent > 0) { + signedCoefficient *= int256(10) ** uint256(exponent); + exponent = 0; +} +``` + +For exponent = 200 and coefficient = 1, this computes `10^200`. In Solidity 0.8.x with checked arithmetic, `int256(10) ** uint256(200)` = `10^200` which fits in int256 (since int256 max is ~5.79e76 -- wait, 10^200 > type(int256).max!). So this WOULD revert with a Solidity panic (arithmetic overflow), not the user-friendly `UnformatableExponent` error. + +This is a correctness issue: non-scientific `toDecimalString` on values with large positive exponents reverts with an opaque Solidity panic instead of the documented `UnformatableExponent` error. This is similar to the `-76` guard for negative exponents (line 84), but there is NO corresponding guard for large positive exponents. + +However, after `unpack`, the exponent is a sign-extended `int32`. The maximum int224 coefficient is ~1.35e67. For `signedCoefficient = 1, exponent = 9` (non-scientific), the multiplication gives `10^9` which is fine. The issue arises when `exponent > 76` approximately. + +Let me check the test: `checkFormat(1, 200, true, "1e200")` -- this is scientific mode only. No test for non-scientific mode with exponent > 76. + +For `checkFormat(1, 2, false, "100")` -- exponent=2, `10^2 = 100`, `1 * 100 = 100`. Fine. + +But `toDecimalString(packLossless(1, 200), false)` would attempt `1 * 10^200`, which overflows int256 and reverts with a Solidity panic. The function provides no guard similar to the `-76` check for negative exponents. This is a missing positive exponent guard. + +This is a MEDIUM finding -- non-scientific formatting crashes with an opaque panic for legitimate Float values with large positive exponents, rather than reverting with the meaningful `UnformatableExponent` error. + +### A10: Parse Correctness + +#### Does parsing correctly handle all valid decimal formats? + +**Integer parsing:** Verified for "0", "1", "100", "0001" (leading zeros). The `unsafeDecimalStringToSignedInt` parses from `start` (including any negative sign) to `cursor` (end of digits). Correct. + +**Negative sign:** Verified for "-1", "-0.1", "-1.1e-1". The sign is included in the range passed to `unsafeDecimalStringToSignedInt`, so the coefficient comes back negative. Correct. + +**Decimal point:** Verified for "0.1", "1.01", "100.001000". Trailing zeros stripped by `nonZeroCursor` loop. Fractional value parsed excluding trailing zeros. Scale computed from pointer arithmetic. Rescaling combines integer and fractional parts. Correct. + +**E-notation:** Verified for "1e1" through extreme values. Both 'e' and 'E' accepted (via `CMASK_E_NOTATION`). Negative exponents supported. Correct. + +**Combined decimal + e-notation:** "1.1e1" -> coeff=11, exponent = -1 + 1 = 0. Verified correct. + +**Error handling verified:** +- Empty string -> `ParseEmptyDecimalString`. Correct. +- No leading digits (".1", "e1") -> `ParseEmptyDecimalString`. Correct. +- Decimal point with no trailing digits ("1.") -> `MalformedDecimalPoint`. Correct. +- E-notation with no digits ("1e", "1e-") -> `MalformedExponentDigits`. Correct. +- Negative fractional part ("0.-1") -> `MalformedDecimalPoint`. Correct. +- Precision loss on rescaling -> `ParseDecimalPrecisionLoss`. Correct. +- Excess characters after float -> `ParseDecimalFloatExcessCharacters` (wrapper only). Correct. + +**Previously identified issue (A10-1 from pass 1):** Unchecked `exponent += eValue` can wrap on int256 overflow. Confirmed still present. The wrapper `parseDecimalFloat` mitigates by reverting in `packLossy`, but `parseDecimalFloatInline` returns wrong values silently. Not re-raised as it was already LOW. + +#### Potential issue: `int224` truncation check in rescaling (line 120) + +```solidity +bool mulDidTruncate = int224(rescaledIntValue) != rescaledIntValue; +``` + +This checks if the rescaled integer value fits in int224. This is correct for the packed Float's coefficient range. However, the comment on line 112 says "scale [0, 1e67]" -- the scale can actually be up to `10^67` since `scale > 67` returns an error. `10^67` * `type(int256).max` would overflow int256, but this is caught by the `mulDidOverflow` check on line 117. Correct. + +#### Potential issue: `nonZeroCursor` loop reads before `fracStart` + +Line 70: `while (LibParseChar.isMask(nonZeroCursor - 1, end, CMASK_ZERO) == 1)` + +When `nonZeroCursor` decrements to `fracStart`, the check reads `fracStart - 1`, which is the byte just before the fractional digits (the decimal point `.`). The decimal point is not `CMASK_ZERO`, so the loop stops. This is correct but relies on the implicit assumption that the character before `fracStart` is always the decimal point and never `'0'`. This assumption holds because we only enter the fractional parsing block when `isMask(cursor, end, CMASK_DECIMAL_POINT)` was true, and `fracStart = cursor + 1`, so `fracStart - 1` is guaranteed to be the `.` character. Correct. + +### A11: Table Correctness + +#### Do `toBytes` functions produce correctly encoded data? + +**Encoding verification for `toBytes(uint16[10][90])`:** +- Memory layout: `encoded` at free memory pointer. Length prefix at `encoded`, data at `encoded+0x20`. +- Cursor starts at `encoded + tableSize` (= encoded + 1800). +- Loop processes entries in reverse (row 89 col 9 -> row 0 col 0). +- Each `mstore(cursor, value)` writes 32 bytes; only the last 2 bytes (at cursor+30 and cursor+31) survive subsequent overwrites. +- First entry lands at bytes (encoded+1830, encoded+1831), last at (encoded+32, encoded+33). Data area spans encoded+32 to encoded+1831, which is exactly 1800 bytes. Correct. +- Final `mstore(cursor, tableSize)` writes length at cursor=encoded. Correct. + +**Encoding verification for `toBytes(uint8[10][90])`:** +- Same pattern with cursor decrement of 1 instead of 2. +- Data area: encoded+32 to encoded+931, exactly 900 bytes = LOG_TABLE_SIZE_BASE. Correct. + +**Encoding verification for `toBytes(uint8[10][100])`:** +- 100 rows * 10 cols = 1000 entries, 1 byte each = 1000 bytes. +- Hardcoded size `1000` matches `10 * 100 * 1`. Correct. + +**Encoding verification for `toBytes(uint8[10][10])`:** +- 10 rows * 10 cols = 100 entries, 1 byte each = 100 bytes. +- Hardcoded size `100` matches. Correct. + +**Encoding verification for `toBytes(uint16[10][100])`:** +- 100 rows * 10 cols = 1000 entries, 2 bytes each = 2000 bytes. +- Hardcoded size `2000` matches. Correct. + +#### Are table dimensions and entry sizes correct? + +| Table function | Type | Rows | Cols | Entries | Bytes | Matches constant? | +|---|---|---|---|---|---|---| +| `logTableDec` | uint16[10][90] | 90 | 10 | 900 | 1800 | `LOG_TABLE_SIZE_BYTES` | +| `logTableDecSmall` | uint8[10][90] | 90 | 10 | 900 | 900 | `LOG_TABLE_SIZE_BASE` | +| `logTableDecSmallAlt` | uint8[10][10] | 10 | 10 | 100 | 100 | (hardcoded) | +| `antiLogTableDec` | uint16[10][100] | 100 | 10 | 1000 | 2000 | (hardcoded) | +| `antiLogTableDecSmall` | uint8[10][100] | 100 | 10 | 1000 | 1000 | (hardcoded) | + +All dimensions match the declared types and the expected table sizes for four-figure log/antilog tables. + +#### Does `ALT_TABLE_FLAG` work as documented? + +`ALT_TABLE_FLAG = 0x8000` is the high bit of uint16. In the log table: +- Main table entries have values 0-9996 (max value 9996 = 0x270C, fitting in 14 bits). +- Flag is ORed into entries where the "mean difference" should come from the alternate small table instead of the regular small table. +- In `lookupLogTableVal` (implementation file), the flag is detected via `and(mainTableVal, 0x8000)`, and the base value is extracted via `and(mainTableVal, 0x7FFF)`. When the flag is set, the small table offset is shifted by `LOG_TABLE_SIZE_BASE` to read from the alt small table instead. +- Flag placement: entries in rows 0-9 (numbers 1.0-1.9) have flags on columns 5-9 (with some variation). Rows 10+ have no flags. This matches the standard four-figure log table structure where the mean differences for small numbers (1.0-1.9) vary more rapidly and need a separate correction table. +- Verified: the maximum base value with flag is `2989 | 0x8000 = 0xAB2D`, which fits in uint16. The minimum base value is 0. + +The flag mechanism is correct and correctly documented. + +#### Spot-check of table values against standard log table + +**Log table (logTableDec):** +| Input | Expected log10 * 10000 | Table value | Correct? | +|---|---|---|---| +| 1.00 | 0 | 0 | Yes | +| 1.01 | 43.2 | 43 | Yes | +| 1.05 | 211.9 | 212 | Yes | +| 2.00 | 3010.3 | 3010 | Yes | +| 5.00 | 6990 | 6990 | Yes | +| 9.99 | 9996 | 9996 | Yes | + +**Antilog table (antiLogTableDec):** +| Input (0.xxx) | Expected 10^x * 1000 | Table value | Correct? | +|---|---|---|---| +| 0.000 | 1000 | 1000 | Yes | +| 0.001 | 1002.3 | 1002 | Yes | +| 0.301 | 1999.5 | 1995 | Yes (rounding) | +| 0.500 | 3162 | 3162 | Yes | +| 0.999 | 9977 | 9977 | Yes | + +--- + +## Findings + +### A08-7 [MEDIUM] Non-scientific `toDecimalString` panics on large positive exponents instead of reverting with `UnformatableExponent` + +**Severity:** MEDIUM +**File:** `src/lib/format/LibFormatDecimalFloat.sol`, lines 77-82 +**Status:** Open + +In the non-scientific formatting path, when `exponent > 0`, the code computes: +```solidity +signedCoefficient *= int256(10) ** uint256(exponent); +``` + +For packed Float values with large positive exponents (e.g., `exponent > 76`), this computation overflows `int256` and reverts with a Solidity arithmetic panic (0x11) rather than the meaningful `UnformatableExponent` error. + +**Concrete example:** `toDecimalString(packLossless(1, 200), false)` attempts `1 * 10^200`, which exceeds `type(int256).max` (~5.79e76) and panics. + +The negative exponent path has an explicit guard (line 84: `if (exponent < -76) revert UnformatableExponent(exponent)`), but the positive exponent path has no corresponding guard. This asymmetry means legitimate Float values with large positive exponents can only be formatted in scientific mode. + +### A08-8 [INFO] `countSigFigs` name does not match standard scientific definition of "significant figures" + +**Severity:** INFORMATIONAL +**File:** `src/lib/format/LibFormatDecimalFloat.sol`, lines 18-50 +**Status:** Open + +The function counts the minimum number of digit positions needed to represent a value in decimal notation, not the count of significant figures per the standard scientific definition. For example, `0.001` (coefficient=1, exponent=-3) returns 3, but has only 1 significant figure by the standard definition. The function is internally consistent with its tests and is dead code in production (A08-6), so the practical impact is nil. However, external consumers may be misled by the name. + +### A10-3 [LOW] `parseDecimalFloatInline` applies `int224` truncation check during rescaling but documentation does not explain the 67-digit limit + +**Severity:** LOW +**File:** `src/lib/parse/LibParseDecimalFloat.sol`, lines 108-123 +**Status:** Open + +The precision loss guard on line 108 (`if (scale > 67)`) and the `int224` truncation check on line 120 work together to prevent silent loss of precision when combining integer and fractional parts. However, the number `67` is a magic constant (noted in A10-2 from pass 4) and the relationship is non-obvious: + +- `int224` max = ~1.35e67, so a coefficient with 67 decimal digits fits in int224. +- The rescaling multiplies the integer part by `10^scale`, where `scale <= 67`. +- The `mulDidTruncate` check on line 120 catches cases where the rescaled value exceeds int224. + +The issue is that the `mulDidOverflow` check on line 117 tests `rescaledIntValue / int256(scale) != signedCoefficient`, which is an int256-level overflow check, while `mulDidTruncate` tests `int224(rescaledIntValue) != rescaledIntValue`, which is the packed Float coefficient range check. These are two distinct failure modes (int256 overflow vs. int224 range) collapsed into a single `ParseDecimalPrecisionLoss` error. The behavior is correct but the intent is unclear without careful analysis. The `67` limit exists to prevent `10^scale` from overflowing `uint256` (10^68 is ~2.68e67, still fits in uint256; 10^77 would not), but this is not documented. + +### A10-4 [INFO] Parser accepts multiple leading negative signs + +**Severity:** INFORMATIONAL +**File:** `src/lib/parse/LibParseDecimalFloat.sol`, line 41 +**Status:** Open + +Line 41 uses `skipMask(cursor, end, CMASK_NEGATIVE_SIGN)` which advances past ALL characters matching the negative sign mask. If the mask matches a single character `-`, then `---123` would skip to `123` and `isNegative` would be `true` (since cursor != start). However, the subsequent `unsafeDecimalStringToSignedInt(start, cursor)` parses from the original `start` (including all the `-` characters), and `LibParseDecimal.unsafeDecimalStringToSignedInt` would need to handle multiple leading dashes. If it doesn't, this would produce an error. If it does, the sign semantics may be surprising (e.g., `--1` = 1 or error?). + +This depends on the behavior of `LibParseChar.skipMask` and `CMASK_NEGATIVE_SIGN`. If `CMASK_NEGATIVE_SIGN` only matches a single `-` character (not multiple), then `skipMask` advances at most one position, and this is not an issue. Since the function is called `skipMask` (not `skip`), it likely advances past all matching characters. However, `unsafeDecimalStringToSignedInt` is from an external library and its handling of `"---123"` is not verified here. + +### A11-5 [INFO] Log table rows 10+ have no `ALT_TABLE_FLAG` entries -- confirms design but lacks documentation + +**Severity:** INFORMATIONAL +**File:** `src/lib/table/LibLogTable.sol`, lines 206-408 +**Status:** Open + +The `ALT_TABLE_FLAG` is only set on entries in rows 0-9 of `logTableDec` (corresponding to numbers 1.0-1.9). Rows 10-89 (numbers 2.0-9.9) have no flagged entries. This is correct per standard four-figure log table design: the mean differences for numbers >= 2.0 are uniform enough that the regular small table suffices, while numbers 1.0-1.9 need an alternate correction table. However, neither the constant documentation nor the table function documentation explains this design constraint. A consumer modifying the tables might inadvertently place flags in rows 10+ or remove them from rows 0-9 without understanding the structural requirement. + +--- + +## Previously Identified Issues -- Correctness Confirmation + +The following issues from prior passes were re-examined for correctness impact: + +| Prior ID | Pass | Issue | Correctness impact in pass 5 | +|---|---|---|---| +| A10-1 (pass 1) | 1 | Unchecked `exponent += eValue` wraps | Confirmed: `parseDecimalFloatInline` returns wrong exponent on int256 overflow. Mitigated in wrapper by `packLossy` revert. | +| A08-5 (pass 4) | 4 | Redundant import path | No correctness impact. | +| A08-6 (pass 4) | 4 | `countSigFigs` dead code | No correctness impact (dead code). | +| A10-1 (pass 4) | 4 | Split imports | No correctness impact. | +| A11-3 (pass 4) | 4 | Magic numbers in `toBytes` | No correctness impact (values are correct). | + +--- + +## Summary + +| ID | Severity | File | Issue | +|----|----------|------|-------| +| A08-7 | MEDIUM | LibFormatDecimalFloat.sol:77-82 | Non-scientific `toDecimalString` panics on large positive exponents | +| A10-3 | LOW | LibParseDecimalFloat.sol:108-123 | Undocumented 67-digit limit and conflated overflow checks in rescaling | +| A08-8 | INFO | LibFormatDecimalFloat.sol:18-50 | `countSigFigs` name misleading vs standard sig fig definition | +| A10-4 | INFO | LibParseDecimalFloat.sol:41 | Parser may accept multiple leading negative signs | +| A11-5 | INFO | LibLogTable.sol:206-408 | `ALT_TABLE_FLAG` row restriction undocumented | + +**MEDIUM findings:** 1 (A08-7) +**LOW findings:** 1 (A10-3) +**INFORMATIONAL findings:** 3 (A08-8, A10-4, A11-5) diff --git a/audit/2026-03-10-01/triage.md b/audit/2026-03-10-01/triage.md new file mode 100644 index 0000000..f99ed57 --- /dev/null +++ b/audit/2026-03-10-01/triage.md @@ -0,0 +1,57 @@ +# Audit Triage — 2026-03-10-01 + +Notes on IDs: When the same agent+number appears in multiple passes for +distinct findings, a `/pN` suffix disambiguates (e.g., A08-1/p1 vs A08-1/p3). +Deduplicated rows list the highest-severity instance and note absorbed +duplicates in the title. + +| ID | Pass | Severity | Title | Status | +|----|------|----------|-------|--------| +| A08-7 | 5 | MEDIUM | Non-scientific `toDecimalString` panics on large positive exponents instead of reverting with `UnformatableExponent` (dupes: A08-1/p1 LOW, A08-3/p2 LOW) | FIXED | +| A01-1 | 0 | LOW | CLAUDE.md omits `script/` directory from architecture | FIXED | +| A01-2 | 0 | LOW | CLAUDE.md omits deployment workflow documentation | FIXED | +| A01-3 | 1 | LOW | `require` uses string revert message instead of custom error in `DecimalFloat.sol` (dupe: A01-01/p4 LOW) | FIXED | +| A01-4 | 2 | LOW | `log10(Float)` deployed-parity test entirely commented out | FIXED | +| A01-5 | 2 | LOW | `pow10(Float)` deployed-parity test entirely commented out | FIXED | +| A01-6 | 2 | LOW | `format(Float, bool)` overload has zero test coverage | FIXED | +| A01-7 | 2 | LOW | `format(Float)` default overload has zero test coverage | FIXED | +| A01-8 | 2 | LOW | `format(Float, Float, Float)` require revert path not tested | FIXED | +| A01-10 | 3 | LOW | `sub` NatSpec is semantically backwards -- says "a from b" but computes a - b (dupes: A06-9/p3 LOW, A06-22/p5 LOW, A01-07/p5 INFO) | FIXED | +| A01-11 | 3 | LOW | `div` parameter NatSpec is ambiguous in `DecimalFloat.sol` | FIXED | +| A01-12 | 3 | LOW | `pow10` NatSpec misleading -- says "raise to the power of 10" but computes 10^a (dupe: A01-05/p5 LOW) | FIXED | +| A02-4 | 2 | LOW | `CoefficientOverflow` error has no direct test (dupe: A06-1/p2 LOW) | FIXED | +| A03-1 | 2 | LOW | `UnformatableExponent` error has no test (dupe: A08-2/p2 LOW) | FIXED | +| A04-1 | 1 | LOW | `ParseDecimalFloatExcessCharacters` lacks a `position` parameter | WONTFIX — error is only used as a soft selector, never reverted; adding a param changes the selector and provides no benefit in the current architecture | +| A06-2 | 2 | LOW | `packLossy` lossy-but-packable path lacks targeted test | FIXED | +| A06-6 | 3 | LOW | `fromFixedDecimalLosslessPacked` NatSpec references non-existent `fromFixedDecimalLossyMem` (dupe: A06-23/p5 LOW) | FIXED | +| A06-7 | 3 | LOW | `packLossy` NatSpec missing `@return lossless` and references non-existent `PackedFloat` type | FIXED | +| A06-11 | 3 | LOW | NatSpec references non-existent function names `divide` and `power10` | FIXED | +| A06-12 | 3 | LOW | 22 of 34 functions missing `@return` NatSpec tags in `LibDecimalFloat.sol` | FIXED | +| A06-16 | 4 | LOW | `inv` uses unique `(lossless);` dead expression instead of `(Float result,)` destructuring | FIXED | +| A06-20 | 4 | LOW | `pow` function directly uses implementation internals, breaking packed API abstraction | FIXED | +| A07-01 | 4 | LOW | 7 unused imports in `LibDecimalFloatDeploy.sol` | FIXED | +| A08-1/p3 | 3 | LOW | `toDecimalString` missing `@param scientific` documentation | FIXED | +| A08-5/p4 | 4 | LOW | Redundant import path `../../lib/implementation/` in `LibFormatDecimalFloat.sol` | FIXED | +| A08-6 | 4 | LOW | `countSigFigs` is dead production code in `LibFormatDecimalFloat.sol` | FIXED | +| A09-1/p1 | 1 | LOW | `unabsUnsignedMulOrDivLossy` missing exponent overflow check on `exponent + 1` (dupes: A09-15/p2 LOW, A09-20/p5 INFO) | FIXED | +| A09-1/p3 | 3 | LOW | `div` missing all `@param` and `@return` NatSpec tags in implementation | FIXED | +| A09-5 | 3 | LOW | `inv` missing `@param` and `@return` NatSpec tags in implementation | FIXED | +| A09-9 | 3 | LOW | `compareRescale` missing `@param` and `@return` tags in implementation | FIXED | +| A09-11 | 2 | LOW | Six internal functions have no direct test coverage | FIXED | +| A09-12 | 2 | LOW | `minus` ExponentOverflow error path untested | FIXED | +| A09-13 | 2 | LOW | `log10` error paths (`Log10Zero`, `Log10Negative`) completely untested (dupes: A02-1/p2 LOW, A02-2/p2 LOW) | FIXED | +| A09-14 | 2 | LOW | `MulDivOverflow` error path in `mulDiv` untested (dupe: A02-3/p2 LOW) | FIXED | +| A09-16/p4 | 4 | LOW | Magic number `100` (alt small log table byte size) in `lookupAntilogTableY1Y2` | FIXED | +| A09-17/p4 | 4 | LOW | Magic number `2000` (antilog table byte size) in `lookupAntilogTableY1Y2` | FIXED | +| A09-22 | 5 | LOW | `div` scale selection binary search has gaps causing extra while-loop iterations | WONTFIX — gas optimization deferred to post-deploy | +| A10-1/p1 | 1 | LOW | Unchecked `exponent += eValue` can silently wrap on int256 overflow in `parseDecimalFloatInline` | FIXED | +| A10-1/p4 | 4 | LOW | Split imports from same module in `LibParseDecimalFloat.sol` | FIXED | +| A10-3 | 5 | LOW | Undocumented 67-digit limit and conflated overflow checks in parse rescaling | FIXED | +| A10-7 | 2 | LOW | No dedicated unit test for `ParseDecimalFloatExcessCharacters` from wrapper | FIXED | +| A10-8 | 2 | LOW | No dedicated unit test for `ParseDecimalPrecisionLoss` from `packLossy` in wrapper | FIXED | +| A10-9 | — | LOW | `parseDecimalFloat` wrapper inconsistently reverts for positive exponent overflow but returns soft error for negative | PENDING | +| A11-3 | 4 | LOW | Inconsistent use of named constants vs magic numbers in `toBytes` overloads | PENDING | +| A11-4 | 2 | LOW | Log table tests have zero assertions -- they only call and log | PENDING | +| A11-5 | 2 | LOW | No test verifies table data integrity against known log10 reference values | PENDING | +| A11-6 | 2 | LOW | No test for `toBytes` encoding correctness (round-trip or spot-check) | PENDING | +| A13-2/p3 | 3 | LOW | `Deploy.sol` constants are undocumented and inconsistently named | FIXED | diff --git a/script/Deploy.sol b/script/Deploy.sol index 4dc3fef..e54708e 100644 --- a/script/Deploy.sol +++ b/script/Deploy.sol @@ -8,7 +8,14 @@ import {LibDecimalFloatDeploy} from "../src/lib/deploy/LibDecimalFloatDeploy.sol import {LibRainDeploy} from "rain.deploy/lib/LibRainDeploy.sol"; import {DecimalFloat} from "../src/concrete/DecimalFloat.sol"; +/// @dev Hash of the "log-tables" deployment suite string. When the +/// `DEPLOYMENT_SUITE` env var is set to "log-tables", the script deploys the +/// log/antilog lookup tables as a data contract. bytes32 constant DEPLOYMENT_SUITE_TABLES = keccak256("log-tables"); + +/// @dev Hash of the "decimal-float" deployment suite string. When the +/// `DEPLOYMENT_SUITE` env var is set to "decimal-float" (or omitted, as it is +/// the default), the script deploys the DecimalFloat contract. bytes32 constant DEPLOYMENT_SUITE_CONTRACT = keccak256("decimal-float"); contract Deploy is Script { diff --git a/src/concrete/DecimalFloat.sol b/src/concrete/DecimalFloat.sol index 45f9e44..fc496d3 100644 --- a/src/concrete/DecimalFloat.sol +++ b/src/concrete/DecimalFloat.sol @@ -5,6 +5,7 @@ pragma solidity =0.8.25; import {LibDecimalFloat, Float} from "../lib/LibDecimalFloat.sol"; import {LibFormatDecimalFloat} from "../lib/format/LibFormatDecimalFloat.sol"; import {LibParseDecimalFloat} from "../lib/parse/LibParseDecimalFloat.sol"; +import {ScientificMinNotLessThanMax} from "../error/ErrDecimalFloat.sol"; contract DecimalFloat { using LibDecimalFloat for Float; @@ -76,7 +77,9 @@ contract DecimalFloat { /// scientific notation. /// @return The string representation of the float. function format(Float a, Float scientificMin, Float scientificMax) public pure returns (string memory) { - require(scientificMin.lt(scientificMax), "scientificMin must be less than scientificMax"); + if (!scientificMin.lt(scientificMax)) { + revert ScientificMinNotLessThanMax(scientificMin, scientificMax); + } Float absA = a.abs(); return LibFormatDecimalFloat.toDecimalString(a, absA.lt(scientificMin) || absA.gt(scientificMax)); } @@ -107,8 +110,8 @@ contract DecimalFloat { } /// Exposes `LibDecimalFloat.sub` for offchain use. - /// @param a The first float to subtract. - /// @param b The second float to subtract. + /// @param a The float to subtract from. + /// @param b The float to subtract. /// @return The difference of the two floats. function sub(Float a, Float b) external pure returns (Float) { return a.sub(b); @@ -137,8 +140,8 @@ contract DecimalFloat { } /// Exposes `LibDecimalFloat.div` for offchain use. - /// @param a The first float to divide. - /// @param b The second float to divide. + /// @param a The dividend (numerator). + /// @param b The divisor (denominator). /// @return The quotient of the two floats. function div(Float a, Float b) external pure returns (Float) { return a.div(b); @@ -223,8 +226,8 @@ contract DecimalFloat { } /// 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. + /// @param a The exponent to raise 10 to. + /// @return The result of 10^a. function pow10(Float a) external view returns (Float) { return a.pow10(LibDecimalFloat.LOG_TABLES_ADDRESS); } diff --git a/src/error/ErrDecimalFloat.sol b/src/error/ErrDecimalFloat.sol index 708c8dd..cc5f0a0 100644 --- a/src/error/ErrDecimalFloat.sol +++ b/src/error/ErrDecimalFloat.sol @@ -47,3 +47,8 @@ error PowNegativeBase(int256 signedCoefficient, int256 exponent); /// @dev Thrown if writing the data by creating the contract fails somehow. error WriteError(); + +/// @dev Thrown when scientificMin is not less than scientificMax in format. +/// @param scientificMin The minimum threshold for scientific notation. +/// @param scientificMax The maximum threshold for scientific notation. +error ScientificMinNotLessThanMax(Float scientificMin, Float scientificMax); diff --git a/src/lib/LibDecimalFloat.sol b/src/lib/LibDecimalFloat.sol index 93df666..9da48f2 100644 --- a/src/lib/LibDecimalFloat.sol +++ b/src/lib/LibDecimalFloat.sol @@ -149,10 +149,10 @@ library LibDecimalFloat { return (signedCoefficient, exponent); } - /// Lossless version of `fromFixedDecimalLossyMem`. This will revert if the + /// Lossless version of `fromFixedDecimalLossyPacked`. This will revert if the /// conversion is lossy. - /// @param value As per `fromFixedDecimalLossyMem`. - /// @param decimals As per `fromFixedDecimalLossyMem`. + /// @param value As per `fromFixedDecimalLossyPacked`. + /// @param decimals As per `fromFixedDecimalLossyPacked`. /// @return float The Float struct containing the signed coefficient and /// exponent. function fromFixedDecimalLosslessPacked(uint256 value, uint8 decimals) internal pure returns (Float) { @@ -288,7 +288,7 @@ library LibDecimalFloat { return toFixedDecimalLossless(signedCoefficient, exponent, decimals); } - /// Pack a signed coefficient and exponent into a single `PackedFloat`. + /// Pack a signed coefficient and exponent into a single `Float`. /// Clearly this involves fitting 64 bytes into 32 bytes, so there will be /// data loss. /// @param signedCoefficient The signed coefficient of the floating point @@ -296,6 +296,7 @@ library LibDecimalFloat { /// @param exponent The exponent of the floating point representation. /// @return float The packed representation of the signed coefficient and /// exponent. + /// @return lossless True if the conversion was lossless, false otherwise. function packLossy(int256 signedCoefficient, int256 exponent) internal pure returns (Float float, bool lossless) { unchecked { int256 initialSignedCoefficient = signedCoefficient; @@ -385,6 +386,7 @@ library LibDecimalFloat { /// exponent of the first floating point number. /// @param b The Float struct containing the signed coefficient and /// exponent of the second floating point number. + /// @return The sum of the two floats. function add(Float a, Float b) internal pure returns (Float) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -396,12 +398,13 @@ library LibDecimalFloat { return c; } - /// Subtract float a from float b. + /// Subtract float b from float a. /// /// This is effectively shorthand for adding the two floats with the second /// float negated. Therefore, the same caveats apply as for `add`. /// @param a The float to subtract from. /// @param b The float to subtract. + /// @return The difference of the two floats (a - b). function sub(Float a, Float b) internal pure returns (Float) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -418,6 +421,7 @@ library LibDecimalFloat { /// ergonomic for the caller. /// @param float The Float struct containing the signed coefficient and /// exponent of the floating point number. + /// @return The negated float. function minus(Float float) internal pure returns (Float) { (int256 signedCoefficient, int256 exponent) = float.unpack(); (signedCoefficient, exponent) = LibDecimalFloatImplementation.minus(signedCoefficient, exponent); @@ -437,6 +441,7 @@ library LibDecimalFloat { /// > same as using the minus operation on the operand. Otherwise, the result /// > is the same as using the plus operation on the operand. /// @param float The float to take the absolute value of. + /// @return The absolute value of the float. function abs(Float float) internal pure returns (Float) { (int256 signedCoefficient, int256 exponent) = float.unpack(); @@ -471,6 +476,7 @@ library LibDecimalFloat { /// exponent of the first floating point number. /// @param b The Float struct containing the signed coefficient and /// exponent of the second floating point number. + /// @return The product of the two floats. function mul(Float a, Float b) internal pure returns (Float) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -481,13 +487,14 @@ library LibDecimalFloat { return c; } - /// Same as divide, but accepts a Float struct instead of separate values. + /// Same as `div`, 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. /// @param a The Float struct containing the signed coefficient and /// exponent of the first floating point number. /// @param b The Float struct containing the signed coefficient and /// exponent of the second floating point number. + /// @return The quotient of the two floats (a / b). function div(Float a, Float b) internal pure returns (Float) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -504,11 +511,11 @@ library LibDecimalFloat { /// ergonomic for the caller. /// @param float The Float struct containing the signed coefficient and /// exponent of the floating point number. + /// @return The multiplicative inverse (1 / float). function inv(Float float) internal pure returns (Float) { (int256 signedCoefficient, int256 exponent) = float.unpack(); (signedCoefficient, exponent) = LibDecimalFloatImplementation.inv(signedCoefficient, exponent); - (Float result, bool lossless) = packLossy(signedCoefficient, exponent); - (lossless); + (Float result,) = packLossy(signedCoefficient, exponent); return result; } @@ -517,6 +524,7 @@ library LibDecimalFloat { /// ergonomic for the caller. /// @param a The first float to compare. /// @param b The second float to compare. + /// @return True if the two floats are numerically equal. function eq(Float a, Float b) internal pure returns (bool) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -528,6 +536,7 @@ library LibDecimalFloat { /// For example, 1e2 is less than 1e3, and 1e2 is less than 2e2. /// @param a The first float to compare. /// @param b The second float to compare. + /// @return True if a is less than b. function lt(Float a, Float b) internal pure returns (bool) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -542,6 +551,7 @@ library LibDecimalFloat { /// other. For example, 1e3 is greater than 1e2, and 2e2 is greater than 1e2. /// @param a The first float to compare. /// @param b The second float to compare. + /// @return True if a is greater than b. function gt(Float a, Float b) internal pure returns (bool) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -554,6 +564,9 @@ library LibDecimalFloat { /// A float is less than or equal to another if its numeric value is less /// than or equal to the other. For example, 1e2 is less than or equal to 1e3 /// and 1e2 is less than or equal to 1e2. + /// @param a The first float to compare. + /// @param b The second float to compare. + /// @return True if a is less than or equal to b. function lte(Float a, Float b) internal pure returns (bool) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -566,6 +579,9 @@ library LibDecimalFloat { /// A float is greater than or equal to another if its numeric value is /// greater than or equal to the other. For example, 1e3 is greater than or /// equal to 1e2 and 1e2 is greater than or equal to 1e2. + /// @param a The first float to compare. + /// @param b The second float to compare. + /// @return True if a is greater than or equal to b. function gte(Float a, Float b) internal pure returns (bool) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); (int256 signedCoefficientB, int256 exponentB) = b.unpack(); @@ -600,6 +616,7 @@ library LibDecimalFloat { /// Smallest integer value less than or equal to the float. /// @param float The float to floor. + /// @return The floored float. 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. @@ -618,6 +635,7 @@ library LibDecimalFloat { /// Smallest integer value greater than or equal to the float. /// @param float The float to ceil. + /// @return The ceiled float. 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. @@ -642,13 +660,14 @@ library LibDecimalFloat { return result; } - /// Same as power10, but accepts a Float struct instead of separate values. + /// Same as `pow10`, 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. /// @param float The Float struct containing the signed coefficient and /// exponent of the floating point number. /// @param tablesDataContract The address of the contract containing the /// logarithm tables. + /// @return The result of 10^float. function pow10(Float float, address tablesDataContract) internal view returns (Float) { (int256 signedCoefficient, int256 exponent) = float.unpack(); (signedCoefficient, exponent) = @@ -665,6 +684,7 @@ library LibDecimalFloat { /// @param tablesDataContract The address of the contract containing the /// logarithm tables. /// @param a The float to log10. + /// @return The base-10 logarithm of a. function log10(Float a, address tablesDataContract) internal view returns (Float) { (int256 signedCoefficient, int256 exponent) = a.unpack(); (signedCoefficient, exponent) = @@ -687,6 +707,7 @@ library LibDecimalFloat { /// @param b The float `b` in `a^b`. /// @param tablesDataContract The address of the contract containing the /// logarithm tables. + /// @return The result of a^b. function pow(Float a, Float b, address tablesDataContract) internal view returns (Float) { (int256 signedCoefficientA, int256 exponentA) = a.unpack(); @@ -713,6 +734,9 @@ library LibDecimalFloat { return pow(a.inv(), b.minus(), tablesDataContract); } + // Uses LibDecimalFloatImplementation directly (rather than the packed + // Float API) to avoid repeated pack/unpack overhead in the squaring + // loop and to preserve unnormalized intermediates. (int256 signedCoefficientB, int256 exponentB) = b.unpack(); (int256 integerB, int256 fractionB) = LibDecimalFloatImplementation.intFrac(signedCoefficientB, exponentB); @@ -761,6 +785,7 @@ library LibDecimalFloat { /// @param a The float to take the square root of. /// @param tablesDataContract The address of the contract containing the /// logarithm tables. + /// @return The square root of a. function sqrt(Float a, address tablesDataContract) internal view returns (Float) { return pow(a, FLOAT_HALF, tablesDataContract); } @@ -778,6 +803,7 @@ library LibDecimalFloat { /// Convenience for `a > b ? a : b`. /// @param a The first float to compare. /// @param b The second float to compare. + /// @return The larger of the two floats. function max(Float a, Float b) internal pure returns (Float) { return gt(a, b) ? a : b; } @@ -785,6 +811,7 @@ library LibDecimalFloat { /// Returns true if the float is zero. Handles the case where the signed /// coefficient is zero and exponent is potentially non zero. /// @param a The float to check. + /// @return result True if the float is zero. function isZero(Float a) internal pure returns (bool result) { uint256 mask = type(uint224).max; assembly ("memory-safe") { diff --git a/src/lib/deploy/LibDecimalFloatDeploy.sol b/src/lib/deploy/LibDecimalFloatDeploy.sol index 5729323..41d0eb2 100644 --- a/src/lib/deploy/LibDecimalFloatDeploy.sol +++ b/src/lib/deploy/LibDecimalFloatDeploy.sol @@ -9,12 +9,7 @@ import { ANTI_LOG_TABLES, ANTI_LOG_TABLES_SMALL } from "../../generated/LogTables.pointers.sol"; -import {LibDataContract, DataContractMemoryContainer} from "rain.datacontract/lib/LibDataContract.sol"; -import {LibBytes} from "rain.solmem/lib/LibBytes.sol"; -import {LibMemCpy, Pointer} from "rain.solmem/lib/LibMemCpy.sol"; -import {DecimalFloat} from "../../concrete/DecimalFloat.sol"; import {LOG_TABLE_DISAMBIGUATOR} from "../table/LibLogTable.sol"; -import {WriteError} from "../../error/ErrDecimalFloat.sol"; library LibDecimalFloatDeploy { /// @dev Address of the log tables deployed via Zoltu's deterministic @@ -29,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(0x12A66eFbE556e38308A17e34cC86f21DcA1CDB73); + address constant ZOLTU_DEPLOYED_DECIMAL_FLOAT_ADDRESS = address(0x72C3b3FaF6bc6b8CB6031218E7ed7a777F72ae99); /// @dev The expected codehash of the DecimalFloat contract deployed via /// Zoltu's deterministic deployment proxy. - bytes32 constant DECIMAL_FLOAT_CONTRACT_HASH = 0x705cdef2ed9538557152f86cd0988c748e0bd647a49df00b3e4f100c3544a583; + bytes32 constant DECIMAL_FLOAT_CONTRACT_HASH = 0xc6b071d246f58791717197720c0b9986e91fad54f987ab40f47ed5caa412fde2; /// 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 72930c5..95715a4 100644 --- a/src/lib/format/LibFormatDecimalFloat.sol +++ b/src/lib/format/LibFormatDecimalFloat.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; import {LibDecimalFloat, Float} from "../LibDecimalFloat.sol"; -import {LibDecimalFloatImplementation} from "../../lib/implementation/LibDecimalFloatImplementation.sol"; +import {LibDecimalFloatImplementation} from "../implementation/LibDecimalFloatImplementation.sol"; import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol"; import {UnformatableExponent} from "../../error/ErrFormat.sol"; @@ -11,48 +11,11 @@ import {UnformatableExponent} from "../../error/ErrFormat.sol"; /// Not particularly efficient as it is intended for offchain use that doesn't /// cost gas. library LibFormatDecimalFloat { - /// Counts the number of significant figures in a decimal float. - /// @param signedCoefficient The signed coefficient of the decimal float. - /// @param exponent The exponent of the decimal float. - /// @return sigFigs The number of significant figures. - function countSigFigs(int256 signedCoefficient, int256 exponent) internal pure returns (uint256) { - if (signedCoefficient == 0) { - return 1; - } - - uint256 sigFigs = 0; - - if (exponent < 0) { - while (signedCoefficient % 10 == 0) { - signedCoefficient /= 10; - exponent++; - } - } - - while (signedCoefficient != 0) { - sigFigs++; - signedCoefficient /= 10; - } - - // Adjust for exponent - if (exponent < 0) { - exponent = -exponent; - // exponent > 0 - // forge-lint: disable-next-line(unsafe-typecast) - sigFigs = sigFigs > uint256(exponent) ? sigFigs : uint256(exponent); - } else if (exponent > 0) { - // exponent > 0 - // forge-lint: disable-next-line(unsafe-typecast) - sigFigs += uint256(exponent); - } - - return sigFigs; - } - /// 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) { @@ -75,6 +38,9 @@ library LibFormatDecimalFloat { } } else { if (exponent > 0) { + if (exponent > 76) { + revert UnformatableExponent(exponent); + } // exponent > 0 // forge-lint: disable-next-line(unsafe-typecast) signedCoefficient *= int256(10) ** uint256(exponent); diff --git a/src/lib/implementation/LibDecimalFloatImplementation.sol b/src/lib/implementation/LibDecimalFloatImplementation.sol index 81b737e..af61ed7 100644 --- a/src/lib/implementation/LibDecimalFloatImplementation.sol +++ b/src/lib/implementation/LibDecimalFloatImplementation.sol @@ -13,6 +13,8 @@ import { import { LOG_TABLE_SIZE_BYTES, LOG_TABLE_SIZE_BASE, + ALT_SMALL_LOG_TABLE_SIZE_BYTES, + ANTILOG_TABLE_SIZE_BYTES, LOG_MANTISSA_LAST_INDEX, ANTILOG_IDX_LAST_INDEX } from "../table/LibLogTable.sol"; @@ -268,6 +270,12 @@ library LibDecimalFloatImplementation { /// > The result is then rounded to precision digits, if necessary, according /// > to the rounding algorithm and taking into account the remainder from /// > the division. + /// @param signedCoefficientA The signed coefficient of the dividend. + /// @param exponentA The exponent of the dividend. + /// @param signedCoefficientB The signed coefficient of the divisor. + /// @param exponentB The exponent of the divisor. + /// @return signedCoefficient The signed coefficient of the quotient. + /// @return exponent The exponent of the quotient. //slither-disable-next-line cyclomatic-complexity function div(int256 signedCoefficientA, int256 exponentA, int256 signedCoefficientB, int256 exponentB) internal @@ -733,6 +741,10 @@ library LibDecimalFloatImplementation { } /// Inverts a float. Equivalent to `1 / x`. + /// @param signedCoefficient The signed coefficient of the float. + /// @param exponent The exponent of the float. + /// @return signedCoefficient The signed coefficient of the inverted float. + /// @return exponent The exponent of the inverted float. function inv(int256 signedCoefficient, int256 exponent) internal pure returns (int256, int256) { return div(1e76, -76, signedCoefficient, exponent); } @@ -1044,6 +1056,12 @@ library LibDecimalFloatImplementation { /// > implement a closed set of comparison operations /// > (greater than, equal,etc.) if desired. It need not, in this case, /// > expose the compare operation itself. + /// @param signedCoefficientA The signed coefficient of the first float. + /// @param exponentA The exponent of the first float. + /// @param signedCoefficientB The signed coefficient of the second float. + /// @param exponentB The exponent of the second float. + /// @return rescaledA The rescaled coefficient of the first float. + /// @return rescaledB The rescaled coefficient of the second float. function compareRescale(int256 signedCoefficientA, int256 exponentA, int256 signedCoefficientB, int256 exponentB) internal pure @@ -1233,7 +1251,7 @@ library LibDecimalFloatImplementation { // + 1800 for log tables // + 900 for small log tables // + 100 for alt small log tables - uint256 offsetSize = 1 + LOG_TABLE_SIZE_BYTES + LOG_TABLE_SIZE_BASE + 100; + uint256 offsetSize = 1 + LOG_TABLE_SIZE_BYTES + LOG_TABLE_SIZE_BASE + ALT_SMALL_LOG_TABLE_SIZE_BYTES; assembly ("memory-safe") { //slither-disable-next-line divide-before-multiply function lookupTableVal(tables, offset, index) -> result { @@ -1241,8 +1259,7 @@ library LibDecimalFloatImplementation { extcodecopy(tables, 30, add(offset, mul(div(index, 10), 2)), 2) let mainTableVal := mload(0) - // add size of the alt log table = 2000 - offset := add(offset, 2000) + offset := add(offset, ANTILOG_TABLE_SIZE_BYTES) mstore(0, 0) extcodecopy(tables, 31, add(offset, add(mul(div(index, 100), 10), mod(index, 10))), 1) result := add(mainTableVal, mload(0)) diff --git a/src/lib/parse/LibParseDecimalFloat.sol b/src/lib/parse/LibParseDecimalFloat.sol index 0ce4b42..ce229ee 100644 --- a/src/lib/parse/LibParseDecimalFloat.sol +++ b/src/lib/parse/LibParseDecimalFloat.sol @@ -11,10 +11,15 @@ import { CMASK_DECIMAL_POINT } from "rain.string/lib/parse/LibParseCMask.sol"; import {LibParseDecimal} from "rain.string/lib/parse/LibParseDecimal.sol"; -import {MalformedExponentDigits, ParseDecimalPrecisionLoss, MalformedDecimalPoint} from "../../error/ErrParse.sol"; +import { + MalformedExponentDigits, + ParseDecimalPrecisionLoss, + MalformedDecimalPoint, + ParseDecimalFloatExcessCharacters +} from "../../error/ErrParse.sol"; +import {ExponentOverflow} from "../../error/ErrDecimalFloat.sol"; import {ParseEmptyDecimalString} from "rain.string/error/ErrParse.sol"; import {LibDecimalFloat, Float} from "../LibDecimalFloat.sol"; -import {ParseDecimalFloatExcessCharacters} from "../../error/ErrParse.sol"; /// @title LibParseDecimalFloat /// @notice Library for parsing decimal floating point numbers from strings. @@ -105,17 +110,24 @@ library LibParseDecimalFloat { // exponent is non positive here. // forge-lint: disable-next-line(unsafe-typecast) uint256 scale = uint256(-exponent); + // 67 is the maximum number of fractional digits we can + // rescale by without overflowing. The coefficient is at + // most int224 (~6.7e66), and the rescaled product + // (coefficient * 10^scale) must fit in int256 (~5.8e76). + // Beyond 67 digits the multiplication would overflow + // int256 for any non-trivial coefficient. if (scale > 67) { return (ParseDecimalPrecisionLoss.selector, cursor, 0, 0); } scale = 10 ** scale; - // scale [0, 1e67] + // scale [1, 1e67] // forge-lint: disable-next-line(unsafe-typecast) int256 rescaledIntValue = signedCoefficient * int256(scale); - // scale [0, 1e67] + // Check 1: the multiplication overflowed int256. // forge-lint: disable-next-line(unsafe-typecast) bool mulDidOverflow = rescaledIntValue / int256(scale) != signedCoefficient; - // truncation is intentional as it is part of the check here. + // Check 2: the rescaled value exceeds int224 precision, + // so it cannot be packed losslessly into a Float. // forge-lint: disable-next-line(unsafe-typecast) bool mulDidTruncate = int224(rescaledIntValue) != rescaledIntValue; if (mulDidOverflow || mulDidTruncate) { @@ -147,7 +159,13 @@ library LibParseDecimalFloat { eValue = eValueTmp; } - exponent += eValue; + { + int256 newExponent = exponent + eValue; + if ((eValue > 0 && newExponent < exponent) || (eValue < 0 && newExponent > exponent)) { + return (ExponentOverflow.selector, cursor, 0, 0); + } + exponent = newExponent; + } } if (signedCoefficient == 0) { diff --git a/src/lib/table/LibLogTable.sol b/src/lib/table/LibLogTable.sol index 426d286..9af9155 100644 --- a/src/lib/table/LibLogTable.sol +++ b/src/lib/table/LibLogTable.sol @@ -27,6 +27,12 @@ uint256 constant LOG_TABLE_SIZE_BASE = LOG_MANTISSA_IDX_CARDINALITY / 10; /// @dev The size in bytes of the full log table (both large and small). uint256 constant LOG_TABLE_SIZE_BYTES = LOG_TABLE_SIZE_BASE * 2; +/// @dev The size in bytes of the alt small log table (uint8[10][10] = 100). +uint256 constant ALT_SMALL_LOG_TABLE_SIZE_BYTES = 100; + +/// @dev The size in bytes of the antilog table (uint16[10][100] = 2000). +uint256 constant ANTILOG_TABLE_SIZE_BYTES = 2000; + /// @dev As we deterministically deploy the log tables, we can run into /// collisions when we actually want distinct addresses. bytes32 constant LOG_TABLE_DISAMBIGUATOR = keccak256("LOG_TABLE_DISAMBIGUATOR_1"); diff --git a/test/src/concrete/DecimalFloat.format.t.sol b/test/src/concrete/DecimalFloat.format.t.sol index 7bfc955..3efa277 100644 --- a/test/src/concrete/DecimalFloat.format.t.sol +++ b/test/src/concrete/DecimalFloat.format.t.sol @@ -6,6 +6,7 @@ import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol"; import {Test} from "forge-std/Test.sol"; import {DecimalFloat} from "src/concrete/DecimalFloat.sol"; import {LibFormatDecimalFloat} from "src/lib/format/LibFormatDecimalFloat.sol"; +import {ScientificMinNotLessThanMax} from "src/error/ErrDecimalFloat.sol"; contract DecimalFloatFormatTest is Test { using LibDecimalFloat for Float; @@ -30,6 +31,48 @@ contract DecimalFloatFormatTest is Test { } } + function formatBoolExternal(Float a, bool scientific) external pure returns (string memory) { + return LibFormatDecimalFloat.toDecimalString(a, scientific); + } + + function testFormatBoolDeployed(Float a, bool scientific) external { + DecimalFloat deployed = new DecimalFloat(); + + try this.formatBoolExternal(a, scientific) returns (string memory str) { + string memory deployedStr = deployed.format(a, scientific); + + assertEq(str, deployedStr); + } catch (bytes memory err) { + vm.expectRevert(err); + deployed.format(a, scientific); + } + } + + function testFormatDefaultDeployed(Float a) external { + DecimalFloat deployed = new DecimalFloat(); + + try this.formatExternal(a, deployed.FORMAT_DEFAULT_SCIENTIFIC_MIN(), deployed.FORMAT_DEFAULT_SCIENTIFIC_MAX()) + returns (string memory str) { + string memory deployedStr = deployed.format(a); + + assertEq(str, deployedStr); + } catch (bytes memory err) { + vm.expectRevert(err); + deployed.format(a); + } + } + + function testFormatScientificMinNotLessThanMaxReverts(Float a, Float scientificMin, Float scientificMax) external { + vm.assume(!scientificMin.lt(scientificMax)); + + DecimalFloat deployed = new DecimalFloat(); + + vm.expectRevert( + abi.encodeWithSelector(ScientificMinNotLessThanMax.selector, scientificMin, scientificMax) + ); + deployed.format(a, scientificMin, scientificMax); + } + function testFormatConstants() external { DecimalFloat deployed = new DecimalFloat(); diff --git a/test/src/concrete/DecimalFloat.log10.t.sol b/test/src/concrete/DecimalFloat.log10.t.sol index 2de3cc8..d58c695 100644 --- a/test/src/concrete/DecimalFloat.log10.t.sol +++ b/test/src/concrete/DecimalFloat.log10.t.sol @@ -4,24 +4,25 @@ pragma solidity =0.8.25; import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol"; import {LogTest} from "test/abstract/LogTest.sol"; +import {DecimalFloat} from "src/concrete/DecimalFloat.sol"; contract DecimalFloatLog10Test is LogTest { using LibDecimalFloat for Float; - function log10External(Float a) external returns (Float) { - return a.log10(logTables()); + function log10External(Float a) external view returns (Float) { + return a.log10(LibDecimalFloat.LOG_TABLES_ADDRESS); } - // function testLog10Deployed(Float a) external { - // DecimalFloat deployed = new DecimalFloat(); + function testLog10Deployed(Float a) external { + DecimalFloat deployed = new DecimalFloat(); - // try this.log10External(a) returns (Float b) { - // Float deployedB = deployed.log10(a); + try this.log10External(a) returns (Float b) { + Float deployedB = deployed.log10(a); - // assertEq(Float.unwrap(b), Float.unwrap(deployedB)); - // } catch (bytes memory err) { - // vm.expectRevert(err); - // deployed.log10(a); - // } - // } + assertEq(Float.unwrap(b), Float.unwrap(deployedB)); + } catch (bytes memory err) { + vm.expectRevert(err); + deployed.log10(a); + } + } } diff --git a/test/src/concrete/DecimalFloat.pow10.t.sol b/test/src/concrete/DecimalFloat.pow10.t.sol index 6a1f072..acf368f 100644 --- a/test/src/concrete/DecimalFloat.pow10.t.sol +++ b/test/src/concrete/DecimalFloat.pow10.t.sol @@ -4,24 +4,25 @@ pragma solidity =0.8.25; import {LibDecimalFloat, Float} from "src/lib/LibDecimalFloat.sol"; import {LogTest} from "test/abstract/LogTest.sol"; +import {DecimalFloat} from "src/concrete/DecimalFloat.sol"; contract DecimalFloatPow10Test is LogTest { using LibDecimalFloat for Float; - function pow10External(Float a) external returns (Float) { - return a.pow10(logTables()); + function pow10External(Float a) external view returns (Float) { + return a.pow10(LibDecimalFloat.LOG_TABLES_ADDRESS); } - // function testPow10Deployed(Float a) external { - // DecimalFloat deployed = new DecimalFloat(); + function testPow10Deployed(Float a) external { + DecimalFloat deployed = new DecimalFloat(); - // try this.pow10External(a) returns (Float b) { - // Float deployedB = deployed.pow10(a); + try this.pow10External(a) returns (Float b) { + Float deployedB = deployed.pow10(a); - // assertEq(Float.unwrap(b), Float.unwrap(deployedB)); - // } catch (bytes memory err) { - // vm.expectRevert(err); - // deployed.pow10(a); - // } - // } + assertEq(Float.unwrap(b), Float.unwrap(deployedB)); + } catch (bytes memory err) { + vm.expectRevert(err); + deployed.pow10(a); + } + } } diff --git a/test/src/lib/LibDecimalFloat.pack.t.sol b/test/src/lib/LibDecimalFloat.pack.t.sol index 395506f..026ca06 100644 --- a/test/src/lib/LibDecimalFloat.pack.t.sol +++ b/test/src/lib/LibDecimalFloat.pack.t.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {LibDecimalFloat, ExponentOverflow, Float} from "src/lib/LibDecimalFloat.sol"; +import {CoefficientOverflow} from "src/error/ErrDecimalFloat.sol"; import {Test} from "forge-std/Test.sol"; contract LibDecimalFloatPackTest is Test { @@ -39,6 +40,37 @@ contract LibDecimalFloatPackTest is Test { this.packLossyExternal(signedCoefficient, exponent); } + function packLosslessExternal(int256 signedCoefficient, int256 exponent) external pure returns (Float) { + return LibDecimalFloat.packLossless(signedCoefficient, exponent); + } + + /// packLossless reverts with CoefficientOverflow when lossy. + function testPackLosslessCoefficientOverflow() external { + // int224.max + 1 can't fit losslessly — packLossy would normalize it + // but packLossless must revert. + int256 signedCoefficient = int256(type(int224).max) + 1; + int256 exponent = 0; + vm.expectRevert(abi.encodeWithSelector(CoefficientOverflow.selector, signedCoefficient, exponent)); + this.packLosslessExternal(signedCoefficient, exponent); + } + + /// packLossy returns lossless=false but a valid non-zero Float when the + /// coefficient exceeds int224 but can be normalized by dividing by 10. + function testPackLossyButPackable() external view { + // int224.max + 1 doesn't fit in int224, but dividing by 10 does. + int256 signedCoefficient = int256(type(int224).max) + 1; + int256 exponent = 0; + (Float float, bool lossless) = this.packLossyExternal(signedCoefficient, exponent); + assertFalse(lossless, "lossless"); + assertTrue(Float.unwrap(float) != Float.unwrap(LibDecimalFloat.FLOAT_ZERO), "non-zero"); + + // The packed value should unpack to a truncated coefficient with + // incremented exponent. + (int256 unpackedCoefficient, int256 unpackedExponent) = LibDecimalFloat.unpack(float); + assertEq(unpackedExponent, 1, "exponent"); + assertEq(unpackedCoefficient, signedCoefficient / 10, "coefficient"); + } + /// Lossy zero when exponent is negative below type(int32).min except for zero. function testPackNegativeExponentLossyZero(int256 signedCoefficient, int256 exponent) external view { exponent = bound(exponent, type(int256).min, int256(type(int32).min) - 77); diff --git a/test/src/lib/deploy/LibDecimalFloatDeploy.t.sol b/test/src/lib/deploy/LibDecimalFloatDeploy.t.sol index 8800a38..fe01559 100644 --- a/test/src/lib/deploy/LibDecimalFloatDeploy.t.sol +++ b/test/src/lib/deploy/LibDecimalFloatDeploy.t.sol @@ -4,7 +4,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std/Test.sol"; import {LibRainDeploy} from "rain.deploy/lib/LibRainDeploy.sol"; -import {LibDecimalFloatDeploy, DecimalFloat} from "src/lib/deploy/LibDecimalFloatDeploy.sol"; +import {LibDecimalFloatDeploy} from "src/lib/deploy/LibDecimalFloatDeploy.sol"; +import {DecimalFloat} from "src/concrete/DecimalFloat.sol"; import {LibDataContract} from "rain.datacontract/lib/LibDataContract.sol"; contract LibDecimalFloatDeployTest is Test { diff --git a/test/src/lib/format/LibFormatDecimalFloat.countSigFigs.t.sol b/test/src/lib/format/LibFormatDecimalFloat.countSigFigs.t.sol deleted file mode 100644 index 96183e0..0000000 --- a/test/src/lib/format/LibFormatDecimalFloat.countSigFigs.t.sol +++ /dev/null @@ -1,125 +0,0 @@ -// 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 {LibFormatDecimalFloat} from "src/lib/format/LibFormatDecimalFloat.sol"; - -/// @title LibFormatDecimalFloatCountSigFigs -contract LibFormatDecimalFloatCountSigFigs is Test { - function checkCountSigFigs(int256 signedCoefficient, int256 exponent, uint256 expected) internal pure { - uint256 actual = LibFormatDecimalFloat.countSigFigs(signedCoefficient, exponent); - assertEq(actual, expected, "Unexpected significant figures count"); - } - - function testCountSigFigsExamples() external pure { - checkCountSigFigs(0, 0, 1); - - // 1 = 1 - checkCountSigFigs(1, 0, 1); - checkCountSigFigs(10, -1, 1); - - // -1 = 1 - checkCountSigFigs(-1, 0, 1); - checkCountSigFigs(-10, -1, 1); - - // 10 = 2 - checkCountSigFigs(10, 0, 2); - checkCountSigFigs(100, -1, 2); - - // -10 = 2 - checkCountSigFigs(-10, 0, 2); - checkCountSigFigs(-100, -1, 2); - - // 0.1 = 1 - checkCountSigFigs(1, -1, 1); - checkCountSigFigs(10, -2, 1); - - // -0.1 = 1 - checkCountSigFigs(-1, -1, 1); - checkCountSigFigs(-10, -2, 1); - - // 0.01 = 2 - checkCountSigFigs(1, -2, 2); - checkCountSigFigs(10, -3, 2); - - // -0.01 = 2 - checkCountSigFigs(-1, -2, 2); - checkCountSigFigs(-10, -3, 2); - - // 0.001 = 3 - checkCountSigFigs(1, -3, 3); - checkCountSigFigs(10, -4, 3); - - // -0.001 = 3 - checkCountSigFigs(-1, -3, 3); - checkCountSigFigs(-10, -4, 3); - - // 1.1 = 2 - checkCountSigFigs(11, -1, 2); - checkCountSigFigs(110, -2, 2); - - // -1.1 = 2 - checkCountSigFigs(-11, -1, 2); - checkCountSigFigs(-110, -2, 2); - - // 1.01 = 3 - checkCountSigFigs(101, -2, 3); - checkCountSigFigs(1010, -3, 3); - - // -1.01 = 3 - checkCountSigFigs(-101, -2, 3); - checkCountSigFigs(-1010, -3, 3); - - // 10.1 = 3 - checkCountSigFigs(101, -1, 3); - checkCountSigFigs(1010, -2, 3); - - // -10.1 = 3 - checkCountSigFigs(-101, -1, 3); - checkCountSigFigs(-1010, -2, 3); - - // 10.01 = 4 - checkCountSigFigs(1001, -2, 4); - checkCountSigFigs(10010, -3, 4); - - // -10.01 = 4 - checkCountSigFigs(-1001, -2, 4); - checkCountSigFigs(-10010, -3, 4); - - // internal zeros are significant - checkCountSigFigs(100100, 0, 6); - checkCountSigFigs(-100100, 0, 6); - - // trailing zeros without decimal are significant - checkCountSigFigs(100, 0, 3); - checkCountSigFigs(1000, 0, 4); - - // trailing zeros after decimal are not significant - // 1.00 and 0.00100 - checkCountSigFigs(100, -2, 1); - checkCountSigFigs(100, -5, 3); - - // positive exponent growth - // 10 - checkCountSigFigs(1, 1, 2); - // 100 - checkCountSigFigs(1, 2, 3); - // -1000 - checkCountSigFigs(-1, 3, 4); - } - - function testCountSigFigsZero(int256 exponent) external pure { - checkCountSigFigs(0, exponent, 1); - } - - function testCountSigFigsOne(int256 exponent) external pure { - exponent = bound(exponent, -76, 0); - // exponent [-76, 0] - // forge-lint: disable-next-line(unsafe-typecast) - int256 one = int256(10 ** uint256(-exponent)); - checkCountSigFigs(one, exponent, 1); - checkCountSigFigs(-one, exponent, 1); - } -} diff --git a/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol b/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol index 2e6136a..b621e00 100644 --- a/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol +++ b/test/src/lib/format/LibFormatDecimalFloat.toDecimalString.t.sol @@ -2,10 +2,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {Test} from "forge-std/Test.sol"; +import {Test, stdError} from "forge-std/Test.sol"; 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"; /// @title LibFormatDecimalFloatToDecimalStringTest /// @notice Test contract for verifying the functionality of LibFormatDecimalFloat @@ -250,4 +251,28 @@ contract LibFormatDecimalFloatToDecimalStringTest is Test { // even though the threshold is 200 we still use scientific notation. checkFormat(1, 200, true, "1e200"); } + + function formatExternal(Float float, bool scientific) external pure returns (string memory) { + 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))); + 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); + this.formatExternal(float, false); + } } diff --git a/test/src/lib/implementation/LibDecimalFloatImplementation.internals.t.sol b/test/src/lib/implementation/LibDecimalFloatImplementation.internals.t.sol new file mode 100644 index 0000000..b9934b6 --- /dev/null +++ b/test/src/lib/implementation/LibDecimalFloatImplementation.internals.t.sol @@ -0,0 +1,148 @@ +// 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 {LibDecimalFloatImplementation} from "src/lib/implementation/LibDecimalFloatImplementation.sol"; +import {LogTest} from "test/abstract/LogTest.sol"; +import {MulDivOverflow} from "src/error/ErrDecimalFloat.sol"; + +/// @title Direct tests for internal functions that previously had no dedicated +/// test coverage (audit finding A09-11). +contract LibDecimalFloatImplementationInternalsTest is LogTest { + // -- absUnsignedSignedCoefficient -- + + function testAbsZero() external pure { + assertEq(LibDecimalFloatImplementation.absUnsignedSignedCoefficient(0), 0); + } + + function testAbsPositive(int256 x) external pure { + x = bound(x, 1, type(int256).max); + // Safe: x is bounded to [1, type(int256).max] so fits in uint256. + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(LibDecimalFloatImplementation.absUnsignedSignedCoefficient(x), uint256(x)); + } + + function testAbsNegative(int256 x) external pure { + x = bound(x, type(int256).min + 1, -1); + // Safe: x is negative so -x is positive and fits in uint256. + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(LibDecimalFloatImplementation.absUnsignedSignedCoefficient(x), uint256(-x)); + } + + function testAbsMinInt256() external pure { + // type(int256).min has no positive counterpart in int256, but fits in uint256. + assertEq( + LibDecimalFloatImplementation.absUnsignedSignedCoefficient(type(int256).min), + uint256(type(int256).max) + 1 + ); + } + + // -- mul512 -- + + function testMul512Zero() external pure { + (uint256 high, uint256 low) = LibDecimalFloatImplementation.mul512(0, 12345); + assertEq(high, 0); + assertEq(low, 0); + } + + function testMul512NoOverflow(uint128 a, uint128 b) external pure { + (uint256 high, uint256 low) = LibDecimalFloatImplementation.mul512(uint256(a), uint256(b)); + assertEq(high, 0); + assertEq(low, uint256(a) * uint256(b)); + } + + function testMul512MaxValues() external pure { + // type(uint256).max * type(uint256).max should produce a 512-bit result. + (uint256 high, uint256 low) = LibDecimalFloatImplementation.mul512(type(uint256).max, type(uint256).max); + // (2^256 - 1)^2 = 2^512 - 2^257 + 1 + // high = 2^256 - 2 = type(uint256).max - 1 + // low = 1 + assertEq(high, type(uint256).max - 1); + assertEq(low, 1); + } + + // -- mulDiv -- + + function testMulDivSimple() external pure { + // 6 * 7 / 3 = 14 + assertEq(LibDecimalFloatImplementation.mulDiv(6, 7, 3), 14); + } + + function testMulDivLargeNumerator() external pure { + // Test with values that overflow uint256 in intermediate multiplication. + uint256 x = type(uint256).max; + uint256 y = 2; + uint256 d = 2; + // (max * 2) / 2 = max + assertEq(LibDecimalFloatImplementation.mulDiv(x, y, d), type(uint256).max); + } + + function mulDivExternal(uint256 x, uint256 y, uint256 d) external pure returns (uint256) { + return LibDecimalFloatImplementation.mulDiv(x, y, d); + } + + function testMulDivOverflowReverts() external { + // Result would be type(uint256).max^2 which exceeds uint256. + vm.expectRevert( + abi.encodeWithSelector(MulDivOverflow.selector, type(uint256).max, type(uint256).max, 1) + ); + this.mulDivExternal(type(uint256).max, type(uint256).max, 1); + } + + // -- compareRescale -- + + function testCompareRescaleSameExponent() external pure { + (int256 a, int256 b) = LibDecimalFloatImplementation.compareRescale(5, 0, 3, 0); + assertEq(a, 5); + assertEq(b, 3); + } + + function testCompareRescaleDifferentExponents() external pure { + // 5e2 vs 3e3 => rescale to compare: 5 vs 30 + (int256 a, int256 b) = LibDecimalFloatImplementation.compareRescale(5, 2, 3, 3); + // After rescaling, a < b should hold (500 < 3000) + assertTrue(a < b); + } + + function testCompareRescaleEqual() external pure { + // 50e1 vs 5e2 are equal (both 500) + (int256 a, int256 b) = LibDecimalFloatImplementation.compareRescale(50, 1, 5, 2); + assertEq(a, b); + } + + // -- mantissa4 -- + + function testMantissa4Zero() external pure { + (int256 idx, bool interpolate, int256 scale) = LibDecimalFloatImplementation.mantissa4(0, -1); + assertEq(idx, 0); + assertFalse(interpolate); + assertEq(scale, 1); + } + + function testMantissa4ExactLookup() external pure { + // 5000e-4 = 0.5, mantissa should be exact at index 5000 + (int256 idx, bool interpolate, int256 scale) = LibDecimalFloatImplementation.mantissa4(5000, -4); + assertEq(idx, 5000); + assertFalse(interpolate); + assertEq(scale, 1); + } + + // -- unitLinearInterpolation -- + + function testUnitLinearInterpolationExact() external pure { + // When x1 == x, should return (y1, yExponent) + (int256 resultCoeff, int256 resultExp) = + LibDecimalFloatImplementation.unitLinearInterpolation(100, 100, 200, -2, 500, 600, -3); + assertEq(resultCoeff, 500); + assertEq(resultExp, -3); + } + + function testUnitLinearInterpolationMidpoint() external pure { + // x1=0, x=5000, x2=10000, y1=1000, y2=2000, all at exponent -4 + // Midpoint interpolation: y = 1000 + (5000/10000) * (2000 - 1000) = 1500e-4 + (int256 resultCoeff, int256 resultExp) = + LibDecimalFloatImplementation.unitLinearInterpolation(0, 5000, 10000, -4, 1000, 2000, -4); + assertTrue(LibDecimalFloatImplementation.eq(resultCoeff, resultExp, 1500, -4)); + } +} diff --git a/test/src/lib/implementation/LibDecimalFloatImplementation.log10.t.sol b/test/src/lib/implementation/LibDecimalFloatImplementation.log10.t.sol index baefacf..c413a15 100644 --- a/test/src/lib/implementation/LibDecimalFloatImplementation.log10.t.sol +++ b/test/src/lib/implementation/LibDecimalFloatImplementation.log10.t.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.25; import {LibDecimalFloatImplementation} from "src/lib/implementation/LibDecimalFloatImplementation.sol"; import {LogTest, console2} from "../../../abstract/LogTest.sol"; +import {Log10Zero, Log10Negative} from "src/error/ErrDecimalFloat.sol"; contract LibDecimalFloatImplementationLog10Test is LogTest { function checkLog10( @@ -62,6 +63,23 @@ contract LibDecimalFloatImplementationLog10Test is LogTest { checkLog10(0.5e1, -1, -0.301e76, -76); } + function log10External(int256 signedCoefficient, int256 exponent) external returns (int256, int256) { + return LibDecimalFloatImplementation.log10(logTables(), signedCoefficient, exponent); + } + + function testLog10ZeroReverts() external { + vm.expectRevert(abi.encodeWithSelector(Log10Zero.selector)); + this.log10External(0, 0); + } + + function testLog10NegativeReverts(int256 signedCoefficient, int256 exponent) external { + signedCoefficient = bound(signedCoefficient, type(int256).min, -1); + // Bound exponent to avoid MaximizeOverflow before reaching the sign check. + exponent = bound(exponent, -1e18, 1e18); + vm.expectRevert(abi.encodeWithSelector(Log10Negative.selector, signedCoefficient, exponent)); + this.log10External(signedCoefficient, exponent); + } + function testLog10One() external { unchecked { int256 exponent = 0; diff --git a/test/src/lib/implementation/LibDecimalFloatImplementation.minus.t.sol b/test/src/lib/implementation/LibDecimalFloatImplementation.minus.t.sol index 79dd25c..8c86a7d 100644 --- a/test/src/lib/implementation/LibDecimalFloatImplementation.minus.t.sol +++ b/test/src/lib/implementation/LibDecimalFloatImplementation.minus.t.sol @@ -8,6 +8,7 @@ import { EXPONENT_MAX } from "src/lib/implementation/LibDecimalFloatImplementation.sol"; import {Test} from "forge-std/Test.sol"; +import {ExponentOverflow} from "src/error/ErrDecimalFloat.sol"; contract LibDecimalFloatImplementationMinusTest is Test { /// Minus is the same as `0 - x`. @@ -23,4 +24,17 @@ contract LibDecimalFloatImplementationMinusTest is Test { assertEq(signedCoefficientMinus, expectedSignedCoefficient); assertEq(exponentMinus, expectedExponent); } + + function minusExternal(int256 signedCoefficient, int256 exponent) external pure returns (int256, int256) { + return LibDecimalFloatImplementation.minus(signedCoefficient, exponent); + } + + /// minus reverts with ExponentOverflow when coefficient is type(int256).min + /// and exponent is type(int256).max, because normalizing requires exponent + 1. + function testMinusExponentOverflow() external { + vm.expectRevert( + abi.encodeWithSelector(ExponentOverflow.selector, type(int256).min, type(int256).max) + ); + this.minusExternal(type(int256).min, type(int256).max); + } } diff --git a/test/src/lib/implementation/LibDecimalFloatImplementation.unabsUnsignedMulOrDivLossy.t.sol b/test/src/lib/implementation/LibDecimalFloatImplementation.unabsUnsignedMulOrDivLossy.t.sol index c6dbccf..cec0738 100644 --- a/test/src/lib/implementation/LibDecimalFloatImplementation.unabsUnsignedMulOrDivLossy.t.sol +++ b/test/src/lib/implementation/LibDecimalFloatImplementation.unabsUnsignedMulOrDivLossy.t.sol @@ -2,11 +2,35 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {Test} from "forge-std/Test.sol"; +import {Test, stdError} from "forge-std/Test.sol"; import {LibDecimalFloatImplementation} from "src/lib/implementation/LibDecimalFloatImplementation.sol"; contract LibDecimalFloatImplementationUnabsUnsignedMulOrDivLossyTest is Test { + function unabsExternal(int256 a, int256 b, uint256 c, int256 exponent) + external + pure + returns (int256, int256) + { + return LibDecimalFloatImplementation.unabsUnsignedMulOrDivLossy(a, b, c, exponent); + } + + /// exponent + 1 overflows when exponent is type(int256).max and c + /// exceeds int256 range (same-sign operands). + function testUnabsExponentOverflowSameSign() external { + uint256 c = uint256(type(int256).max) + 2; + vm.expectRevert(stdError.arithmeticError); + this.unabsExternal(1, 1, c, type(int256).max); + } + + /// exponent + 1 overflows when exponent is type(int256).max and c + /// exceeds int256 range (mixed-sign operands). + function testUnabsExponentOverflowMixedSign() external { + uint256 c = uint256(type(int256).max) + 2; + vm.expectRevert(stdError.arithmeticError); + this.unabsExternal(-1, 1, c, type(int256).max); + } + /// a and b are both not negative. function testUnabsUnsignedMulOrDivLossyPositive(uint256 a, uint256 b, uint256 c, int256 exponent) external pure { a = bound(a, 0, uint256(type(int256).max)); diff --git a/test/src/lib/parse/LibParseDecimalFloat.t.sol b/test/src/lib/parse/LibParseDecimalFloat.t.sol index f165c60..835729a 100644 --- a/test/src/lib/parse/LibParseDecimalFloat.t.sol +++ b/test/src/lib/parse/LibParseDecimalFloat.t.sol @@ -404,6 +404,64 @@ contract LibParseDecimalFloatTest is Test { checkParseDecimalFloatFail("0.-1", MalformedDecimalPoint.selector, 2); } + /// Exponent overflow when fractional exponent + e-notation exponent wraps. + /// (A10-1/p1) + function testParseExponentOverflowFracPlusEValue() external pure { + // exponent = -1 (from ".1") + int256.min (from e-notation) wraps. + // "0.1e-" = 5 chars + 77 digits = 82 + checkParseDecimalFloatFail( + "0.1e-57896044618658097711785492504343953926634992332820282019728792003956564819968", + ExponentOverflow.selector, + 82 + ); + // exponent = -1 (from ".1") + int256.max (from e-notation) does NOT + // overflow — it's a valid large positive exponent. + // "0.1e" = 4 chars + 77 digits = 81 + checkParseDecimalFloat( + "0.1e57896044618658097711785492504343953926634992332820282019728792003956564819967", + 1, + type(int256).max - 1, + 81 + ); + } + + /// ParseDecimalPrecisionLoss from the wrapper when packLossy returns + /// lossless=false. The inline parse succeeds but the coefficient exceeds + /// int224, so packLossy normalizes it lossily. (A10-8) + function testParseDecimalFloatPrecisionLossFromPackLossy() external { + // 68 nines exceeds int224.max (~1.35e67) so packLossy must divide + // by 10 to fit, returning lossless=false. + (bytes4 err,) = this.parseDecimalFloatExternal( + "99999999999999999999999999999999999999999999999999999999999999999999" + ); + assertEq(err, ParseDecimalPrecisionLoss.selector); + } + + /// Exponent exceeds int32 range — packLossy reverts with ExponentOverflow + /// for positive exponents, returns soft error for negative. (A10-8) + function testParseDecimalFloatExponentOverflowFromPackLossy() external { + // Positive exponent overflow reverts. + vm.expectRevert(abi.encodeWithSelector(ExponentOverflow.selector, int256(1), int256(2147483648))); + this.parseDecimalFloatExternal("1e2147483648"); + + // Negative exponent overflow returns soft error (rounds toward zero). + (bytes4 err,) = this.parseDecimalFloatExternal("1e-2147483649"); + assertEq(err, ParseDecimalPrecisionLoss.selector); + } + + /// ParseDecimalFloatExcessCharacters from the wrapper when trailing + /// non-numeric characters remain after a valid parse. (A10-7) + function testParseDecimalFloatExcessCharacters() external { + (bytes4 err,) = this.parseDecimalFloatExternal("1hello"); + assertEq(err, ParseDecimalFloatExcessCharacters.selector); + + (bytes4 err2,) = this.parseDecimalFloatExternal("1.2.3"); + assertEq(err2, ParseDecimalFloatExcessCharacters.selector); + + (bytes4 err3,) = this.parseDecimalFloatExternal("1e2e3"); + assertEq(err3, ParseDecimalFloatExcessCharacters.selector); + } + /// Can't have more than max total precision. Add decimals after the max int. function testParseLiteralDecimalFloatPrecisionRevert0() external pure { checkParseDecimalFloatFail(