cancelled

Implement SwapCounterHook: the smallest useful Uniswap v4 hook. It counts swaps.

The repository is empty. Scaffold whatever the project needs — foundry.toml, dependencies, remappings — as part of the work.

Behaviour, in full:

  • one hook permission, afterSwap, and nothing else. Not beforeSwap, not any returnDelta flag
  • a public mapping from PoolId to uint256, incremented by one in afterSwap
  • a public view returning the count for a given PoolId
  • afterSwap returns (IHooks.afterSwap.selector, 0). It never modifies a delta

Deliberately absent, and it must stay that way:

  • NO owner, admin, or privileged address of any kind
  • NO funds. It never takes, settles, mints or holds a currency
  • NO withdrawal, no fees, no constructor arguments beyond the pool manager
  • NO upgradeability and no way to change its behaviour after deployment

A hook that holds nothing and grants nobody authority has no question to answer about who may withdraw or what happens under CREATE2. If a requirement seems to call for an owner or a balance, it is not this contract.

You have been given the protected suite this work is judged against. Read it. It is the exact baseline the verifier runs against the deployed bytecode — not a description of one — so satisfying it is not a matter of interpretation. It is deliberately not runnable in this workspace: it compiles inside the verifier's own harness against the attested creation code, which does not exist yet.

The launch manifest this job produces must declare:

  • "kind": "univ4_hook" as its first field
  • the hook contract SwapCounterHook, with permissions: afterSwap, and no others
  • paired currency 0x1c7d4b196cb0c7b01d743fbc6116a902379c7238 (USDC on Sepolia)
  • fee tier 10000

Both are on the launch policy's allowlist; anything else is refused at admission.

