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:
FeeCommitHookpools fix their fee cap when they are created, and the swapper pins the fee seen in the quote throughhookData. The hook reverts if execution would charge more. - Before signing: the
@feelock/sdkguard quotes every candidate pool, simulates the real swap from the user's own address, and returnsSAFE,UNVERIFIED,FEE_MISMATCHorLOOKUP_FAILEDper 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
CanonicalPoolRegistryon 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 withNotIssuer. 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 inbeforeSwap.- 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'sregistered / not_registered / lookup_failedstates. - 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 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.
┌──────────────────────────── 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 itsowner()) can declare the token's canonical pool, once. No owner, no upgrades.FeeCommitHook: pools can only be created throughinitializePool, which storesmaxFee. The fee manager moves the fee within[0, maxFee].beforeSwapcharges the current fee and reverts withFeeAboveUserLimitwhenhookDatacarries a lower limit. V4Quoter forwards the samehookData, so a quote and its swap follow the same rule.@feelock/sdk:feelockGuardruns 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".
- 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
gasUsedequal 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_V4in the SDK); the fork test runs against it (FOUNDRY_PROFILE=fork forge test). The SDK builds Universal RouterV4_SWAPcalldata (buildUniversalRouterSwapCall) with the swapper's pinned fee inhookData. Monad has Universal Routers with two V4Router param layouts: 2.1.x expectsminHopPriceX36, 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).
No login and no install. Open feelock.vercel.app:
- 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.
- 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.
?feelock=onopens with the switch on. If the public RPC is down, the app says so and shows a recorded snapshot (?snapshotforces 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.
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 installLocal 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 devMonad 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:8787Each 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.
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 forkCI (.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).
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
hookDatapins 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, orFEE_MISMATCHwhen 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:StateFlipFeeHookquotes and simulates the same 0.3% (0 bps apart, stillUNVERIFIED), 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 withFeeAboveUserLimit(10000, 3000)(test,pnpm -F @feelock/sdk demo --scenario flip --execute). - No spot state. v4's flash accounting only settles balances when
unlockreturns, 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.solcrashes 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. declareByOwnertrusts the token's currentowner(); 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), soFeeCommitHook.beforeInitializeonly ever runs for third-party initializations, and always reverts.
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 |
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.
- 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:
QuoteAwareFeeHookand 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: Claudeline. Every claim in this README is backed by a test or a testnet transaction linked above.
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.