Modules
How modules combine
Fees are in pips: 1,000,000 pips are 100%, so 3,000 pips are 0.30%. Moves and cuts are in basis points: 10,000 are 100%. Every module writes into one internal function, _rules, which runs on each swap in a fixed order:
- The refusals first: allowlist, cooldown, price guard. Nothing is written when a swap is refused.
- Then the bookkeeping: the cooldown's last swap, the volatility average, the price guard's window.
- Then the fee proposals, where the highest wins, and the rebates, where the largest wins.
On a hook of its own, every swap pays at least the base fee (0 to 100,000 pips, 3,000 by default), and the rebate comes off the result. The pool must be opened with the dynamic fee flag for the override to apply. This is the end of _beforeSwap for anti-snipe, cooldown and AI agent rebate together:
function _beforeSwap(address, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
(uint24 fee, uint8 rebate, uint8 refused) = _rules(key.toId(), tx.origin, params.zeroForOne);
if (refused == COOLING_DOWN) revert CoolingDown(lastSwapAt[key.toId()][tx.origin] + COOLDOWN);
uint256 f = fee > BASE_FEE ? fee : BASE_FEE;
if (rebate > 0) f = f * (100 - rebate) / 100;
return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, uint24(f) | LPFeeLibrary.OVERRIDE_FEE_FLAG);
}A stacked hook does not compose its own fee: it answers the hook stack with its proposal, its rebate and a veto, and the stack composes the answers of every hook in the pool.
function beforeSwap(address origin, PoolKey calldata key, SwapParams calldata params)
external
returns (uint24 feePips, uint8 rebatePct, bool veto);
function afterSwap(PoolKey calldata key, SwapParams calldata params, BalanceDelta delta) external;
}
contract Example is ILog {
using PoolIdLibrary for PoolKey;
using StateLibrary for IPoolManager;
/// The v4 Marketplace hook stack (the Dam): the only caller.
address public immutable hookStack;
uint256 public constant CALM_FEE = 500; // 0.05%
uint256 public constant MAX_FEE = 20000; // 2%
uint256 public constant FEE_PER_PERCENT = 2000; // pips added for each 1% of average move
uint256 public constant CLOSED_FEE = 10000; // 1% while the stock market is closed
IPoolManager public constant POOL_MANAGER = IPoolManager(0x8366a39CC670B4001A1121B8F6A443A643e40951);
struct PoolState {
uint64 openedAt;
int24 lastTick;
uint32 ewma; // 100 x the average tick move between swaps
}01Anti-snipe fee
A high fee at the open that falls to nothing.
Targets: own hook and stacked hook. Callbacks on an own hook: afterInitialize, beforeSwap.
The Solidity it writes
uint256 public constant LAUNCH_FEE = 250000; // 25% at the open
uint256 public constant LAUNCH_WINDOW = 600; // seconds for it to fall to zero
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal
override
returns (bytes4)
{
if (!key.fee.isDynamicFee()) revert NeedsDynamicFee();
PoolId id = key.toId();
PoolState storage s = pools[id];
s.openedAt = uint64(block.timestamp);
return this.afterInitialize.selector;
}
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId id, address, bool)
internal
view returns (uint24, uint8, uint8)
{
uint24 fee;
PoolState storage s = pools[id];
{
uint256 elapsed = block.timestamp - s.openedAt;
if (elapsed < LAUNCH_WINDOW) {
uint256 f = LAUNCH_FEE * (LAUNCH_WINDOW - elapsed) / LAUNCH_WINDOW;
if (f > fee) fee = uint24(f);
}
}
return (fee, 0, 0);
}02Volatility fee
Cheap when the price is calm, dearer when it jumps.
Targets: own hook and stacked hook. Callbacks on an own hook: afterInitialize, beforeSwap.
The Solidity it writes
uint256 public constant CALM_FEE = 500; // 0.05%
uint256 public constant MAX_FEE = 20000; // 2%
uint256 public constant FEE_PER_PERCENT = 2000; // pips added for each 1% of average move
function _afterInitialize(address, PoolKey calldata key, uint160, int24 tick)
internal
override
returns (bytes4)
{
if (!key.fee.isDynamicFee()) revert NeedsDynamicFee();
PoolId id = key.toId();
PoolState storage s = pools[id];
s.openedAt = uint64(block.timestamp);
s.lastTick = tick;
return this.afterInitialize.selector;
}
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId id, address, bool)
internal
returns (uint24, uint8, uint8)
{
uint24 fee;
PoolState storage s = pools[id];
(, int24 tick,,) = poolManager.getSlot0(id);
{
int256 d = int256(tick) - int256(s.lastTick);
uint256 move = uint256(d < 0 ? -d : d);
uint256 e = (7 * uint256(s.ewma) + 100 * move) / 8;
if (e > type(uint32).max) e = type(uint32).max;
s.ewma = uint32(e);
s.lastTick = tick;
// e is 100 x the average move in ticks, and 100 ticks are a 1% move.
uint256 v = CALM_FEE + FEE_PER_PERCENT * e / 10_000;
if (v > MAX_FEE) v = MAX_FEE;
if (v > fee) fee = uint24(v);
}
return (fee, 0, 0);
}03Off-hours fee
A higher fee while the stock market is closed.
Targets: own hook and stacked hook. Callbacks on an own hook: beforeSwap.
The Solidity it writes
uint256 public constant CLOSED_FEE = 10000; // 1% while the stock market is closed
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal
pure
override
returns (bytes4)
{
if (!key.fee.isDynamicFee()) revert NeedsDynamicFee();
return this.afterInitialize.selector;
}
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId, address, bool)
internal
view returns (uint24, uint8, uint8)
{
uint24 fee;
if (_marketClosed(block.timestamp) && CLOSED_FEE > fee) fee = uint24(CLOSED_FEE);
return (fee, 0, 0);
}
/// NYSE regular hours, 14:30 to 20:00 UTC, Monday to Friday. Holidays count as open.
function _marketClosed(uint256 t) internal pure returns (bool) {
uint256 weekday = (t / 1 days + 4) % 7; // 0 is Sunday
if (weekday == 0 || weekday == 6) return true;
uint256 second = t % 1 days;
return second < 14 hours + 30 minutes || second >= 20 hours;
}04Cooldown
One swap per wallet per window.
Targets: own hook and stacked hook. Callbacks on an own hook: beforeSwap.
The Solidity it writes
uint256 public constant COOLDOWN = 30; // seconds between two swaps of one wallet
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId id, address origin, bool)
internal
returns (uint24, uint8, uint8)
{
uint256 last = lastSwapAt[id][origin];
if (last != 0 && block.timestamp < last + COOLDOWN) return (0, 0, COOLING_DOWN);
lastSwapAt[id][origin] = block.timestamp;
return (0, 0, 0);
}05Price guard
Stops swaps that push a runaway price further.
Targets: own hook and stacked hook. Callbacks on an own hook: afterInitialize, beforeSwap.
The Solidity it writes
int24 public constant MAX_MOVE_TICKS = 953; // a 10% move
uint256 public constant GUARD_WINDOW = 300; // seconds
function _afterInitialize(address, PoolKey calldata key, uint160, int24 tick)
internal
override
returns (bytes4)
{
PoolId id = key.toId();
PoolState storage s = pools[id];
s.openedAt = uint64(block.timestamp);
s.windowStart = uint64(block.timestamp);
s.windowTick = tick;
return this.afterInitialize.selector;
}
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId id, address, bool zeroForOne)
internal
returns (uint24, uint8, uint8)
{
PoolState storage s = pools[id];
(, int24 tick,,) = poolManager.getSlot0(id);
if (block.timestamp >= s.windowStart + GUARD_WINDOW) {
s.windowStart = uint64(block.timestamp);
s.windowTick = tick;
} else {
int256 moved = int256(tick) - int256(s.windowTick);
// A swap of token0 for token1 lowers the tick; the other way raises it.
if ((moved > MAX_MOVE_TICKS && !zeroForOne) || (moved < -MAX_MOVE_TICKS && zeroForOne)) return (0, 0, PRICE_GUARD);
}
return (0, 0, 0);
}06Launch allowlist
Only listed wallets can swap at the open.
Targets: own hook and stacked hook. Callbacks on an own hook: afterInitialize, beforeSwap. The deployer's wallet becomes the owner of the allowlist and can add or remove wallets after the deploy.
The Solidity it writes
uint256 public constant ALLOWLIST_WINDOW = 900; // seconds after the open
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal
override
returns (bytes4)
{
PoolId id = key.toId();
PoolState storage s = pools[id];
s.openedAt = uint64(block.timestamp);
return this.afterInitialize.selector;
}
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId id, address origin, bool)
internal
view returns (uint24, uint8, uint8)
{
PoolState storage s = pools[id];
if (block.timestamp < s.openedAt + ALLOWLIST_WINDOW && !allowed[origin]) return (0, 0, NOT_ALLOWED);
return (0, 0, 0);
}
/// The deployer adds or removes wallets from the launch allowlist.
function setAllowed(address[] calldata wallets, bool ok) external {
if (msg.sender != owner) revert NotOwner();
for (uint256 i; i < wallets.length; i++) {
allowed[wallets[i]] = ok;
emit Allowed(wallets[i], ok);
}
}07Holder rebate
Holders of a token pay a lower fee.
Targets: own hook and stacked hook. Callbacks on an own hook: beforeSwap. The balance is read with a capped static call: a token that reverts counts as zero, so it can never make a swap fail.
The Solidity it writes
address public constant HOLD_TOKEN = 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168;
uint256 public constant HOLD_MIN = 1000000; // in the token's own units
uint8 public constant HOLDER_REBATE = 25; // percent off the fee
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal
pure
override
returns (bytes4)
{
if (!key.fee.isDynamicFee()) revert NeedsDynamicFee();
return this.afterInitialize.selector;
}
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId, address origin, bool)
internal
view returns (uint24, uint8, uint8)
{
uint8 rebate;
if (_balance(HOLD_TOKEN, origin) >= HOLD_MIN && HOLDER_REBATE > rebate) rebate = HOLDER_REBATE;
return (0, rebate, 0);
}
/// A balance read that can never make the swap fail: a token that reverts or answers badly counts as zero.
function _balance(address token, address wallet) internal view returns (uint256 b) {
(bool ok, bytes memory ret) = token.staticcall{gas: 30_000}(abi.encodeWithSelector(bytes4(0x70a08231), wallet));
if (ok && ret.length >= 32) b = abi.decode(ret, (uint256));
}08AI agent rebate
Registered AI agents pay a lower fee.
Targets: own hook and stacked hook. Callbacks on an own hook: beforeSwap. The identity is read from the chain's ERC-8004 identity registry, with the same capped static call.
The Solidity it writes
address public constant AGENT_REGISTRY = 0x8004A169FB4a3325136EB29fA0ceB6D2e539a432; // ERC-8004 identity registry
uint8 public constant AGENT_REBATE = 25; // percent off the fee
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal
pure
override
returns (bytes4)
{
if (!key.fee.isDynamicFee()) revert NeedsDynamicFee();
return this.afterInitialize.selector;
}
/// The rules, in order: the refusals first (nothing is written when a swap is refused),
/// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
function _rules(PoolId, address origin, bool)
internal
view returns (uint24, uint8, uint8)
{
uint8 rebate;
if (_balance(AGENT_REGISTRY, origin) > 0 && AGENT_REBATE > rebate) rebate = AGENT_REBATE;
return (0, rebate, 0);
}
/// A balance read that can never make the swap fail: a token that reverts or answers badly counts as zero.
function _balance(address token, address wallet) internal view returns (uint256 b) {
(bool ok, bytes memory ret) = token.staticcall{gas: 30_000}(abi.encodeWithSelector(bytes4(0x70a08231), wallet));
if (ok && ret.length >= 32) b = abi.decode(ret, (uint256));
}09Swap tax
A cut of every swap, sent to a wallet.
Targets: own hook only. Callbacks on an own hook: afterSwap, afterSwapReturnDelta.
The Solidity it writes
uint256 public constant TAX_BPS = 30; // 0.3% of each swap
address public constant TAX_TO = 0x000000000000000000000000000000000000dEaD; // burned
/// The cut is taken in the swap's unspecified currency: the output of an exact-input swap,
/// the input of an exact-output one. The swapper's delta carries it, the pool is untouched.
function _afterSwap(address, PoolKey calldata key, SwapParams calldata params, BalanceDelta delta, bytes calldata)
internal
override
returns (bytes4, int128)
{
bool specifiedIs0 = (params.amountSpecified < 0) == params.zeroForOne;
(Currency currency, int128 amount) = specifiedIs0 ? (key.currency1, delta.amount1()) : (key.currency0, delta.amount0());
if (amount < 0) amount = -amount;
uint256 cut = uint256(uint128(amount)) * TAX_BPS / 10_000;
if (cut == 0) return (this.afterSwap.selector, 0);
poolManager.take(currency, TAX_TO, cut);
return (this.afterSwap.selector, int128(int256(cut)));
}10Liquidity lock
No liquidity leaves the pool before a date.
Targets: own hook only. Callbacks on an own hook: afterInitialize, beforeRemoveLiquidity.
The Solidity it writes
uint256 public constant LP_LOCK = 2592000; // 30 days after the open
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal
override
returns (bytes4)
{
PoolId id = key.toId();
PoolState storage s = pools[id];
s.openedAt = uint64(block.timestamp);
return this.afterInitialize.selector;
}
function _beforeRemoveLiquidity(address, PoolKey calldata key, ModifyLiquidityParams calldata, bytes calldata)
internal
view
override
returns (bytes4)
{
uint256 until = pools[key.toId()].openedAt + LP_LOCK;
if (block.timestamp < until) revert LiquidityLocked(until);
return this.beforeRemoveLiquidity.selector;
}