Docs · Hooks

The probe

How censoredHooks swaps through a hook's real pool, and adds and removes liquidity, without a transaction, without tokens, and with an exact answer.

Reading a hook's bytecode says what it could do. The probe says what it does: it runs a real swap through the real pool at the current block and compares the result with what a pool without a hook would have paid.

A contract that is never deployed

The probe is a small contract. Its source is short: it implements unlockCallback, and in it either swaps or adds then removes liquidity through the PoolManager, then reverts with the result.

It is never deployed. For each measurement, the scanner sends one eth_call to an empty address and passes a state override that places the probe's runtime code at that address for the length of the call. Inside the call:

  1. The probe calls unlock on the PoolManager.
  2. The PoolManager calls back into the probe's unlockCallback.
  3. The probe calls swap (or modifyLiquidity) with the real pool key. The PoolManager runs the hook's callbacks exactly as it would for anyone else.
  4. The probe reverts with the balance delta it was given, or with the revert data if the action failed.

Because the call reverts, nothing settles. The probe never has to pay the tokens it owes, so it needs no balance and no approvals, and nothing reaches the chain. The node still executes every line of the hook along the way, and the revert carries the result out.

Reading the pool first

Before probing, the scan reads the pool's state straight from the PoolManager's storage with extsload:

  • slot0: the square-root price, the current tick, the protocol fee for each direction and the LP fee;
  • the liquidity active at the current price.

A pool with no price has never been initialized and is skipped. A pool with no liquidity at its price is thin: there is nothing to measure.

Sizing the swap

Inside one tick-spacing interval, liquidity is constant. A swap that does not cross the interval's edge moves the price along a single curve, and its output can be computed exactly from the price, the liquidity and the fee. The probe uses that.

The scan finds the edge of the current interval in the direction of the swap, and sizes the swap at no more than a quarter of the way there, and no more than a small fraction of the liquidity at the price. With P the square-root price, L the liquidity, f the pool fee and a the amount in, the hookless output is:

a' = a * (1 - f)

token0 in:  P' = L * P / (L + a' * P)      out = L * (P - P')
token1 in:  P' = P + a' / L                out = L * (P' - P) / (P * P')

f is the pool fee as the pool stores it: the LP fee plus the protocol fee for that direction, combined the way the PoolManager combines them. If the expected output is too small to measure precisely, the pool is marked thin.

Measuring the take

The swap is an exact-input swap. The PoolManager returns what the probe paid and what it got. The take compares the rate the probe received with the rate a hookless pool would have given:

take = 1 - (got / paid) / (expected / amountIn)

It is expressed in parts per million (10,000 ppm = 1%). A hook that takes nothing gives a take of zero, up to rounding. A hook that keeps 3% of the output gives about 30,000. A hook that takes part of the input, through beforeSwapReturnDelta, shows up the same way, since the rate is computed per unit paid. A negative take means the hook added to the output.

The probe swaps once in each direction. Each direction ends in one of three ways:

  • Measured. The swap went through and the take is computed as above.
  • Refused. The swap reverted. Only a revert counts as refused. The report shows the hook's revert reason when it left one: the PoolManager wraps hook reverts, the scan unwraps them, and custom errors are named from the public signature database where possible.
  • Thin. The swap went through but consumed nothing, because the pool's liquidity ends at the current price on that side. It is reported as "no liquidity below the price" (selling currency0) or "no liquidity above the price" (selling currency1). This is common on launch pools nobody has traded yet, where all the liquidity sits on one side of the starting price. A pool with no liquidity at its price, or too little to measure precisely, is marked thin before any swap is tried. A thin direction is not measured and is not held against the hook.

Fee and take

The take is everything beyond the fee stored in the pool, and the two are judged separately:

  • The stored fee is the pool fee: the fee in the pool key, or, on a dynamic-fee pool, the LP fee the hook has written into the pool's storage. The probe expects it, so it does not show up as take. Its level is judged in check 04, which fails above 10%.
  • A fee the hook returns from beforeSwap for one swap only is not stored, so it is counted in the take, judged in check 02.

A trader pays both. The report shows the current fee next to each pool and the take on each swap line.

The LP round trip

The second probe adds liquidity and removes it in the same unlock. The range is centred on the current price (about 600 ticks either side, at least two tick spacings, rounded to the tick spacing), and the amount is a small fraction of the liquidity already there. Then the same liquidity is removed.

Three outcomes:

  • Both steps succeed. The scan compares what came back with what went in, token by token, and keeps the worst loss. v4 rounds in the pool's favour, so a loss of a few units is normal; anything up to 0.1% counts as whole.
  • The deposit is refused. The hook does not accept outside liquidity. The pool is closed to outside LPs; only the hook's own liquidity is in it. This is common for launch pools and is not a failure for an outside LP, who simply cannot enter.
  • The withdrawal is refused. Liquidity went in and could not come out. This is a fail.

What the probe is, and is not

It is the hook's real code, on the real pool, at the real block, through the real PoolManager. It is not a model of the hook.

It is also one particular trade: a small amount, from a fresh address that has never touched the pool, with empty hookData, at one block. A hook that treats senders, sizes, times or hookData differently will be measured on this trade, not on yours. See Limits.