DeFi Accounting
Day 2··11 min read

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 implementation

Day 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:

A = 10 · S = 3 · p = 10 / 3

One share represents approximately 3.333 assets. If Bob deposits 5 assets, the exact number of shares owed to him is:

shares = 5 × 3 / 10 = 1.5

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.

OperationExact user inputVault calculatesRounding
DepositAssets suppliedShares receivedDown
MintShares requestedAssets requiredUp
WithdrawAssets requestedShares requiredUp
RedeemShares suppliedAssets receivedDown

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:

A = 23 · S = 13
OperationExact resultApplied result
Deposit 6 assets6 × 13 / 23 = 3.3913… shares3 shares
Mint 4 shares4 × 23 / 13 = 7.0769… assets8 assets
Withdraw 6 assets6 × 13 / 23 = 3.3913… shares4 shares
Redeem 4 shares4 × 23 / 13 = 7.0769… assets7 assets

Floor and ceiling without floating point

Integer division already implements floor division for non-negative values:

floorMulDiv(a, b, c) = floor(a × b / c)

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:

ceil = quotient, if remainder = 0; otherwise quotient + 1

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:

floor(5 × 3 / 10)

Multiplying first preserves the numerator:

floor(15 / 10) = 1

Dividing first destroys information immediately:

floor(5 / 10) × 3 = 0 × 3 = 0

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:

floor(1 × 3 / 1,000) = floor(0.003) = 0 shares

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:

d × S < A

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:

49 × 20 / 500 = 1.96 shares

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:

1.96 shares = 1,960,000 share 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:

rounding loss = 0.75 / 100.75 × 100 = 0.7444%

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:

  1. Deposit 1 asset into an empty vault and receive its only share.
  2. Donate 100 assets directly, increasing assets without minting shares.
  3. Leave the vault at 101 assets and 1 share.
  4. Let a victim deposit 100 assets.

The victim’s preview is:

floor(100 × 1 / 101) = 0 shares

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:

1.25 × 10⁶ = 1,250,000

Similarly, 3.4 is represented as 3,400,000. Multiplying the encoded values produces two copies of the scale:

(1.25Q)(3.4Q) = 4.25Q²

To return the result to the original six-decimal representation, divide by (Q) exactly once:

1,250,000 × 3,400,000 / 1,000,000 = 4,250,000

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: down

mint 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:

floor × denominator ≤ numerator ≤ ceil × denominator

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