Reckn, ETHGlobal Tokyo 2026. Repository: https://github.com/psyto/reckn.
The hook: tokyo-2026/src/RecordGatedHook.sol.
Deployed on Sepolia at 0x68116b8086283E51227c61FD791b6Da1A4230080,
pool on the real PoolManager 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543.
Everything below is something we hit while building, with the measurement or the address that shows it. We have tried to keep out opinions we cannot back.
This one cost us a wrong assumption and is the item we would most like an answer to.
Our hook reverts NoSettledRecord(address swapper) and we expected swapper to be the account
that sent the transaction. On chain it is not. From the real refusal
0xda60d7dfβ¦:
reason 0x43a7f347 000000000000000000000000 25cc9656151acfd74e1cb4bd31f0e6e42bcb9525
^ our router, the contract that called unlock()
tx from 0xfa2582ecAD1186A171CB9626d1FcFDC0f7995321 <- the EOA that actually swapped
That is correct and documented behaviour once you know it β sender is whoever the PoolManager
is unlocked for β but it has a consequence worth stating loudly somewhere a hook author will
read it: a hook cannot gate on "who is trading" using sender. Everyone behind the same
router looks identical. The options we could find are hookData, which the router has to be
willing to forward and which the trader can therefore choose, or trusting a specific router
address, which is a different trust assumption than the hook looks like it is making.
Asked at the event, and answered β thank you. The pattern is a trusted router: the hook
keeps an allowlist of routers and calls IMsgSender(sender).msgSender() on them. The docs are
admirably direct about the cost β "verify the contracts are valid before adding them to the
list of trusted routers" β and there is no mechanism to check that a router tells the truth.
We are not going to implement it, and the reason is the whole project. Reckn exists to take
the party you have to trust out of a payment decision. A gate that depends on an allowlist
somebody maintains puts one back, in the last place we had removed it from. Our own build-time
check would refuse it as well: an allowlist needs an owner, and scripts/no-keys.sh fails the
build on one.
So the limit stays, and it is now a position rather than an omission: this pool is gated on a record existing, not on who is spending it. If a later version of v4 can hand a hook the end user without a trusted intermediary, that is the thing we would build on.
We stated the limit in our own contract rather than let it be found later:
It does not identify the swapper. β¦ A second agent could trade behind the first one's record.
A hook revert reaches the caller wrapped: WrappedError(address target, bytes4 selector, bytes reason, bytes details), outer selector 0x90bfb865. The trap is that
vm.expectRevert(WrappedError.selector) passes for any hook failing for any reason β
including a hook that is broken rather than refusing.
Our first version of that test asserted the outer selector and was green while proving nothing.
What the row actually needs is to strip four bytes, decode, and assert target is your hook and
reason starts with your error:
(address target,, bytes memory reason,) = abi.decode(stripped, (address, bytes4, bytes, bytes));
assertEq(target, address(hook));
assertEq(bytes4(reason_first_word), RecordGatedHook.NoSettledRecord.selector);Suggestion: a expectWrappedRevert(target, selector) style helper, or just this snippet in
the hook docs. Everyone writing a gating hook needs exactly this and will otherwise ship the
weaker assertion. (Our rows: test/RecordGatedHook.t.sol.)
Our first gate test initialised a pool, never added liquidity, and asserted the swap "went
through". It did β and moved nothing. beforeSwap had stopped refusing, which is a strictly
weaker claim than a swap executing, and the test could not tell the difference.
Adding liquidity via modifyLiquidity inside unlock turned it into a real measurement:
1.000000000000000000 in, 0.987158034397061298 out on 100e18 of liquidity over
[-600, 600] β 0.3% fee plus slippage. On chain:
0xa40bb316β¦.
Suggestion: the hook examples that ship with a test would be much stronger if the swap test provided liquidity and asserted balances. As written they teach the shape that cannot fail.
uint160(hook) & ALL_HOOK_MASK == BEFORE_SWAP_FLAG needs a mined CREATE2 salt, which is
expected. What is not obvious is that forge script broadcasts new X{salt: s}(...) through
the deterministic factory 0x4e59b44847b379578588920cA78FbF26c0B4956C, so the address must be
predicted against that address and not against the sending EOA. Predicting against the sender
produces a plausible address, a successful deploy, and a pool that then rejects the hook.
We found it by dry-running the whole script against a local anvil forked from Sepolia before
sending anything; the hook landed at the same address there as on the real chain, which is what
determinism is supposed to give you. Salt 20541 for BEFORE_SWAP_FLAG alone.
Suggestion: one line in the hook-deployment docs naming the factory. HookMiner handles this
for you, which is precisely why someone hand-rolling it will get it wrong.
Because we only flagged beforeSwap, liquidity provision never consults the hook. That is what
we want β our gate is on trading, not on providing liquidity β but it is the kind of thing a
reviewer notices and assumes is an oversight. We wrote it into the contract comment for that
reason.
Suggestion: the flag docs could say out loud that gating trading and gating liquidity are separate decisions, since the flags make it look like one surface.
SwapParamsandModifyLiquidityParamslive inv4-core/src/types/PoolOperation.sol, not inIPoolManager. Several examples and answers still show the old import path.- A swap cannot be one EOA transaction:
unlockmeans you deploy a router even for a demo where each step has to be a separate transaction somebody can open in a block explorer. - To land a refused swap on chain β which is the whole point of a gate β you have to send it
with an explicit
--gas-limit, because a reverting call cannot be gas-estimated. Not a v4 issue, but it is what a hook demo needs and nothing says so.
A settlement, not a self-report, creates the right to write one ENS record about a job β and the
hook makes that record the pass to a pool. Three transactions, same sender, same pool, same swap:
refused, then executed, then refused again after the record was cleared.
docs/tokyo-2026/RECEIPTS.md has all of them, checked in both
directions against a record generated from the chain.
The workload our zkVM re-executes is a v3 SwapRouter02 swap. Earning happens on v3,
spending happens on v4, and we keep those apart deliberately: we have not proven a v4 swap
inside the zkVM and do not claim to.