Understanding Rounding Errors in DeFi: How Small Bugs Lead to Big Exploits
By Nican0r · Lead Invariants EngineerUnderstanding Rounding Errors in DeFi: How Small Bugs Lead to Big Exploits
Solidity has no floating-point numbers. Every calculation operates on unsigned integers, and every division truncates. This means that every division in your protocol is a potential source of rounding error, and in DeFi, rounding errors are not just imprecisions — they are attack vectors.
This post goes deep on rounding in Solidity: how it works, why it matters, and how the "favor the protocol" pattern prevents exploits. This is the same class of vulnerability we found during the Centrifuge engagement.
Fixed-Point Arithmetic in Solidity
Since Solidity lacks decimals, DeFi protocols use fixed-point arithmetic. A value of 1e18 represents 1.0, and 5e17 represents 0.5. This is known as WAD notation (18 decimal places).
uint256 constant WAD = 1e18;
// Multiply two WAD values
function wadMul(uint256 a, uint256 b) pure returns (uint256) {
return (a * b) / WAD;
}
// Divide two WAD values
function wadDiv(uint256 a, uint256 b) pure returns (uint256) {
return (a * WAD) / b;
}
The problem is in the division. (a * b) / WAD always rounds down (toward zero) because Solidity truncates integer division. This means:
wadMul(1e18 + 1, 1e18 - 1) // = 999999999999999999 (not 1e18)
One wei lost. Seems harmless. It is not.
The mulDiv Pattern
OpenZeppelin's Math.mulDiv is the standard for safe multiplication-then-division with explicit rounding control:
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
// Round DOWN (floor)
uint256 shares = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Floor);
// Round UP (ceil)
uint256 assets = Math.mulDiv(shares, totalAssets, totalShares, Math.Rounding.Ceil);
The critical advantage of mulDiv is that it handles the intermediate multiplication using a 512-bit product internally, preventing overflow that would occur with naive (a * b) / c when a * b exceeds type(uint256).max.
When to Round Up vs Round Down
The golden rule: always round in the direction that favors the protocol, never the user.
| Operation | Who benefits from more? | Round direction | Reasoning |
|---|---|---|---|
| Deposit (assets to shares) | User wants more shares | Round DOWN shares | Protocol keeps the rounding surplus |
| Withdraw (shares to assets) | User wants more assets | Round DOWN assets | Protocol keeps the rounding surplus |
| Mint (shares to assets) | User wants fewer assets | Round UP assets | User pays a tiny premium |
| Redeem (assets to shares) | User wants fewer shares | Round UP shares | User burns a tiny extra |
function convertToShares(uint256 assets) public view returns (uint256) {
// Depositing: round DOWN shares (user gets slightly fewer)
return Math.mulDiv(assets, totalSupply(), totalAssets(), Math.Rounding.Floor);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
// Withdrawing: round DOWN assets (user gets slightly less)
return Math.mulDiv(shares, totalAssets(), totalSupply(), Math.Rounding.Floor);
}
function previewMint(uint256 shares) public view returns (uint256) {
// Minting: round UP assets required (user pays slightly more)
return Math.mulDiv(shares, totalAssets(), totalSupply(), Math.Rounding.Ceil);
}
How 1 Wei Errors Compound
Consider a vault with 1,000,000 USDC (1e12 units with 6 decimals) and 1,000,000 shares. An attacker performs 10,000 deposit-withdraw cycles:
// Each cycle:
// 1. Deposit 1 USDC → get 1 share (correct, no rounding needed)
// After yield: totalAssets = 1,000,001, totalShares = 1,000,001
// 2. Deposit 1 USDC → shares = 1 * 1,000,001 / 1,000,001 = 1 (no error here)
// But with a different ratio:
// totalAssets = 1,000,000, totalShares = 999,999 (after a withdrawal)
// Deposit 1 USDC → shares = 1 * 999,999 / 1,000,000 = 0 (!!!)
// User deposited 1 USDC and got 0 shares!
The reverse is also dangerous. If shares round up:
// totalAssets = 1,000,000, totalShares = 999,999
// Deposit 1 USDC → shares = (1 * 999,999 + 1,000,000 - 1) / 1,000,000 = 1
// User got 1 share for 1 USDC, but each share is worth 1.000001 USDC
// Instant profit: 0.000001 USDC
// After 10,000 cycles: 0.01 USDC profit
// After 10,000,000 cycles: 10 USDC profit (automated by a bot)
With larger imbalances between shares and assets (which occur naturally after yield accrual), the per-operation error grows, and the compounding accelerates.
The Centrifuge Case Study
During our Centrifuge engagement, we discovered exactly this class of rounding vulnerability in an ERC-7540 vault implementation. The protocol had deposit caps to limit exposure, but rounding errors in the share-to-asset conversion meant that the effective cap could be slightly exceeded with each operation.
Our fuzzer ran thousands of deposit operations and found that the cumulative rounding error allowed depositors to bypass the cap by a material amount. The fix involved switching to consistent use of Math.mulDiv with the correct rounding direction for each operation.
The First Depositor Attack
A specific rounding exploit targets empty or near-empty vaults:
// 1. Attacker deposits 1 wei → gets 1 share
// 2. Attacker directly transfers 1,000,000 USDC to the vault
// (not through deposit — just a raw ERC-20 transfer)
// 3. Now: totalAssets = 1,000,000e6 + 1, totalShares = 1
// 4. Victim deposits 999,999 USDC
// shares = 999,999e6 * 1 / (1,000,000e6 + 1) = 0 shares!
// 5. Attacker withdraws their 1 share → gets everything
The standard defense is to mint dead shares on first deposit:
function _initialDeposit(uint256 assets) internal {
uint256 deadShares = 1000;
_mint(address(0xdead), deadShares); // Unrecoverable shares
totalAssets += assets;
}
This makes the donation attack economically infeasible because the attacker would need to donate proportionally more to affect the share price.
Invariant Properties for Rounding Correctness
Two key properties catch rounding bugs:
// 1. Protocol solvency: can never owe more than it has
function invariant_solvency() public returns (bool) {
uint256 totalClaimable = vault.convertToAssets(vault.totalSupply());
return vault.totalAssets() >= totalClaimable;
}
// 2. No zero-share deposits: depositing assets must always yield shares
function invariant_noZeroShareDeposit(uint256 amount) public returns (bool) {
amount = clamp(amount, vault.minDeposit(), vault.maxDeposit());
uint256 shares = vault.convertToShares(amount);
return shares > 0;
}
Key Takeaways
- Every division is a rounding decision. Make it intentional with explicit rounding directions.
- Use
Math.mulDivwithMath.Rounding.FloororMath.Rounding.Ceil— never naivea * b / c. - Always favor the protocol. Round against the user on every operation.
- Protect empty vaults against first-depositor attacks with dead shares.
- Fuzz extensively with small, irregular amounts to catch compounding errors.
Rounding bugs are subtle, systematic, and pervasive. They are also reliably catchable with the right invariant properties and sufficient fuzzing. Request an audit with Recon to get expert analysis of your protocol's rounding behavior and a comprehensive fuzzing campaign to verify it.