Hooks / Risk
DrawdownCap
A limit down. The pool may not fall more than a fixed distance below where the current epoch opened, and the limit resets on a schedule rather than on anyone's say-so.
DrawdownCap implements 3 of the fourteen Uniswap v4 callbacks:
afterInitialize, beforeSwap, afterSwap.
How it works
{CircuitBreakerHook} is symmetric and reactive: a violent move in either direction halts the pool, then the halt clears. This is the other shape, and it is the one commodity and equity venues actually use. It is asymmetric, because a collapse and a rally are not the same event for the people holding the asset.
It is a hard cap rather than a trigger, so the fall never happens rather than being noticed after it did. And it resets on a clock, so everyone can see in advance when selling reopens and at what level. allowed while openTick - tick <= maxFallTicks, where openTick is the tick at the start of the epoch Buying is never restricted.
A pool at its limit can still be bid up, and doing so does not raise the limit for that epoch, because the reference is the epoch's opening price and not a running high. When the epoch rolls, the pool takes its current price as the new opening and gets a fresh allowance. As with {RatchetFloorHook}, the cap is expressed as a price a router can trade into: {sqrtPriceLimitDownX96} returns the value to pass as a swap's `sqrtPriceLimitX96`, so a seller fills as far as the cap allows and stops there.
The `afterSwap` revert is the backstop for callers that pass no limit. Liquidity operations are never blocked, so nobody is trapped by a limit-down epoch. One tick is one basis point to within rounding, so `maxFallTicks = 1000` is a ten percent daily limit.
Prior art
Trading halts and price bands are standard on regulated venues and absent on-chain, where the closest equivalents are governance pause switches and oracle-deviation guards. Hook implementations of trading hours exist. A scheduled, asymmetric, self-resetting limit down with no privileged role does not.
Where it does not help
A limit down does not stop a decline, it defers one. If the market has genuinely repriced, the pool reopens each epoch and falls again, one limit at a time, and in the meantime the gap between the pool and the real price is an arbitrage that grows. It buys holders time to react, which is worth something, and it costs liquidity providers the trades they would rather have made, which is not free.
Configuring a pool
Uniswap v4 removed hookData from initialize, so a hook that needs per-pool parameters has to
receive them out of band. DrawdownCap 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,
DrawdownCapHook.Config({
maxFallTicks: /* uint24 */ 0,
epochSeconds: /* uint32 */ 0
})
);
// 2. Initialize the pool. The hook rejects a pool it was never configured for.
poolManager.initialize(key, startingSqrtPriceX96);
| Parameter | Type | Units |
|---|---|---|
maxFallTicks | uint24 | tick |
epochSeconds | uint32 | seconds |
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("drawdown-cap");
const address = hookAddress("drawdown-cap", 1);
const key = poolKeyFor({hook: address, currencyA: USDC, currencyB: WETH, tickSpacing: 60});
console.log(poolId(key));
What it reverts with
| Error | Meaning |
|---|---|
InvalidConfig() | `maxFallTicks` or `epochSeconds` was zero. |
LimitDown(int24,int24) | The swap would take the pool past this epoch's limit down. Pass `sqrtPriceLimitDownX96` as a price limit. |
PoolAlreadyInitialized() | The pool already exists, so its configuration is final. |
PoolNotConfigured() | The pool was initialized without a configuration for this hook. |
Addresses
| Chain | Chain ID | Address |
|---|---|---|
| unichain | 130 | 0x96DEB6525EACf0847E7a1E0250ABe65a892dd0c0 |
| robinhood | 4663 | 0xCDbF94558886fB07615550c21CDbAf3b9b68d0c0 |
| base | 8453 | 0xd9bDc663eaDf9f1Cf3dEEa393D45Be6342b510C0 |
| arbitrum | 42161 | 0xf3dffF3782A5789fC987389a647C8061A76e10c0 |
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/DrawdownCapHook.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)" # DrawdownCap
cast call $HOOK "specURI()(string)" # https://hookforge.pages.dev/schema/hooks/drawdown-cap.json
cast call $HOOK "hookTags()(string[])" # risk, circuit-breaker, oracle-free, no-admin