Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how
Back to Blog
Who Eats the Wei: Rounding as a Security Boundary in Uniswap V3
UniswapDeFiSolidityWeb3 Security

Who Eats the Wei: Rounding as a Security Boundary in Uniswap V3

12 min

TL;DR — Quick Summary

  • Integer division truncates, and in an AMM every truncation is a decision about who eats the dust. Uniswap V3 answers identically every time: the pool wins.
  • The rounding direction is an explicit parameter on every function in SqrtPriceMath, not a consequence of how the expression is written. Amounts paid in round up; amounts paid out round down.
  • Both delta formulas reduce to L times a difference of square root prices. Token0 divides by their product; token1 does not divide at all.
  • The inverse functions — amount to price — follow one rule: never overshoot. Exact-input must not pass the target price, exact-output must reach the promised amount.
  • The overflow probe in the add branch only works if the multiplication is allowed to wrap, so it must sit inside unchecked. The fallback addition deliberately sits outside it, because unchecked scope must never quietly widen when porting.

The full walkthrough, with the code written on screen. The article below covers the same ground if you would rather read it.

Introduction

Integer division truncates. Everyone knows that.
But in a decentralised exchange, every single truncation is a decision about where that lost fraction goes. Every division in this library is a decision about who eats the dust — the user, or the pool.
Uniswap V3 is completely ruthless about the answer. The pool wins. Every time.
This is section 3 of Building Uniswap V3, and it is the engine room. Everything the swap loop does later, every mint, every burn, comes down to this one library.

Where the formulas come from

They are not arbitrary, and deriving them once means never having to memorise them.
From the V3 worldview the virtual reserves are simple:
1x = L / sqrt(P)
2y = L * sqrt(P)
Now move the price from sqrt(Pa) up to sqrt(Pb), holding liquidity constant. The reserve changes are just algebra.
For token1 it is almost embarrassing:
1Δy = L * sqrt(Pb) − L * sqrt(Pa)
2 = L * (sqrt(Pb) − sqrt(Pa))
Factor out the L and you are done. That is the whole formula.
Token0 takes one more step:
1Δx = L / sqrt(Pa) − L / sqrt(Pb)
2 = L * (sqrt(Pb) − sqrt(Pa)) / (sqrt(Pa) * sqrt(Pb))
Notice what that second form buys. One subtraction, one multiply-divide, one division — and crucially, no intermediate division that throws away precision before the result is final. The obvious form computes L / sqrt(Pa) and L / sqrt(Pb) separately, truncating each before subtracting, and loses precision twice. That is why the contract computes it the way it does.

A worked example

Liquidity of 1,000,000. Move the price from 1.0 up to 1.0201, so sqrt(P) goes from 1 to 1.01.
Δy = 1,000,000 × 0.01 = 10,000 token1.
Δx = 10,000 / (1.01 × 1) ≈ 9,901 token0.
Sanity check that, because it is a good habit. Moving the price up means the pool absorbs token1 and releases token0. At a price around 1.0201, those 9,901 token0 are worth about 10,100 token1 at the new price and 9,901 at the old — the trade sits between them, as it must. Two views of the same movement, and the formulas agree.

Who eats the wei

Every one of those divisions truncates. So V3 makes the direction explicit: every function takes a rounding flag, and the convention is identical everywhere.
ComputingDirectionWhere it appears
What the user pays inRound upMint, the input side of a swap
What the pool pays outRound downBurn, the output side of a swap
The user pays the extra wei, or receives one fewer. Put like that it sounds petty.
It is not. Watch what happens if you get it backwards.

Inverting one flag builds a faucet

Suppose burn rounded up what the pool pays out.
Then every mint-and-burn cycle returns slightly more than was deposited, any time the true amount is fractional. One wei per call.
One wei sounds harmless — until you find the pair where a wei is a cent. Tokens with 2 decimals exist. Plenty of them exist. And an attacker does not do this once; they do it in a loop, inside a single transaction, and they do not care that each iteration is small.
The pool's solvency invariant is that its balance after an operation is at least what the math says it should be. Rounding in the caller's favour anywhere breaks it by one wei, and a one-wei break that can be repeated cheaply is not a one-wei break.

The practical version

When you review a fork of this code, check the rounding flags first. Before the logic. Before anything.
Rounding direction bugs are among the most commonly reported findings in audits of V3 forks and V3-style vaults, precisely because forks tweak these libraries. Somebody copies SqrtPriceMath, flips a boolean they did not understand, and ships it. The code compiles, the tests that only assert on approximate amounts still pass, and nothing reverts.
Building something that holds user funds? The Founder Security Sprint is a focused engagement for teams shipping their first protocol — exactly this kind of review, before you are live. And if you want to read real protocol code every week alongside other security engineers, that is Zealynx Insiders.

The other half: never overshoot

