V4 PoolManager storage layout — on-chain-truth verification slots

Source of truth: contract_reference/uniswap/V4/PoolManager.sol (canonical Uniswap V4-core PoolManager, the verified deployed source at 0x000000000004444c5dc75cB358380D2e3dE08A90). This note records the storage slots the V4 on-chain-truth verifier reads via PoolManager.extsload.

Why V4 differs from V3

V3 exposes ticks(int24) + tickBitmap(int16) as public view functions on each UniswapV3Pool — the verifier calls them directly (selectors 0xf30dba93 / 0x5339c296).

V4’s PoolManager is a singleton whose tick state lives inside the per-pool Pool.State struct value, reachable only via raw storage reads (extsload). It exposes extsload(bytes32), extsload(bytes32,uint256), and the batched extsload(bytes32[]) (selector 0xdbd035ff). There is no public ticks()/tickBitmap() view.

Interface ownership and identity

The contract-facing full liquidity-map verifier in degenbot-rpc::liquidity_verifier owns these slot reads for all callers. A V4 target is the canonical PoolManager address plus its PoolId; the StateView address is optional scalar/bootstrap configuration and is not a full-map verification target. The private V4 tick-window reader is retired. PoolIngress supplies the complete map to this shared verifier, and the storage slot derivation is an internal read adapter rather than a second verification implementation.

Top-level PoolManager storage (slot assignment via forge inspect)

contract PoolManager is IPoolManager, ProtocolFees, NoDelegateCall, ERC6909Claims, Extsload, Exttload.

Storage-bearing ancestors (C3-linearized, base→derived): Exttload (transient, no permanent storage) → Extsload (no storage) → ERC6909 (3 mappings) → ERC6909Claims (none added) → NoDelegateCall (none) → Owned (owner) → ProtocolFees (protocolFeesAccrued, protocolFeeController) → PoolManager.

Slot

Variable

Source contract

0

owner

Owned

1

protocolFeesAccrued

ProtocolFees

2

protocolFeeController

ProtocolFees

3

isOperator

ERC6909

4

balanceOf

ERC6909

5

allowance

ERC6909

6

_pools

PoolManager

So _pools (mapping PoolId => Pool.State) is at top-level base slot 6.

Pool.State struct — nested-mapping slot offsets

struct State {
    Slot0  slot0;                          // `type Slot0 is bytes32` — 1 slot
    uint256 feeGrowthGlobal0X128;          // +1
    uint256 feeGrowthGlobal1X128;          // +2
    uint128 liquidity;                     // +3 (mappings always start a new slot)
    mapping(int24 tick => TickInfo) ticks;                // +4
    mapping(int16 wordPos => uint256) tickBitmap;          // +5
    mapping(bytes32 => Position.State) positions;          // +6
}

forge inspect reports State occupies 224 bytes = 7 slots (4 value + 3 mapping), confirming the relative offsets.

Slot-derivation math

For a pool poolId (the bytes32 PoolId, derived from the PoolKey via keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks))):

  1. S_state = keccak256(abi.encode(poolId, uint256(6))) — the Pool.State value base.

  2. ticks mapping base within State = S_state + 4.

  3. tickBitmap mapping base within State = S_state + 5.

Per-tick / per-word (nested-mapping slot math):

  • TickInfo[tick]   = keccak256(abi.encode(int256(tick),   S_state + 4))

  • BitmapWord[word] = keccak256(abi.encode(int256(word),   S_state + 5))

abi.encode of an int24/int16 key sign-extends to 256 bits (two’s-complement left-padding) — the slot-derive helper MUST sign-extend negative ticks/words before hashing, or the slot diverges for the negative half of the tick range.

TickInfo field layout (first slot)

struct TickInfo {
    uint128 liquidityGross;  // bits 0..127   of slot+0
    int128  liquidityNet;    // bits 128..255 of slot+0
    uint256 feeGrowthOutside0X128;  // slot+1   (not read by the verifier)
    uint256 feeGrowthOutside1X128;  // slot+2   (not read by the verifier)
}

Reading the TickInfo’s first storage slot yields a packed bytes32:

  • liquidityGross = uint128(uint256(word) & ((1<<128)-1))

  • liquidityNet   = int128(int256(word >> 128)) (sign-extend the high half)

Read strategy (batched)

One extsload(bytes32[]) call — selector 0xdbd035ff, calldata selector || abi.encode(bytes32[] slots) — covers the supplied tick and bitmap slots for the pool in a single round trip. If bitmap discovery finds on-chain-only ticks, the shared verifier performs one additional batched read for those rows. Decode the returned bytes32[] in order; tick slots yield the packed (gross, net) per the layout above, bitmap slots yield the raw uint256 word.

On-chain cross-check status

The deployed PoolManager storage layout is exactly what forge inspect computes from the verified deployed source (the vendored contract_reference/uniswap/V4/PoolManager.sol). A runtime extsload round-trip against a live V4 pool (RPC_URL available) is a nice-to-have sanity check, not a layout-correctness gate — the layout is canonical by construction.