Hooks / Risk
OracleBand
Refuses to let a pool settle at a price the wider market does not recognise.
OracleBand implements 3 of the fourteen Uniswap v4 callbacks:
afterInitialize, beforeSwap, afterSwap.
How it works
A thin pool can be walked anywhere. Buy through the last tick of liquidity and the pool will quote you a price no other venue would, and whatever reads that pool afterwards, a lending market, a vault, another pool's oracle, inherits the number. The pool is not wrong: it faithfully reports what it was paid.
It is simply alone. This hook gives the pool a second opinion. Before and after every swap it compares the pool price against a reference from an {IPriceOracle} and rejects the swap if the result lands outside a band around it: deviation = |poolPrice / referencePrice - 1|, rejected when deviation > maxDeviationBps The check runs on both sides of the swap, and the pair is what makes it hard to defeat.
The `beforeSwap` check refuses to trade from a price that is already outside the band, so a pool pushed out of line in one transaction cannot be traded against in the next. The `afterSwap` check refuses to leave the pool outside the band, which is what stops the walk in the first place. Neither check can be satisfied by splitting a large swap into small ones, because the constraint is on the resulting price rather than on the size of the trade.
Prices are compared in `sqrtPriceX96`, squared back through `FullMath` so the comparison is on the actual price ratio and not on an approximation of it that drifts as the deviation grows. Liquidity operations are untouched. A provider can always withdraw, including while the band is refusing swaps, which is the property that makes it safe to sit behind one.
The obvious objection: this makes the pool depend on an oracle, and oracles fail. So the hook treats failure as refusal rather than as permission. A feed older than `maxStaleness` halts swapping instead of waving it through, and a feed that reverts propagates rather than being caught.
A pool that would rather trade blind than not trade should not use this hook, and the fee-based hooks in this catalogue are the oracle-free alternative.
Prior art
Oracle-deviation checks exist inside individual protocols, and Detox uses Pyth to detect MEV and redirect it. Both act after the fact, on a pool that has already printed the price. Enforcing the band as a precondition on both sides of the swap, so the out-of-band price is never written at all, is what is new here.
Where it does not help
The pool inherits the oracle's liveness. If the feed stops, swapping stops, and on a chain where the feed updates on a deviation threshold rather than a heartbeat, a quiet market can look stale. Set maxStaleness against the feed's actual publication cadence, not against how fresh you would like it to be.
Configuring a pool
Uniswap v4 removed hookData from initialize, so a hook that needs per-pool parameters has to
receive them out of band. OracleBand 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,
OracleBandHook.Config({
oracle: /* address */ 0,
maxDeviationBps: /* uint32 */ 0,
maxStaleness: /* uint32 */ 0
})
);
// 2. Initialize the pool. The hook rejects a pool it was never configured for.
poolManager.initialize(key, startingSqrtPriceX96);
| Parameter | Type | Units |
|---|---|---|
oracle | address | |
maxDeviationBps | uint32 | basis points (10000 = 100%) |
maxStaleness | uint32 |
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("oracle-band");
const address = hookAddress("oracle-band", 1);
const key = poolKeyFor({hook: address, currencyA: USDC, currencyB: WETH, tickSpacing: 60});
console.log(poolId(key));
What it reverts with
| Error | Meaning |
|---|---|
InvalidConfig() | `maxDeviationBps` or `maxStaleness` was zero, or no oracle was given. |
OutsideBand(uint256,uint32) | The pool price is outside the band around the reference. |
PoolAlreadyInitialized() | The pool already exists, so its configuration is final. |
PoolNotConfigured() | The pool was initialized without a configuration for this hook. |
ReferenceStale(uint256,uint32) | The reference price is older than the pool tolerates, so the pool declines to trade. |
Addresses
| Chain | Chain ID | Address |
|---|---|---|
| unichain | 130 | 0x1EF02aDF27051C3Ce6675D106239471f3D4C50C0 |
| robinhood | 4663 | 0x81702603B79E31274F4BA493c8bb06b48c5CD0c0 |
| base | 8453 | 0x24A6D3d3822D2aabAa21cCD12E5f35B1dC1F90C0 |
| arbitrum | 42161 | 0x92724249d217BFC7d14277E83844645C5862d0c0 |
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/OracleBandHook.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)" # OracleBand
cast call $HOOK "specURI()(string)" # https://hookforge.pages.dev/schema/hooks/oracle-band.json
cast call $HOOK "hookTags()(string[])" # risk, oracle, manipulation-resistance, no-admin