# Uniswap V4 end to end: four broken assumptions, answered structurally

> Canonical: https://blazephoenix.xyz/learn/uniswap-v4-end-to-end
> License: CC BY 4.0 (attribution + link) · © 2026 BlazePhoenix
> Updated: 2026-08-16

Uniswap V4 broke four assumptions every router rests on: pools have no addresses, arbitrary hooks run inside the swap, fees can exist only at execution time, and pools can hold raw ETH. BlazePhoenix answers all four structurally — derive-then-prove discovery, hooks screened by address arithmetic without ever being called, read-or-refuse dynamic fees, native ETH as a first-class kind. Measured 2026-08-13: 62.9% of V4's ETH liquidity is native (Arbitrum 99.6%); a wrapped-only integration reaches only the rest.

Uniswap V4 is not a new pool contract; it is a new shape of venue, and it invalidates four assumptions that every router built between 2020 and 2024 rests on. Pools have no addresses — they are entries in one singleton's storage, keyed by a hash, so there is nothing to look up and nothing to call. Arbitrary code runs inside the swap — a hook, deployed by the pool's creator, executes at defined points of the settlement the router is performing. Fees need not be constant — a pool may declare a dynamic fee whose value exists only in live state at the moment of the trade. And pools may hold the chain's native currency — raw ETH, which has no balanceOf and no transferFrom.

Each break, taken alone, is an integration inconvenience. Taken together they explain why V4 support across the industry is so often partial: a fixed list of curated pools instead of discovery, wrapped-only pairs instead of native ones, hooks avoided entirely, dynamic fees quoted optimistically or skipped. BlazePhoenix answers all four structurally, so that a V4 leg is — to the Solver, to the floors, and to the trader — indistinguishable from any other leg. This article is the four answers, each with its enforcement site and its boundary.

## Break one: pools with no addresses — derive, then prove

A V4 pool has no address to compute, but it has an identifier: the keccak hash of its PoolKey — the two sorted currencies, the fee, the tick spacing and the hook. So the Deterministic Derivation 𝒟 derives poolIds rather than addresses, and then does something no address derivation can: it proves the pool exists, by reading the PoolManager's own storage for that id and requiring a non-zero square-root price and non-zero liquidity. Nobody can forge that proof, because nobody but the manager writes that storage; a fabricated id resolves to zeros and is discarded.

The scan that produces candidate ids is a cost ladder, cheapest rung first, and it stops at eight proven pools (V4_CAP = 8). A warm pair costs a single probe; only a genuinely unknown pair pays for the cold-start grid, and even that payment is bounded at compile time — 99 fee-and-spacing combinations generated, at most 40 probed. Two safety properties fall out of the construction rather than from a check: the scan derives hookless ids only, so a hooked pool can never be emitted by discovery at all — its hookless id does not exist in the manager and fails the liveness proof before any policy question is asked — and duplicate ids arising from different tier guesses collapse to one candidate, so a single pool cannot saturate the funnel by aliasing.

```
rung 1  the tier pattern already learned for this pair    -> one probe (steady state)
rung 2  a provenance check before paying for a cold scan  -> one read
rung 3  the canonical Uniswap tiers, batched              -> one extsload for the batch
rung 4  the (fee, spacing) pairs the registration declares-> bounded by the registration
rung 5  cold-start grid: 99 combinations, max 40 probed   -> compile-time bound

scan stops at V4_CAP = 8 proven pools; hookless ids only; duplicates collapse.
```

## Reading a pool that answers no calls

With an id proven live, the dispatcher needs price and liquidity — and there is no slot0() to call, because there is no pool contract. The Core reads the singleton's storage directly by extsload: the pool's base slot is keccak256(poolId ‖ 6), the packed word at that slot carries the square-root price in its low 160 bits with the protocol fee and LP fee packed above it, and the active liquidity sits three words further on.

That layout is not taken from documentation. It was verified against the canonical StateView contract on a mainnet fork — read the same pool both ways, require the same numbers — because a storage layout inferred from prose is an assumption, and an assumption about where a number lives is the cheapest possible way to misprice every trade through a venue. With price and liquidity in hand, the V4 branch reduces to the same concentrated-liquidity closed form as every V3-shaped pool: same mathematics, same 512-bit mulDiv overflow discipline. The singleton changes where the state lives, not what the state means.

