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 →
Back to Blog
A Uniswap V3 Position Is a Hash and Five Numbers
UniswapDeFiSolidityWeb3 Security

A Uniswap V3 Position Is a Hash and Five Numbers

12 min

TL;DR — Quick Summary

  • There is no LP token in the Uniswap V3 core. A position is a flat mapping keyed by keccak256(owner, tickLower, tickUpper) and a struct of five numbers.
  • The owner field is whoever calls the pool — for most users that is the periphery's position manager, not them. The NFT is periphery bookkeeping the core neither knows nor cares about.
  • Fees cannot auto-compound, because they belong to specific positions while liquidity is a range-wide quantity. They accrue as tokensOwed, a plain debt that sits inert until collected.
  • Accrue against the old checkpoint, then advance it. Invert those two lines and every position's fees silently become zero, with no revert and no wrong number.
  • tokensOwed += ... is allowed to overflow, with Uniswap's own comment saying so. Knowing when a protocol is right to accept an overflow is as much an auditor's skill as finding the ones it wrongly ignores.

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

Introduction

In Uniswap V2, when you provide liquidity you get a token back. An LP token: fungible, tradeable, the receipt.
Go looking for that in the V3 core and it is not there.
Your position is a hash and five numbers. This is section 6 of Building Uniswap V3, and it is where fees stop being a global number and become yours.

Why the token disappeared

Because fungibility disappeared first.
In V2 every liquidity provider held the same thing: a pro-rata slice of the whole curve. Same asset, same payoff, different sizes. Perfectly fungible, so one token per pair works.
In V3, an LP on the range −100 to 200 and an LP on 200 to 400 hold genuinely different assets. Different payoffs, different risk, different exposure. There is no single token that could represent both.
So the core does the simplest possible thing: a flat mapping, keyed by the hash of (owner, tickLower, tickUpper).
Hashing the triple into one bytes32 costs less gas than three nested mappings and keeps addressing down to a single storage slot.

Two consequences worth internalising

Adding liquidity twice with the same owner and range merges into one position. There is no position ID and no count. Same key, same slot. A contract that expects to hold several distinct positions over one range is going to be surprised.
The owner field is whoever calls the pool. For most real users that is not them — it is the periphery's NonfungiblePositionManager, which owns one giant bag of pool positions and tracks who owns what by NFT token ID on top.
So the NFT you see in the interface is periphery bookkeeping. The core neither knows nor cares.

What is not in the struct

No entry price. No timestamps. Five fields: liquidity, two fee growth checkpoints, and two debts.
Everything else is derivable, and the absence is deliberate — every field stored is a field that must be maintained correctly on every path that touches it.

Why fees cannot compound

In V2, fees accumulated inside the reserves, so every LP's share appreciated on its own. You did nothing.
V3 cannot do that. Fees belong to specific positions — whoever was in range when that fee accrued — while liquidity is a range-wide quantity. To compound a position's fees you would have to convert an arbitrary pile of token0 and token1 into perfectly ratioed range liquidity at the current price. That is a swap plus a mint. Per position. On every touch.
So V3 declines. Fees accrue as tokensOwed — a plain debt the pool owes you — and sit inert, earning nothing, until collect moves them.
If you want compounding, that is a periphery or vault problem. Which is where a long trail of V3 auto-compounder exploits comes from: it is a genuinely hard problem, and the core deliberately did not solve it.
Reading a protocol for what it deliberately does not do is a skill worth practising. The gaps are where integrators get hurt.
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 real protocol code every week alongside other security engineers, that is Zealynx Insiders.

The update function: checkpoint, then accrue

Three steps, and the order of the last two is the whole invariant.

Step one: cache and gate

Read the struct once into memory. That is cheaper than repeated storage reads, and it freezes the pre-update values for the fee math coming next. Then branch on the liquidity delta.

The poke

A call with a zero liquidity delta is a poke: "recompute my fees, do not touch my liquidity." The burn-zero idiom that triggers it shows up later in the module.
The require in that branch — 'NP', no position — rejects pokes on empty positions. It is worth being precise about what it protects, because it is not what people assume.
With zero liquidity the fee accrual is necessarily zero. Multiply any growth delta by zero liquidity and you get zero. So no phantom debt could ever be minted, require or not.
What the require actually stops is a poke on a nonexistent position succeeding quietly — writing checkpoint storage for a position with no liquidity, letting a call with the wrong owner or wrong range return as though it had done something.
It makes nonexistent positions unrepresentable in the fee flow. Touching one fails loudly instead of no-op'ing convincingly. That is a different and better property, and it is a useful pattern to recognise: a guard whose value is not preventing corruption but preventing a convincing silence.

Step two: accrue

Read this one inside out.
The subtraction is growth since my checkpoint. Two relative accumulators, so it must be free to wrap — the same unchecked argument as the tick library. Checkpoint at 3, current at 1 after the global wrapped, and 1 − 3 wraps to exactly the right delta.
Then multiply by the position's liquidity — and notice which liquidity: the pre-update value, out of the memory cache. The fees being settled were earned by the liquidity that was actually in place, not by whatever you are changing it to right now.
Then divide by Q128 to strip the fixed-point scaling.

Why the X128 scaling exists

It is not decoration.
A one-dollar fee spread over one hundred million dollars of liquidity is 10^-8 per unit. In plain integer math that is zero. The fee simply disappears.
Shifted left 128 bits it is comfortably representable. That is what the X128 buys: 128 bits of sub-token precision, so tiny per-unit fees survive integer division rather than truncating to nothing.

A worked accrual

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.

Liquidity 5,000. Checkpoint at 70 fee units per unit of liquidity. Current inside growth 90.
Delta is 20, scaled. Multiply by 5,000, divide by 2^128, and you get 100,000. The position earned 100,000 wei of token0 while it was in range.

Step three: checkpoint and settle

The checkpoint is always overwritten — that is what makes the next delta start from now. But you accrue against the old checkpoint first, and only then advance it.
Invert those two lines and every position's earned fees vanish on every touch.
Because if you advance the checkpoint first, the delta you then compute is current minus current. Zero. Every time. Nobody ever earns anything.
And the bug is completely silent: no revert, no obviously wrong number, just a protocol where fees quietly never accrue. It would pass any test that checks a fee calculation in isolation and fail only a test that checks a fee is received after a swap.
(The two guards around the writes are pure gas — skip the storage write when nothing changed. They do nothing semantic.)

The overflow Uniswap accepts on purpose

The last line is the interesting one for auditors.
tokensOwed += ..., inside the unchecked block, with Uniswap's own comment above it, shipped in production: overflow is acceptable, you have to withdraw before you hit uint128 max fees.
So this can wrap. If you never collect, you can lose the balance. Why is that acceptable?
Because uint128 max wei is about 3.4 × 10^20 of an 18-decimal token, in fees, for one position. That is astronomically far from anything real. And the failure hurts exactly one party: the negligent owner. Never the pool, never the other liquidity providers.
Here is the part worth sitting with. The alternative is worse. Checked math here means a wrapped accumulator reverts — which would brick burns for everyone, converting a self-inflicted loss for one careless actor into a liveness failure for the whole pool.
This is the kind of line you flag in an audit and then close as acknowledged. Knowing when a protocol is right to accept an overflow is as much an auditor's skill as finding the ones it wrongly ignores.

What you have after this

Positions are a hash and five numbers. Fees are a debt, not a compounding balance. And the ordering of two lines decides whether anyone earns anything at all.
Next in this series: the pool itself. State, slot0, and initialization — where all these libraries get wired into one contract, and where you will see why the pool packs seven values into a single storage slot.

FAQ

1. Why does Uniswap V3 have no LP token?
Because positions are not fungible. Each provider chooses their own price range, so two positions over different ranges are different assets with different payoffs and cannot be represented by one share token. The core stores positions in a mapping keyed by keccak256(owner, tickLower, tickUpper). The NFT users see comes from the periphery's NonfungiblePositionManager, which holds pool positions on their behalf.
2. Why don't Uniswap V3 fees compound automatically?
Because compounding would require converting an arbitrary mix of token0 and token1 into correctly ratioed range liquidity at the current price — a swap plus a mint, per position, on every touch. Fees belong to individual positions while liquidity is range-wide, so there is no shared pot to grow. V3 records fees as tokensOwed, a debt that earns nothing until collected, and leaves compounding to the periphery.
3. What is a "poke" in Uniswap V3?
A call to update a position with a zero liquidity delta, meaning "recompute my fees without changing my liquidity." It is triggered in practice by burning zero liquidity. The 'NP' require rejects pokes on nonexistent positions — not to prevent phantom fees, which are impossible at zero liquidity, but to stop a call with the wrong owner or range from succeeding silently and writing checkpoint storage for a position that does not exist.
4. Why must fee accrual happen before the checkpoint is advanced?
Because the accrual computes currentGrowthInside − checkpoint. If the checkpoint is advanced first, that expression becomes current minus current, which is zero on every call, so no position ever earns anything. The failure is silent — nothing reverts and no obviously wrong value appears — which makes it the kind of bug that survives unit tests of the calculation and only surfaces in an end-to-end test that asserts a fee was actually received.
5. Why is the tokensOwed overflow acceptable?
Because reaching uint128 max in accrued fees for a single position requires roughly 3.4 × 10^20 tokens of an 18-decimal asset, which is far beyond anything realistic, and the loss falls solely on an owner who never collected. The alternative is worse: checked arithmetic would make the wrapped accumulator revert, bricking burns for everyone and turning one actor's negligence into a pool-wide liveness failure.

Glossary

TermDefinition
Fee GrowthA global accumulator of fees per unit of liquidity, settled by differencing two snapshots.
Liquidity ProviderA participant who deposits assets into a pool to back trades in exchange for fees.
Slot0The packed storage slot holding a V3 pool's price, tick, oracle cursor and reentrancy lock.
Concentrated LiquidityAllocating liquidity to a chosen price range rather than the whole curve.
TickA discrete price point where tick i means a price of 1.0001^i, used as a position boundary.
Range OrderA position placed entirely on one side of the price, converting between tokens as price passes through.

Write it yourself

This article is section 6 of Building Uniswap V3, a free 18-section module on the Zealynx Academy. In this one you write the Position library — the hashed key, the memory cache, the accrual with its X128 scaling, and the checkpoint-then-settle ordering — and a 13-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.