Hooks / Order flow and MEV
ArbTaxDecay
Prices the staleness of a pool: the longer a pool goes untraded, the more the next swap pays.
ArbTaxDecay implements 2 of the fourteen Uniswap v4 callbacks:
afterInitialize, beforeSwap.
How it works
Loss-versus-rebalancing is the dominant cost of providing liquidity to a constant-function AMM. It is paid when an arbitrageur brings a stale pool price back to the market price, and the size of that arbitrage grows with how long the pool sat unpriced. Ordinary flow does not have this property: a swap that lands one second after another swap is almost certainly not an arbitrage, because there was no time for the reference price to drift.
This hook turns that observation into a fee. It measures the time since the pool last traded and adds a surcharge that grows with it along a saturating curve, capped at `maxSurcharge`: surcharge(elapsed) = maxSurcharge * elapsed / (elapsed + halfLife) At `elapsed == halfLife` the arbitrageur pays half the cap; a swap in the same second as the previous one pays only `baseFee`. The surcharge is an LP fee, so the value it captures is paid to in-range liquidity providers.
The hook never custodies funds and holds no privileged role. Two properties make this cheap to reason about. It needs no oracle, so there is nothing to manipulate and no liveness dependency.
And it is monotone in a quantity the arbitrageur cannot control: waiting longer to arbitrage a pool only raises the toll, so the strategy that minimizes the tax is to trade the pool more often, which is exactly the behaviour that keeps the price fresh for everyone else. Prior art: dynamic-fee hooks keyed on realized volatility or on price movement are common, and the LVR literature (Milionis, Moallemi, Roughgarden, Zhang) motivates charging arbitrageurs more. Keying the fee on time-since-last- trade rather than on a price signal is the part that is new here, and it is what removes the oracle.
Limitation, stated plainly: on a pool that trades continuously the surcharge is near zero, so this hook does nothing for a busy major pair. It is aimed at the long tail, where pools are quiet for minutes or hours at a time and the arbitrage on the first trade back is the whole of the LP's loss.
Prior art
Dynamic-fee hooks keyed on realized volatility or on price movement are common, and the loss-versus-rebalancing literature (Milionis, Moallemi, Roughgarden, Zhang) motivates charging arbitrageurs more. Keying the fee on time since the last trade rather than on a price signal is what is new here, and it is what removes the oracle.
Where it does not help
On a pool that trades continuously the surcharge is near zero, so this does nothing for a busy major pair. It is aimed at the long tail, where pools sit quiet for minutes or hours and the arbitrage on the first trade back is the whole of the provider loss.
Configuring a pool
Uniswap v4 removed hookData from initialize, so a hook that needs per-pool parameters has to
receive them out of band. ArbTaxDecay 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,
ArbTaxDecayHook.Config({
baseFee: /* uint24 */ 0,
maxSurcharge: /* uint24 */ 0,
halfLife: /* uint32 */ 0
})
);
// 2. Initialize the pool. The hook rejects a pool it was never configured for.
poolManager.initialize(key, startingSqrtPriceX96);
| Parameter | Type | Units |
|---|---|---|
baseFee | uint24 | hundredths of a bip (3000 = 0.30%) |
maxSurcharge | uint24 | hundredths of a bip (3000 = 0.30%) |
halfLife | 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("arb-tax-decay");
const address = hookAddress("arb-tax-decay", 1);
const key = poolKeyFor({hook: address, currencyA: USDC, currencyB: WETH, tickSpacing: 60, dynamicFee: true});
console.log(poolId(key));
What it reverts with
| Error | Meaning |
|---|---|
FeeTooLarge(uint24) | A fee was configured above the protocol maximum of 100%. |
InvalidHalfLife() | `halfLife` was zero, which would make every swap pay the full surcharge. |
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. |
Addresses
| Chain | Chain ID | Address |
|---|---|---|
| unichain | 130 | 0x7C74267c059d1F073bE0D38ce2593a122e399080 |
| robinhood | 4663 | 0x968b61840d695f6232004bdA038f28B9Af34D080 |
| base | 8453 | 0x497DF79D84864CF23B9e1fF5966f01dD03dF5080 |
| arbitrum | 42161 | 0x06135B883a7220f1a4dE96dB557014C7d4009080 |
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/ArbTaxDecayHook.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)" # ArbTaxDecay
cast call $HOOK "specURI()(string)" # https://hookforge.pages.dev/schema/hooks/arb-tax-decay.json
cast call $HOOK "hookTags()(string[])" # mev, lvr, dynamic-fee, oracle-free