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 / burn | ERC-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.
| bit | flag | bit | flag | |
|---|---|---|---|---|
| 13 | beforeInitialize | 6 | afterSwap | |
| 12 | afterInitialize | 5 | beforeDonate | |
| 11 | beforeAddLiquidity | 4 | afterDonate | |
| 10 | afterAddLiquidity | 3 | beforeSwapReturnDelta | |
| 9 | beforeRemoveLiquidity | 2 | afterSwapReturnDelta | |
| 8 | afterRemoveLiquidity | 1 | afterAddLiquidityReturnDelta | |
| 7 | beforeSwap | 0 | afterRemoveLiquidityReturnDelta |
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.
| trade | specified | unspecified | where you can charge |
|---|---|---|---|
| exact-in | input | output | beforeSwap (input) or afterSwap (output) |
| exact-out | output | input | beforeSwap (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.
beforeSwapReturnDeltais 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
| PoolManager | 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32 |
| Universal Router | 0x8B844f885672f333Bc0042cB669255f93a4C1E6b |
| V4 Quoter | 0x3972C00f7ed4885e145823eb7C655375d275A1C5 |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
git submodule update --init --recursive — v4-core has nested submodules and PoolManager will
not compile without them.