
On this page▾
TL;DR — Quick Summary
slot0packs seven values into 248 bits of one storage slot. The types were chosen to fit, not the other way round.- The pool stores both the square root price and the tick, even though the tick is derivable. Deriving it costs a logarithm; storing 24 extra bits in a slot already being written costs nothing — but the two must then be kept consistent by hand, and forks have broken that.
- The real pool's constructor takes no arguments, because constructor args are part of the CREATE2 init code hash. Zero args means every pool has an identical hash, which is what makes any pool's address computable offline.
unlockeddefaults to false, so a fresh pool is locked and initialization is unlocking. There is no separate "is initialized" check anywhere.- The lock is load-bearing in a way it is not in most contracts: V3 verifies payment by snapshotting its own balance, so reentrancy would let one transfer settle two debts.
The full walkthrough, with the code written on screen. The article below covers
the same ground if you would rather read it.
Introduction
Five sections of libraries, and now the pool itself. The very first thing in it is a storage layout that tells you exactly what Uniswap V3 optimises for.
A cold storage read costs 2,100 gas. A warm one costs 100. On a swap, storage reads are the dominant cost — not the math, not the loops. The reads.
V2 already knew this and packed two reserves and a timestamp into one slot. V3 goes further.
This is section 7 of Building Uniswap V3.
Seven values, 248 bits, one slot
Add them up yourself:
| Field | Bits |
|---|---|
sqrtPriceX96 | 160 |
tick | 24 |
observationIndex | 16 |
observationCardinality | 16 |
observationCardinalityNext | 16 |
feeProtocol | 8 |
unlocked | 8 |
| Total | 248 |
Eight bits to spare, and that is not luck.
The price is a
uint160 partly because Q64.96 needs at most 160 bits for the supported range — and partly because 160 leaves exactly enough room for everything else the swap needs beside it. The type was derived from two constraints at once.So when
swap starts, it copies slot0 into memory once and never pays for those reads again.Why store the tick as well as the price
This looks redundant. The pool stores both
sqrtPriceX96 and tick, and the tick is derivable from the price — that is exactly what getTickAtSqrtRatio does.So why store both?
Because deriving it costs a logarithm: about 2,000 gas, every time. Storing 24 extra bits in a slot you are already writing costs nothing.
Classic space-for-time. But it comes with a duty: the two must be kept consistent by hand, in the swap loop, on every tick crossing. There is no invariant check enforcing it and no compiler help.
That is an invariant real V3 forks have broken. A pool whose stored tick drifts from its stored price will keep executing swaps — it will simply apply the wrong liquidity net at the wrong boundaries, and misprice quietly.
The constructor with no arguments
Next, the immutables: factory, both tokens, the fee, the tick spacing, and the max liquidity per tick. Written once at construction, then embedded directly into the deployed bytecode, so reading them costs no storage access at all.
And the real V3 pool does something strange to get them. Its constructor takes no arguments. Instead it calls back into whoever deployed it and asks for the parameters.
Why the contortion?
Because CREATE2 addresses derive from the hash of the init code — and constructor arguments are part of the init code. With zero arguments, every pool ever deployed has an identical init code hash.
Which means anyone can compute any pool's address offline from nothing but the two tokens and the fee. No registry lookup, no storage read, no external call.
The periphery leans on this hard for callback validation, and it is the single design decision that makes the router's security check one comparison instead of a factory round-trip.
The lock, and why it is not boilerplate
It is a modifier. Require unlocked, set false, run the body, set true. Four lines. Every V3 fork has it and most people skim past it.
Three things, in increasing order of importance.
First, the flag lives inside
slot0, not in a separate storage variable the way V2 did it. The swap is already loading and writing that slot, so keeping the flag there makes locking nearly free — a conventional guard costs a full cold read and two writes.Second,
unlocked defaults to false. So a freshly deployed pool is locked. Every guarded function reverts until initialize flips it, which means initialization is unlocking — and there is no separate "is this pool initialized" check scattered through the contract. One flag does both jobs.Third, the auditor's view, which is the one that matters.
V3 never calls
transferFrom. It verifies payment by snapshotting its own token balance, calling back into you, and then requiring the balance grew by what you owed.So if a callback could reenter
mint or swap mid-flight, a single token transfer could be counted toward two different debts. The balance check would pass twice on one payment.That is why every fork touching the callback flow has to preserve this guard, and why lock coverage is among the first things to check when auditing a V3 fork. The lock is not defence in depth here. It is the only thing making the optimistic-transfer model sound.
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 read protocol code like this every week alongside other security engineers, that is Zealynx Insiders.
How the pool learns its own balance
You would expect a plain
balanceOf call. Uniswap hand-rolls a staticcall instead and checks the returned data length.Require the call succeeded and that at least 32 bytes came back, then decode.
That length check does double duty: it validates the response, and it is why the
extcodesize check the compiler would otherwise insert is redundant. A call to an address with no code returns success and zero bytes, so the length check catches it more cheaply than the compiler's own guard would.The two balance functions are identical except for which token they read. Symmetric pairs like that deserve a second look in any audit — when two functions are supposed to differ by exactly one identifier, that is precisely where a copy-paste error hides, and it will not look wrong. It reads as correct code because it is correct code, just about the other token.
Initialization
The one-shot guard first. Require the stored price is still zero, revert
'AI' otherwise. The price doubles as the initialized flag, so no extra storage is spent recording that the pool has been opened.Then the whole of
slot0 in one assignment. The price we were given. The tick derived from it. Observation index zero, and the two cardinality fields hardcoded to one — the seam where the oracle plugs in later. Protocol fee zero. And unlocked, true.That last field is the moment the pool comes alive. Everything guarded by the lock was reverting until this line.
And writing it as one struct assignment rather than seven separate writes is the entire point of the packing: one storage write for the whole hot path's state.
Then the event, so indexers and the periphery can see the opening price.
The timestamp truncation
One more deliberate choice worth naming: the block timestamp is cast down to 32 bits.
That truncation is intentional and documented as intentional. It works only because everything downstream subtracts these values inside
unchecked blocks — the same modular-arithmetic reasoning that makes fee growth safe. A uint32 timestamp wraps in 2106, and every consumer takes a difference, so the wrap cancels.This is the fourth place in seven sections where deliberate overflow is load-bearing. By now it should read as a pattern rather than a surprise.
What you have after this
The pool exists. It has a price, a tick, a lock, and a way to see its own balances — which is everything the next three sections need.
Next in this series:
mint, where liquidity actually enters the pool and the callback pattern appears for the first time. That is where the balance check does its real work.FAQ
1. What is slot0 in Uniswap V3?
slot0 is a struct deliberately sized to fit one 256-bit storage slot, holding the current sqrtPriceX96 (160 bits), tick (24), three oracle fields (16 each), feeProtocol (8) and the unlocked reentrancy flag (8) — 248 bits in total. Packing them means a swap loads all of them with a single cold read and writes them back with a single write.2. Why does Uniswap V3 store both the price and the tick?
Because deriving the tick from the price requires a logarithm costing roughly 2,000 gas on every call, while storing 24 extra bits in a slot that is already being written costs nothing. The trade-off is that the two values must be kept consistent manually in the swap loop, with no invariant check enforcing it — a duty that real V3 forks have broken, producing pools that misprice quietly rather than reverting.
3. Why does the Uniswap V3 pool constructor take no arguments?
Because CREATE2 derives a contract's address from the hash of its init code, and constructor arguments are part of that init code. With zero arguments, every pool shares one identical init code hash, so any pool's address can be computed offline from just the two token addresses and the fee. The pool instead calls back into its deployer to fetch its parameters. This is what makes the periphery's callback check a single address comparison.
4. Why is the reentrancy lock so important in Uniswap V3 specifically?
Because V3 never calls
transferFrom. It verifies payment by snapshotting its own token balance, calling back into the caller, and requiring the balance grew by the amount owed. If a callback could reenter mint or swap mid-flight, one token transfer could satisfy two separate balance checks, settling two debts with one payment. The lock is not defence in depth here — it is the assumption the entire optimistic-transfer model rests on.5. Why does a fresh Uniswap V3 pool start locked?
Because
unlocked is a bool that defaults to false, so every function carrying the lock modifier reverts until initialize sets it true. This makes initialization and unlocking the same event, removing the need for a separate "is initialized" check throughout the contract. It also means a pool whose initialize never sets the flag is permanently bricked and unrecoverable, since initialize is the only function that can set it.Glossary
| Term | Definition |
|---|---|
| Slot0 | The packed storage slot holding a V3 pool's price, tick, oracle cursor, protocol fee and reentrancy lock. |
| CREATE2 | An EVM opcode deploying to an address derived from deployer, salt and init code hash, computable in advance. |
| Callback Validation | Proving the caller of a protocol callback is the genuine protocol contract before releasing funds. |
| Reentrancy Guard | A flag preventing a function from being re-entered before it completes. |
| sqrtPriceX96 | The square root of the pool price times 2^96, the value V3 stores instead of the price. |
| Fee Growth | A global accumulator of fees per unit of liquidity, settled by differencing two snapshots. |
Write it yourself
This article is section 7 of Building Uniswap V3, a free 18-section module on the Zealynx Academy. In this one you write the pool's state — the packed
slot0, the immutables, the lock modifier, both balance helpers and initialize — and a 15-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.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →
Keep reading