The swap loop asks the inverse question. It does not have a price range — it has an amount, and it wants to know where the price ends up.
So both formulas invert:
1from token1: Δsqrt(P) = amount / L
2from token0: sqrt(P') = L * sqrt(P) / (L ± amount * sqrt(P))
And here the rounding rule collapses to one phrase: never overshoot.
For exact input, the rounding must guarantee the price does not pass the target. Charging a user for price movement beyond their limit is precisely the thing a limit exists to prevent.

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from the firm behind 42 audits. Founders get a two-day audit session on the $500/year plan.

No spam. Unsubscribe anytime.

For exact output, it must guarantee the price does move far enough. You promised an amount and you have to deliver it.
Same principle underneath, stated from two directions. The error always lands on the pool's side.

The overflow probe, and why it must wrap

One piece in there deserves study on its own, because it looks like a bug until you understand it.
The add branch multiplies the amount by the square root price, then checks whether that product divided by the amount gives the square root price back. That is the classic wrapping-multiplication overflow probe: if the product overflowed, the round trip will not recover the original.
And it only works if the multiplication is allowed to wrap. Under Solidity 0.8 a checked multiply reverts on overflow, so the probe never gets to run and the branch it guards becomes unreachable.
So that whole branch has to sit inside unchecked. Same lesson as the previous section: unchecked is not a gas trick here, it is what makes the check function at all.
If the probe reports an overflow, the code falls back to an algebraically equal form that avoids the large intermediate. And that fallback's addition deliberately sits outside the unchecked block, because the original computed it with a checked add.
That last detail is real porting discipline, and it is exactly what a fork gets wrong: the scope of an unchecked block must never quietly widen when you move code. Widening it turns a guarded addition into a silently wrapping one, in a function whose entire job is to be exact.

Writing it

The section builds the library in pieces, and four of them carry the ideas.
Sort the prices first. Every function in the library begins by ordering the two square root prices so the lower one is known. It matters because the lower price is the one that ends up dividing.
Then guard it. Require the lower price is above zero. That is not defensive programming for its own sake — the lower price is a divisor, and this single line is the only thing between a caller and a division by zero.
Then the two divisions, which must agree. getAmount0Delta performs a multiply-divide and then a division. Round-up means round both up. Round-down means floor both.
Mixing them is a subtle bug worth being able to see: if the first rounded up and the second floored, the function's net direction would depend on the specific numbers, which is another way of saying it has no direction at all — and a function with no reliable direction cannot support a solvency invariant.
Then the signed wrapper, where the convention gets encoded once for everybody. Positive liquidity means the user is adding, so they are paying in, so round up. Negative liquidity means removing, so the pool pays out, so round down and negate.
That single ternary is the rounding policy of the entire protocol, in four lines. Every mint and burn in the rest of the module inherits it from there rather than restating it — which is what makes the policy auditable, because there is exactly one place to check.

What you have after this

Both delta formulas, in both directions, with every rounding direction chosen deliberately rather than falling out of how an expression happened to be written.
This library gets called from inside the swap loop thousands of times per transaction. Every flag in it is load-bearing.
Next in this series: the Tick library, where liquidity stops being one number and becomes bookkeeping at the boundaries. That is where the fee growth outside trick appears — the piece of V3 that confuses more people than anything else in the protocol, and the piece that makes fee accounting possible without ever iterating over positions.

FAQ

1. Which way does Uniswap V3 round, and why?
Amounts the user pays in round up; amounts the pool pays out round down. Both directions favour the pool, which preserves the invariant that the pool's balance after an operation is at least what the math requires. The direction is passed as an explicit parameter rather than emerging from the expression, so every call site makes the choice visible.
2. What happens if a rounding flag is inverted?
The pool leaks one wei per operation in the caller's favour. That is exploitable rather than negligible: an attacker loops the mint-and-burn cycle within a single transaction, and on a token with few decimals a wei is not dust. Nothing reverts and no invariant check fires, so the bug surfaces as a slow drain or a sudden one when someone notices the loop is cheap. It is among the most common findings in audits of V3 forks.
3. Why does `getAmount0Delta` use a common denominator instead of two separate divisions?
To avoid truncating twice. The obvious form computes L / sqrt(Pa) and L / sqrt(Pb) separately, losing precision in each before subtracting. The common-denominator form — L * (sqrt(Pb) − sqrt(Pa)) / (sqrt(Pa) * sqrt(Pb)) — performs one subtraction, one multiply-divide and one division, with no intermediate division discarding precision before the result is final.
4. Why must the overflow probe be inside an `unchecked` block?
The probe detects overflow by multiplying, dividing the product back by one operand, and comparing against the other. That only detects anything if the multiplication is permitted to wrap. Under Solidity 0.8 a checked multiply reverts first, so the probe never executes and the fallback branch it guards becomes dead code. Wrapping the branch in unchecked restores it. The fallback's addition stays outside, because the original used a checked add and unchecked scope must not widen during a port.
5. What does "never overshoot" mean for the next-price functions?
For exact-input swaps, rounding must guarantee the resulting price does not move past the caller's target, since exceeding a price limit defeats the purpose of setting one. For exact-output swaps, rounding must guarantee the price moves far enough to deliver the promised amount. Both are the same principle from opposite sides: any rounding error is absorbed by the pool, never by the user's guarantee.

Glossary

TermDefinition
Rounding DirectionThe deliberate per-operation choice of rounding so that truncation always favours the pool.
sqrtPriceX96The square root of the pool price multiplied by 2^96, the value V3 stores instead of the price.
Q64.96Fixed-point format with 64 integer and 96 fractional bits, packed into a uint160.
Rounding ErrorValue lost or gained through integer truncation, exploitable when it accumulates or can be repeated cheaply.
Fee GrowthA global accumulator of fees per unit of liquidity, letting a pool settle any position by differencing two snapshots.
Concentrated LiquidityAllocating liquidity to a chosen price range rather than the whole curve.

Write it yourself

This article is section 3 of Building Uniswap V3, a free 18-section module on the Zealynx Academy. In this one you write SqrtPriceMath — both delta formulas, both inverse functions, the signed wrappers and the overflow probe — and a 21-test suite checks every rounding direction independently.
If you would rather learn it alongside other engineers, with a live build session every week and office hours where you bring your own code, that is Zealynx Insiders.

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from the firm behind 42 audits. Founders get a two-day audit session on the $500/year plan.

No spam. Unsubscribe anytime.