Skip to content
EarthIsMinePublic

About

quote-to-execution fee integrity for Uniswap v4 hooks

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

FeeLock

CI

The fee you see in the quote is the fee you pay.

A Uniswap v4 hook can charge a different fee on every swap, including a low one when a quoter asks and a high one when a real trade comes in. Best-quote routing walks straight into that. FeeLock closes the gap on Monad in two layers:

  • On chain: FeeCommitHook pools fix their fee cap when they are created, and the swapper pins the fee seen in the quote through hookData. The hook reverts if execution would charge more.
  • Before signing: the @feelock/sdk guard quotes every candidate pool, simulates the real swap from the user's own address, and returns SAFE, UNVERIFIED, FEE_MISMATCH or LOOKUP_FAILED per pool.

Built for Monad Metropolis (track: Trust, Identity & AI Infra). Live on Monad testnet: feelock.vercel.app (contracts in Deployments, judges start at For judges). Specification: docs/SPEC.md · demo script: docs/DEMO.md · submission text: docs/SUBMISSION.md.

What is new in this build window. FeeLock is a new codebase, built on 2026-10-12 and 2026-10-13 inside the Monad Metropolis build window (2026-09-01 to 2026-10-13). The idea comes from our ETHGlobal Tokyo project Klamp, which could only route around bad pools. Built here, for Monad:

  • Klamp kept each token's canonical pool in ENS text records on Sepolia. FeeLock replaces that with an on-chain CanonicalPoolRegistry on Monad, written once and read with one call. Its CREATE2 issuer check (declareByCreate2) is ported from Klamp's registrar; the token-owner() path (declareByOwner) is new; anyone else reverts with NotIssuer. The demo launchpad (ported) now creates a FeeCommitHook pool and declares it inside the launch transaction.
  • FeeCommitHook (a roadmap item in Klamp) is new: a fee cap fixed at pool creation, and the swapper's pinned fee enforced in beforeSwap.
  • The guard is redesigned: dual simulation (quote vs the real swap from the user's address) and the verdicts SAFE / UNVERIFIED / FEE_MISMATCH / LOOKUP_FAILED. The registry lookup keeps Klamp's registered / not_registered / lookup_failed states.
  • Monad-specific work: gas limits sized for a chain that bills the limit, a rate-limited transport for the public RPC, the testnet deployment, and Universal Router calldata for the official v4 deployment on Monad mainnet.

Who it is for. Routers, wallets and AI trading agents that pick a pool by its quote and sign without a human reading the route; launchpads that want their token's real pool on record; and the traders behind all of them.

The problem

The demo pair MON/FLD has two pools:

Pool Hook What the quoter sees What a swap pays
canonical FeeCommitHook, cap 1%, fee 0.3% 0.3% 0.3%
look-alike QuoteAwareFeeHook (attack) 0.05% 10%

The look-alike also sits one tick spacing above the canonical price, so it wins every quote comparison. On Monad testnet, the same 0.001 MON trade:

Route Quoted Received
Best quote (look-alike) 386,995.97 FLD 348,490.94 FLD (−9.94%) (tx)
FeeLock (canonical) 382,486.63 FLD 382,486.63 FLD (= quote) (tx)

At 5% slippage the naive swap reverts and the trader only loses gas; at 15% it goes through and the trader loses about 10%. Slippage settings cannot fix this: the loss is whatever the tolerance allows.

How it works

            ┌──────────────────────────── before signing (SDK) ────────────────────────────┐
 candidate  │ CanonicalPoolRegistry   FeeCommitHook        V4Quoter quote   eth_call of the  │  verdict
 pools ────▶│ declared by issuer?     trusted hook, cap?   (what a router   real swap from   │──▶ per pool
            │                                              sees)            the user's addr  │
            └───────────────────────────────────────────────────────────────────────────────┘
                                                   │ SAFE pool + hookData (pinned fee) + gas limit
                                                   ▼
            ┌──────────────────────────── at execution (hook) ─────────────────────────────┐
            │ FeeCommitHook.beforeSwap: fee = committed current fee; revert if above the     │
            │ cap or above the fee pinned in hookData                                        │
            └───────────────────────────────────────────────────────────────────────────────┘
  • CanonicalPoolRegistry: only a token's issuer (its CREATE2 deployer or its owner()) can declare the token's canonical pool, once. No owner, no upgrades.
  • FeeCommitHook: pools can only be created through initializePool, which stores maxFee. The fee manager moves the fee within [0, maxFee]. beforeSwap charges the current fee and reverts with FeeAboveUserLimit when hookData carries a lower limit. V4Quoter forwards the same hookData, so a quote and its swap follow the same rule.
  • @feelock/sdk: feelockGuard runs the registry and commitment lookups, the quote and the simulation at one block, and judges each pool:
Verdict When Recommended action
SAFE Static pool, or trusted FeeCommitHook pool whose cap is within the user's limit Proceed
UNVERIFIED Any other hook or dynamic-fee pool (a canonical one too, if nothing caps its fee) Requote over SAFE pools; warn if none
FEE_MISMATCH Simulated execution pays less than the quote beyond tolerance (10 bps), or a committed pool charges above its cap Block
LOOKUP_FAILED Any registry, commitment, quote or simulation lookup failed Hold and retry; the user cannot override

A route is as bad as its worst hop: FEE_MISMATCH > LOOKUP_FAILED > UNVERIFIED > SAFE. A failed lookup never reads as "not registered" or "not committed".

Why Monad

  • Fast blocks, stale quotes. Blocks come about every 400 ms, so the state a quote saw is quickly out of date. The guard reads at one pinned, uncached block, and the hook enforces the cap and the pinned fee at execution instead of trusting the quote.
  • The gas limit is billed. On Monad testnet the receipts of the demo swaps show gasUsed equal to the gas limit. The guard sets the limit from a fresh estimate plus 10% (deploy scripts use --gas-estimate-multiplier 110).
  • Public RPC limits. Monad's public RPC caps clients at 15 requests/sec and answers with a JSON-RPC error, not an HTTP 429. The SDK transport spaces requests (maxRequestsPerSecond) so the guard does not fail closed for that reason.
  • Mainnet stays official. On Monad mainnet FeeLock targets the official Uniswap v4 deployment (MONAD_MAINNET_V4 in the SDK); the fork test runs against it (FOUNDRY_PROFILE=fork forge test). The SDK builds Universal Router V4_SWAP calldata (buildUniversalRouterSwapCall) with the swapper's pinned fee in hookData. Monad has Universal Routers with two V4Router param layouts: 2.1.x expects minHopPriceX36, the router listed first decodes the older five fields and silently misreads the newer ones, so each router is paired with its layout (MONAD_MAINNET_UNIVERSAL_ROUTERS). On a mainnet fork, a swap through the official router with SDK calldata pays exactly the official quoter's quote, and a fee raised after the quote reverts it (FEELOCK_FORK_TESTS=1 pnpm -F @feelock/sdk test).

For judges

No login and no install. Open feelock.vercel.app:

  1. Without a wallet you can still click Review swap: the app finds the three pools, quotes them and, with the FeeLock switch on, simulates the swap and shows each pool's verdict (look-alike blocked at −9.94%, canonical SAFE, state-flip UNVERIFIED). Every pool, in detail opens the full comparison.
  2. To swap, connect MetaMask (or any EIP-6963 wallet) on Monad Testnet (chain 10143) with a little testnet MON from faucet.monad.xyz. Swap 0.001 MON with FeeLock off (15% slippage) and on, and compare the result cards.
  3. ?feelock=on opens with the switch on. If the public RPC is down, the app says so and shows a recorded snapshot (?snapshot forces it).

The on-chain backstop (scenario 2) needs the hook owner's key, so it is shown in the demo video and reproducible with the CLI on a local anvil chain (see Quick start) or from the test StateFlipAttack.t.sol.

Quick start

Requirements: Foundry 1.8+, Node 22+, pnpm 10.

git clone --recurse-submodules https://github.com/EarthIsMine/FeeLock && cd FeeLock
cp .env.example .env && ln -s ../.env contracts/.env   # Foundry only reads contracts/.env
pnpm install

Local demo (anvil):

anvil                           # terminal 1
pnpm deploy:local               # terminal 2: deploys everything, same addresses as contracts/deployments/31337.json
pnpm -F @feelock/sdk demo       # naive vs FeeLock in the terminal
FEELOCK_PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  pnpm -F @feelock/sdk demo --scenario flip --execute   # scenario 2, as anvil's default account (the hook owner)
VITE_CHAIN_ID=31337 pnpm -F web dev

Monad testnet (contracts already deployed):

pnpm -F @feelock/sdk demo --chain 10143             # simulation only
FEELOCK_PK=0x… pnpm -F @feelock/sdk demo --chain 10143 --execute
pnpm -F web dev                                      # http://localhost:5173, add ?feelock=on to start guarded

?snapshot shows the recorded testnet snapshot; the page also falls back to it, labelled, when the RPC is down.

Pool indexer (optional): without it, candidate pools come from the deployments file.

pnpm -F worker serve --chain 10143        # scans PoolManager Initialize + registry PoolDeclared, classifies, serves
curl 'localhost:8787/pools?token=0x1556af603750fd504Bd044bDcd61439D1eBD71dF'
VITE_POOLS_API=http://localhost:8787 pnpm -F web dev
pnpm -F @feelock/sdk demo --chain 10143 --pools-api http://localhost:8787

Each pool is classified static, fee-committed, unverified or unknown (a failed lookup), with a canonical flag. Monad's public RPCs limit eth_getLogs to 100 blocks, so the worker scans in 100-block chunks (--chunk), halves a chunk the RPC refuses, and checkpoints to data/pools.<chainId>.json so a long first scan resumes (the full testnet history since the deploy block, ~214k blocks, took 20 minutes on the public RPC; later passes only read new blocks). The classification is a hint only: the guard still checks every pool before signing.

Tests

cd contracts && forge test                               # 77 tests: registry, hook, fuzz + invariant, both attacks, flash manipulation, E2E, router
FOUNDRY_PROFILE=fork forge test                          # 6 tests against the official v4 deployment on Monad mainnet
pnpm -F @feelock/sdk test                                # 62 tests, on throwaway anvil chains deployed by the real scripts
pnpm -F worker test                                      # 4 tests: indexing, resume, chunk halving, HTTP API
FEELOCK_FORK_TESTS=1 pnpm -F @feelock/sdk test           # + 4 swaps through the official Universal Router on a mainnet fork

CI (.github/workflows/ci.yml) runs all of the above except the fork test on every push and pull request, plus the ABI sync check and the web build; the fork test runs on demand (Actions → CI → Run workflow).

Repository layout

contracts/      Foundry: CanonicalPoolRegistry, FeeCommitHook, demo contracts, deploy scripts, deployments/
packages/sdk/   @feelock/sdk: lookups, quote + simulation, judge, feelockGuard, CLI demo (cli/demo.ts)
apps/web/       Vite + React app: pool list, guard toggle, swap, verdicts, receipts, snapshot fallback
apps/worker/    Pool indexer: Initialize + PoolDeclared logs → classified pools, data/pools.<chainId>.json, HTTP API
scripts/        sync-abi.ts (forge out → SDK ABIs), deploy-local.sh
docs/           SPEC.md, DEMO.md, SUBMISSION.md

Design notes and limits

  • hookData pins the fee seen in the quote, not only the user's limit: a fee manager raising the fee within the cap after the quote makes the swap revert instead of costing more.
  • Execution-time enforcement covers FeeCommitHook pools only. Any other hook can ignore hookData; the guard keeps such pools out of the route (UNVERIFIED, or FEE_MISMATCH when the simulation shows the gap).
  • The simulation cannot see a hook that branches on block values, or one whose state changes between simulation and execution. For FeeCommitHook pools the cap and the pinned fee still hold; for other pools this is why they are never SAFE. Scenario 2 shows it: StateFlipFeeHook quotes and simulates the same 0.3% (0 bps apart, still UNVERIFIED), then its owner raises the fee to 10% and a naive swap at 15% slippage loses ~9.7%; the same move on the FeeCommitHook pool stops at the 1% cap and the swap carrying the pinned fee reverts with FeeAboveUserLimit(10000, 3000) (test, pnpm -F @feelock/sdk demo --scenario flip --execute).
  • No spot state. v4's flash accounting only settles balances when unlock returns, so inside one callback anyone can move a pool's price with capital they do not have, call a hook, move it back and settle. Hooks that judge by spot price or balances can be fooled that way. FeeCommitHook reads none: the fee comes from the stored commitment, checked against the cap fixed at creation and the swapper's pinned fee, and only the fee manager can change it. The registry gates on CREATE2/owner() issuers and on "initialized", which cannot be undone. FlashManipulation.t.sol crashes the pool's price to 1/120 with an unpaid swap inside one callback: every swap still pays exactly the committed 0.3%, a pinned lower fee still reverts, and the commitment is unchanged afterwards. A custom fee manager that reads spot state could be fooled, but only within [0, maxFee], and a pinned fee still stops it.
  • declareByOwner trusts the token's current owner(); whoever owns it first and declares fixes the pool for good.
  • The guard handles single-hop routes (DemoSwapRouter); the judge already combines multi-hop verdicts.
  • v4 skips a hook's own callbacks when the hook calls PoolManager (Hooks.noSelfCall), so FeeCommitHook.beforeInitialize only ever runs for third-party initializations, and always reverts.

Deployments

Monad testnet (10143), deployed from block 70435253. Full record: contracts/deployments/10143.json.

Contract Address Deploy tx
PoolManager (v4-core, testnet copy) 0x8a464ab1f9fe9b63ac9600b8ad3e98d69b033db8 0xe1275d30…
V4Quoter (v4-periphery) 0x31909f3de05310bc39137e945cde05d8db3481ef 0xb7bcfb4b…
StateView (v4-periphery) 0xd088d8ec06fe20a514535334fb0fcdaaf3cd4093 0x8159a928…
DemoSwapRouter 0x0defcb2536555a077cca551017f410d3bdaf2fcb 0xe88885f6…
CanonicalPoolRegistry 0xa97527da9e8b787e8793ac0a5e19fcb1a7d27586 0xc0ba1386…
FeeCommitHook 0x3729e78c687307975b5eef4abc26905d50902080 0x6c0bc07c…
DemoLaunchpad 0x7952f6ca5e2491ee23b0e443a5a3a8c60b27bdbf 0x187a38d0…
PoolSeeder 0x1626180ddee783d675035b924bda07705c5f89b5 0x6a8e55ab…
QuoteAwareFeeHook (attack demo) 0x4620ba7142c9e88d3ac905a6bc1f85c440c54080 0x17cedea2…
StateFlipFeeHook (scenario 2 demo) 0xe9fed65ca6691bd5b08f92c92c3a634f802e0080 0x5d705f83…

Demo token FLD: 0x1556af603750fd504Bd044bDcd61439D1eBD71dF. Pools:

Pool poolId Hook
canonical 0xb021a587a99dc657be5c2d013e3b5b776e07ec6c6557db90ac7fd517d5601af0 0x3729e78c687307975B5EEf4aBc26905d50902080
look-alike 0xb50665c0235b2690219a63e190fc9b2f9a690e9a236691dcafd3819f063dfa8e 0x4620ba7142C9e88d3Ac905A6bC1f85C440C54080
state-flip 0x35c2f47a9fd83f7d3e9568516a33bd562f45c970fa67de43d94ccf40422e1ca8 0xe9FeD65ca6691BD5b08f92C92c3A634f802e0080

Live demo swaps (testnet)

Same trade, 0.001 MON → FLD, sent with pnpm -F @feelock/sdk demo --chain 10143 --execute:

Route Pool Quoted Received Tx
Naive best quote look-alike (QuoteAwareFeeHook) 386,995.97 FLD 348,490.94 FLD (−9.94%) 0xdda717bc…
FeeLock guard canonical (FeeCommitHook, hookData pins 0.30%) 382,486.63 FLD 382,486.63 FLD (= quote) 0x9b008fc9…

Scenario 2 (demo --chain 10143 --scenario flip --execute): the state-flip pool quoted and simulated the same 370,167.84 FLD (0 bps apart, FeeLock: UNVERIFIED). The owner then raised its fee to 10% (tx), and a naive swap at 15% slippage received 334,171.13 FLD (−9.72%) (tx); the fee was reset afterwards (tx). The same move on the canonical pool stopped at its 1% cap, and the swap carrying the pinned 0.3% fee reverted with FeeAboveUserLimit(10000, 3000).

Monad mainnet uses only the official Uniswap v4 deployment (PoolManager 0x188d586Ddcf52439676Ca21A244753fA19F9Ea8e); FeeLock does not deploy its own v4 copy there.

Credits, prior code and AI use

  • Prior code. The idea comes from Klamp (ETHGlobal Tokyo 2026, same team). FeeLock is a fresh codebase. Files ported or adapted from Klamp record their source in the file header and commit message: QuoteAwareFeeHook and its attack test, the demo launchpad, token and pool seeder, the registry's CREATE2 declaration check, and the SDK's pool-key hashing and lookup states.
  • Third-party code. Uniswap v4-core and v4-periphery (BUSL-1.1 until their change date) and forge-std as Foundry dependencies; viem, React and Vite in TypeScript.
  • AI use. Much of the code, tests and docs were written with Claude Code (Anthropic) under the author's direction and review; those commits carry a Co-Authored-By: Claude line. Every claim in this README is backed by a test or a testnet transaction linked above.

License

MIT for FeeLock code. Uniswap v4 dependencies are BUSL-1.1 until their change date; FeeLock does not deploy its own copy of v4 to Monad mainnet and uses only the official deployment there. See LICENSE.

About

quote-to-execution fee integrity for Uniswap v4 hooks

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages