Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
The Pool Calls You Back: How Uniswap V3 Takes Payment

The Pool Calls You Back: How Uniswap V3 Takes Payment

10 min
Uniswap V3 · Part 8 of 9
On this page▾

TL;DR — Quick Summary

  • Transfer-first is impossible in V3: what you owe for a given liquidity in a given range depends on the price at the moment your transaction executes, which you cannot know when you sign.
  • So the pool computes what you owe, calls uniswapV3MintCallback on you, and then checks its own balance grew — reverting 'M0' or 'M1' if it did not. Callback validation is therefore the caller's problem.
  • Two consequences auditors never forget: mint's caller must be a contract, and the balance check is <=, which means core V3 pools cannot support fee-on-transfer tokens.
  • A position holds token0 for the part of its range above the price and token1 for the part below — which gives exactly three cases.
  • Only the in-range case touches the pool's global liquidity. Update it in all three and the swap math divides by liquidity that is not there. That bug, at a boundary, is the heart of a nine-figure exploit.

There is a video walkthrough of this section, with the code written on screen, on the channel. The article below covers the same ground.

Introduction

In Uniswap V2, adding liquidity worked like this: you sent your tokens to the pair, then called mint, and the contract worked out what you had deposited by seeing how much its balance had grown.
Transfer first, compute after. Simple, and it worked — because V2 could accept any ratio of tokens and price your shares accordingly.
V3 cannot do that, and the reason is the whole point of this section.
This is section 8 of Building Uniswap V3.

Why transfer-first is impossible

How much do you owe for a given amount of liquidity in a given range?
It depends on where the price is at the moment your transaction executes. Not when you signed it. Not when you broadcast it.
So you literally cannot know the right amounts in advance. Which means transfer-first is off the table, and V3 inverts the order: the pool tells you what you owe, then calls you back and expects payment inside that call.

Four steps

  1. The pool computes exactly what you owe.
  2. It calls uniswapV3MintCallback on whoever called it.
  3. Inside that callback, you transfer the tokens.
  4. The pool checks its own balance actually grew by what it was owed, reverting 'M0' or 'M1' if not.
This is the same optimistic-transfer shape that makes the reentrancy lock load-bearing: the pool never calls transferFrom, it verifies by balance.

Two consequences

Mint's caller must be a contract. Something has to implement the callback, and a plain wallet cannot. That is why every real user goes through the periphery rather than touching the pool directly.
The balance comparison is <=, not ==. Overpaying is allowed — the excess is simply donated to the pool.
Run that through a fee-on-transfer token, though, and the received amount is smaller than the sent amount. The check fails. Which means core V3 pools do not support fee-on-transfer tokens, at all.
That is not a bug; it is a documented boundary. But several forks rediscovered it in production, after a "small" token integration that nobody thought was a protocol-level decision.

The intellectual core: three cases

A position between two prices promises to hold token0 for the part of its range above the current price, and token1 for the part below it.
Think about why. As the price rises through your range, the pool sells your token0 to traders. So the part of the range above the price is the part you still have to supply token0 for.
Which means where the price sits, right now, decides what you actually pay in. There are exactly three cases.
Case 1 — price below your entire range. The whole range is above the price, so it is all token0. You owe nothing in token1. With liquidity 1,000 and a range from 1.0 to 1.1: 90.91 token0, 0 token1.
Case 2 — price inside your range. The range splits at the current price. Token0 covers the part above, token1 the part below, so you pay both. At a square root price of 1.05: 43.29 token0 and 50 token1.
Case 3 — price at or above your range. All token1. 100 of it, and no token0.

The line that matters most

Only case 2 touches the pool's global liquidity variable.
That variable has a specific meaning: it is the active liquidity at the current tick. It is what the swap loop divides fees by. It is what price movement is computed against.
A position whose range does not contain the current tick contributes nothing to it. Not a smaller amount — nothing. It gets added later, by cross, when the price walks into the range.
Update liquidity in all three cases and the swap math starts dividing by liquidity that is not there.
That exact bug — liquidity double-counted at a boundary — is the heart of a nine-figure exploit. It is easy to miss precisely because the code looks more uniform when you get it wrong: three symmetric branches all doing the same thing reads better than three branches where one is special.
Building something that holds user funds? The Founder Security Sprint is a focused engagement for teams shipping their first protocol. And if you want to work through cases like this out loud, with someone checking you, that is Zealynx Insiders.

The bookkeeping, and its order

Every liquidity change is recorded in three places, in a specific order, and the tests check the order.
  1. Both boundary ticks — the lower with a false flag, the upper with true. That single boolean decides the sign applied to liquidity net: lower ticks add liquidity when you cross into them, upper ticks subtract.
  2. The bitmap, if a tick flipped between zero and nonzero. That is what makes the swap loop's word scan find it — or stop finding it.
  3. The position's fees, settled from the two outside snapshots.
  4. On a removal, clear any tick that flipped to empty.
Forgetting the bitmap flip on removal is the nastier direction of that bug: the swap loop would keep stopping at a tick with no liquidity data behind it.
And clearing dead ticks is not just a storage refund. It is a reset — it wipes stale fee growth outside values, so that if the tick is ever initialized again it starts from the convention rather than from garbage left behind by a previous life.

The fee settlement runs even at zero delta

Notice that the fee settlement happens even when the liquidity delta is zero.
That is not an accident. It is exactly what makes the burn-zero poke work — the idiom that lets a position recompute its fees without changing its liquidity, which the next section depends on.

What you have after this

Liquidity can enter the pool. The callback pattern is in place, the three cases are handled, and the global liquidity variable means what the swap loop needs it to mean.
Next in this series: burn and collect — where removing liquidity turns out to give you nothing at all, and the reason that split exists.

FAQ

1. Why does Uniswap V3 use a mint callback instead of transferFrom?
Because the amounts owed depend on the pool's price at execution time, which the caller cannot know when signing. The pool computes the exact amounts, calls uniswapV3MintCallback on the caller, and then verifies its own balance grew by at least those amounts. Transfer-first, as V2 did it, would require knowing the amounts in advance.
2. Why can't a regular wallet call mint on a Uniswap V3 pool?
Because the caller must implement uniswapV3MintCallback to pay during the pool's call, and an externally owned account cannot implement a function. This is why users go through the periphery's position manager, which implements the callback on their behalf and holds the resulting positions.
3. Why don't Uniswap V3 pools support fee-on-transfer tokens?
Because the pool verifies payment by requiring its balance grew by at least the amount owed. A fee-on-transfer token delivers less than was sent, so the balance check fails and the mint reverts with 'M0' or 'M1'. It is a documented boundary of the core rather than a bug, but forks have rediscovered it in production after adding such a token.
4. When does minting change the pool's global liquidity?
Only when the current price is inside the position's range. The pool's liquidity variable means the liquidity active at the current tick, so a position that does not span that tick contributes nothing to it. Out-of-range liquidity is added later by cross, when the price moves into the range. Updating it in all three cases makes the swap loop divide by liquidity that is not present.
5. What are the three cases when providing concentrated liquidity?
If the price is below the range, the position is entirely token0. If it is above, entirely token1. If it is inside, the range splits at the current price and the position holds both — token0 for the portion above, token1 for the portion below. Only the third case affects the pool's active liquidity.

Glossary

TermDefinition
Callback ValidationProving the caller of a protocol callback is the genuine protocol contract before releasing funds.
Active LiquidityThe liquidity currently in range and backing trades at the pool's present price.
Liquidity NetThe signed liquidity applied to the active total when the price crosses a tick.
Tick BitmapA sparse bitmap recording which ticks are initialized, so the swap loop finds the next in one storage read.
Fee GrowthA global accumulator of fees per unit of liquidity, settled by differencing two snapshots.
Slot0The packed storage slot holding the pool's price, tick, oracle cursor and reentrancy lock.

Write it yourself

This article is section 8 of Building Uniswap V3, a free 18-section module on the Zealynx Academy. In this one you write _updatePosition, _modifyPosition and mint — the three cases, the bookkeeping order, and the callback with its balance check — and a 21-test suite checks each of them.
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 an audit firm that publishes its reports.

Carlos (Bloqarl)
Written by

Founder at Zealynx

Keep reading
Uniswap V2 Router Explained — addLiquidity Line by Line
DeFi analysisOct 16, 2023

Uniswap V2 Router Explained — addLiquidity Line by Line

10 min read
How to Build Compound V2 From Scratch (18 Sections, Line by Line)
DeFi analysisMay 28

How to Build Compound V2 From Scratch (18 Sections, Line by Line)

13 min read
Uniswap v4 hook attacks: 4 exploit patterns with PoCs
DeFi analysisMay 25

Uniswap v4 hook attacks: 4 exploit patterns with PoCs

25 min read