Solidity Precision Loss, Rounding Direction & Numerical Accounting
Overview & Empirical Grounding
This security auditing skill is synthesized from 321 empirical audit contest findings (41 Critical, 280 High severity) extracted from the Zaevlad/audit-findings-dataset corpus.
Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit Mathematics & Precision patterns.
Key Trigger Keywords
precision loss, rounding direction, division before multiplication, truncation, muldiv, fixed point, fullmath
Threat Model & Core Attack Vectors
Detailed failure modes identified across empirical audit findings:
- Unchecked Assumptions & Protocol State Discrepancy: Deviations between internal accounting state and actual balances/external conditions.
- Missing Boundary Checks & Validation: Failing to constrain user inputs, return values, or stale external data feeds.
- Execution Ordering & Invariant Breakage: Invariants momentarily violated during external calls or intermediary calculations.
- Economic & MEV Incentives: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.
Step-by-Step Audit Checklist
When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:
1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (
onlyOwner,onlyRole, custom guards) and ensure roles are initialized in constructor/initializer. - Trace all token balance state changes and external contract interactions.
2. Deep Invariant & Boundary Verification
- Input Validation: Check zero-address (
address(0)), zero-amount, array length parity, and upper/lower bounds. - State Transition Sequencing: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- External Return Handling: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- Failure Paths & DoS Hazards: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.
3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (forge test):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract AuditPoCTest is Test {
// Setup target protocol and mock dependencies
function setUp() public {
// Fork mainnet or deploy local environment
}
function test_exploit_vector() public {
// 1. Establish initial attacker balance
// 2. Trigger vulnerability
// 3. Assert invariant violation or balance drain
}
}
Empirical Case Studies from Zaevlad/audit-findings-dataset
The following real-world contest findings illustrate the root causes and remediation patterns:
Case Study: Claim functions don't validate if the epoch is
- Severity: High (Dataset Weight: 1.00, ID: #22571)
- Vulnerability Mechanism:
Both claim functions fail to validate if the epoch for the request has been already settled, leading to loss of funds when claiming requests for the current epoch. The issue is worsened as claimAndRequestDeposit() can be used to claim a deposit on behalf of any account, allowing an attacker to wipe other's requests. When the vault is closed, users can request a deposit, transfer assets and later claim shares, or request a redemption, transfer shares and later redeem assets. Both of these processes store the assets or shares, and later convert these when the epoch is settled. For deposits, the core of the implementation is given by _claimDeposit():
solidity function _claimDeposit( address owner, address receiver ) internal returns (uint256 shares) { shares = previewClaimDeposit(owner); uint256 lastRequestId = lastDepositRequestId[owner]; uint256 assets = epochs[lastRequestId].depositRequestBalance[owner]; epochs[lastRequestId].depositRequestBalance[owner] = 0; _update(address(claimableSilo), receiver, shares); emit ClaimDeposit(lastRequestId, owner, receiver, assets, shares); }```solidity function previewClaimDeposit(address owner) public view returns (uint256) { uint256 las
Proof of Concept / Vulnerable Pattern
The following proof of concept demonstrates the scenario in which a user claims their own deposit during the current epoch: ```solidity function test_ClaimSameEpochLossOfFunds_Scenario_A() public { asset.mint(alice, 1_000e18); vm.prank(alice); vault.deposit(500e18, alice); // vault is closed vm.prank(owner); vault.close(); // alice requests a deposit vm.prank(alice); vault.requestDeposit(500e18, alice, alice, ""); // the request is successfully created assertEq(vault.pendingDepositRequest(alice), 500e18); // now alice claims the deposit while vault is still open vm.prank(alice); vault.claimDeposit(alice); // request is gone assertEq(vault.pendingDepositRequest(alice), 0); } ``` This other proof of concept illustrates the scenario in which an attacker calls claimAndRequestDeposit() to wipe the deposit of another account. ```solidity function test_ClaimSameEpochLossOfFunds_Scenario_B() public { asset.mint(alice, 1_000e18); vm.prank(alice); vault.deposit(500e18, alice); // vault is closed vm.prank(owner); vault.close(); // alice requests a deposit vm.prank(alice); vault.requestDeposit(500e18, alice, alice, ""); // the request is successfully created assertEq(vault.pendingDepositReques
- Remediation Recommendation:
Check that the epoch associated with the request is not the current epoch.
solidity function _claimDeposit( address owner, address receiver ) internal returns (uint256 shares) { uint256 lastRequestId = lastDepositRequestId[owner]; if (isCurrentEpoch(lastRequestId)) revert(); shares = previewClaimDeposit(owner); uint256 assets = epochs[lastRequestId].depositRequestBalance[owner]; epochs[lastRequestId].depositRequestBalance[owner] = 0; _update(address(claimableSilo), receiver, shares); emit ClaimDeposit(lastRequestId, owner, receiver, assets, shares); }```solidity function _claimRedeem( address owner, address receiver ) internal whenNotPaused returns (uint256 assets) { uint256 lastRequestId = lastRedeemRequestId[owner]; if (isCurrentEpoch(lastRequestId)) revert(); assets = previewCla
Case Study: Voting does not take into account end of stak-
- Severity: High (Dataset Weight: 1.00, ID: #23011)
- Vulnerability Mechanism: The protocol allows to vote in Voter contract by means of staked position in MlumStaking. To vote, user must have staking position with certain properties. However, the voting does not implement check against invariant that the remaining lock period needs to be longer then the epoch time to be eligible for voting. Thus, it is possible to vote with stale voting position. Additionally, if position's lock period finishes inside of the voting epoch it is possible to vote, withdraw staked position, stake and vote again in the same epoch. Thus, voting twice with the same stake amount is possible from time to time. Ultimately, the invariant that voting once with same balance is only allowed is broken as well. The voting will decide which pools receive LUM emissions and how much.
The documentation states that: Who is allowed to vote? Only valid Magic LUM Staking Position are allowed to vote. The overall lock needs to be longer then 90 days and the remaining lock period needs to be longer then the epoch time.
User who staked position in the MlumStaking contract gets NFT minted as a proof of stake with properties describing this stake. Then, user can use that staking position to vote for p
Proof of Concept / Vulnerable Pattern
Scenario 1:
```solidity
function testGT_vote_twice_with_the_same_stake() public {
vm.prank(DEV);
_voter.updateMinimumLockTime(2 weeks);
_stakingToken.mint(ALICE, 1 ether);
vm.startPrank(ALICE);
_stakingToken.approve(address(_pool), 1 ether);
_pool.createPosition(1 ether, 2 weeks);
vm.stopPrank();
skip(1 weeks);
vm.prank(DEV);
_voter.startNewVotingPeriod();
vm.startPrank(ALICE);
_voter.vote(1, _getDummyPools(), _getDeltaAmounts());
vm.expectRevert(IVoter.IVoter__AlreadyVoted.selector);
_voter.vote(1, _getDummyPools(), _getDeltaAmounts());
vm.stopPrank();
assertEq(_voter.getTotalVotes(), 1 ether);
skip(1 weeks + 1);
vm.startPrank(ALICE);
_pool.withdrawFromPosition(1, 1 ether);
vm.stopPrank();
vm.startPrank(ALICE);
vm.expectRevert();
_voter.vote(1, _getDummyPools(), _getDeltaAmounts());
_stakingToken.approve(address(_pool), 1 ether);
_pool.createPosition(1 ether, 2 weeks);
_voter.vote(2, _getDummyPools(), _getDeltaAmounts());
vm.stopPrank();
assertEq(_voter.getTotalVotes(), 2 ether);
}
Scenario 2:
function testGT_vote_twice_with_the_same_stake() public {
- Remediation Recommendation: It is recommended to enforce the invariant that the remaining lock period must be longer than the epoch time to be eligible for voting. Additionally, it is recommended to prevent double voting at any time. One of the solution can be to prevent voting within the epoch if staking position was not created before epoch started.
Case Study: cancelling redeem requests permanently blocks the withdrawal queue
- Severity: Critical (Dataset Weight: 1.00, ID: #23335)
- Vulnerability Mechanism: AccountableWithdrawalQueue can deadlock at the head if the current head entry (_queue.nextRequestId) is fully removed (e.g., by a cancel that zeroes shares and clears controller) without advancing nextRequestId.
In AccountableWithdrawalQueue::_processUpToShares and AccountableWithdrawalQueue::_processUpToRequestId, the loop checks if (shares_ == 0) break; before incrementing nextRequestId:
(uint256 shares_, uint256 assets_, bool processed_) =
_processRequest(request_, liquidity, maxShares_, precision_);
if (shares_ == 0) break;
When the head is an empty entry (controller == address(0)), AccountableWithdrawalQueue::_processRequest returns (0, 0, true), shares_ == 0, the loop breaks:
if (request.controller == address(0)) return (0, 0, true);
The head never advances, so every subsequent call to process or preview gets stuck on the same empty head forever.
This can be triggered by any user whose request is currently at the head by canceling any dust amount (even 1 wei) such that their head entry is fully deleted at the time of processing (e.g., instant cancel‑fulfillment) in AccountableWithdrawalQueue::_delete:
#### Proof of Concept / Vulnerable Pattern
```solidity
Add the following test to `test/vault/AccountableWithdrawalQueue.t.sol`:
```solidity
function testHeadDeletionDeadlocksQueue() public {
// Setup: deposits are instant, redemptions are queued, cancel is instantly fulfilled
strategy.setInstantFulfillDeposit(true);
strategy.setInstantFulfillRedeem(false);
strategy.setInstantFulfillCancelRedeem(true);
// Seed vault with liquidity and create first (head) request by Alice
// This helper deposits for Alice and Bob at 1e36 price.
_setupInitialDeposits(1e36, DEPOSIT_AMOUNT);
// 1) Alice creates a redeem request -> head of queue (requestId = 1)
uint256 aliceSharesToQueue = 1;
vm.prank(alice);
uint256 headId = vault.requestRedeem(aliceSharesToQueue, alice, alice);
assertEq(headId, 1, "first request should be head (id = 1)");
// 2) Alice cancels; cancel is fulfilled instantly by the strategy.
// This fully removes the head request entry (controller becomes address(0)),
// but _queue.nextRequestId is NOT advanced by the implementation.
vm.prank(alice);
vault.cancelRedeemRequest(headId, alice);
// Sanity: queue indices should still point at the deleted head
(uint128 nex
- Remediation Recommendation: Recommended Mitigation: Consider incrementing the counter if it’s processed, and continue instead of break:
if (shares_ == 0) {
if (processed_) {
++nextRequestId;
continue;
}
break;
}
Case Study: Math512Lib sqrt512 and div512by256 Vulnerability
- Severity: High (Dataset Weight: 1.00, ID: #23398)
- Vulnerability Mechanism: Description: Math512Lib::sqrt512 implements a full-width integer Newton-Raphson square root. This hinges on the assumption that the initial guess is larger than the upper limb and fits within 256 bits such that the iteration is strictly monotonically decreasing, i.e. converges on the true square root. However, when the most significant bit of the upper limb is odd, floor division by two can result in an initial guess that is smaller than the upper limb, causing the quotient of floor(([x1 x0]) / root) >= 2ˆ256 to be too wide.
function sqrt512(uint256 x1, uint256 x0) internal pure returns (uint256 root) {
if (x1 == 0) {
return FixedPointMathLib.sqrt(x0);
}
@> root = 1 << (128 + (LibBit.fls(x1) / 2));
uint256 last;
do {
last = root;
// Because !floor(sqrt(UINT512_MAX)) = 2^256-1! and guesses converging towards the
// correct result the result of all divisions is guaranteed to fit within 256 bits.
@> (, root) = div512by256(x1, x0, root);
root = (root + last) / 2;
} while (root != last);
return root;
}
The high digit returned by Math512Lib::div512by256 is correctly ignored per the intended impl
Proof of Concept / Vulnerable Pattern
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {console, Test} from "forge-std/Test.sol";
import {stdError} from "forge-std/StdError.sol";
import {LibBit} from "solady/src/utils/LibBit.sol";
import {FixedPointMathLib} from "solady/src/utils/FixedPointMathLib.sol";
import {Math512Lib} from "../../src/libraries/Math512Lib.sol";
contract Math512LibHarness {
using Math512Lib for uint256;
function fullMul(uint256 x, uint256 y) public pure returns (uint256 z1, uint256 z0) {
return Math512Lib.fullMul(x, y);
}
function sqrt512(uint256 x1, uint256 x0) public pure returns (uint256) {
return Math512Lib.sqrt512(x1, x0);
}
function div512by256(uint256 x1, uint256 x0, uint256 d) public pure returns (uint256 y1, uint256 y0) {
return Math512Lib.div512by256(x1, x0, d);
}
}
contract Math512LibTest is Math512LibHarness, Test {
// BUG: when msb is odd in [129, 245], the quotient is too wide
function test_oddMsbInitialRootArithmeticError(uint8 msb) public {
uint256 x0;
// Choose a msb that is odd and in [129, 245] such that LibBit.fls(x1) returns an odd index
msb = uint8(129 + 2 * bound(
- Remediation Recommendation: Recommended Mitigation: Ensure that the initial square root guess is always larger than the upper limb such that the iteration is monotonically decreasing.
Compute the long division remainder as:
let r := addmod(addmod(mulmod(r1, not(0), d), r1, d), x0, d)
Case Study: An attacker can drain the entire protocol balance of sUSDe during the yield phase due to incorrect redemption accounting logic in pUSDeVault::_withdraw
- Severity: High (Dataset Weight: 1.00, ID: #23521)
- Vulnerability Mechanism: After transitioning to the yield phase, the entire protocol balance of USDe is deposited into sUSDe and pUSDe can be deposited into the yUSDe vault to earn additional yield from the sUSDe. When initiating a redemption, yUSDeVault::_withdraw is called which in turn invokes pUSDeVault::redeem:
function _withdraw(address caller, address receiver, address owner, uint256 pUSDeAssets, uint256
shares) internal override {,!
if (!withdrawalsEnabled) {
revert WithdrawalsDisabled();
}
if (caller != owner) {
_spendAllowance(owner, caller, shares);
}
_burn(owner, shares);
@> pUSDeVault.redeem(pUSDeAssets, receiver, address(this));
emit Withdraw(caller, receiver, owner, pUSDeAssets, shares);
}
This is intended to have the overall effect of atomically redeeming yUSDe -> pUSDe -> sUSDe by previewing and applying any necessary yield from sUSDe:
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares)
internal override {,!
if (PreDepositPhase.YieldPhase == currentPhase) {
// sUSDeAssets = sUSDeAssets + user_yield_sUSDe
@> assets += previewYield(caller, shares)
#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {MockUSDe} from "../contracts/test/MockUSDe.sol";
import {MockStakedUSDe} from "../contracts/test/MockStakedUSDe.sol";
import {MockERC4626} from "../contracts/test/MockERC4626.sol";
import {pUSDeVault} from "../contracts/predeposit/pUSDeVault.sol";
import {yUSDeVault} from "../contracts/predeposit/yUSDeVault.sol";
import {console2} from "forge-std/console2.sol";
contract CritTest is Test {
uint256 constant MIN_SHARES = 0.1 ether;
MockUSDe public USDe;
MockStakedUSDe public sUSDe;
pUSDeVault public pUSDe;
yUSDeVault public yUSDe;
address account;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
address owner = msg.sender;
// Prepare Ethena and Ethreal contracts
USDe = new MockUSDe();
sUSDe = new MockStakedUSDe(USDe, owner, owner);
// Prepare pUSDe and Depos
- Remediation Recommendation:
Recommended Mitigation: While the assets corresponding to the accrued yield should be included when previewing the sUSDe withdrawal, only the base assets should be passed to the subsequent call to
_withdraw():
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares)
internal override {,!
if (PreDepositPhase.YieldPhase == currentPhase) {
// sUSDeAssets = sUSDeAssets + user_yield_sUSDe
-- assets += previewYield(caller, shares);
++ uint256 assetsPlusYield = assets + previewYield(caller, shares);
-- uint sUSDeAssets = sUSDe.previewWithdraw(assets);
++ uint sUSDeAssets = sUSDe.previewWithdraw(assetsPlusYield);
_withdraw(
address(sUSDe),
caller,
re
---
### Case Study: Value leakage due to pUSDe redemptions rounding against the protocol/yUSDe depositors
- **Severity**: High (Dataset Weight: 1.00, ID: #23526)
- **Vulnerability Mechanism**:
Description: After transitioning to the yield phase, redemptions of both pUSDe and yUSDe are processed by
pUSDeVault::_withdraw such that they are both paid out in sUSDe. This is achieved by computing the sUSDe
balance corresponding to the required USDe amount by calling its previewWithdraw() function:
```solidity
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares)
internal override {
if (PreDepositPhase.YieldPhase == currentPhase) {
// sUSDeAssets = sUSDeAssets + user_yield_sUSDe
assets += previewYield(caller, shares);
uint sUSDeAssets = sUSDe.previewWithdraw(assets); // @audit - this rounds up because
sUSDe requires the amount of sUSDe burned to receive assets amount of USDe to round up, but below
we are transferring this rounded value out to the receiver which actually rounds against the
protocol/yUSDe depositors!
_withdraw(
address(sUSDe),
caller,
receiver,
owner,
assets, // @audit - this should not include the yield, since it is decremented from
depositedBase,
sUSDeAssets,
#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {MockUSDe} from "../contracts/test/MockUSDe.sol";
import {MockStakedUSDe} from "../contracts/test/MockStakedUSDe.sol";
import {MockERC4626} from "../contracts/test/MockERC4626.sol";
import {pUSDeVault} from "../contracts/predeposit/pUSDeVault.sol";
import {yUSDeVault} from "../contracts/predeposit/yUSDeVault.sol";
import {console2} from "forge-std/console2.sol";
contract RoundingTest is Test {
uint256 constant MIN_SHARES = 0.1 ether;
MockUSDe public USDe;
MockStakedUSDe public sUSDe;
pUSDeVault public pUSDe;
yUSDeVault public yUSDe;
address account;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
address owner = msg.sender;
USDe = new MockUSDe();
sUSDe = new MockStakedUSDe(USDe, owner, owner);
pUSDe = pUSDeVault(
address(
new ERC1967Pr
- Remediation Recommendation: Recommended Mitigation: Rather than calling previewWithdraw() which rounds up, call convertToShares() which rounds down:
function previewWithdraw(uint256 assets) public view virtual override returns (uint256) {
return _convertToShares(assets, Math.Rounding.Up);
}
function convertToShares(uint256 assets) public view virtual override returns (uint256) {
return _convertToShares(assets, Math.Rounding.Down);
}
Remediation & Hardening Principles
- Fail-Safe Defaults: Always favor reverting rather than proceeding with invalid or unexpected state.
- Defensive Type Safety: Use OpenZeppelin's vetted libraries (
SafeERC20,ReentrancyGuardUpgradeable,SafeCast,Math). - Two-Step Critical Actions: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- Comprehensive Testing Matrix: Pair unit tests with property-based invariant testing (Foundry
invariantor Echidna) and mutation testing.