MarkoutFee implements 3 of the fourteen Uniswap v4 callbacks: afterInitialize, beforeSwap, afterSwap.
drag to orbit
Uniswap v4 hook · Order flow and MEV
MarkoutFee
A fee that learns. The pool measures whether its own past trades turned out to be informed, and charges the next one accordingly.
- Family
- Order flow and MEV
- Callbacks
- 3 of 14
- Fee
- dynamic
- Admin keys
- none
- Licence
- MIT
How it works
Every dynamic-fee hook published so far prices a swap from something observable at the moment it arrives: the size, the recent volatility, the gas it bid, the distance from an oracle. All of those are proxies for the question that actually matters, which is whether the person on the other side knew something. None of them measure it, because at the moment a swap arrives that fact has not happened yet.
It has happened a few seconds later. If a swap pushed the price down and the price kept falling, the seller was right and the liquidity that filled them lost; that is an informed trade, and it is the cost the LP literature calls adverse selection. If the price came back, the swap was noise and the fee it paid was pure revenue.
This is the markout that every market maker computes on their own flow, and a pool has everything needed to compute it: the tick it was left at, and the tick it is at now. So this hook grades each swap after the fact. On the next swap it compares the current tick to the tick the previous swap left behind, decides whether that swap was informed, and folds the verdict into an exponentially weighted average.
The fee it quotes is: fee = baseFee + maxSurcharge * toxicity A pool whose flow is mostly retail converges toward `baseFee` and becomes cheap. A pool being picked off converges toward the cap and becomes expensive, without anybody deciding that, and without an oracle to manipulate or a governance process to capture. The score is public, so anything else on-chain can read a pool's measured toxicity rather than guessing at it.
Two details keep the measurement honest. A verdict is only recorded once `minHorizon` seconds have passed, because a comparison inside the same block is measuring the swap's own price impact rather than what happened next. And the verdict is graded by magnitude rather than treated as a coin flip: a move that continues by a tenth of a tick is weak evidence, a move that continues by a hundred ticks is strong, so the observation is scaled and clamped instead of being rounded to a yes or a no.
{FlowClassifierHook} measures a related quantity and deliberately does nothing with it; this hook is the other half of that pair, the one that acts on what it measures. The hook takes no fee for itself, custodies nothing, and has no privileged role. `baseFee`, the cap, the smoothing and the horizon are fixed before the pool exists and can never be changed.
Prior art
Dynamic fees keyed on realized volatility, swap size, price movement or an oracle gap are all well covered, and markout is the standard way a market maker grades its own flow off-chain. Computing markout on-chain, from the pool's own tick history, and feeding it back as the pool's fee, is the contribution here. It is the difference between reacting to a proxy for adverse selection and measuring the thing itself.
Where it does not help
The verdict on a swap arrives with the next swap, so a pool that trades once a day prices today's flow on yesterday's evidence, and a pool with no second swap never grades the first. It is a lagging signal by construction: a regime change is paid for at the old rate until the average catches up. Note also that a sustained one-directional run grades as informed, because every swap in it is followed by one pushing the same way.
That is the intended reading rather than a flaw, but it does mean a pool tracking a strong trend will quote its trend-following flow expensively even when that flow is not picking anyone off.
Using it
Uniswap v4 removed hookData from initialize, so per-pool parameters arrive out of band.
Fix them for a pool key whose pool does not exist yet, then initialize. Nobody can change them afterwards,
including you.
hook.configure(
key,
MarkoutFeeHook.Config({
baseFee: /* uint24 */ 0,
maxSurcharge: /* uint24 */ 0,
alphaWad: /* uint64 */ 0,
minHorizon: /* uint32 */ 0,
saturationTicks: /* uint24 */ 0
})
);
poolManager.initialize(key, startingSqrtPriceX96);
The pool's fee field must be LPFeeLibrary.DYNAMIC_FEE_FLAG. The hook rejects a pool initialized without it, which is the most common integration failure.
Parameters
| Parameter | Type | Units |
|---|---|---|
baseFee | uint24 | hundredths of a bip (3000 = 0.30%) |
maxSurcharge | uint24 | hundredths of a bip (3000 = 0.30%) |
alphaWad | uint64 | fixed point, 1e18 = 1.0 |
minHorizon | uint32 | seconds |
saturationTicks | uint24 | ticks |
From TypeScript
npm i @hookforge/sdk
import {getHook, hookAddress, poolKeyFor} from "@hookforge/sdk";
const hook = getHook("markout-fee");
const key = poolKeyFor({
hook: hookAddress("markout-fee", 8453), // Base
currencyA: USDC, currencyB: WETH,
tickSpacing: 60, dynamicFee: true,
});
What it reverts with
| Error | Meaning |
|---|---|
FeeTooLarge(uint24) | A fee was configured above the protocol maximum of 100%. |
InvalidSaturation() | saturationTicks of zero would make every move maximally informed. |
InvalidSmoothing() | alphaWad must be in (0, 1e18]: zero never learns, above one overshoots. |
NotDynamicFee() | The hook was attempted to be initialized with a non-dynamic fee. |
PoolAlreadyInitialized() | The pool already exists, so its configuration is final. |
PoolNotConfigured() | The pool was initialized without a configuration for this hook. |
SurchargeTooLarge() | baseFee + maxSurcharge must leave room under the 100% protocol maximum. |
The callbacks it claims
Uniswap v4 reads a hook's permissions from the low fourteen bits of its own address, which is why deploying one
means mining a CREATE2 salt. This hook claims 3, so every deployment of it has an address ending
in 0x10c0.
- beforeInitialize
- afterInitialize
- beforeAddLiquidity
- afterAddLiquidity
- beforeRemoveLiquidity
- afterRemoveLiquidity
- beforeSwap
- afterSwap
- beforeDonate
- afterDonate
- beforeSwapReturnsDelta
- afterSwapReturnsDelta
- afterAddLiquidityReturnsDelta
- afterRemoveLiquidityReturnsDelta
It says what it is, on-chain
Nothing about a hook's address tells an indexer, a wallet, a router or an agent what the pool does, which is why
hook discovery today is a curated list. This hook answers for itself, in one eth_call, with no
registry in the loop.
cast call $HOOK "hookName()(string)" # MarkoutFee
cast call $HOOK "specURI()(string)" # https://markout-fee.pages.dev/hook.json
cast call $HOOK "hookTags()(string[])" # mev, dynamic-fee, markout, adverse-selection, oracle-free
Build, test and deploy
git clone --recurse-submodules https://github.com/nirholas/markout-fee
cd markout-fee
forge build && forge test
# Dry run: mines the salt, prints the address, sends nothing.
forge script script/Deploy.s.sol --rpc-url $RPC_URL
# For real.
forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcast --verify
Status
Unaudited. Built to an audited shape, on OpenZeppelin's audited hook bases, and tested against
a real PoolManager. No third party has reviewed it. Read "where it does not help" above before
putting money behind it. Not affiliated with Uniswap Labs.