Fixed-Point Arithmetic and Rounding in DeFi Vaults
Why integer division moves value, how ERC-4626 chooses rounding directions, and how precision and slippage checks protect vault users.
View the tested implementationDay 1 ended with clean ratios: deposits minted proportionate shares, redemptions returned proportionate assets, and every worked value divided exactly. Day 2 begins where those equations stop producing whole numbers:
When exact division is impossible, who receives the remainder?
This is not merely a numerical question. A rounding direction determines whether value stays with the vault or moves to the user performing an operation.
The complete implementation and test suite live in the DeFi accounting repository.
Why rounding exists
Suppose a vault contains 10 assets and has issued 3 shares:
One share represents approximately 3.333 assets. If Bob deposits 5 assets, the exact number of shares owed to him is:
Assume, for now, that one share is the smallest representable unit. The vault cannot mint 1.5 shares; it must mint either 1 or 2.
If it rounds up to 2 shares, the new state is 15 assets and 5 shares. The price falls to 3 assets per share. Alice’s original 3 shares fall from a value of 10 assets to 9, while Bob’s 2 shares are worth 6 despite his depositing only 5.
Rounding up transferred 1 asset of value from Alice to Bob.
If the vault rounds down to 1 share, the new state is 15 assets and 4 shares. Each share is worth 3.75 assets. Bob’s claim is worth less than his deposit, and the remainder stays with the vault’s existing shareholder.
That gives us the first rounding rule:
When a deposit calculates the shares given to a user, round down.
The four-operation rounding matrix
ERC-4626 expresses vault entry and exit in four related ways. Two begin with an exact asset amount; two begin with an exact share amount.
| Operation | Exact user input | Vault calculates | Rounding |
|---|---|---|---|
| Deposit | Assets supplied | Shares received | Down |
| Mint | Shares requested | Assets required | Up |
| Withdraw | Assets requested | Shares required | Up |
| Redeem | Shares supplied | Assets received | Down |
The mnemonic is simple:
- An amount given to the user rounds down.
- An amount required from the user rounds up.
Both directions favour the vault over the acting user. A depositor or redeemer cannot receive more than the exact ratio grants them; a minter or withdrawer cannot pay less than the exact ratio requires.
Using 23 assets and 13 shares makes all four cases visible:
| Operation | Exact result | Applied result |
|---|---|---|
| Deposit 6 assets | 6 × 13 / 23 = 3.3913… shares | 3 shares |
| Mint 4 shares | 4 × 23 / 13 = 7.0769… assets | 8 assets |
| Withdraw 6 assets | 6 × 13 / 23 = 3.3913… shares | 4 shares |
| Redeem 4 shares | 4 × 23 / 13 = 7.0769… assets | 7 assets |
Floor and ceiling without floating point
Integer division already implements floor division for non-negative values:
For (4 × 23 / 13), the integer quotient is 7 and the remainder is 1. Floor returns 7. Ceiling returns 8 whenever that remainder is nonzero:
A common ceiling shortcut is (product + divisor - 1) / divisor. It is mathematically correct, but the addition may overflow a fixed-width integer even when the final quotient fits. The Rust implementation instead calculates quotient and remainder separately:
fn mul_div_ceil(a: u64, b: u64, c: u64) -> Result<u64, AccountingError> {
debug_assert!(c > 0);
let c = c as u128;
let product = (a as u128) * (b as u128);
let quotient = product / c;
let remainder = product % c;
let result = if remainder == 0 {
quotient
} else {
quotient + 1
};
u64::try_from(result).map_err(|_| AccountingError::Overflow)
}The operands are widened to u128 before multiplication. Two values can each fit inside u64 while their product requires as many as 128 bits.
Multiply before dividing
Operation order matters under integer arithmetic. Consider:
Multiplying first preserves the numerator:
Dividing first destroys information immediately:
The expressions are equivalent over real numbers but not after intermediate integer rounding. Vault conversions therefore multiply first, using a wider intermediate, and divide only at the end.
Zero-share deposits
Rounding down becomes dangerous when the result falls below one smallest share unit. Consider a vault with 1,000 assets and only 3 representable shares. A deposit of 1 asset produces:
If accepted, the user transfers an asset but receives no ownership. Assets rise to 1,001, shares remain at 3, and the entire deposit benefits existing shareholders.
For a deposit (d), zero shares are produced when:
In other words, the deposit is worth less than one smallest share unit. The library rejects that operation before changing any state:
let minted = self.preview_deposit(amount)?;
if minted == 0 {
return Err(AccountingError::DepositMintsZeroShares { assets: amount });
}The symmetric rule rejects a redemption that would burn nonzero shares while returning zero assets.
Nonzero does not mean fair
A zero-output check prevents total loss, but it does not guarantee a reasonable trade.
Take a vault with 500 assets and 20 shares. Each whole share is worth 25 assets. A deposit of 49 assets should receive:
Floor rounding mints only 1 share. At the pre-deposit quote, the discarded 0.96-share fraction represents 24 assets of value. Looking at the actual post-deposit state, the new share is backed by approximately 26.1428 assets (549 / 21), so the depositor’s immediate economic shortfall is about 22.86 assets. Either view shows the same design problem: the result is nonzero but unacceptable.
Rounding down preserved the vault’s accounting integrity. It did not make the transaction fair to this depositor.
That requires a second protection: the user supplies a minimum acceptable output. If the calculated shares fall below min_shares, the entire transaction reverts:
if minted < min_shares {
return Err(AccountingError::DepositBelowMinShares {
minted,
min_shares,
});
}The same idea applies in both directions:
- Deposit: require at least
min_shares. - Mint: spend at most
max_assets. - Withdraw: burn at most
max_shares. - Redeem: receive at least
min_assets.
ERC-4626’s base methods do not include these limit arguments. Integrations commonly provide them through routers, wrappers, or extended interfaces, using the standard’s preview functions to establish acceptable bounds.
Precision makes the remainder smaller
Our examples deliberately allowed only whole shares, making the loss easy to see. Real tokens normally divide a share into many subunits.
If one share contains 1,000,000 units:
The earlier deposit can now represent its result exactly. In general, floor rounding discards less than one smallest unit. Increasing precision reduces that unit’s size, although its economic value still depends on the vault’s exchange rate.
For example, an ideal output of 100.75 shares rounded to 100 discards 0.75 shares:
The more share units a user receives, the smaller the relative effect of losing less than one unit.
Donation and inflation attacks
An attacker can deliberately manipulate a nearly empty vault’s exchange rate:
- Deposit 1 asset into an empty vault and receive its only share.
- Donate 100 assets directly, increasing assets without minting shares.
- Leave the vault at 101 assets and 1 share.
- Let a victim deposit 100 assets.
The victim’s preview is:
If the vulnerable vault accepts the deposit, it holds 201 assets against the attacker’s single share. The attacker redeems 201 after spending 101, capturing the victim’s entire 100-asset deposit.
Rejecting zero-share deposits makes the victim’s operation revert, preventing the theft. It does not prevent the exchange-rate manipulation itself. More complete ERC-4626 implementations may combine additional share precision with virtual assets and virtual shares to make first-depositor attacks uneconomic. OpenZeppelin’s ERC-4626 documentation walks through this defence.
Fixed-point scales
Tokens do not store a floating-point value such as 1.25. With a six-decimal scale (Q = 10^6), they store:
Similarly, 3.4 is represented as 3,400,000. Multiplying the encoded values produces two copies of the scale:
To return the result to the original six-decimal representation, divide by (Q) exactly once:
Decoding gives (4,250,000 / 1,000,000 = 4.25), matching (1.25 × 3.4).
Encoding the policy in the API
The vault now exposes four previews whose names make both the operation and rounding direction explicit:
vault.preview_deposit(6)?; // 3 shares: down
vault.preview_mint(4)?; // 8 assets: up
vault.preview_withdraw(6)?; // 4 shares: up
vault.preview_redeem(4)?; // 7 assets: downmint and withdraw join the existing deposit and redeem operations. Each plain operation delegates to a slippage-protected variant. All prospective totals and balances are calculated before mutation, so overflow, insufficient balance, insolvency, zero output, or slippage failure leaves the vault unchanged.
Testing inequalities, not decimals
The library now has 65 passing tests: 31 unit tests, 10 property tests, and 24 scenario tests.
The property tests do not compare floating-point approximations. They use widened integer cross-multiplication to prove that floor and ceiling bracket the exact rational value:
They also verify that:
- Floor and ceiling differ by at most one integer unit.
- Rounded mints and withdrawals cannot reduce the remaining share price.
- User balances always sum to total share supply.
- Total user claims never exceed vault assets.
- Every rejected operation is atomic.
What I learned
Rounding is part of a protocol’s economic policy, not cleanup performed after the real calculation.
- Rounding outputs down and required inputs up prevents an acting user from extracting value from everyone else.
- That vault-favouring rule alone does not guarantee a fair quote for the acting user.
- Zero-output checks prevent complete donations, while user-selected slippage bounds reject unacceptable trades.
- More precision reduces the economic size of one discarded unit.
- Multiplying before dividing preserves information, but requires a wider intermediate to avoid overflow.
- Fixed-point multiplication must remove exactly one copy of its scale.
The equations are still ratios. The engineering work lies in preserving their meaning after every value becomes a finite-width integer.
Day 3 moves to APR, APY, and compounding.
References
- Ethereum Improvement Proposals, ERC-4626: Tokenized Vaults.
- OpenZeppelin, ERC-4626 documentation: exchange rates, rounding, and inflation attacks.
- Rust standard library,
u64andu128primitive integer types.