← Research

Rounding direction is a security property

Most people read integer division in a contract as an accounting detail. It isn't. In a share-based system — a vault, a lending market, a staking pool — the direction you round is the difference between a protocol that conserves value and one that leaks it a fraction at a time.

The shape of the bug is always the same. Somewhere, value is converted between two units — shares and assets, collateral and debt — and the conversion isn't exact. The code has to drop a remainder. If it drops that remainder in the user's favor, every conversion hands out a sliver. One sliver is dust. A scripted loop over thousands of conversions is a withdrawal that exceeds what was ever deposited.

A minimal example

Consider a vault that mints shares on deposit and burns them on withdrawal:

// shares for a deposit
shares = assets * totalShares / totalAssets;   // rounds down — good for the vault

// assets for a redemption
assets = shares * totalAssets / totalShares;   // rounds down — good for the vault

Both round down, and both happen to favor the vault. Now someone "improves" the redemption path to feel fairer to users:

// "fairer" redemption
assets = ceilDiv(shares * totalAssets, totalShares);  // rounds UP — favors the caller

Each redemption now returns at most one extra unit. Deposit a known amount, redeem it in many small pieces, and the rounding gifts accumulate. With cheap gas and a tight loop, the residual is real money.

The rule

When a conversion can't be exact, round against the party who benefits from the result.

Mint shares? Round the share count down. Quote a redemption? Round the assets out down. Compute debt? Round it up. The protocol should never be the one absorbing the remainder. It reads as ungenerous; it is simply solvent.

How we test it

An invariant test is worth more than a comment here, because the property survives refactors that a comment won't. The one that catches this class directly:

// after any sequence of deposits and withdrawals by any actors,
// the vault never pays out more than it took in (modulo realized yield)
assert(totalAssetsOut <= totalAssetsIn + realizedYield);

We run it under a fuzzer with randomized amounts and interleavings, and separately assert the round-trip direction at the unit level: convertToAssets(convertToShares(x)) <= x. If that ever returns more than you put in, something upstream is rounding the wrong way.

Why it hides

This survives review because each line looks reasonable in isolation, the unit tests use round numbers that divide evenly, and the loss per call is below anyone's intuition for "material." It shows up only when you read the conversions as a system and ask, every time, who keeps the remainder?

:: dr-note-003 ::

If you're shipping a vault or any share-accounted system and want a second set of eyes on the conversions, that's exactly the kind of focused review we do.