Open the app

Routing a correcting pool

Read what a router, an aggregator or an indexer has to change for a pool that corrects: gas, the route's effect on the split, and the events.

What changes for a router, an aggregator, a quoter or an indexer when the pool on the other side of a swap runs an arbitrage correction. Four things change: the gas the swap needs, who gets paid out of what the correction captures, the fact that a swap can now be refused, and what the pool emits.

Which Pools These Are

A Hookr pool names its root in PoolKey.hooks, and the root is what decides whether a correction can run. Five roots are live on chain 4663, and the low fourteen bits of every one of their addresses are the same flag word, 0x28cc, so the address itself is the only thing that distinguishes them.

RootAddressCorrection
Default0xb3cA29cF721380CEe8b8e4755F3865Ebc68Fe8cCnone
Recapture, current0xb914f955294799de4b891bd2EA8AF628Fa1c68CCboth phases, and it can refuse a swap
Recapture, fourth0xE5429dB8f63912E632E86733905667AaEb6ea8cCboth phases, no refusal
Recapture, third0xa99902a2922014bBe2Bf2dCF15742ac5104828CcafterSwap only
Recapture, first0xc7c516CD5546bCB2592Fe3f8aa91C2A4bA3768CCnone on ordinary traffic

A superseded root is not a closed pool. Markets opened on one keep trading, so a router cannot treat "not the current recapture root" as "not a Hookr pool".

It cannot treat them as all correcting either. The first root's lane is admitted only when 3.1M gas is still free at afterSwap, which an ordinary swap does not carry, so nothing corrects there on real traffic; the third root corrects in one phase, not two. Sizing recapture across roots as four running lanes over-counts by two. GET /api/wth/pools on hookr.fun lists the pools on a root, with ?root= to ask about another one.

The Gas Point

Under-gassing a swap on the current recapture root reverts it. It does not silently skip the correction, and a router that has read otherwise about this lane has read about an earlier library.

HookrModularCorrectionLibV2, which the default root links, gated dispatch on gasleft() <= EXECUTOR_GAS_STIPEND + DISPATCH_GAS_RESERVE and skipped with SKIP_LOW_GAS (HookrModularCorrectionLibV2.sol:57 and :80). HookrModularCorrectionLibV3, which the current recapture root links, has no gasleft check at all: it declares SKIP_LOW_GAS at :55 and never references it, and its note at :30-36 states the consequence, that a swap sent with a fixed limit too small to cover the correction can run out of gas and revert, and that this is deliberate. A fixed floor let any caller take the arbitrage by sending just under it.

The practical effect is the opposite of the fail-open reading. Because the correction is now inside the cost of the call, eth_estimateGas prices it into its own answer rather than converging below it, so the ordinary estimate plus the usual headroom is the right instrument here. What is dangerous is overriding it downwards: the executor call takes 63/64 of the remaining gas, and the outer swap then runs out during settlement.

A floor is still worth carrying, as a floor and not a replacement, because an estimate taken against a block where the lane found nothing to do prices a cheaper swap than one where it did. The Hookr app carries 1,100,000 in src/lib/gas-headroom.ts, applied only on a pool whose root corrects, over an estimate plus 25%. Two fork observations sit behind that number, at a 750,000 limit and at 1,000,000; the run is recorded in a comment rather than in an evidence file, and the executor it ran against is not identified in either repository, so take the shape of the result rather than the constant and measure the pools being routed. Unused gas is refunded.

One more thing decides whether the pre-swap half runs at all, and it is not gas. The kernel sizes a beforeSwap correction only for a swap carrying the canonical full-fill price limit for its direction, MIN_SQRT_PRICE_LIMIT or MAX_SQRT_PRICE_LIMIT (HookrSwapKernelV5Wth.sol:323, :576-578). A router that sets its own sqrtPriceLimitX96 for slippage protection takes the post-swap half only, with no event marking the half that did not run. Use amountOutMinimum for slippage on these pools.

The Route Decides Who Gets Paid

A correction's realised profit is split in bps of the gross. Which of the two splits applies is decided by how the swap reached the pool, not by anything the trader chooses.

RecipientThrough the Hookr routerThrough any other caller
The pool's creator40%40%
The trader who triggered it20%nothing
The pool's in-range LPs20%40%
WTH10%10%
Hookr10%10%

The trader share exists only when there is an authenticated trader to pay. The root's own swap kernel decides that, not the accounting kernel: HookrSwapKernelV5Wth._prepare (:263-304) authenticates sender against the pool's frozen trustedRouter and trustedQuoter at their registered code hashes, and only then decodes a recipient out of the hookData envelope. An unauthenticated caller returns early at :277-284 with the recipient left at zero, because a rebate paid to a router contract would not reach the person who swapped.

