ArbTaxDecay

ArbTaxDecay implements 2 of the fourteen Uniswap v4 callbacks: afterInitialize, beforeSwap.

drag to orbit

Uniswap v4 hook · Order flow and MEV

ArbTaxDecay

Prices the staleness of a pool: the longer a pool goes untraded, the more the next swap pays.

Family
Order flow and MEV
Callbacks
2 of 14
Fee
dynamic
Admin keys
none
Licence
Apache-2.0

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.

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,
    ArbTaxDecayHook.Config({
        baseFee: /* uint24 */ 0,
        maxSurcharge: /* uint24 */ 0,
        halfLife: /* uint32 */ 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

ParameterTypeUnits
baseFeeuint24hundredths of a bip (3000 = 0.30%)
maxSurchargeuint24hundredths of a bip (3000 = 0.30%)
halfLifeuint32seconds

From TypeScript

npm i @hookforge/sdk

import {getHook, hookAddress, poolKeyFor} from "@hookforge/sdk";

const hook = getHook("arb-tax-decay");
const key  = poolKeyFor({
  hook: hookAddress("arb-tax-decay", 8453),   // Base
  currencyA: USDC, currencyB: WETH,
  tickSpacing: 60, dynamicFee: true,
});

What it reverts with

ErrorMeaning
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.

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 2, so every deployment of it has an address ending in 0x1080.

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)"    # ArbTaxDecay
cast call $HOOK "specURI()(string)"     # https://arb-tax-decay.pages.dev/hook.json
cast call $HOOK "hookTags()(string[])"  # mev, lvr, dynamic-fee, oracle-free

Build, test and deploy

git clone --recurse-submodules https://github.com/nirholas/arb-tax-decay
cd arb-tax-decay
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.

Try it

This is the hook running, not a picture of it. Connect a wallet on a chain it is deployed to, or bring the whole stack up locally in one command and use it with no funds and no wallet risk at all.

Loading the demo… if this does not change, JavaScript is blocked and the demo cannot run.

Run the whole thing locally
git clone --recurse-submodules https://github.com/nirholas/arb-tax-decay
cd arb-tax-decay

anvil &
forge script script/DeployLocal.s.sol --rpc-url http://127.0.0.1:8545 --broadcast \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

node web/build.mjs && npx serve web/dist

The deploy script writes web/local.json itself and the build merges it, so the page points at the chain you just created without you editing anything. Point a wallet at http://127.0.0.1:8545 and every button on this page works.

Anvil's first account is pre-funded and its key is public by design. Never use it anywhere real.