Skip to main content

Pool identity

A pool is (token0, token1, poolType) — nothing else. There are no fee tiers: exactly one canonical pool per combination. Lookup is getPool(tokenA, tokenB, poolType), which answers with the tokens in either order.

Listing a pool

The factory creates pools only for accounts holding its creator role:
An ordinary address calling either reverts NotAPoolCreator() — and so does initialize on an unpriced pool. What holds the role are listers: contracts that apply a listing policy and create and price a pool in a single call.
Listing is idempotent: whoever lands second is handed the existing pool, and an existing price is never touched — so a lister cannot be used to move a market. Contracts that hold the role directly — such as the launchpad — call createPoolWithTerms themselves. The role holders are enumerable on-chain (getRoleMemberCount / getRoleMember for POOL_CREATOR_ROLE()), and role changes emit OpenZeppelin’s RoleGranted / RoleRevoked. The position manager offers the same flow to LP frontends, forwarding data to the lister unread:

Before offering pool creation in a UI

  1. Check the lister, not the user: factory.canCreatePool(publicLister). A user’s own address is not a creator.
  2. Offer CL only through the public lister.
  3. Check the type has defaults — defaultsOf(CL) with a non-zero tick spacing — or creation reverts DefaultsNotSet().
  4. Check getPool(a, b, CL) first: an existing, priced pool is simply handed back, so show it as already listed.
  5. Collect an opening price and encode it: abi.encode(PoolType.CL, sqrtPriceX96).
  6. On a chain with no native wrapper, send no msg.value.

Pool types are an open set

PoolType is a user-defined uint8, not an enum — deliberately, so the ABI never rejects a value newer than your build:
Numbers are forever and new types go at the end (the value is part of the CREATE2 salt). Write integrations that pass unknown types through rather than reject them.

Address derivation

The deployer, not the factory. Pools are CREATE2-deployed by a separate deployer contract — read its address from factory.poolDeployer(). Derivation:
Or simply call deployer.computePoolAddress(token0, token1, poolType).
Two classic V3 habits break here: hardcoding one init code hash, and deriving from the factory address (it is the deployer). There is one hash per pool type and per deployment — a pool is an EIP-1167 minimal proxy, so the hash is of the proxy’s creation code, which embeds the address of its type’s implementation (deployer.implementationOf(poolType)), and that address belongs to the deployment. Read and cache initCodeHashOf per deployment; implementation registration is append-only, so each type’s hash never changes once set.

Reading a pool

Uniform across every pool type:
The caveats that matter:
  • Uninitialized pools return zeros, never revert — a pool created directly through the factory exists unpriced until a creator initializes it; listers create and price in one call.
  • tick on a tickless (STABLE) pool is a measurement of where the price sits, nothing more — the pool holds nothing at it. Never mint against a tick read from a STABLE pool.
  • feeInfo() is backward-looking on dynamic pools: it is the fee the last swap charged. For real quotes, use the Quoter. Fees are in hundredths of a bip and can range up to 999,999 — the units, the splits, and how to work out what a trade actually paid are on Fees.
  • feeToken() names the fee coin: Paid means each trade pays its fee in the token it sells; Token0 / Token1 mean every fee is taken in that slot’s token — from the output side when trading the other way. Fee growth and protocol fees then accrue on that side only.
  • tickSpacing is mutable on CL pools (governance), fixed on CP, 0 on STABLE.
  • liquidity() is in-range liquidity on CL — unrelated to the total across all ticks.

Positions and resolveTicks

Pool-level positions are keyed keccak256(owner, tickLower, tickUpper). But some pool types override the range: CP pins every position to the spacing-aligned full range; STABLE has exactly one range and ignores the arguments. Before computing a key, ask the pool:
Compute the key from what the pool settled on, or you will read an empty position and conclude — wrongly — that nothing is owed. The emitted Mint/Burn/Collect events always carry the resolved range.

Tick data for routing engines

The tick tree indexes by the tick itself, not tick / tickSpacing — that is what lets governance retune spacing on a live pool without moving a stored bit:
tickBitmap(int16 word) returns the leaf word; tickTreeRoot() the top. The TickLens walks it for you: getPopulatedTicksInWord(pool, word) returns exact-sized (tick, liquidityNet, liquidityGross) arrays, and wordOf(tick) computes the word — no spacing argument. CL and CP only; a STABLE pool’s whole depth sits at one price and is liquidity(). STABLE-only reads: curveReserve0() / curveReserve1() — the curve’s pricing reserves, deliberately not the pool’s balances (uncollected fees sit alongside), plus amplificationX100() and amplificationRamping().