Skip to main content

Uniswap v4 — the destination

Where our tokens graduate to. Read from lib/v4-core in ../launchpad and ../888, not from a blog. Everything below is either in that source or was measured on Arbitrum One.

The shape of it

One singleton PoolManager holds every pool's tokens. Pools are not contracts — a pool is a PoolId, the hash of its PoolKey. Creating one is a storage write, not a deployment, which is the whole reason a launchpad can afford a pool per token.

struct PoolKey {
Currency currency0; // sorted numerically, currency0 < currency1
Currency currency1;
uint24 fee; // LP fee, max 1_000_000 (100%). 0x800000 = dynamic
int24 tickSpacing;
IHooks hooks; // part of the identity
}

The hook is part of the identity. It cannot be changed, replaced or upgraded for the life of the pool, and graduating with a different hook address creates a different pool. Every seam a pool will ever need must exist in its hook on day one. This is the single most important constraint v4 puts on a launchpad's architecture.

Flash accounting

Nothing moves token-by-token. A caller takes the lock with unlock(data), the manager calls back into unlockCallback, and inside that window operations accumulate deltas per currency. The lock cannot close while any delta is non-zero (CurrencyNotSettled).

sync(currency) → transfer → settle()pay what you owe
take(currency, to, amount)collect what you are owed
mint / burnERC-6909 claim tokens, to avoid moving ERC20s at all
clear(currency, amount)forgive dust owed to you

../launchpad/src/BondingCurve.sol _pay() is the minimal correct pattern: sync, transfer, settle.

Hooks: the address is the permission set

The low 14 bits of the hook's address decide which callbacks the manager calls. There is no registry and no setter.

bitflagbitflag
13beforeInitialize6afterSwap
12afterInitialize5beforeDonate
11beforeAddLiquidity4afterDonate
10afterAddLiquidity3beforeSwapReturnDelta
9beforeRemoveLiquidity2afterSwapReturnDelta
8afterRemoveLiquidity1afterAddLiquidityReturnDelta
7beforeSwap0afterRemoveLiquidityReturnDelta

So a hook address is mined, not chosen. HookMiner finds a CREATE2 salt whose resulting address carries the right bits — and the address depends on the deployer, so mine against address(this) in tests and against the CREATE2 proxy 0x4e59b44847b379578588920cA78FbF26c0B4956C in a broadcasting script. Get this wrong and the manager refuses every swap.

A *ReturnDelta flag is only valid alongside its action flag (isValidHookAddress).

Charging a fee from a swap

This is the part that is easy to get wrong, and it cost ../888 real time.

beforeSwap returns a BeforeSwapDelta — upper 128 bits the specified currency, lower 128 the unspecified. afterSwap returns an int128 against the unspecified currency only.

tradespecifiedunspecifiedwhere you can charge
exact-ininputoutputbeforeSwap (input) or afterSwap (output)
exact-outoutputinputbeforeSwap (output) or afterSwap (input)

So which currency a hook can take depends on the trade's direction and its exactness, not on a setting. Build the four-row matrix explicitly for any fee hook. Miss a row and exact-output becomes a free exit.

  • beforeSwapReturnDelta is the only way to take a fee from a swap's input.
  • A hook delta that flips exact-in into exact-out reverts (HookDeltaExceedsSwapAmount).

afterSwapReturnsDelta excludes the pool from Uniswap's own router. RHUB is live and correct and still not quotable in Uniswap's interface for exactly this. The fix is their hook routing allowlist, which requires Arbiscan verification. Aggregators route it fine.

Fees

key.fee is the LP fee in hundredths of a bip, capped at 1_000_000 (100%). Set the high bit (0x800000) and the pool is dynamic-fee: the hook calls updateDynamicLPFee(key, newFee), or returns a fee with OVERRIDE_FEE_FLAG (0x400000) from beforeSwap for a single swap. A pool can therefore charge fee = 0 and let its hook take everything — which is what pons does.

Non-negotiables inside the lock

  • Wrap everything. A revert in a hook callback reverts the swap. A hook that can always revert is a pool nobody can trade or exit. Gas-cap and swallow.
  • The PoolManager's balance is not any pool's depth. The singleton holds every v4 pool. Judge a venue by active liquidity, then confirm by actually swapping.
  • Exclude the PoolManager from any holder set. It is structurally the largest holder of every v4 token in existence.
  • Never put fee-on-transfer logic in the ERC20. v4 settles by balance delta and v3 checks the callback; either way it breaks. Fees belong in the hook.

Live on Arbitrum One

PoolManager0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32
Universal Router0x8B844f885672f333Bc0042cB669255f93a4C1E6b
V4 Quoter0x3972C00f7ed4885e145823eb7C655375d275A1C5
Permit20x000000000022D473030F116dDEE9F6B43aC78BA3

git submodule update --init --recursive — v4-core has nested submodules and PoolManager will not compile without them.