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
Floor Is Not Truncation: The Uniswap V3 TickBitmap
UniswapDeFiSolidityWeb3 Security

Floor Is Not Truncation: The Uniswap V3 TickBitmap

11 min

TL;DR — Quick Summary

  • The swap loop asks one question constantly — where is the next initialized tick? — and it cannot scan 1.7 million of them. The tick bitmap answers in one storage read, a mask, and a bit scan.
  • Solidity's integer division truncates toward zero; the bitmap needs floor. For negative ticks these differ, and getting it wrong makes a downward swap hunt on the wrong side of the price.
  • flipTick uses XOR rather than set or clear, which makes initializing and clearing a tick the same code path with no branch.
  • The mask is written as (1 << bitPos) - 1 + (1 << bitPos) rather than the cleaner (1 << (bitPos + 1)) - 1 because bitPos can be 255, and the cleaner form overflows.
  • Searching upward starts from compressed + 1, not compressed. Return the tick the loop is already standing on and it spins in place forever.

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

Introduction

The swap loop has a question it asks constantly, and it cannot afford a slow answer.
The price is here. It is moving down. Where is the next initialized tick?
Between here and there nothing changes — same liquidity, same math — so the pool can take one clean step. But it has to know where that step ends, and there are roughly 1.7 million possible ticks. You are not scanning those.
The answer is one storage read. Not a loop: a mask and a bit scan.
This is section 5 of Building Uniswap V3.

A bitmap, and everything around it

One bit per tick, one meaning initialized. That part is obvious. The interesting part is everything surrounding it.

Do not waste bits

Only ticks divisible by the tick spacing can ever be initialized. So the bitmap does not index tick space — it indexes compressed space:
1compressed = tick / tickSpacing
And there is one subtlety in that division you must not skip.

Floor is not truncation

Solidity's integer division truncates toward zero. The bitmap needs floor division — toward negative infinity — so that every real tick maps into the compressed slot at or below it.
For positive ticks those are the same thing. For negative ticks they are not.
Take a spacing of 60 and a tick of −7. That sits between usable tick −60 and usable tick 0.
−7 / 60 truncates to 0 — which is the wrong side. Zero is above our tick, not below it.
The correction ticks it down to −1, which represents usable tick −60: the usable tick at or below −7. Correct.
Get this wrong and a swap moving down through negative territory hunts for the next tick on the wrong side of the price. That is an off-by-one that corrupts which positions are active — no revert, just wrong liquidity applied to a real trade.
The floor-versus-truncation confusion is a recurring bug class in V3 forks, specifically in forks that decide to "simplify" this library. It looks like redundant arithmetic until you try it with a negative number.

Word and bit

The compressed index then splits:
1wordPos = compressed >> 8
2bitPos = compressed % 256
Notice it shifts rather than divides, because an arithmetic shift right already floors for negatives. No correction needed here — you get it free from the shift, which is a small piece of consistency worth noticing given the trouble the previous division caused.
The bit position carries a cast that reads like line noise until you work it out: uint8(uint24(compressed % 256)).
Take compressed = −1. Then −1 % 256 is −1. Cast to uint24 and you get all Fs. Take the bottom byte and you get 255 — the top bit of word −1, which is exactly where compressed −1 belongs.

A worked decomposition

Spacing 60, tick 85,200.
compressed = 1420. wordPos = 1420 >> 8 = 5. bitPos = 1420 − 5×256 = 140.
Word 5, bit 140.

Why XOR and not set

The function is called flipTick, and it uses XOR. Not set, not clear.
That is deliberate. The caller only ever invokes it at a genuine zero boundary — when a tick's gross liquidity goes from zero to something, or back to zero. XOR makes the operation self-inverse, so the same code path initializes a tick and clears it, with no branch and no need to read the current state first.
In front of it sits a require that the tick is spacing-aligned. That is the integrity gate, and it is worth understanding what it protects: an unaligned tick would be invisible to every search, which means real funds stuck at a boundary the swap loop can never find.
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 — asking what each line protects and what happens if it is gone — that is Zealynx Insiders.

The search

The question is: within this word, where is the nearest set bit, in this direction? The answer is arithmetic, not iteration.
Searching down, you want bits at or below your position, so you build a mask:
1mask = (1 << bitPos) - 1 + (1 << bitPos)
You might reasonably ask why it is written that way rather than the cleaner (1 << (bitPos + 1)) - 1, which is the same number and reads far better.
Because bitPos can be 255. Add one and the shift overflows. The awkward form is the correct form — and this is a good example of code that looks like it was written badly until you find the input that breaks the readable version.

Working the mask in binary

With a toy 8-bit word, since the real 256-bit ones do not fit on a screen.
1word = 0 0 1 0 0 1 1 0 bits 1, 2 and 5 are initialized
2 ^ we are at bit 4, searching down
3mask = 0 0 0 1 1 1 1 1 the bottom five bits
4AND = 0 0 0 0 0 1 1 0 nonzero: something is below us

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.

