Execution integrity for Uniswap v4

A hook can quote one price
and charge another.

The quote comes from eth_call. The charge happens in a transaction. A hook is arbitrary code that runs in both, and can tell them apart.

  1. What the simulator was quoted996,999,005,991,991
  2. What the transaction delivered817,539,331,628,894
17.99%taken without being quoted, by a hook that reads tx.gasprice

0x, 14 September 2026

“Uniswap v4 hooks were a mistake”

  • 84,163 hooks analysed, six chains
  • 54.2% malicious
  • 19.4% safe

They named one: 0x800cef53… on Base, an ETH/NVDAc pool: a median fee of 18% when it charged, and $143,037 taken.

Their analysis, their numbers. Cited, not reproduced here.

Hayden Adams, in reply

Skill issue, don’t route to bad hooks

Pointing integrators at the Uniswap API, which “avoids malicious hooks”. He is right, and this repo is an attempt to make that advice executable, because it leaves open the question an integrator actually faces: how do you know which ones are bad, at the moment you route?

15,309,659v4 pools indexed on Base
98.5%of them carry a hook
4of 25 measurable hooks charge more than they quote

The event everyone indexes does not record it

PoolManager emits Swap between beforeSwap and afterSwap, so its amounts exclude whatever the hook takes in afterSwap. Measured on Base, for a hook taking exactly one percent:

Swap event
3,941,355,102,139,778,949
swap() return value
3,901,941,551,118,381,160
difference
39,413,551,021,397,789. Exactly one percent

Read from the event, that hook appears to hand users an extra percent. It charges them. Any analytics built on Swap events under-reports exactly the hooks that take the most.

Named, on mainnet

A uniform random sample of Base fills, each re-quoted against the state immediately before it. These are the hooks that survived their own measurement noise.

HookFillsNet chargedMedian excessIn hooklist
0xa5c4a1be…5a41453622%99 bpsno
0x1f91c998…e02acc78211%400 bpsyes
0x0d5d83c5…aba8cc4117%45 bpsno
0x985c14ba…ca2acc7513%142 bpsyes

Roughly half of all charged fills in this sample are measurement error, quantified and published beside the result rather than left for a reader to find. Charged and over-delivered counts per hook.

Detection does not catch it

Static bytecode analysis, differential eth_call, and debug_traceCall were each scored against what hooks actually did to settled trades. Every one of them scored zero recall. Only re-quoting settled trades caught anything, and that works after someone has already been paid less than they were quoted.

A score tells you what a hook did last week. It cannot tell you what it is doing to your transaction right now.

The full matrix, and the small ground truth it rests on.

Caught on mainnet

The same swap, priced two ways

Fork Base at block 51,247,545 and send one identical swap twice, from two callers, into a pool behind hook 0xf54473f4…. Neither caller is known to the hook. Both were deployed seconds earlier.

a naive router receives
17,582,769
fee 0, nothing taken afterwards
SwornRouter receives
16,340,546
fee 700, and the hook moves a further slice out in afterSwap

Charged 707 bps more for the same trade. Sworn probed, saw what it was actually being offered, and settled on the hookless pool beside it for 17,438,404, recovering 672 bps.

The offline pipeline in this repo did not flag that hook. It saw a handful of charged fills against nearly as many over-delivered ones and correctly refused to call it a signal. A full re-quote of 10,000 fills, with a noise floor and a sensitivity sweep, missed a hook that one in-transaction probe caught immediately. That tells against this measurement as much as anyone else’s.

The solution

Ask once.

Every router today asks a hook a question off-chain, then acts on the answer on-chain. Those are two different calls, and a hook can answer them differently. Sworn makes the quote and the trade the same call.

contracts/src/SwornRouter.sol, verbatim

// 4. The assertion the whole design rests on.
if (execIn != amountsIn[chosen] || execOut != amountsOut[chosen]) {
    revert Divergence(chosen, probed, executed);
}

Both sides are checked, so a hook cannot quietly take more input either. That is the entire product. Everything below exists to make those four lines mean something.

  1. unlockPoolManager hands control back to the router
  2. proberun each candidate for real, then revert. State and transient storage roll back
  3. selectkeep the best probed delta and the route that produced it
  4. executerun that route through the same entry point
  5. assertexecuted == probed, or the whole transaction reverts

Why a hook cannot tell it is being probed

To cheat Sworn a hook would have to answer the probe honestly and the execution dishonestly, which means telling them apart. Here is everything it could try.

tx.gasprice, tx.origin, block.*
The probe runs in the same transaction. Every one of these is identical.
msg.sender, call depth, calldata
Probe and execution go through the same external self-call. The probing flag is read only after the last externally observable call.
gasleft()
Both get the same stipend, enforced against EIP-150’s 63/64 rule.
A counter in storage
The probe reverts, so its own bookkeeping rolls back with it.
A counter in transient storage
EIP-1153 slots do not survive the revert either.
Refusing to be probed
A reverting candidate is skipped, and the swap settles through another.

A hook that wants to overcharge you has to overcharge the probe by the same amount, at which point Sworn routes around it and the hook earns nothing.

Twelve attacker capabilities, each with a working fixture in ToxicHooks.sol that tries the attack and fails.

What the guarantee is worth

For every measured fill, every other pool that could have filled the same trade was quoted against the same pre-fill state. Probing costs a fixed amount of gas and saves a proportion of the trade, so it pays above a trade size and not below it.

$22.77
break-even trade size
below this, probe gas exceeds the expected saving
65 bps
median protection where a better route existed
3.0%
of fills with an alternative had a better one
105 of 3,478
$0.0045
median cost to protect one trade

Only 7.3% of these fills pay out in a token this repo can value from the chain, and 2 candidate routes quoting implausible multiples were excluded as mispriced dust rather than counted as recovered value. Both are published so the dollar figures can be discounted accordingly.