HookForge

Hooks / 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.

CircuitBreaker implements 3 of the fourteen Uniswap v4 callbacks: afterInitialize, beforeSwap, afterSwap.

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.

Configuring a pool

Uniswap v4 removed hookData from initialize, so a hook that needs per-pool parameters has to receive them out of band. CircuitBreaker takes them through configure, which anyone may call for a pool key whose pool does not exist yet, and which nobody may call afterwards. The parameters are part of what the pool is, so they are fixed for its lifetime.

// 1. Fix the terms, before the pool exists.
hook.configure(
    key,
    CircuitBreakerHook.Config({
        maxTickMove: /* uint24 */ 0,
        windowSeconds: /* uint32 */ 0,
        cooldownSeconds: /* uint32 */ 0
    })
);

// 2. Initialize the pool. The hook rejects a pool it was never configured for.
poolManager.initialize(key, startingSqrtPriceX96);
ParameterTypeUnits
maxTickMoveuint24tick
windowSecondsuint32seconds
cooldownSecondsuint32seconds

From TypeScript

The SDK ships the catalogue, the address book and the pool-key helpers, so a client never hardcodes an address or recomputes a pool id by hand.

npm i @hookforge/sdk

import {getHook, hookAddress, poolKeyFor, poolId} from "@hookforge/sdk";

const hook = getHook("circuit-breaker");
const address = hookAddress("circuit-breaker", 1);
const key = poolKeyFor({hook: address, currencyA: USDC, currencyB: WETH, tickSpacing: 60});
console.log(poolId(key));

What it reverts with

ErrorMeaning
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.

Addresses

ChainChain IDAddress
unichain1300x0c21ED115396E51B78CD658DA833369AeE3750c0
robinhood46630xD80eef4823D8Fa3219604629703f96DaAa4090C0
base84530x799a653385E6d50770b1e6E7bc1994674C7e10C0
arbitrum421610x7b353a0b80170F165dc07C9F9b8903Ce9b92d0C0

These addresses are deterministic, not live. They are the CREATE2 addresses the deploy script mines so that the low fourteen bits encode this hook's permissions. Until the deploy runs on a chain, there is no code at them. Check before you send anything anywhere.

Source and verification

The contract is contracts/src/hooks/CircuitBreakerHook.sol, and everything on this page is generated from it: the prose is its NatSpec, the parameters are its configure ABI, the callbacks above are the flags it declares, and the tags are the strings its own hookTags() returns. A hook cannot be documented here as something it is not.

Ask the deployed contract what it is and it answers directly, with no registry in the loop:

cast call $HOOK "hookName()(string)"    # CircuitBreaker
cast call $HOOK "specURI()(string)"     # https://hookforge.pages.dev/schema/hooks/circuit-breaker.json
cast call $HOOK "hookTags()(string[])"  # risk, circuit-breaker, oracle-free, no-admin