Hooks / Curves
RatchetFloor
A price floor that only ever moves up.
RatchetFloor implements 2 of the fourteen Uniswap v4 callbacks:
afterInitialize, afterSwap.
How it works
A token's floor price is normally a promise: a treasury that says it will bid, a team that says it will buy back. Promises are only as good as the balance behind them and the people holding the keys. This hook makes the floor a property of the pool instead.
It records the highest tick the pool has ever reached and refuses to let the price settle more than `offsetTicks` below it: floor = max(floor, highWaterTick - offsetTicks) The floor is monotone by construction. It has no setter, no owner and no emergency path, so it cannot be lowered by anyone, including whoever deployed the pool. A rally raises it permanently; a decline never moves it.
How a swap meets the floor matters, so it is worth being precise. Uniswap v4 swaps already take a `sqrtPriceLimitX96`, and {sqrtPriceFloorX96} returns exactly the value to pass: a swap carrying it fills as much as the floor allows and stops there, which is the behaviour a seller wants. The `afterSwap` check is the backstop for callers that pass no limit, and for those the swap reverts rather than partially filling.
Routers should read the floor; the revert exists so that a router which does not cannot break the invariant. One tick is one basis point to within rounding, so `offsetTicks = 2000` is a floor twenty percent below the high.
Prior art
Floor prices are usually a treasury commitment (protocol-owned liquidity, OHM-style backing) or a buyback hook that spends fees defending a level. Both depend on a balance and on whoever can move it. Enforcing a monotone floor as an invariant of the pool, with no treasury and no key, is a different construction: nothing is spent defending it and nothing can lower it.
Where it does not help
This guarantees the pool will not print below the floor. It does not guarantee anyone can sell at the floor, because it holds no capital: once the price reaches the floor there is simply no more selling into the pool, and a holder who wants out has to wait for the price to recover or trade elsewhere. It converts a liquidity risk into a liquidity halt, honestly and predictably, but it does not make the risk disappear. A pool that needs a real bid at the floor needs a treasury behind it, and this is not that.
Configuring a pool
Uniswap v4 removed hookData from initialize, so a hook that needs per-pool parameters has to
receive them out of band. RatchetFloor 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,
RatchetFloorHook.Config({
offsetTicks: /* uint24 */ 0
})
);
// 2. Initialize the pool. The hook rejects a pool it was never configured for.
poolManager.initialize(key, startingSqrtPriceX96);
| Parameter | Type | Units |
|---|---|---|
offsetTicks | uint24 | tick |
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("ratchet-floor");
const address = hookAddress("ratchet-floor", 1);
const key = poolKeyFor({hook: address, currencyA: USDC, currencyB: WETH, tickSpacing: 60});
console.log(poolId(key));
What it reverts with
| Error | Meaning |
|---|---|
BelowFloor(int24,int24) | The swap would leave the pool below its floor. Pass `sqrtPriceFloorX96` as the swap's price limit. |
InvalidOffset() | `offsetTicks` was zero, which would pin the floor to the current price and stop the pool trading down. |
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 | 0x1EC0A28410eE094Ab636b2cf90174b878A75D040 |
| robinhood | 4663 | 0xee7bd53C33B8a3ffd2b2fbcBB9f1f99C0c42d040 |
| base | 8453 | 0x0Bc7Fe634b8fe736fcb7c96ee180f7DeB4bED040 |
| arbitrum | 42161 | 0xD70b7e7F9b45598167EB5199078ED15160345040 |
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/RatchetFloorHook.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)" # RatchetFloor
cast call $HOOK "specURI()(string)" # https://hookforge.pages.dev/schema/hooks/ratchet-floor.json
cast call $HOOK "hookTags()(string[])" # price-floor, launch, no-admin, oracle-free