Rounding Errors in Solidity: Causes and Real Bugs
Quick answer. Rounding errors in Solidity come from truncating division: there are no floating point numbers, so any remainder is dropped and results move toward zero. Bugs appear when a protocol ignores the remainder or handles it with flawed logic.
In our experience, rounding errors in Solidity are among the top three to five root causes of Medium, High and Critical findings.
They fill our audit reports, and they come up in audit contests and other reports across the industry.
This guide explains their root cause and walks through two real findings that play out very differently. It ends with what to check in your own security reviews.
Why Solidity Rounds
The core reason is truncation. Solidity has no floating point numbers, nothing that can represent a decimal or a non-whole number.
In Python, or almost any other language, 9 / 10 gives 0.9. In Solidity, 9 / 10 is 0.
Division truncates, and there is nothing to the right of the decimal point to hold a fraction.
Decimals as a workaround
That is why every ERC-20 token has a decimals function. It tells you how many decimals are used to represent one unit of the token.
| Token | Decimals | One whole token | Half a token |
|---|---|---|---|
| Wrapped ether (WETH) | 18 | 1 × 1018 | 5 × 1017 |
| USDC | 6 | 1 × 106 (1,000,000) | 5 × 105 (500,000) |
| A made-up USDF | 0 | 1 | Not representable |
Decimals artificially place the decimal point, here 18 or 6 digits from the right, so the integers below it are available for precision.
Give a token zero decimals and one unit is just 1. There is no way to represent half of it: 1 / 2 in Solidity is 0.
With USDC, half a token works. One USDC is 1 × 106, and dividing by two gives 5 × 105, or 500,000.
That is only possible because one USDC is a one followed by six zeros, not a plain 1.
Where it goes wrong
Bugs follow when a protocol does not use enough decimals for its calculations. It rounds off when it does not expect to, or does not handle the rounding at all.
Sometimes the team thinks rounding off is fine when it is not. Sometimes they do account for rounding, but the logic that accounts for it is flawed.
That logic is hard to get right. Take -9 / 10: it also gives 0. For a negative number, that is rounding up, from a negative value to zero.
For 9 / 10, it is rounding down.
Logic that tries to correct for rounding often mixes up the negative case and the positive case. The second finding below is exactly that.
Finding: Average Price Rounds Down
The first is not a critical issue, but it is a really common one in DeFi protocols.
It is a Medium finding titled "Updated average price always rounds down", in the increasePosition function.
How PnL depends on the average price
Positions have an average entry price, and their profit and loss (PnL) is computed from it.
- Long PnL = current value minus entry value = size in tokens × current price minus size in tokens × average price.
- Short PnL = entry value minus current value, the same calculation reversed, since a short profits when the token falls.
Every time a position increases, the average entry price must be updated. The function computes it from the existing position and the order being added.
uint256 nextValue = position.size * position.averagePrice + orderSize * executionPrice;
uint256 nextSize = position.size + orderSize;
// Vulnerable: always truncates
uint256 nextAveragePrice = nextValue / nextSize;
The existing size times its average price, plus the order size times the execution price, gives a total dollar value.
Dividing by the new total size gives the average price of the position after the increase.
That all makes sense, except for the deadly division sign. Whenever you see a division in a system, think of rounding errors first, and of divide-by-zero reverts too.
Why truncation helps a long
If the numerator is not exactly divisible by the denominator, the remainder is dropped.
So you can add to a position without moving the average price, as long as what you add rounds off as a remainder.
For a long, that means growing the size in tokens while the average entry price stays put, even though you bought the new tokens at a higher price.
Say you bought tokens at $100, and the price is now $101.
If your new order is small enough to round off, it is as if you bought it at your old $100 average rather than the $101 market price.
The numbers
The finding gives a worked example. Prices in this system have 8 decimals.
- Existing position size: 1 × 1018, at an average price of 100 × 108 ($100).
- Order size: 1 × 1010, a trivial amount next to the position, at an execution price of 101 × 108 ($101).
- Updated average: (1018 × 100 × 108 + 1010 × 101 × 108) / (1018 + 1010).
At full precision, that is 10,000,000,000.99999999. Solidity does not care about all those nines: it returns 10,000,000,000, which is exactly 100 × 108.
The position grew by 1 × 1010 while the price rose from $100 to $101, and the average price did not move at all.
The amount is small relative to the position. But with the right token, it could have a material effect.
A malicious actor could use it to buy tokens at the average price instead of the market price.
The fix
For longs, which benefit from rounding down, round up instead, using a round-up division from a standard Solidity math library. Round-up division rounds up whenever there is a remainder.
That errs on the side of a higher average entry price for longs, which means a slightly lower PnL, so there is nothing to exploit.
For shorts, keep the truncating round-down division. A lower average price decreases a short's PnL, so rounding down already works against the trader.
// Fixed: round against the trader
uint256 nextAveragePrice = isLong
? (nextValue + nextSize - 1) / nextSize // round up for longs
: nextValue / nextSize; // round down for shorts
Finding: Incorrect Tick Rounding
The second finding is a different kind of rounding error. Here the team did try to account for rounding and control it themselves, and made logical errors along the way.
The finding is titled "Incorrect tick rounding", and the part that matters is the roundAhead function.
What roundAhead should do
This protocol uses a concentrated liquidity curve, which is broken into discrete ticks. If that is new to you, start with Uniswap V3 concentrated liquidity and the constant product AMM.
A position is either zero for one or one for zero, depending on its direction.
Take a tick that is not on the tick spacing, such as 7 with a spacing of 10.
The function must snap it to a tick divisible by the spacing: 10 or 0.
Rounding ahead for a zero for one position should take 7 to 10. For a one for zero position, it should take 7 to 0.
The bug
The finding's description: for zero for one, a negative rounded tick is rounded up twice.
Truncation first reduces its magnitude, then the code adjusts it up again by the tick spacing. Meanwhile, positive rounded ticks are rounded down and never adjusted up.
function roundAhead(int24 tick, int24 tickSpacing, bool zeroForOne) internal pure returns (int24) {
int24 roundedTick = (tick / tickSpacing) * tickSpacing; // truncation as a feature
if (roundedTick == tick) return tick;
if (zeroForOne && roundedTick < 0) {
roundedTick += tickSpacing;
}
// ... a separate adjustment for one for zero
return roundedTick;
}
The first line uses truncation as a feature. 17 / 10 is 1, times 10 is 10, so the remainder of 7 is dropped and the tick snaps to 10.
If rounding changed nothing, as with a tick of exactly 10, the function returns early.
Now take 7. 7 / 10 is 0, times 10 is still 0, so the tick rounded backwards, but zero for one should go up to 10.
The special case only adds the tick spacing when the rounded tick is less than zero, and here it is exactly zero, so nothing happens.
Take -7 for zero for one. -7 / 10 is 0, so the result is 0, which happens to be correct: no adjustment was needed.
Now take -17. -17 / 10 is -1, times 10 is -10.
That is not equal to the tick, it is zero for one, and the rounded tick is negative, so the code adds the spacing again: -10 becomes 0.
The function skipped right over -10, the closest correct tick, and landed on 0. It rounded up twice.
Testing it in Chisel
Copying the function into a Chisel shell shows the full set of errors.
| Tick | zeroForOne | Should be | Original roundAhead |
|---|---|---|---|
| 17 | true | 20 | 10 |
| 7 | true | 10 | 0 |
| 7 | false | 0 | 0 |
| -7 | true | 0 | 0 |
| -7 | false | -10 | 0 |
| -17 | false | -20 | -10 |
| -17 | true | -10 | 0 |
The fix
The recommendation replaces both special cases. For zero for one, adjust up by the tick spacing in two cases.
The first is when the rounded tick is positive.
Truncation always shrinks the magnitude, so a positive tick such as 17 was pulled back to 10, and zero for one needs it moved ahead.
The second is when the rounded tick is zero and the original tick was positive, like 7.
If the original was -7, the rounded zero is already correct, so no extra adjustment. One for zero uses the same logic, inverted.
function roundAhead(int24 tick, int24 tickSpacing, bool zeroForOne) internal pure returns (int24) {
int24 roundedTick = (tick / tickSpacing) * tickSpacing;
if (roundedTick == tick) return tick;
if (zeroForOne) {
if (roundedTick > 0 || (roundedTick == 0 && tick > 0)) roundedTick += tickSpacing;
} else {
if (roundedTick < 0 || (roundedTick == 0 && tick < 0)) roundedTick -= tickSpacing;
}
return roundedTick;
}
Rerun in Chisel, every case comes out right.
For zero for one, 7 goes to 10, 17 to 20 and 2 to 10, while -2 and -7 stop at 0 and -17 goes to -10.
For one for zero, -7 goes to -10, -17 to -20, 7 to 0 and 17 to 10. A tick of 0 stays at 0.
That is a lot of handling for one very simple rounding step. Once you build logic around truncation, complex edge cases appear quickly.
What to Check at Every Division
The two findings came from completely different systems, and both came from truncation and the complexity it adds.
Whenever you see a division sign in a security review, think about truncation.
- Is truncation handled the right way?
- Does the code expect truncation to occur here?
- What happens with really small values? Can they round off in a way that harms the system or gives a user an advantage?
- Are truncated amounts handled correctly afterwards?
- What happens with negative numbers? Truncation reduces magnitude, so a negative value rounds toward zero, which is up in value: -7 / 10 is 0, not -1.
- Can the denominator be zero and cause a revert?
Rounding is also behind many accounting bugs; see DeFi accounting vulnerabilities.
How Guardian Helps
Guardian's smart contract audits build an invariant suite around accounting, solvency and rounding and math edge cases. Senior researchers then attack what that suite and the design review leave standing.
The suite outlives the audit as a reusable regression harness, so with Vanguard every fix and later update can be pressure-tested again.
Frequently Asked Questions
Why does Solidity round down?
Solidity has no floating point numbers, so division truncates and drops any remainder. 9 / 10 returns 0. Truncation moves results toward zero, so positive results round down and negative results round up.
How do token decimals help with rounding?
Decimals reserve integer digits for fractions. USDC uses 6 decimals, so one USDC is 1,000,000 units and half a USDC is 500,000. A token with zero decimals cannot represent half a unit at all.
Which way should an average entry price round?
Against the trader. For longs, round the updated average price up so PnL comes out slightly lower. For shorts, round down, since a lower average price reduces a short's PnL.
Why is -7 / 10 equal to 0 in Solidity?
Division truncates toward zero, so -0.7 becomes 0, which rounds up in value. Logic that assumes division always rounds down breaks on negative numbers, as the incorrect tick rounding finding shows.
Sources
Pressure-test your math with Guardian
Guardian builds invariant suites around rounding, accounting and solvency, then sends researchers after the edge cases.