A smart contract does not need an overflow, reentrancy bug, or broken access-control check to lose money.
Sometimes, the exploit is hidden inside an ordinary division:
uint256 result = amount * rate / SCALE;
The expression looks harmless. It may even produce the expected answer in every unit test.
But financial smart contracts operate with integer arithmetic. Fractions are discarded, rounding direction changes who receives value, and an error of one unit can be repeated across thousands of transactions.
In a financial protocol, rounding is not merely a mathematical implementation detail.
Rounding is a value-transfer policy.
Every division should therefore answer three questions:
- Which direction does the calculation round?
- Which party benefits from that direction?
- Can the rounding advantage be repeated or amplified?
This article examines the most dangerous precision problems in Solidity and the engineering patterns used to prevent them.
Solidity Does Not Have Native Fixed-Point Arithmetic
Most financial formulas use fractions:
interest = principal × rate × time
fee = amount × fee percentage
shares = assets × total shares ÷ total assets
collateral value = token amount × oracle price
Solidity primarily performs these calculations with integers.
For unsigned integers:
uint256 result = 5 / 2;
The result is:
2
The fractional component is discarded.
For positive values, this behaves like rounding down:
2.5 → 2
This appears insignificant until the result represents:
- vault shares;
- debt;
- collateral;
- protocol fees;
- interest;
- rewards;
- liquidation bonuses;
- exchange rates;
- token prices.
The lost fraction does not disappear economically. One party receives less value, while another party retains the remainder.
Precision Loss Is Not Always Small
Consider a protocol calculating a percentage:
function calculateFee(
uint256 amount,
uint256 feeBps
) public pure returns (uint256) {
return amount * feeBps / 10_000;
}
For a 0.3% fee:
amount = 100
feeBps = 30
fee = 100 × 30 ÷ 10,000
fee = 0
The mathematically correct result is 0.3, but the smallest representable integer result is zero.
If the protocol permits small operations, a user may split one large transaction into many smaller transactions and avoid fees entirely.
Suppose:
One transaction:
10,000 × 30 ÷ 10,000 = 30 fee units
Now split it into 100 transactions:
100 × 30 ÷ 10,000 = 0 fee units
The total economic operation is identical, but the protocol receives no fee.
This is a rounding amplification attack.
The important issue is not the amount lost in one calculation. It is whether the calculation can be repeated under attacker control.
Division Before Multiplication Destroys Precision
One of the most common implementation errors is dividing too early.
Vulnerable calculation
function calculateReward(
uint256 amount,
uint256 rewardRate
) public pure returns (uint256) {
return (amount / 1e18) * rewardRate;
}
For:
amount = 1.5e18
rewardRate = 100
The intermediate division produces:
1.5e18 / 1e18 = 1
The result becomes:
1 × 100 = 100
The expected value was:
150
One-third of the reward was lost before the multiplication occurred.
Better operation ordering
function calculateReward(
uint256 amount,
uint256 rewardRate
) public pure returns (uint256) {
return amount * rewardRate / 1e18;
}
Multiplying first retains more precision:
1.5e18 × 100 ÷ 1e18 = 150
The general rule is:
multiply before dividing
But that rule introduces another problem: the intermediate multiplication may overflow even when the final result fits inside uint256.
Multiply First Without Creating Intermediate Overflow
Consider:
uint256 result = x * y / denominator;
The product x * y can exceed type(uint256).max, even when:
(x × y) ÷ denominator
would fit inside uint256.
Solidity's checked arithmetic will revert before performing the division.
A full-precision multiplication-and-division operation avoids this intermediate overflow:
import {Math} from
"@openzeppelin/contracts/utils/math/Math.sol";
function calculate(
uint256 x,
uint256 y,
uint256 denominator
) public pure returns (uint256) {
return Math.mulDiv(x, y, denominator);
}
mulDiv computes:
floor(x × y ÷ denominator)
with full intermediate precision.
For calculations that must round upward:
function calculateUp(
uint256 x,
uint256 y,
uint256 denominator
) public pure returns (uint256) {
return Math.mulDiv(
x,
y,
denominator,
Math.Rounding.Ceil
);
}
Using a reviewed math library is safer than implementing custom 512-bit arithmetic.
Rounding Direction Is an Economic Decision
Suppose a lending protocol calculates interest owed by a borrower:
interest = principal × rate ÷ scale
If the result is rounded down, the borrower pays slightly less.
If it is rounded up, the borrower pays slightly more.
Now consider calculating collateral credited to the borrower:
collateral value = collateral amount × price ÷ scale
If this calculation rounds up, the protocol may credit collateral that does not economically exist.
A conservative lending protocol generally follows this principle:
- round debt upward;
- round required payments upward;
- round collateral value downward;
- round assets paid to users downward;
- round shares charged to users upward.
This is not a universal rule. The correct direction depends on the operation.
The broader security principle is:
When an exact result is not representable, round against the party attempting to extract value from the protocol.
Each public operation should document its intended beneficiary.
For example:
/// @notice Calculates debt including accrued interest.
/// @dev Rounds upward so debt is never understated.
function currentDebt(
uint256 principal,
uint256 index
) public pure returns (uint256) {
return Math.mulDiv(
principal,
index,
1e18,
Math.Rounding.Ceil
);
}
A calculation without a documented rounding policy should be treated as incomplete financial logic.
Implementing Ceiling Division Safely
Developers sometimes implement ceiling division like this:
uint256 result = (x + denominator - 1) / denominator;
Mathematically, this is valid for positive integers.
In Solidity, however, x + denominator - 1 may overflow.
A safer implementation is:
function ceilDiv(
uint256 x,
uint256 denominator
) public pure returns (uint256) {
if (x == 0) return 0;
return (x - 1) / denominator + 1;
}
Or use a reviewed library implementation:
uint256 result = Math.ceilDiv(x, denominator);
Ceiling division is especially important for:
- shares required to withdraw an exact asset amount;
- assets required to mint an exact number of shares;
- debt repayment requirements;
- protocol fee collection;
- auction payment calculations.
Repeated Rounding Can Become an Extraction Strategy
A one-unit discrepancy may appear harmless during review.
But attackers optimize transaction structure around deterministic arithmetic.
Suppose rewards are calculated independently for every claim:
reward = userWeight * rewardPool / totalWeight;
Each calculation rounds down.
If unclaimed dust remains in the contract, that may be acceptable.
But suppose a protocol recalculates a user's balance after many small actions and accidentally rounds in the user's favor each time.
An attacker may:
- split one operation into many small operations;
- receive the favorable rounding remainder repeatedly;
- merge the resulting position;
- withdraw more value than a single equivalent operation would provide.
A useful invariant is:
performing an operation in N parts must not produce more value
than performing the same operation once
This property is called path independence.
For a fee calculation:
fee(a) + fee(b) should not be materially lower than fee(a + b)
For share minting:
shares(a) + shares(b) should not exceed shares(a + b)
when splitting deposits should not be advantageous
Exact equality is not always possible with integer arithmetic. However, the difference must be bounded and must not create an attacker-controlled profit.
Fixed-Point Scales Must Be Explicit
A common Solidity convention represents decimal values using a scale.
For example:
uint256 constant WAD = 1e18;
Then:
1.0 = 1e18
0.5 = 5e17
2.25 = 2.25e18
A multiplication between two WAD values requires rescaling:
function wadMul(
uint256 x,
uint256 y
) public pure returns (uint256) {
return Math.mulDiv(x, y, WAD);
}
Division requires scaling the numerator:
function wadDiv(
uint256 x,
uint256 y
) public pure returns (uint256) {
return Math.mulDiv(x, WAD, y);
}
The dangerous part is not only precision loss. It is mixing values with different units.
Consider:
uint256 collateralAmount; // 6 decimals
uint256 oraclePrice; // 8 decimals
uint256 debtAmount; // 18 decimals
These values cannot be safely compared without normalization.
A line such as:
require(
collateralAmount * oraclePrice >= debtAmount,
"Undercollateralized"
);
has no meaningful financial interpretation unless all three units are known and normalized.
Senior-level financial code should make units visible:
uint256 collateralAmount6;
uint256 oraclePrice8;
uint256 debtValue18;
Better still, isolate unit conversion in dedicated functions.
Decimal Normalization Can Introduce Both Truncation and Overflow
Suppose a token uses six decimals and the internal accounting system uses 18 decimals.
Normalization upward:
uint256 normalized = amount * 1e12;
Normalization downward:
uint256 tokenAmount = normalized / 1e12;
The downward conversion loses all values below 1e12 internal units.
For example:
normalized = 999,999,999,999
normalized / 1e12 = 0
If a protocol reduces a user's internal balance by 999,999,999,999 but transfers zero tokens, the user loses value.
If it transfers one token unit but reduces the balance by less than 1e12, the protocol loses value.
The conversion must specify:
- whether the operation rounds up or down;
- whether dust remains credited;
- whether zero-output operations revert;
- whether the caller can repeat the operation;
- whether normalized values can overflow.
A safe withdrawal flow often rejects non-zero inputs that produce zero output:
error ZeroOutput();
function denormalizeDown(
uint256 amount18
) public pure returns (uint256 amount6) {
amount6 = amount18 / 1e12;
if (amount18 != 0 && amount6 == 0) {
revert ZeroOutput();
}
}
Whether reverting is correct depends on the protocol's dust policy.
Fee Calculations Need Different Formulas for Inclusive and Exclusive Fees
There is an important difference between adding a fee to an amount and extracting a fee from an amount that already includes it.
Fee added on top
Suppose amount excludes the fee:
gross amount = amount + amount × fee rate
The fee can be calculated as:
function feeOnRaw(
uint256 amount,
uint256 feeBps
) public pure returns (uint256) {
return Math.mulDiv(
amount,
feeBps,
10_000,
Math.Rounding.Ceil
);
}
Fee included in the total
Suppose total already includes the fee.
The fee is not:
total * feeBps / 10_000
Instead:
fee = total × fee rate ÷ (1 + fee rate)
In basis points:
function feeOnTotal(
uint256 total,
uint256 feeBps
) public pure returns (uint256) {
return Math.mulDiv(
total,
feeBps,
10_000 + feeBps,
Math.Rounding.Ceil
);
}
Confusing these formulas causes previews, deposits, accounting records, and emitted events to disagree.
Share Accounting Is Especially Sensitive to Rounding
Vaults commonly calculate shares as:
shares = assets × total supply ÷ total assets
And assets as:
assets = shares × total assets ÷ total supply
A naïve implementation might be:
function convertToShares(
uint256 assets
) public view returns (uint256) {
return assets * totalSupply / totalAssets;
}
This contains several risks:
- division by zero when the vault is empty;
- intermediate multiplication overflow;
- deposits returning zero shares;
- manipulable exchange rates;
- inconsistent rounding between deposit and withdrawal paths;
- direct token donations changing
totalAssets; - small deposits losing most or all of their value.
The zero-share deposit problem
Assume:
total assets = 1,000,000
total shares = 1
A user deposits 999,999 asset units:
shares = 999,999 × 1 ÷ 1,000,000
shares = 0
The user transfers assets but receives no shares.
If the existing shareholder owns all outstanding shares, the new deposit effectively becomes a donation to that shareholder.
At minimum, deposits that calculate zero shares should revert:
error ZeroShares();
function deposit(
uint256 assets
) external returns (uint256 shares) {
shares = previewDeposit(assets);
if (shares == 0) revert ZeroShares();
// Transfer assets and mint shares.
}
But this check alone does not prevent exchange-rate manipulation.
ERC-4626 Inflation Attacks
An empty or nearly empty tokenized vault may be vulnerable to a donation-based inflation attack.
A simplified attack works as follows:
- The attacker deposits a minimal amount and receives the initial shares.
- The attacker transfers assets directly to the vault.
- The donation increases
totalAssetswithout increasingtotalSupply. - The share price increases dramatically.
- A victim deposits assets.
- The victim's calculated share amount rounds down to zero.
- The attacker redeems the existing shares and captures the victim's deposit.
Example:
Attacker deposits: 1 asset
Attacker receives: 1 share
Attacker donates: 100 assets
Vault state:
totalAssets = 101
totalSupply = 1
Victim deposits: 100 assets
Victim shares:
100 × 1 ÷ 101 = 0
After the victim's transfer:
totalAssets = 201
totalSupply = 1
The attacker owns the only share and may redeem almost all the assets.
The vulnerability combines:
- exchange-rate manipulation;
- direct donations;
- low initial liquidity;
- floor rounding;
- missing slippage protection.
It is therefore inaccurate to describe this only as a rounding bug.
Rounding is the mechanism that converts manipulated accounting into captured value.
Virtual Assets, Virtual Shares, and Decimal Offsets
One mitigation is to include virtual values in the conversion formula.
Conceptually:
shares =
assets × (totalSupply + virtualShares)
÷ (totalAssets + virtualAssets)
Virtual assets and shares establish an initial exchange rate and reduce the attacker's ability to manipulate an empty vault.
A decimal offset can also give shares greater precision than the underlying asset.
A simplified conversion function may look like:
function _convertToShares(
uint256 assets,
Math.Rounding rounding
) internal view returns (uint256) {
return Math.mulDiv(
assets,
totalSupply() + 10 ** decimalsOffset,
totalAssets() + 1,
rounding
);
}
This approach can:
- reduce loss from low-share precision;
- make zero-share deposits less likely;
- capture part of an attacker's donation for the vault;
- make inflation attacks economically unprofitable.
The exact parameters still require protocol-specific analysis. A virtual offset is not a replacement for user-provided slippage limits.
ERC-4626 Operations Require Different Rounding Directions
A compliant tokenized vault cannot apply the same rounding direction to every conversion.
The economic intention is:
| Operation | User specifies | Protocol calculates | Conservative rounding |
|---|---|---|---|
| Deposit | Exact assets | Shares received | Down |
| Mint | Exact shares | Assets required | Up |
| Withdraw | Exact assets | Shares burned | Up |
| Redeem | Exact shares | Assets received | Down |
This protects the vault from giving away unbacked value.
Deposit
The user provides an exact amount of assets.
The vault calculates shares to mint:
shares = Math.mulDiv(
assets,
totalSupply,
totalAssets,
Math.Rounding.Floor
);
Rounding down prevents the vault from minting more shares than the assets support.
Mint
The user requests an exact number of shares.
The vault calculates the assets required:
assets = Math.mulDiv(
shares,
totalAssets,
totalSupply,
Math.Rounding.Ceil
);
Rounding up prevents the user from receiving exact shares while paying slightly too few assets.
Withdraw
The user requests an exact amount of assets.
The vault calculates shares to burn:
shares = Math.mulDiv(
assets,
totalSupply,
totalAssets,
Math.Rounding.Ceil
);
Rounding up prevents users from withdrawing exact assets while burning insufficient shares.
Redeem
The user provides an exact number of shares.
The vault calculates assets returned:
assets = Math.mulDiv(
shares,
totalAssets,
totalSupply,
Math.Rounding.Floor
);
Rounding down prevents the vault from returning more assets than those shares support.
This asymmetry is intentional.
Using floor rounding everywhere may allow users to underpay for minted shares or burn too few shares during withdrawal.
Preview Functions Do Not Replace Slippage Protection
A user may call:
uint256 expectedShares = vault.previewDeposit(assets);
Then submit:
vault.deposit(assets, receiver);
The exchange rate can change between simulation and transaction execution.
An attacker may manipulate the vault before the user's transaction is included.
A safer router or vault extension accepts a minimum output:
function deposit(
uint256 assets,
address receiver,
uint256 minShares
) external returns (uint256 shares) {
shares = previewDeposit(assets);
if (shares < minShares) {
revert InsufficientSharesReceived(
shares,
minShares
);
}
_deposit(msg.sender, receiver, assets, shares);
}
Equivalent protections include:
-
minSharesfor deposits; -
maxAssetsfor mints; -
maxSharesfor withdrawals; -
minAssetsfor redemptions; - transaction deadlines.
Rounding safety and slippage safety solve related but different problems.
Interest Accrual Can Leak Value Over Time
Consider a lending protocol that accrues interest using:
interest =
principal *
annualRate *
elapsed /
YEAR /
1e18;
Several problems may arise:
- precision is lost at multiple division points;
- multiplication may overflow;
- frequent accrual may produce different results than infrequent accrual;
- small interest amounts may repeatedly round to zero;
- debt may be understated;
- the protocol's global debt may diverge from user-level debt.
Suppose a borrower's interest for one block rounds to zero.
If anyone can trigger accrual every block and the protocol resets the accrual timestamp after each call, interest may remain zero indefinitely.
This creates a frequency-dependent rounding exploit.
The protocol should preserve fractional interest using an index or high-precision accumulator rather than discarding it on every update.
A common model stores a global borrow index:
new index =
previous index × accumulated rate
A user's debt is derived from:
debt =
normalized debt × current index
The index should use sufficient precision, and the final debt calculation should generally avoid understating the borrower's obligation.
Reward Distribution Needs Remainder Accounting
A reward-per-share system often uses:
rewardPerShare +=
rewards * ACC_PRECISION / totalStaked;
The division may leave a remainder.
If that remainder is discarded during every distribution, some rewards become permanently unclaimable.
A more accurate system can carry the remainder forward:
uint256 public undistributedRemainder;
function distribute(
uint256 newRewards
) internal {
uint256 rewards =
newRewards + undistributedRemainder;
uint256 increment = Math.mulDiv(
rewards,
ACC_PRECISION,
totalStaked
);
uint256 distributed = Math.mulDiv(
increment,
totalStaked,
ACC_PRECISION
);
undistributedRemainder = rewards - distributed;
rewardPerShare += increment;
}
The correct implementation depends on the reward model, but the key accounting property is:
distributed rewards + retained remainder
must equal funded rewards
Untracked dust is an accounting error even when no individual transaction loses a large amount.
Liquidation Thresholds Must Fail Conservatively
Assume a protocol checks:
collateral value × liquidation threshold >= debt
An unsafe implementation may round collateral value upward:
uint256 collateralValue = Math.mulDiv(
collateralAmount,
oraclePrice,
PRICE_SCALE,
Math.Rounding.Ceil
);
That can make an insolvent position appear healthy.
The safer direction is normally downward:
uint256 collateralValue = Math.mulDiv(
collateralAmount,
oraclePrice,
PRICE_SCALE,
Math.Rounding.Floor
);
Debt calculations should normally avoid rounding downward:
uint256 debtValue = Math.mulDiv(
normalizedDebt,
borrowIndex,
INDEX_SCALE,
Math.Rounding.Ceil
);
Then:
bool healthy =
Math.mulDiv(
collateralValue,
liquidationThresholdBps,
10_000,
Math.Rounding.Floor
) >= debtValue;
The exact boundary behavior must be specified.
Ask:
- Is equality healthy or liquidatable?
- Does one unit of rounding alter liquidation eligibility?
- Can an attacker oscillate around the boundary?
- Do the preview and execution paths use identical calculations?
- Can oracle decimal changes break normalization?
Never Use Rounding to Hide an Accounting Mismatch
Developers sometimes resolve a failing invariant by adding or subtracting one:
if (calculatedAmount < expectedAmount) {
calculatedAmount += 1;
}
This may silence a test while creating an unexplained transfer of value.
A one-unit correction is appropriate only when it implements an explicit rounding rule.
The code should be expressible mathematically:
floor(x × y ÷ denominator)
or:
ceil(x × y ÷ denominator)
It should not be:
approximately calculate the result and adjust it
until the test passes
Financial arithmetic should be derived before it is implemented.
Testing Precision and Rounding Properties
Example-based tests are not enough.
A unit test such as:
function testFeeCalculation() public {
assertEq(calculateFee(1_000 ether, 30), 3 ether);
}
tests a value that divides cleanly.
Attackers search for values that do not divide cleanly.
Test boundary values
Always include:
0
1
denominator - 1
denominator
denominator + 1
type(uint256).max
Also test values around:
- one share;
- one asset unit;
- minimum deposit;
- liquidation threshold;
- fee boundaries;
- decimal conversion boundaries;
- empty and nearly empty vault states.
Test rounding inequalities
For floor rounding:
result × denominator <= x × y
For ceiling rounding:
result × denominator >= x × y
The exact invariant may need full-precision test arithmetic to avoid overflow.
Test split-operation behavior
function testFuzz_splitDepositDoesNotCreateValue(
uint256 assetsA,
uint256 assetsB
) public {
// Compare depositing assetsA + assetsB once
// against depositing assetsA and assetsB separately.
}
Investigate any case in which splitting creates more redeemable assets.
Test round-trip conversions
For a conservative vault:
assets
→ shares rounded down
→ assets rounded down
must not return more assets than the initial amount.
function invariant_roundTripCannotCreateAssets()
external
view
{
uint256 assets = handler.sampleAssets();
uint256 shares = vault.convertToShares(assets);
uint256 returnedAssets =
vault.convertToAssets(shares);
assertLe(returnedAssets, assets);
}
Test conservation
Useful protocol invariants include:
total user claims <= recoverable assets
total collected fees + unpaid fee remainder
= total generated fees
total distributed rewards + retained rewards
= total funded rewards
a sequence of conversions cannot create value
no non-zero deposit succeeds while minting zero shares
Stateful fuzzing is especially valuable because many rounding exploits require carefully ordered sequences rather than one isolated call.
Precision-Security Review Checklist
Before deploying financial arithmetic, verify the following.
Formula design
- Is the mathematical formula documented?
- Are all variables labeled with units and decimal scales?
- Is multiplication performed before division?
- Can intermediate multiplication overflow?
- Is full-precision
mulDivrequired?
Rounding policy
- Is the rounding direction explicit?
- Which party benefits from the remainder?
- Is the direction conservative for the protocol?
- Do preview and execution functions use the same policy?
- Can users repeat the favorable rounding?
Token decimals
- Are token and oracle decimals validated?
- Are all values normalized before comparison?
- Can downscaling produce zero?
- Is dust retained, refunded, or rejected?
- Can a token's unusual decimal count cause overflow?
Vault accounting
- Can a deposit mint zero shares?
- Can a withdrawal burn zero shares?
- Can direct donations manipulate the exchange rate?
- Is the empty-vault state protected?
- Are virtual shares or assets appropriate?
- Do users have slippage limits?
Fees and interest
- Are fees inclusive or exclusive?
- Should fees round upward or downward?
- Can transaction splitting avoid fees?
- Can frequent accrual suppress interest?
- Are fractional remainders carried forward?
Testing
- Are boundary values covered?
- Are floor and ceiling inequalities tested?
- Are split operations compared with combined operations?
- Are round-trip conversions tested?
- Are conservation invariants enforced?
- Are empty and low-liquidity states fuzzed?
Final Principle
Precision loss becomes exploitable when four conditions meet:
- a financial formula produces a fractional result;
- the implementation silently chooses a rounding direction;
- that direction benefits an external actor;
- the actor can repeat, amplify, or manipulate the calculation.
The defense is not simply “use more decimals.”
Secure financial arithmetic requires:
- explicit units;
- full-precision multiplication and division;
- operation-specific rounding;
- conservative accounting;
- remainder tracking;
- slippage protection;
- invariant and fuzz testing.
When reviewing a division operation, do not ask only:
Is this calculation mathematically close enough?
Ask:
Where does the discarded value go, who receives it, and can they trigger this calculation repeatedly?
That is the difference between ordinary integer arithmetic and production-grade financial engineering.
Top comments (0)