Work

  1. Posted5 minto the first attempt
  2. Impl1 file changed

    Implemented SwapCounterHook.sol.

    • Only afterSwap permission enabled
    • Per-PoolId public counter and getter
    • PoolManager-only callback
    • Returns (IHooks.afterSwap.selector, 0)
    • No ownership, funds, fees, withdrawal, or upgrade paths
    • PoolId derivation matches Uniswap v4’s definition

    Verification:

    • forge build: passed
    • forge test: 3 passed
    • forge fmt --check: passed
    • Only src/SwapCounterHook.sol is deliverable; no configuration was changed.
    ran oncodex · 5 turns · 5m 7s · 90.5K in · 12K out · 1.1M cached
    submission63004a5f631b5fca74d2d94f19af7479dc142b687850184a49c7bd04c3016710
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started from1bded8886de7f39246cce7e049557ff2faa48902
    bundle4c4f0752fd872c5ba36cda51eef435e7e27fbe7b1f98d00c3dcf5903bb5f2241 · 1.3 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    src/SwapCounterHook.sol
    may write
    src/SwapCounterHook.sol
  3. Tests1 file changed

    Implemented test/SwapCounterHook.t.sol.

    Coverage includes:

    • Exact hook permissions
    • Initial, repeated, and per-pool counts
    • Callback selector and zero delta
    • Unauthorized caller rejection
    • Overflow rollback
    • Absent admin/fund-management entry points
    • 1,000 fuzz runs across callback inputs

    Verification:

    • forge build: passed
    • forge test --fuzz-runs 1000: 8 passed, 0 failed
    • forge fmt --check: passed
    • Scope check: only the assigned test file was added

    No implementation defect was found. The referenced protected suite was not present in the workspace; only src/SwapCounterHook.sol was provided.

    ran oncodex · 6 turns · 5m 20s · 76.4K in · 13.1K out · 886K cached
    submission7364c1b8be92f4b2cc306b1bba601851117ccebf17910adc510c91fc95540b8a
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started frombeb8837cb496f0dafdd2c479fbfcbf600c54e5e6
    bundlec71998359b52f0bd156c3f7711011f0b8856b6523b047dd18355b491352ec829 · 3.9 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    test/SwapCounterHook.t.sol
    may write
    test/SwapCounterHook.t.sol
  4. Reviewfound 1 high, 3 medium, 1 low

    Review complete. No repository files were changed — findings are in .imd-findings.json (which .git/info/exclude already covers), and git status shows the tree otherwise clean.

    What I checked, and what held up

    I rebuilt the committed src/ against the real uniswap/v4-core in a scratch directory (/tmp/imdrev, outside the repo) so claims could be run rather than argued. The implementation is genuinely correct where it counts:

    • afterSwap identifier is b47b2fb1 — byte-identical to canonical v4 afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes), despite the types being hand-rolled.
    • The local toId matches v4-core's keccak256(poolKey, 0xa0) under a 256-run differential fuzz, including tickSpacing == type(int24).min.
    • Full external surface is exactly five methods: afterSwap, getHookPermissions, getSwapCount, poolManager, swapCount. No owner, no funds, no fallback, ETH rejected.
    • End-to-end control against a real PoolManager: hook at flag bits 0x40, pool at fee 10000 / tickSpacing 200, two swaps → getSwapCount == 2.

    Findings (5)

    high — constructor never binds getHookPermissions() to the deployed address. In v4, permissions are the address bits; the struct is metadata nothing on-chain reads. Canonical BaseHook reverts at deploy time on a bad salt, this doesn't. Verified against a real PoolManager: flags 0x0000 (plain CREATE) → initialize reverts HookAddressNotValid; flags 0x0080 → pool initializes, LPs deposit, then every swap reverts HookCallFailed forever with swapCount stuck at 0 and no recovery path; flags 0x0044 → PoolManager runs the hook in the afterSwapReturnDelta path while the struct says that flag is off. The suite passes on instances whose address contradicts it — setUp's hook landed at bits 0x020a and 0x3598 in two runs, both rejected by v4, while testPermissionsEnableOnlyAfterSwap passed.

    medium — _assertMissing conflates "reverted" with "doesn't exist". A hardcoded-operator backdoor with receive(), withdraw(), collectFees(), pause() passes all 8 tests. The spec's central constraint is untested.

    medium — the selector assertions are tautological. Reordering two fields of SwapParams passes 8/8 while the real PoolManager reverts every swap; the test derives the expected selector from the mutated interface.

    medium — no build config committed. Four plausible unpinned Foundry defaults produce four distinct creation-code hashes, so the CREATE2 address a mined salt yields isn't determined by the repo — which feeds directly back into the high finding. Out of my write scope to fix.

    low — address(0) pool manager deploys fine and yields a permanently dead, immutable hook.

    The top finding is marked high deliberately: the fix is one require in the constructor, and the failure it prevents is a live pool with liquidity in it that can never swap again.

    ran onclaude · claude-opus-5 · 30 turns · 11m 9s · 54 in · 47.2K out · 1.9M cached
    submission7e1ba8cd57aecdb3eb117b5017e5fa53f45c96299e8f70154790d4ec744da9a4
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from455a54015bef1e70d7d7297fa2bc47959361e7e1
    bundlenone
    may write
    src/SwapCounterHook.soltest/SwapCounterHook.t.sol
    • highConstructor never binds getHookPermissions() to the deployed address, so a mis-mined hook bricks the pool instead of failing at deploy timesrc/SwapCounterHook.sol:67

      In Uniswap v4 a hook's permissions are the low 14 bits of its address, not the getHookPermissions() struct. PoolManager reads only the address; getHookPermissions() is never called on-chain by anything and nothing in this contract or in test/SwapCounterHook.t.sol ever inspects address(this).

      The canonical BaseHook closes this with Hooks.validateHookPermissions(this, getHookPermissions()) in its constructor, which reverts HookAddressNotValid at deployment when the mined salt is wrong. Here the same mistake is silent: the contract deploys happily at any address, and the failure surfaces later either at pool creation or, worse, on the first swap of a pool that already holds LP funds.

      The whole suite passes against instances whose address bits contradict the permissions struct: new SwapCounterHook(manager) in setUp landed at flag bits 0x020a (beforeRemoveLiquidity + beforeSwapReturnDelta + afterAddLiquidityReturnDelta, and NOT afterSwap) and at 0x3598 in a second run, while testPermissionsEnableOnlyAfterSwap asserted 'only afterSwap' and passed both times.

      Fix is one line in the constructor: require(uint160(address(this)) & 0x3FFF == 0x40) (AFTER_SWAP_FLAG = 1 << 6).

      Built the committed src against uniswap/v4-core (PoolManager, PoolSwapTest, PoolModifyLiquidityTest), deployed SwapCounterHook, and vm.etch'd its runtime code at addresses with chosen flag bits (immutable poolManager is preserved).

      Pool key: fee 10000, tickSpacing 200, two 18-dec ERC20s.

      (a) CONTROL, flags 0x0040: initialize + addLiquidity + 2 swaps -> getSwapCount == 2.

      The counting logic itself is correct.

      (b) flags 0x0000, i.e. what a plain CREATE deploy gives you: pm.initialize(key, sqrtPrice(0)) reverts HookAddressNotValid(0x4444...0000), selector 0xf4844814 -> the USDC/fee-10000 pool in the manifest can never be created.

      (c) the actual address of new SwapCounterHook(pm) (flag bits 0x3598): pm.initialize reverts identically.

      (d) flags 0x0080 (BEFORE_SWAP mined instead of AFTER_SWAP): initialize AND addLiquidity both succeed, so the pool goes live and LPs deposit; then the first swapRouter.swap reverts WrappedError(hook, 0x575e24b4 /* beforeSwap /, 0x, 0xa9e35b2f / HookCallFailed */) because the hook has no beforeSwap and no fallback.

      Every swap on that pool reverts forever, getSwapCount stays 0, and the address is immutable so there is no recovery.

      (e) flags 0x0044: PoolManager accepts the pool and runs the hook through the afterSwapReturnDelta path while getHookPermissions().afterSwapReturnDelta == false -- the manifest's 'no returnDelta flag' claim is asserted only in a struct that no chain component reads.

      Expected in (b)-(e): the deployment itself reverts.

      Actual: deployment succeeds and the damage is deferred.

    • medium_assertMissing treats 'reverted' as 'does not exist', so the no-owner/no-funds guarantee is not actually testedtest/SwapCounterHook.t.sol:190

      testForbiddenAdminAndFundEntryPointsDoNotExist is the only test defending the spec's central constraint (no owner, no admin, no funds, no withdrawal, no pause, no upgrade). It asserts only that a call fails. Any privileged function that reverts for the caller -- which is exactly what a privileged function does -- is indistinguishable from an absent one.

      It also probes six hardcoded signatures, so a backdoor named withdraw(address,uint256), sweep(), or getOwner() is never probed at all, and a payable receive() lets the contract hold ETH without any test noticing. The current implementation is in fact clean (forge inspect methods lists exactly afterSwap, getHookPermissions, getSwapCount, poolManager, swapCount, and address(hook).call{value: 1 ether}('') reverts) -- but that is not what this test establishes.

      Sound version: assert over the compiled ABI/selector set, or assert on extcodesize/selector presence rather than call failure.

      Applied this diff to src/SwapCounterHook.sol and ran the committed suite unchanged: add address private constant _operator = address(0xA11CE);, receive() external payable {}, function pause() external view { require(msg.sender == _operator, "not operator"); }, function withdraw() external { require(msg.sender == _operator, "not operator"); payable(_operator).transfer(address(this).balance); }, function collectFees() external { require(msg.sender == _operator, "not operator"); payable(_operator).transfer(address(this).balance); }.

      Result: forge test -> 8 passed, 0 failed, including testForbiddenAdminAndFundEntryPointsDoNotExist.

      Expected: that test fails, because the contract now has a privileged operator, accepts ETH, and exposes withdraw()/collectFees()/pause().

      Actual: it passes, because each call reverts with 'not operator' and _assertMissing only checks success == false.

      (Note: the same backdoor keyed to msg.sender at construction IS caught, but only by accident -- the test contract is the deployer, so it is the operator.)

    • mediumSelector and struct-layout assertions are tautological: the tests validate the hook against the hook's own type declarations, so drift from v4-core is undetectabletest/SwapCounterHook.t.sol:79

      src/SwapCounterHook.sol re-declares PoolKey, SwapParams, BalanceDelta, PoolId and IHooks locally instead of importing v4-core, the contract does not declare is IHooks, and the test imports those same declarations from the implementation file. So _assertEq(selector, IHooks.afterSwap.selector) compares the implementation to itself and holds for any type layout the two happen to share.

      The one thing that matters for a v4 hook -- that the callback ABI is byte-identical to v4-core's, because Hooks.callHook compares bytes4(result) against the selector it called -- is never asserted.

      I verified the current code is correct (afterSwap identifier is b47b2fb1, matching cast sig 'afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)'; and the local PoolIdLibrary.toId agrees with v4-core's assembly keccak256(poolKey, 0xa0) under a 256-run differential fuzz over currency0/currency1/fee/tickSpacing/hooks, including tickSpacing == type(int24).min). It is correct today, but nothing pins it.

      Fix: assert the literal bytes4(0xb47b2fb1), and/or import the types from v4-core and declare contract SwapCounterHook is IHooks.

      Reordered two fields in src/SwapCounterHook.sol:15 so SwapParams becomes {int256 amountSpecified; bool zeroForOne; uint160 sqrtPriceLimitX96;} -- the only change.

      Result: forge test -> 8 passed, 0 failed; testAfterSwapCountsPoolAndAlwaysReturnsNoDelta and the fuzz test still assert the 'right' selector, because the test derives the expected selector from the mutated interface.

      Against a real PoolManager the same mutant fails on the first swap: WrappedError(hook, 0xb47b2fb1, 0x, 0xa9e35b2f /* HookCallFailed */) -- PoolManager calls the canonical selector b47b2fb1, the mutant's afterSwap now has a different selector, and the contract has no fallback.

      Expected: a test suite that fails when the callback ABI stops matching v4.

      Actual: it passes on a hook that cannot serve a single swap.

    • mediumNo build configuration is committed, so the creation code -- and therefore the mined CREATE2 hook address -- is not determined by the repositorysrc/SwapCounterHook.sol:2

      The task called for scaffolding (foundry.toml, dependencies, remappings) and HEAD contains only src/SwapCounterHook.sol and test/SwapCounterHook.t.sol -- no foundry.toml, no remappings.txt, no lib/. Two consequences. (1) Nothing pins optimizer/optimizer_runs/via_ir/evm_version, so the attested creation code depends on whichever Foundry defaults the building machine has.

      Since a v4 hook address must be mined with CREATE2 over keccak256(creationCode ++ abi.encode(poolManager)), an init-code hash that is not reproducible from the repo means a salt mined against one build yields a different address for another -- which lands you directly in finding #1's scenarios (b)-(e).

      (2) pragma solidity 0.8.26 is an exact pin with nothing to satisfy it offline; a verifier with no network and no cached 0.8.26 cannot build at all, and there is no committed compiler/config to point at. This is out of my write scope to fix (config paths are off-limits for this task), so it is reported rather than patched.

      Built the committed src/ four times under plausible unpinned defaults and hashed the creation code (forge inspect src/SwapCounterHook.sol:SwapCounterHook bytecode | cast keccak): no foundry.toml -> 0x70e61234423a9555f695b0ebf84ed399af971883f5a4e726ad852647654f9390 (2920 bytes); optimizer=true, runs=200 -> 0x3294509055bdcccf3815efe1e853f9aeda021615536069b1813a1a8543de3a7e; optimizer=true, runs=200, via_ir=true -> 0x87b0b469483a650098c1ca9ba7818bb9f73133eea589424c4b9487e4329d0959; optimizer=true, runs=1000000 -> 0xcdb9ac63227a4a63f1ee2ed1bf4538625225da5ce2d2fe38da70b653bb54180d.

      Four configurations, four distinct init-code hashes, therefore four different CREATE2 addresses for the same salt.

      Expected: one build config committed with the source so the attested creation code is reproducible.

      Actual: the address a mined salt produces depends on a setting that lives nowhere in the repo.

    • lowConstructor accepts address(0) as the pool manager, producing a permanently dead hooksrc/SwapCounterHook.sol:67

      poolManager is immutable and unchecked. Deployed with address(0) the contract is well-formed but can never be called by anything, since msg.sender is never the zero address, so onlyPoolManager rejects every callback. There is no setter (correctly so, per the spec), so recovery means redeploying and re-mining the salt.

      A single if (address(_poolManager) == address(0)) revert(...) makes it a deploy-time failure. Ranked low because it needs a deployment-script mistake to trigger and moves no funds; it is the same class of miss as finding #1 -- the constructor validates nothing.

      SwapCounterHook hook = new SwapCounterHook(IPoolManager(address(0))); succeeds and hook.poolManager() == address(0).

      Then any call to afterSwap -- including from the PoolManager the pool was configured with -- reverts NotPoolManager().

      Verified: address(hook).call(abi.encodeCall(IHooks.afterSwap, (address(this), key, params, BalanceDelta.wrap(0), bytes("")))) returns success == false with revert data NotPoolManager.selector, and getSwapCount stays 0 for every pool forever.

      Expected: the constructor reverts.

      Actual: deployment succeeds.

  5. Manifestfailed
    waits onImpl, Tests, Review
    may write
    launch.json
  6. Published
  7. Deployedto Sepolia