Yaamlabs
Case study 09

Staking rounding drift

DeFi

A staking contract paid rewards proportionally. The arithmetic was correct in every individual case and wrong in aggregate.

Smart contract audit, DeFi protocol, severity CRITICAL, reported 2024

At a glance

  • Challenge. Integer division in the reward maths truncated the same way every time, and a participant chose position size and timing to control how often.
  • Approach. We read the reward accounting line by line, then modelled many small interactions on a fork to compare the two sides of the ledger.
  • Result. The residual was kept with the pool, division reordered against the scaling factor and boundary cases added to tests, then retested.
  • Impact. A participant holding no special role could draw value from the pool over time, needing only funds and patience, no flash loan or price move.
  • Classification. CWE-682 Incorrect Calculation. Component: Staking reward accounting.

The challenge

Integer division does not lose value politely. It loses it in a direction. There are no fractions on chain, so every reward calculation that divides truncates, and truncation always moves the same way. In isolation that is a rounding error of one unit, which is exactly why it reads as harmless in a diff.

It stops being harmless when somebody controls how often it happens. A participant who chooses the size of their position and the moment they enter and exit is choosing how many times the division runs and which side of the boundary it lands on. The contract is not being tricked. It is being used precisely as written, many times, by somebody who has read the arithmetic more carefully than the people who shipped it.

This is why the bug is hard to see in review. There is no unchecked call, no reentrancy, no missing modifier, no obvious overflow. Every line is correct. The defect lives in the accounting invariant, the relationship between what the pool believes it owes and what it has actually paid out, and an invariant is not visible in any single line.

The test suite does not catch it either, because tests assert outcomes one participant at a time. The drift only appears when you model many small interactions in sequence and compare the two sides of the ledger at the end.

Our approach

We read the reward accounting line by line first: the order of multiplication and division, where truncation happened relative to the scaling factor, how the accumulator updated on deposit, on withdrawal and on claim, and what happened at the boundaries where a position was small enough that the numerator fell below the denominator.

Then we stopped arguing about it and modelled it. Reading a contract tells you what it does once. A model tells you what it does ten thousand times.

The model tracked, on every interaction:

  • The pool’s accounted liability after each interaction
  • The total actually transferred out
  • The residual left behind by each division, and where that residual ended up
  • Whether the residual stayed recoverable by the pool or became claimable by the next caller
  • The position sizes and timing that maximised the divergence

The divergence accumulated in a predictable direction, it was reachable by any participant holding no special role, and it needed no flash loan, no price manipulation and no second protocol. That last point is what moved it from an accounting curiosity to a critical: the preconditions were funds and patience.

Confirmed, written up with the model and the parameters that reproduce it, and in the client channel the same hour.

From finding to fix

  1. Read the staking reward math
  2. Modelled the rounding on a fork
  3. Showed drift accruing to one address
  4. Refactored the rounding, retested

The outcome

The accounting was reworked so the residual stayed with the pool rather than the caller. The division was moved relative to the scaling factor so truncation favoured the contract, and the accumulator was reconciled against the balance it was supposed to represent.

The boundary cases went into the test suite before redeploy, using the model’s parameters as fixtures, so the next change to the reward maths has something to fail against.

We retested the redeployed contract against the same model. Reported as CRITICAL in 2024. Retest included in the engagement.

Check your own system

  • Order multiplication before division and check where truncation lands relative to the scaling factor.
  • Ensure any rounding favours the pool, so the residual of each division stays with the contract.
  • Model many deposits, withdrawals and claims in sequence and compare accounted liability against tokens paid out.
  • Add the boundary cases, where a position is smaller than the denominator, to the test suite as fixtures.

Read more case studies, see Smart Contract Audits, or tell us about your system on the contact page.