Crypto Training
Compositional Rounding Attacks: When Safe Math Becomes Unsafe
Rounding bugs are usually not single-line bugs. They emerge when multiple safe-looking operations compose into a leak loop. This post shows how to model, test, and patch that class of failures.
Rounding bugs in production protocols usually look harmless in code review.
One line rounds down. Another line rounds up. Both lines are “defensible.”
The loss appears when these lines are chained across user flows that can be repeated under attacker control.
Why this class is hard to catch#
Teams often review each function locally.
Attackers execute the system globally.
| Review lens | Typical conclusion | Reality in incidents |
|---|---|---|
| Single operation | “Dust is tiny” | Dust compounds over loops |
| Single contract | “No direct theft path” | Composition creates theft path |
| Single block | “Gas blocks abuse” | Bundles + private order flow reduce cost |
The right question is not “is this rounding choice reasonable?”
The right question is “who controls repetition count and path ordering?”
Threat model for compositional precision attacks#
Attack prerequisites are usually:
- At least two rounding boundaries with different direction.
- A user-controlled loop surface.
- No hard lower bound on effective trade or share delta.
A minimal Solidity example of compositional leakage#
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
library MathX {
function mulDivDown(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) {
return (x * y) / d;
}
function mulDivUp(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) {
if (x == 0) return 0;
return ((x * y) - 1) / d + 1;
}
}
contract DriftVault {
using MathX for uint256;
uint256 public totalAssets;
uint256 public totalShares;
mapping(address => uint256) public shareOf;
function deposit(uint256 assets) external {
uint256 shares = totalShares == 0
? assets
: assets.mulDivUp(totalShares, totalAssets); // attacker-favorable here
totalAssets += assets;
totalShares += shares;
shareOf[msg.sender] += shares;
}
function withdraw(uint256 shares) external {
require(shareOf[msg.sender] >= shares, "insufficient");
uint256 assets = shares.mulDivDown(totalAssets, totalShares); // opposite direction
shareOf[msg.sender] -= shares;
totalShares -= shares;
totalAssets -= assets;
// transfer(assets)
}
}
This alone is not always fatal. It becomes exploitable when the attacker can route through other operations that move totals in favorable micro-steps.
Design rules that survive adversarial composition#
1) Choose and document a global rounding policy#
Do not let each engineer choose per function.
2) Normalize before division and keep domains consistent#
- Scale to a common precision first.
- Avoid division before multiplication unless you can prove no loss sensitivity.
3) Add minimum effective delta checks#
Reject operations where computed effect is zero or one-unit drift-prone.
uint256 shares = assets.mulDivDown(totalShares, totalAssets);
require(shares >= MIN_SHARES_OUT, "dust-op");
4) Treat composition as an invariant problem#
Test n-step sequences, not one-shot calls.
Invariants that catch this bug class#
function invariant_totalValueNotLeaking() public {
uint256 before = model.systemValue();
vm.roll(block.number + 1);
fuzzedActorSequence();
uint256 after_ = model.systemValue();
// bounded for fees, unbounded leak is forbidden
assertGe(after_ + model.accountedFees(), before);
}
Useful invariant families:
| Invariant | Purpose |
|---|---|
| Value conservation (net of explicit fees) | Detects hidden drift transfer |
| Path equivalence (A→B vs direct) | Detects compositional mismatch |
| Split/merge equivalence | Detects loopable dust extraction |
Patch pattern#
- Replace scattered arithmetic with a single library.
- Encode direction per operation type.
- Add guardrails on minimum effective operation size.
- Add sequence fuzzing with attacker-controlled repetition count.
Video explainer#
Audit checklist#
- Does any user path have opposite rounding direction from its inverse path?
- Can the user repeat the path cheaply?
- Can the user select inputs near precision boundaries?
- Are there cross-contract conversions with mismatched decimal assumptions?