TickHarberger

TickHarberger implements 4 of the fourteen Uniswap v4 callbacks: afterInitialize, beforeSwap, afterSwap, afterSwapReturnsDelta.

drag to orbit

Uniswap v4 hook · Fees and MEV

TickHarberger

Leases the right to price a pool's flow, one tick range at a time, under a Harberger tax.

Family
Fees and MEV
Callbacks
4 of 14
Fee
static
Admin keys
none
Licence
Apache-2.0

How it works

An automated market maker charges the same fee everywhere, which is the same as saying it believes every price level is equally valuable to trade at. No venue with a human on it believes that. The flow near the current price is worth something quite different from the flow forty percent away, and it changes hour by hour, and a governance vote on a single pool-wide number is not a mechanism for discovering it.

Auction-managed AMMs answered this by selling the right to set the fee for the whole pool, which is a real improvement and still one number. This sells it per tick range. Somebody who believes the band the price is sitting in is worth more than the market thinks can buy exactly that band, and nothing else.

The lease is Harberger. A holder names their own price for the range and pays continuous rent on that number, and anybody may take the range from them at any moment by paying it. Naming a low price is cheap and loses the range; naming a high one keeps it and costs.

There is no auction to run, no round to wait for, and nobody who decides who wins: the holder's own valuation is both the tax base and the strike, which is what makes the two honest at once. Rent accrues only while a range holds the current price, which is the only time the right is worth anything. That is not a concession, it is what makes squatting self-defeating: holding a distant range costs nothing, and the valuation that makes it free to hold is also the price at which it is taken away the moment it becomes valuable.

Rent goes to the pool's liquidity providers by donation, so the people whose capital is being priced are the people paid for it. What the leaseholder earns is the fee they set, taken on top of the pool's own, which is the position they paid rent to occupy.

Prior art

The am-AMM line of work (Adams, Milionis, Moallemi, Roughgarden) auctions the right to manage a whole pool, and v4 hooks implementing it exist. Harberger taxes on-chain go back to Radical Markets and appear in Wildcards, This Artwork Is Always On Sale and the partial-common-ownership NFT designs. Dynamic-fee hooks set one fee per pool from volatility or flow.

Leasing the fee-setting right per tick range under a continuous self-assessed tax, so that price levels are priced separately and by whoever thinks they know better, is the contribution here.

Where it does not help

Rent only accrues where the price actually is, so a pool that never moves pays rent on one range and leaves the rest free to hold. That is the intended incentive and it does mean the mechanism says nothing about ranges the price never visits. The lease is denominated in the pool's second currency, which must be an ERC-20, so a pool of native currency against nothing else cannot use it.

Rent reaches providers through `donate`, which credits whoever is in range when it settles rather than whoever was in range while it accrued; settling often keeps that close, and `settleRent` is callable by anyone for exactly that reason. Finally, a leaseholder charging the maximum is still charging it, so `maxFeePips` is the real protection for traders and a pool that sets it carelessly has sold them.

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,
    TickHarbergerHook.Config({
        rangeWidth: /* int24 */ 0,
        rentRateBps: /* uint32 */ 0,
        maxFeePips: /* uint24 */ 0,
        minDeposit: /* uint128 */ 0
    })
);

poolManager.initialize(key, startingSqrtPriceX96);

Parameters

ParameterTypeUnits
rangeWidthint24
rentRateBpsuint32basis points (10000 = 100%)
maxFeePipsuint24hundredths of a bip (3000 = 0.30%)
minDeposituint128

From TypeScript

npm i @hookforge/sdk

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

const hook = getHook("tick-harberger");
const key  = poolKeyFor({
  hook: hookAddress("tick-harberger", 8453),   // Base
  currencyA: USDC, currencyB: WETH,
  tickSpacing: 60,
});

What it reverts with

ErrorMeaning
CallbackNotPoolManager()Only the PoolManager may drive the unlock callback. Named distinctly because BaseHook declares its own.
DepositTooSmall(uint128)The deposit is below the pool's minimum, which would leave the lease with no runway.
FeeTooHigh(uint24)The fee asked for is above what this pool allows a leaseholder to charge.
HookFeeTooLarge()Fee is higher than the maximum allowed fee.
InvalidMaxFee()The ceiling on a leaseholder's fee must itself be below the protocol maximum.
InvalidRangeWidth()A range narrower than the tick spacing, or not a whole number of them, cannot be a range.
InvalidRentRate()A rent of zero is not a Harberger tax, and one above the whole valuation per day is confiscation.
InvalidValuation()A valuation of zero would make the lease free to take and free to hold, which is not a lease.
NothingToDo()There is nothing to settle or withdraw.
PoolAlreadyInitialized()The pool already exists, so its configuration is final.
PoolNotConfigured()The pool was initialized without a configuration for this hook.
SafeERC20FailedOperation(address)An operation with an ERC-20 token failed.

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

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)"    # TickHarberger
cast call $HOOK "specURI()(string)"     # https://tick-harberger.pages.dev/hook.json
cast call $HOOK "hookTags()(string[])"  # fees, harberger, auction, mev, no-admin

Build, test and deploy

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

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.