CircuitBreaker implements 3 of the fourteen Uniswap v4 callbacks: afterInitialize, beforeSwap, afterSwap.
drag to orbit
Uniswap v4 hook · Risk
CircuitBreaker
Halts swapping for a cooldown after the price moves further than a pool is willing to move in one window, and lets liquidity leave the whole time.
- Family
- Risk
- Callbacks
- 3 of 14
- Fee
- static
- Admin keys
- none
- Licence
- MIT
How it works
Every venue outside crypto stops trading after a limit move, for a reason that has nothing to do with paternalism: a violent move is usually either an error or an attack, and the cheapest defence against both is to stop, let information arrive, and start again. On-chain the same event is normally handled by a governance multisig that pauses a contract minutes after it mattered. This hook makes the rule mechanical and local to one pool.
It keeps a reference tick, refreshed at most once per `windowSeconds`. After every swap it compares the new tick to that reference. If the pool moved further than `maxTickMove`, swapping halts for `cooldownSeconds` and then resumes on its own.
There is no admin, no pause key and no way for anyone, including the deployer, to halt a pool that has not moved or to extend a halt that has expired. The design decision worth stating: the swap that breaches the limit is allowed to complete. Reverting it instead would turn the hook into a price cap, and a price cap on an AMM is a strictly worse instrument than a halt.
It cannot be enforced (the same move arrives as several smaller swaps), it strands the pool at a price the market has left, and it guarantees that the arbitrage against the pool stays open and profitable for as long as the cap holds. Halting after the fact gives up the last swap and buys the thing that actually matters, which is time. Liquidity operations are never blocked.
A provider can withdraw during a halt, which is the property that makes this safe to use: the worst case for someone caught in a halted pool is that they exit rather than trade. 0001^1`), so `maxTickMove = 500` is a five percent move. Prior art: pause-guardian patterns are everywhere and oracle-deviation checks exist as hooks.
An autonomous, self-clearing, per-pool halt with no privileged role and no oracle does not.
Prior art
Pause-guardian patterns are everywhere and oracle-deviation checks exist as hooks. An autonomous, self-clearing, per-pool halt with no privileged role and no oracle does not.
Where it does not help
A halt is a blunt instrument: it stops honest trading as well as the attack, and it leaves the pool arbitrageable the moment it lifts. It is the right trade only where the alternative is a pool drained at a price nobody would have quoted.
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.
hook.configure(
key,
CircuitBreakerHook.Config({
maxTickMove: /* uint24 */ 0,
windowSeconds: /* uint32 */ 0,
cooldownSeconds: /* uint32 */ 0
})
);
poolManager.initialize(key, startingSqrtPriceX96);
Parameters
| Parameter | Type | Units |
|---|---|---|
maxTickMove | uint24 | ticks |
windowSeconds | uint32 | seconds |
cooldownSeconds | uint32 | seconds |
From TypeScript
npm i @hookforge/sdk
import {getHook, hookAddress, poolKeyFor} from "@hookforge/sdk";
const hook = getHook("circuit-breaker");
const key = poolKeyFor({
hook: hookAddress("circuit-breaker", 8453), // Base
currencyA: USDC, currencyB: WETH,
tickSpacing: 60,
});
What it reverts with
| Error | Meaning |
|---|---|
InvalidConfig() | maxTickMove, windowSeconds and cooldownSeconds must all be non-zero. |
PoolAlreadyInitialized() | The pool already exists, so its configuration is final. |
PoolHalted(uint64) | Swapping is halted until until. Liquidity may still be added or removed. |
PoolNotConfigured() | The pool was initialized without a configuration for this hook. |
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 3, so every deployment of it has an address ending
in 0x10c0.
- 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)" # CircuitBreaker
cast call $HOOK "specURI()(string)" # https://circuit-breaker-1bp.pages.dev/hook.json
cast call $HOOK "hookTags()(string[])" # risk, circuit-breaker, oracle-free, no-admin
Build, test and deploy
git clone --recurse-submodules https://github.com/nirholas/circuit-breaker
cd circuit-breaker
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.