BlockBatchClearing implements 5 of the fourteen Uniswap v4 callbacks: beforeInitialize, beforeAddLiquidity, beforeRemoveLiquidity, beforeSwap, beforeSwapReturnsDelta.
drag to orbit
Uniswap v4 hook · Order flow and MEV
BlockBatchClearing
Every swap in a block clears at one price, and orders that want opposite sides fill against each other for free.
- Family
- Order flow and MEV
- Callbacks
- 5 of 14
- Fee
- static
- Admin keys
- none
- Licence
- Apache-2.0
How it works
Almost all of what is called MEV is the value of being first. A continuous market prices each order at the state the one before it left behind, so the right to arrive earlier is worth money, and that right is sold. Batch auctions were proposed for exactly this in the equities literature, and the answer they give is not to police the ordering but to abolish it: collect every order in an interval and clear them all at one uniform price.
Being first in a batch is worth nothing, because there is no first. CoW Protocol runs that design with a network of solvers off-chain. Doing it on-chain has needed a solver too, since a uniform price for a batch against a bonded curve looks like a fixed point: the price depends on the net trade, and the net trade depends on the price.
For a constant-product pool it is not a fixed point. Requiring that both sides clear at one price `p` and that the invariant hold gives a quadratic in `p` with an exact solution, and this hook solves it in closed form. There is no solver, no auction, no bond and nobody to trust with the ordering.
What falls out of it is the part traders will notice. Orders in opposite directions settle against each other before the curve is touched at all, so matched flow pays no slippage whatsoever; only the imbalance moves the price. A block where buyers and sellers are evenly matched clears at the spot price for everybody, which is a thing a continuous AMM cannot do at any size.
The cost is that a swap does not return tokens. It returns a claim on a batch that has not cleared yet, and the trader collects afterwards. That is the honest price of not having an ordering to sell.
Prior art
Frequent batch auctions are Budish, Cramton and Shim's answer to the equities latency race. CoW Protocol clears uniform-price batches with off-chain solvers, and Gnosis ran an on-chain batch exchange over an order book. On v4, TWAMM spreads one order over time and various hooks reorder or tax within a block.
Clearing a whole block's flow against a bonded curve at one uniform price, solved in closed form on-chain with no solver and no auction, is the contribution here.
Where it does not help
A swap returns nothing at the moment it is made, so this cannot be routed through by an aggregator expecting tokens back and is unusable for anything atomic. Orders are exact-input only, for the same reason an exact output cannot be promised before the price is known. A batch also clears when somebody touches the pool in a later block, so the last batch of a quiet period waits for the next swap or for anybody to call `clear`; the trader's funds are held meanwhile.
And the uniform price is uniform within a block, which means the ordering advantage is gone but the choice of which block to be in is not.
Using it
Uniswap v4 removed hookData from initialize, so per-pool parameters arrive out of band.
Fix them for a pool key whose pool does not exist yet, then initialize. Nobody can change them afterwards,
including you.
poolManager.initialize(key, startingSqrtPriceX96);
Parameters
This hook takes no per-pool configuration.
From TypeScript
npm i @hookforge/sdk
import {getHook, hookAddress, poolKeyFor} from "@hookforge/sdk";
const hook = getHook("block-batch-clearing");
const key = poolKeyFor({
hook: hookAddress("block-batch-clearing", 8453), // Base
currencyA: USDC, currencyB: WETH,
tickSpacing: 60,
});
What it reverts with
| Error | Meaning |
|---|---|
AddLiquidityThroughHook() | Liquidity belongs to the hook, not to the pool, so the pool's own path is closed. |
AlreadyBound() | This hook serves one pool, bound the first time one initializes with it. |
AmountTooSmall() | The deposit was too small to mint a share, or the withdrawal too small to return anything. |
CallbackNotPoolManager() | Only the PoolManager may drive the unlock callback. |
ERC20InsufficientAllowance(address,uint256,uint256) | Indicates a failure with the spender’s allowance. Used in transfers. |
ERC20InsufficientBalance(address,uint256,uint256) | Indicates an error related to the current balance of a sender. Used in transfers. |
ERC20InvalidApprover(address) | Indicates a failure with the approver of a token to be approved. Used in approvals. |
ERC20InvalidReceiver(address) | Indicates a failure with the token receiver. Used in transfers. |
ERC20InvalidSender(address) | Indicates a failure with the token sender. Used in transfers. |
ERC20InvalidSpender(address) | Indicates a failure with the spender to be approved. Used in approvals. |
ExactOutputUnsupported() | A batch cannot promise an exact output before it knows the price everybody clears at. |
InsufficientInitialLiquidity() | The first deposit must exceed the permanently locked minimum. |
InvalidFee() | A fee at or above the whole trade is not a fee. |
NoReserves() | The pool holds nothing, so there is nothing to clear against. |
NotCleared() | The batch is still open, so there is no price yet. |
NothingToCollect() | The order does not exist, or has already been collected. |
SafeCastOverflowedIntDowncast(uint8,int256) | Value doesn't fit in an int of bits size. |
SafeCastOverflowedUintDowncast(uint8,uint256) | Value doesn't fit in a uint of bits size. |
SafeCastOverflowedUintToInt(uint256) | A uint value doesn't fit in an int of bits size. |
SafeERC20FailedOperation(address) | An operation with an ERC-20 token failed. |
The callbacks it claims
Uniswap v4 reads a hook's permissions from the low fourteen bits of its own address, which is why deploying one
means mining a CREATE2 salt. This hook claims 5, so every deployment of it has an address ending
in 0x2a88.
- beforeInitialize
- afterInitialize
- beforeAddLiquidity
- afterAddLiquidity
- beforeRemoveLiquidity
- afterRemoveLiquidity
- beforeSwap
- afterSwap
- beforeDonate
- afterDonate
- beforeSwapReturnsDelta
- afterSwapReturnsDelta
- afterAddLiquidityReturnsDelta
- afterRemoveLiquidityReturnsDelta
It says what it is, on-chain
Nothing about a hook's address tells an indexer, a wallet, a router or an agent what the pool does, which is why
hook discovery today is a curated list. This hook answers for itself, in one eth_call, with no
registry in the loop.
cast call $HOOK "hookName()(string)" # BlockBatchClearing
cast call $HOOK "specURI()(string)" # https://block-batch-clearing.pages.dev/hook.json
cast call $HOOK "hookTags()(string[])" # mev, batch-auction, uniform-price, order-flow, no-admin
Build, test and deploy
git clone --recurse-submodules https://github.com/nirholas/block-batch-clearing
cd block-batch-clearing
forge build && forge test
# Dry run: mines the salt, prints the address, sends nothing.
forge script script/Deploy.s.sol --rpc-url $RPC_URL
# For real.
forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcast --verify
Status
Unaudited. Built to an audited shape, on OpenZeppelin's audited hook bases, and tested against
a real PoolManager. No third party has reviewed it. Read "where it does not help" above before
putting money behind it. Not affiliated with Uniswap Labs.
Try it
This is the hook running, not a picture of it. Connect a wallet on a chain it is deployed to, or bring the whole stack up locally in one command and use it with no funds and no wallet risk at all.
Loading the demo… if this does not change, JavaScript is blocked and the demo cannot run.
Run the whole thing locally
git clone --recurse-submodules https://github.com/nirholas/block-batch-clearing
cd block-batch-clearing
anvil &
forge script script/DeployLocal.s.sol --rpc-url http://127.0.0.1:8545 --broadcast \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
node web/build.mjs && npx serve web/dist
The deploy script writes web/local.json itself and the build merges it, so the page points at the
chain you just created without you editing anything. Point a wallet at
http://127.0.0.1:8545 and every button on this page works.
Anvil's first account is pre-funded and its key is public by design. Never use it anywhere real.