HookForge

Hooks / Order flow and MEV

PriorityFeeTax

Charges a swap in proportion to what it paid the block producer to get where it is in the block.

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

How it works

Position in a block is worth something only to flow that is racing: an arbitrageur closing a gap against a centralized venue, a liquidator, a sandwicher. A person swapping a hundred dollars of one token for another does not care whether they land at index 3 or index 30, and does not bid for it. So the priority fee a transaction attaches is a revealed measure of how much the trade is worth to the trader beyond the trade itself, and that surplus is value the pool's liquidity providers are the counterparty to.

basefee`, the priority fee per unit of gas actually paid, and adds a surcharge along a saturating curve: surcharge(priority) = maxSurcharge * priority / (priority + halfPriority) At `priority == halfPriority` the swap pays half the cap. The surcharge is an LP fee, so it goes to in-range liquidity; the hook takes nothing and holds nothing. The trader cannot dodge it by bidding low, because bidding low is exactly the concession the hook is asking for: a searcher who drops their priority fee to avoid the surcharge loses the race that made the trade profitable.

That is the point. The hook prices the option to be early rather than trying to detect who is early. Prior art: fee mechanisms keyed on realized volatility, on price movement and on swap size are all well covered.

The idea that priority fees reveal flow toxicity is discussed in the ordering-fee literature and in Uniswap's own research on priority-ordering auctions, but the surcharge itself has been implemented at the sequencer or the router, never inside the pool where the liquidity providers who bear the cost can be paid directly. Chain support, stated plainly. This works where there is a real priority-fee market: Ethereum, Base, Unichain, Optimism, Blast and other OP-stack chains.

On Arbitrum One transactions are ordered first-come-first-served and the priority fee is normally zero, so on that chain the hook charges `baseFee` and nothing more. It is safe there, it is simply inert, and a pool on Arbitrum should use {ArbTaxDecayHook} instead.

Prior art

Fee mechanisms keyed on realized volatility, on price movement and on swap size are all well covered. That priority fees reveal flow toxicity is discussed in the ordering-fee literature and in Uniswap research on priority-ordering auctions, but the surcharge has only ever been implemented at the sequencer or the router, never inside the pool where the liquidity providers who bear the cost can be paid directly.

Where it does not help

Needs a real priority-fee market. On Arbitrum One, where ordering is first-come-first-served and the priority fee is normally zero, the hook is safe but inert and a pool there should use ArbTaxDecay instead.

Configuring a pool

Uniswap v4 removed hookData from initialize, so a hook that needs per-pool parameters has to receive them out of band. PriorityFeeTax 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,
    PriorityFeeTaxHook.Config({
        baseFee: /* uint24 */ 0,
        maxSurcharge: /* uint24 */ 0,
        halfPriorityWei: /* uint128 */ 0
    })
);

// 2. Initialize the pool. The hook rejects a pool it was never configured for.
poolManager.initialize(key, startingSqrtPriceX96);
ParameterTypeUnits
baseFeeuint24hundredths of a bip (3000 = 0.30%)
maxSurchargeuint24hundredths of a bip (3000 = 0.30%)
halfPriorityWeiuint128wei per gas

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("priority-fee-tax");
const address = hookAddress("priority-fee-tax", 1);
const key = poolKeyFor({hook: address, currencyA: USDC, currencyB: WETH, tickSpacing: 60, dynamicFee: true});
console.log(poolId(key));

What it reverts with

ErrorMeaning
FeeTooLarge(uint24)A fee was configured above the protocol maximum of 100%.
InvalidHalfPriority()`halfPriorityWei` was zero, which would make every non-zero priority fee 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

ChainChain IDAddress
unichain1300xE1cbeD2C3A27dE98202ac5EA439fc8Cb06a71080
robinhood46630x9ae664D21C02e33658A608AF6A5fE8cC1fDE9080
base84530x782f29DCa46C5b5d6D9321A824aAf901e18B5080
arbitrum421610x8092f20876753cb41A4CCEA20665288C58af1080

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/PriorityFeeTaxHook.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)"    # PriorityFeeTax
cast call $HOOK "specURI()(string)"     # https://hookforge.pages.dev/schema/hooks/priority-fee-tax.json
cast call $HOOK "hookTags()(string[])"  # mev, dynamic-fee, order-flow, oracle-free