Rounding Is a Security Decision
Integer division always discards something. Who absorbs that loss — the protocol or the user — determines whether a vault can be drained one wei at a time.
Published by Xevry
Solidity has no floating point. Every share price, exchange rate and fee is computed with integer arithmetic, and every division truncates toward zero. That truncation is not a numerical detail. It is a transfer of value, and the direction of that transfer is a design decision with security consequences.
The rule
Round in favor of the protocol. Always.
When a user deposits, round the shares they receive down. When a user withdraws a fixed amount of assets, round the shares burned up. Any rounding the other way creates a sliver of value an attacker can extract — and slivers become significant when they can be repeated in a loop or amplified by a manipulated denominator.
ERC-4626 encodes this directly in its specification: convertToShares, previewDeposit and previewRedeem round down, while previewMint and previewWithdraw round up.
The first-depositor inflation attack
The best-known consequence of rounding is the vault inflation attack. A naive vault computes shares like this:
function deposit(uint256 assets) external returns (uint256 shares) { uint256 supply = totalSupply(); shares = supply == 0 ? assets : assets * supply / totalAssets(); _mint(msg.sender, shares); asset.transferFrom(msg.sender, address(this), assets);}Walk through it with an empty vault:
- The attacker deposits 1 wei and receives 1 share.
- The attacker donates X tokens directly to the vault with a plain
transfer. No shares are minted.totalAssets()is now X + 1. - A victim deposits X tokens. Their shares are
X * 1 / (X + 1), which rounds down to 0. - The attacker holds the only share, now backed by 2X + 1 tokens, and redeems it — netting the victim's entire deposit.
The attacker typically front-runs the victim's deposit transaction, so this is most dangerous for newly deployed vaults and for vaults that can return to zero supply.
Defenses
| Defense | How it works | Trade-off |
|---|---|---|
| Virtual shares and assets | Add a constant offset to both supply and assets in conversions, so a donation must be orders of magnitude larger to cause meaningful rounding. | Small, bounded loss distributed to the virtual position. |
| Decimals offset | Represent shares with more decimals than the asset (e.g. +6), multiplying the attacker's required donation by 10^offset. | Share amounts are larger numbers; integrators must handle decimals correctly. |
| Dead shares | Mint an initial amount of shares to an unrecoverable address at deployment. | Requires an initial deposit; protects only that vault instance. |
| Internal accounting | Track deposited assets in storage rather than reading balanceOf(this), so donations do not move the price. | Unsolicited transfers are stranded unless explicitly swept. |
OpenZeppelin's ERC-4626 implementation combines virtual shares with a configurable _decimalsOffset(). It is a sensible default, but it is a mitigation with tunable strength, not a proof — the offset should be chosen with the expected deposit sizes in mind.
Precision loss beyond vaults
The same thinking applies everywhere a division occurs:
- Multiply before you divide.
a / b * closes precision thata * c / bkeeps — provided the multiplication cannot overflow. UsemulDivfor full-precision 512-bit intermediate results. - Fees that round to zero can be bypassed by splitting one large action into many small ones.
- Reward-per-token accumulators must be scaled (commonly by 1e18 or more) or small stakers silently earn nothing.
- Decimal mismatches between tokens (6 vs 18) amplify every rounding error by 10^12.