The share moves to the LPs of the pool that triggered the correction instead, under HookrModularCorrectionLibV3's fallback constants at :46-48, which :82-87 builds into every request and :88-92 overrides only when a recipient was authenticated. This is the ordinary case for external flow, not an edge case. Debugging an unexpected LP-fallback split means checking caller authentication against the pool's frozen router, not the hookData encoding: the accounting kernel is reached afterwards by DELEGATECALL, with a re-encoded envelope, and never sees the correction recipient.

An aggregator choosing a path is choosing this on its user's behalf. Two identical swaps, one through 0xf0E528c39f33F565876cbaa7e0DFaCa38Df966E9 and one through a Universal Router, do not pay the same trader the same amount.

The shares are not a per-pool setting: the pool freezes a policy id, 0xd2653e091cb7002585fd8b1192b58f11b9c951061dc797123e37d5e2ccb45cef, which is keccak256 of a string naming all five of them, and that preimage names the authenticated-route waterfall.

Do not present the rounding as settled. HookrWthFeePolicyV2.allocate floors each named share and gives Hookr the residual, but nothing calls it: it is the only definition in the repository and it has no caller, and its isValid guard rejects the fallback column outright, since it requires traderBps == 2000 and triggerPoolBps == 2000. No Hookr contract in the correction path computes a payout at all. The hook names three shares in bps, the adapter checks they sum to 8,000, and the partner's executor does the arithmetic and the paying. An indexer recomputing the five legs from the gross is computing what was requested, not what was paid, and should label it that way.

The fallback split itself is readable: HookrModularCorrectionLibV3.sol:44-48 and :82-92 in the public contracts repository. The correction trust boundary says how far that can be checked.

A Swap Can Be Refused

On the current recapture root, 0xb914f955294799de4b891bd2EA8AF628Fa1c68CC, beforeSwap asks the executor whether a registered v3 pool for this pair is locked for re-entrancy, and reverts the swap when the answer is a clean true. A lock means the caller is inside that pool's callback.

This is by design and not a fault. It refuses the arbitrage bot that moves the reference venue first and closes its leg against the Hookr pool in the same transaction. A router that aggregates across several venues inside one transaction can trip it without intending anything of the kind, if one of the legs it holds open is a registered reference venue for the pair.

There is nothing to retry inside the same transaction. Split the route so the Hookr leg is not taken from inside another pool's callback, or route the pair elsewhere. The two selectors the root accepts for the view are checkV3PoolsMev, 0x6075521e, and checkV3PoolsMEV, 0x85c30352. Both are literals in HookrSwapKernelV5Wth.sol:81-82 in the public contracts repository, and both were read off the deployed root as well. The kernel tries them in that order and || short-circuits only on true, so on an ordinary swap it pays for both.

Quoting

Everything on SDK, router and quoter still applies: the Hookr quoter prices what a Hookr-router swap pays, Uniswap's V4Quoter prices what a Universal Router swap pays, and the two disagree by potBps on a pool with a pot.

A correcting pool adds a second and larger source of divergence. A correction that runs in beforeSwap moves the pool's price before the swap is priced, so the price a quote was taken at can be stale by one correction even within the same block. Whether the Hookr quoter simulates that beforeSwap correction was not checked for this documentation, so neither assume it does nor assume it does not: quote close to execution, and use amountOutMinimum for slippage rather than a price limit, which a pool with input cuts rejects anyway.

What an Indexer Sees

The correction is a seventh value stream through the pool, and it must not be summed with the six on Events for indexers. It has its own three events, emitted by the root:

event CorrectionAttemptSucceeded(
    PoolId indexed poolId, uint8 indexed phase, bytes32 indexed planDigest, uint256 realizedProfitQuote
);
event CorrectionAttemptFailed(PoolId indexed poolId, uint8 indexed phase, bytes32 payloadHash, bytes32 reasonHash);
event CorrectionAttemptSkipped(PoolId indexed poolId, uint8 indexed phase, bytes32 payloadHash, bytes32 reasonHash);

Four rules for counting them.

  • One swap can emit two CorrectionAttemptSucceeded. The lane gets one chance before the swap is priced and another after it settles. phase is indexed and carries 1 for the pre-swap chance and 2 for the post-swap one, the values HookrArbTypesV3 fixes. Deduplicating by transaction hash, the obvious thing to do, halves the measured recapture.
  • Failed and Skipped carry no value. They are the fail-open outcomes: the executor reverted, or the lane declined to run. A pool that emits them is working as specified.
  • realizedProfitQuote is the gross, in the pool's quote currency. The five recipient amounts are not in the log. Derive them from the bps above and the route, and label a derived split as derived.
  • The LP share is paid by the executor, not by Hookr. The policy library names it triggerPoolLp, and nothing in Hookr's published source performs that payment. An indexer that needs the LP leg confirmed should measure it against the pool rather than infer it from the event.

GET /api/wth/pools returns the signature and topic0 of each of these, plus the coordinator's MarketCreated filtered to the root, each computed from the deployed ABI rather than typed by hand.