The most significant set bit of the result is bit 2. So the distance is 4 − 2 = 2: the nearest initialized tick is two compressed steps down.
And bit 5, which is above us, was correctly masked out. That is the whole algorithm.

Searching up has two asymmetries

Both are load-bearing.
It starts from compressed + 1, not compressed. "Greater than" excludes where you already are — and the swap loop is already standing on that tick. Return it again and the loop spins in place forever.
The mask inverts. Everything below the start, complemented, gives everything at or above. And this time the least significant set bit is the nearest, and the distance is added rather than subtracted.

The return that looks like a bug

If no bit is set in the word, the function returns the word's edge and reports the tick as uninitialized. It does not recurse.
That is intentional, and the function's name states it: nextInitializedTickWithinOneWord.
The swap loop advances to that boundary, loops, and searches the next word. One storage read per call, bounded work per step, no unbounded scan anywhere.
The consequence — which surprises people reading the loop for the first time — is that a swap through a long empty stretch performs several iterations that cross nothing. They are cheap, and they are what keeps the worst case linear in words rather than in ticks.

"Prevented externally" is a claim, not a fact

The whole body sits in one unchecked block. The original's comment says overflow and underflow are possible, but prevented externally by limiting both tick spacing and tick.
That is true. The bit distances are provably in range because of the masking, and the products are bounded because callers only pass valid ticks.
But notice where those guarantees live: one layer up, in checkTicks and in the pool.
When you audit a fork, "prevented externally" is a claim you verify, not accept. The library is correct only as long as its callers uphold a contract written in a comment — and a fork that adds a new call site, or relaxes checkTicks, breaks that contract without touching this file at all.
That is the general shape worth carrying: a safety property enforced in a different file from the one that depends on it is exactly the property a diff review misses.

What you have after this

The swap loop now has a cheap answer to the only question it asks between steps: one storage read, a mask, and a bit scan.
Next in this series: Position, where fees stop being a global number and become yours. That is where the checkpoint from the previous section gets used, and where the fee growth values finally do their job.

FAQ

1. How does Uniswap V3 find the next initialized tick?
Through a bitmap indexed by word: mapping(int16 => uint256), where each bit marks one usable tick as initialized. The current tick is decomposed into a word index and a bit index, a mask is built covering the bits on the relevant side, and the result is ANDed with the word. The nearest set bit gives the distance. It is one storage read and some bit arithmetic, with no iteration over ticks.
2. Why does the tick bitmap need floor division instead of Solidity's default?
Because the bitmap must map every tick to the usable tick at or below it, and Solidity's integer division truncates toward zero rather than toward negative infinity. For positive ticks the two agree; for negative ticks they do not. With a spacing of 60, tick −7 truncates to compressed 0, which represents usable tick 0 — above the target. Floor gives −1, representing usable tick −60, which is correct.
3. Why does flipTick use XOR instead of setting or clearing the bit?
Because it is only ever called at a genuine transition — when a tick's gross liquidity moves from zero to nonzero, or back to zero. XOR makes the operation self-inverse, so initializing and clearing share one branchless code path and neither requires reading the current bit first. It also makes an accidental double-flip a no-op rather than a corruption.
4. Why is the bitmap mask written in such an awkward form?
(1 << bitPos) - 1 + (1 << bitPos) is used instead of the cleaner (1 << (bitPos + 1)) - 1 because bitPos can be 255. In the cleaner form, bitPos + 1 is 256 and the shift overflows. The two expressions are otherwise identical, so the awkward one is chosen purely to remain correct at the top of the range.
5. Why does the search return an uninitialized tick at the word boundary?
Because the function is deliberately bounded to a single word — hence nextInitializedTickWithinOneWord. If the masked word is empty it returns the word's edge and flags the tick uninitialized rather than recursing. The swap loop steps to that boundary, does no crossing work, and searches the next word. This bounds the gas of any single iteration and keeps the worst case linear in words rather than in ticks.

Glossary

TermDefinition
Tick BitmapA sparse bitmap recording which ticks are initialized, so the swap loop can find the next in one storage read.
TickA discrete price point where tick i means a price of 1.0001^i.
Tick SpacingThe fixed interval constraining which ticks may be used, and the divisor the bitmap compresses by.
Liquidity NetThe signed liquidity applied to the active total when the price crosses a tick.
Fee GrowthA global accumulator of fees per unit of liquidity, settled by differencing two snapshots.
Active LiquidityThe liquidity currently in range and backing trades at the pool's present price.

Write it yourself

This article is section 5 of Building Uniswap V3, a free 18-section module on the Zealynx Academy. In this one you write TickBitmap — the floor correction, the word and bit decomposition, flipTick with its alignment gate, and both directions of the search — and a 15-test suite checks each of them, including the negative-tick cases.
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.