> equation: slot=keccak256(poolId&#x2225;6),P=low160(extsload(slot)) — The V4 read path (E4 in the whitepaper's equation appendix): the pool's base slot is derived from its id, read straight out of the PoolManager's storage, and verified against StateView on a mainnet fork rather than assumed from documentation.

## Break two: arbitrary code inside the swap — screened by arithmetic, never interrogation

A hook is arbitrary code running inside the settlement the Router is performing — the most dangerous primitive an aggregator must integrate, because a hostile hook's goal is to alter the accounting between the moment the Router commits to pay and the moment it is paid. The intuitive defence, asking the hook what it intends to do, is not a defence: a hostile hook lies, or re-enters. BlazePhoenix never asks a hook anything. Three independent layers stand instead, and any one of them suffices.

The address cannot lie. V4 encodes a hook's permissions in the low fourteen bits of its own address, fixed at deployment by the CREATE2 salt and physically immutable — and the PoolManager itself enforces those same bits when deciding whether to invoke the hook. Exactly two of those bits permit modifying swap accounting; the Router masks them with a single AND before any token moves. No call into untrusted code, nothing to deceive, and evasion would require an address whose bits contradict the manager's own enforcement.

The allow-list is closed by default: a V4 entry is registrable only if its hook is on the Hub's curated list, so a hostile hook never reaches the Solver's candidate set. And the anti-scam projector Ξ makes the route unrepresentable: any route touching a delta-altering or non-listed hook is valued at exactly zero in the Meta-Equation, so it cannot win a maximisation it does not participate in. The residual is stated with the mechanism: an allow-listed, delta-free hook can still revert mid-swap and waste the gas of trying — the price of refusing, on principle, to call untrusted code to find out what it would have done.

## Break three: fees that exist only at execution time — read, or refuse

A static-fee V4 pool carries its fee in its key, where it is immutable and unambiguous. A dynamic-fee pool carries the sentinel 0x800000 instead, and its real fee lives in slot0 — which raises the only genuinely hard question in the integration: what should a quote do when the number it needs is not knowable with certainty until the swap runs?

The answer is a three-line policy in the Core's fee gate, pinned by a regression test against real V4 state. A static-fee key is the truth and is used as-is. A dynamic-fee key carrying a protocol-fee component that cannot be decomposed exactly returns an unquotable fee, which zeroes the leg — fail-closed, the venue declined rather than guessed at. Otherwise the live LP fee is read from slot0 and used: measured, not nominal. The alternative — quoting the sentinel at face value — prices the trade as free while execution pays the live rate, which is precisely the quote-versus-execution gap this protocol exists to close.

One residue is bounded rather than removed, because no contract can remove it: a hook with fee-override permission can set a different fee inside beforeSwap than the one standing in slot0 when the quote was taken. Three mechanisms bound the consequence — the hook must first clear the address-bit mask and the allow-list to be routable at all, the per-leg floor requires each leg to deliver 80% of its attested expectation, and the Iron-Law floor Φ is re-derived from realised output before payout. A hostile override lands as a revert or as a shortfall inside the slack the user already accepted, never as a fill below the floor.

## Break four: native ETH as a kind, not a special case

The Router's working assumption everywhere is that an asset is an ERC-20 with balanceOf — an assumption that pays for itself a hundred times and fails in exactly one place: a V4 pool whose currency is the chain's native asset. The design decision worth explaining is where the exception lives. Encoding it as a distinct kind — KIND_V4_NATIVE — makes it visible in the pool record, carried in the route plan, and greppable by an auditor, instead of a condition rediscovered at each call site. The quote mathematics needs no branch at all: the pool identifier derives from the two sorted currencies, and address(0) sorts first by construction.

Execution keeps the invariant that matters. The Router stays WETH-canonical everywhere, and a native V4 leg unwraps just in time inside its own unlock frame: WETH.withdraw, then settle with value to pay the pool, with ETH received from a native take re-wrapped into WETH in the same frame. Native ETH never exists at rest in the contract, so the holds-nothing-between-transactions invariant survives contact with an asset that has no token interface. The one new surface this required is a receive() function, gated as narrowly as the requirement allows: a transient slot holds the single address permitted to send raw ETH to the Router at that instant — the canonical WETH contract during the unwrap, the PoolManager during a native take, zero at every other moment — so bare ETH from anyone else reverts exactly as it did before the function existed.

The kind earns its place empirically. Measured on 2026-08-13 across the chains served, 62.9% of the ETH-denominated liquidity inside V4 sits in native pools — Arbitrum 99.6%, Optimism 95.0%, Base 48.9%. Wrapping at the edge and routing only the wrapped side would have made all of it unreachable; the honest description of a wrapped-only V4 integration is not "supports V4" but "supports the third of V4 that happens to be wrapped."

## What the integration proves

Settlement inverts the usual control flow — the Router calls unlock on the manager and performs the entire swap inside the callback it is granted: swap, settle every owed currency through sync → settle → take, release the lock, with the in-flight currencies carried across the unlock boundary in transient storage. One detail is worth recording as evidence for the fork-first validation discipline: the BalanceDelta the manager returns is packed — amount0 in the high 128 bits, amount1 in the low — and every mock written for the integration agreed with the integration, because mock and integration were written from the same mental model. Only a real fork, returning the real packed layout, disagreed. A mock can only contradict you about things you already suspected.

The deliverable, stated in one sentence: V4 pools are discovered without a directory, proven to exist against storage nobody can forge, priced by the same closed form as every other concentrated venue, fee-gated by measurement with a fail-closed refusal where measurement is not exact, screened for hostile hooks by immutable address arithmetic before a token moves, settled through the singleton's own lock protocol, and reachable in native ETH without the Router ever holding a wei of it at rest — end to end, on the same floors, with the same guarantees, as a Uniswap V2 pair from 2020.

**Verify it yourself:** derive any V4 poolId as keccak256(abi.encode(currency0, currency1, fee, tickSpacing, hooks)), compute its base slot as keccak256(abi.encode(poolId, uint256(6))), read it with cast call <PoolManager> "extsload(bytes32)(bytes32)" <slot> --rpc-url https://mainnet.base.org, and compare the low 160 bits against StateView.getSlot0(poolId) — the two reads must agree

Related: https://blazephoenix.xyz/learn/eightfold-dispatcher · https://blazephoenix.xyz/learn/create2-discovery · https://blazephoenix.xyz/learn/the-primitive-equations · https://blazephoenix.xyz/learn/measured-not-nominal
