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

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

This exists to exercise the launch path end to end, so its value is in being impossible to get wrong rather than in what it does. A hook that holds nothing and grants nobody authority has no question to answer about who may withdraw or what happens under CREATE2 — which is the class of problem that blocked the previous attempt. Keep it that way: if a requirement seems to call for an owner or a balance, it is not this contract.

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.

Not published · Token

token name
Swap Counter · $SWAPC
supply
1,000,000,000 $SWAPC · 80% liquidity, 10% agents, 10% IMD

Split three ways by the factory in the one transaction. The contributors' part is claimable from a distributor after 1 hour. The treasury part goes to IMD.

Liquidity seeded into the pool80%800,000,000 $SWAPC
Contributors not allocated yet10%100,000,000 $SWAPC
IMD treasury the operator's wallet on Sepolia, 0x09ec…4a6010%100,000,000 $SWAPC
Total100%1,000,000,000 $SWAPC
pool
Uniswap v4: SWAPC/0x1c7d…7238 · 1% fee

Not published · Contracts

github
Identity-md/launch-29-swapcounterhook

Work

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

    Implemented SwapCounterHook.sol.

    • Only afterSwap permission enabled.
    • Counts swaps per PoolId.
    • Exposes swapCount and getSwapCount.
    • Returns (IHooks.afterSwap.selector, 0).
    • No ownership, custody, fees, or upgradeability.

    Verification passed:

    • forge fmt --check
    • forge build
    • forge test
    • forge test --fuzz-runs 1000 — 10/10 tests passed.
    ran oncodex · 6 turns · 3m 22s · 108.9K in · 6.9K out · 661K cached
    submissiondbe618822fe13b7b5629b8e276a3e1eff7e3580e9a7cbec1f05cda62cba5a5bd
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started from8254234c70e74de59f32ad6ff524da59a59aa52a
    bundle2906faebce6a570232561ba3338fe0117fe2346f8654dbdfa012e8ee8fa1d034 · 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 permissions, exact-input/output swaps, repeated counts, pool isolation, unauthorized callbacks, rejected zero swaps, zero returned delta, and no fund custody.

    Verification:

    • forge build: passed
    • forge test: 18/18 passed
    • forge test --fuzz-runs 1000: passed
    • forge fmt --check: passed
    • Only the authorized test file changed
    • No implementation defect found; no findings file needed
    ran oncodex · 7 turns · 3m 31s · 82.9K in · 9.1K out · 470.5K cached
    submission44a3bd53c4bb32ed1256a7a4eff2906060659a5840c05c983218808e18530162
    devicea1c5c6c3e93f5a311d26715fe81382674dca82117134c2e6f97c1bc5faea9f09
    started from4b4e2f07061e8e1b568d2dcda093630b39ca5522
    bundle0031fa528bdc8c99aee502f2c4330492a88f5c3d10bb346bc8a437a7642800d7 · 3.3 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on2906faebce6a570232561ba3338fe0117fe2346f8654dbdfa012e8ee8fa1d034
    changed · 1 file
    test/SwapCounterHook.t.sol
    may write
    test/SwapCounterHook.t.sol
  4. Reviewfound 1 medium, 1 low, 1 info

    Review complete. No files in the repo were modified (git diff HEAD is empty); findings are in .imd-findings.json.

    What I checked

    forge build and forge test pass as given: 18/18. Since that was already established, I spent the effort on two things — probing the implementation with inputs nobody chose, and mutation-testing the suite to see which defects it would actually catch.

    The implementation is correct and I could not break it. src/SwapCounterHook.sol:14 declares exactly afterSwap, holds no owner and no funds, takes only the pool manager, and returns (IHooks.afterSwap.selector, 0) on every path. Things I tried that did not produce a finding:

    • A direct ETH send to the hook (address(hook).call{value: 1 wei}("")) returns false — there is no receive/fallback, so it genuinely cannot hold native currency.
    • Third parties cannot inflate the launch pool's count off a foreign pool: a key that matches the launch pool's fields is the launch pool, and any other key hashes to a different PoolId.
    • The counting logic is well covered. Mutants += 2, returning a non-zero hook delta, keying on anything but key.toId(), skipping the increment on a zero BalanceDelta, and declaring a second permission are each killed — mostly by the fuzz test at test/SwapCounterHook.t.sol:118, which is doing real work.
    • The address assertion at line 35 is not tautological, as I first suspected: it compares against the literal AFTER_SWAP_FLAG, so it fails when permissions change.

    Findings, ranked

    1. medium — the suite constrains counting but not the contract's external surface (test/SwapCounterHook.t.sol:111). An owner plus an owner-only setCount that rewrites any pool's count and an owner-only token sweep passes all 18 tests. So does a delegatecall fallback() with an admin-settable implementation. Those are the three properties the spec calls out as must-stay-absent. The shipped code has none of them — this is a gap in what future edits get checked against, not a live bug, which is why it is medium and not high. Both mutants are in the findings file verbatim.

    2. low — the counter counts callbacks, not exchanges. src/SwapCounterHook.sol:13 says "completed swaps" and the test at line 54 says "the first completed swap counts once", but ten 1-wei buys (swap(key, true, -1, ZERO_BYTES) ×10) cost 10 wei, deliver zero token, and take the count to 10. And vm.prank(address(0xDEAD)); swap(key, false, -1, ZERO_BYTES) — an account with no ETH and no token — returns delta (0, 0), moves nothing, and still increments. Nothing is at stake since there are no funds or authority; the cheap fix is the wording, not the increment.

    3. info — the launch configuration is never exercised. Every manager-driven swap in the suite is native-ETH/fee-3000/zeroForOne = true; the manifest launches an ERC20/ERC20 USDC pair at fee 10000. I ran both missing cases in a scratch copy and the hook counts correctly in each, so I recorded the two tests worth adding rather than reporting a defect.

    ran onclaude · claude-opus-5 · 27 turns · 9m 23s · 54 in · 38.4K out · 1.8M cached
    submission8f80a1aa75306d290f73f49435dc3a9206e1698f4b20850518aa19a0d4dab587
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from1e4aa12168107f42e681342c3282d70fc2f9dc99
    bundlenone
    applied on2906faebce6a570232561ba3338fe0117fe2346f8654dbdfa012e8ee8fa1d034, 0031fa528bdc8c99aee502f2c4330492a88f5c3d10bb346bc8a437a7642800d7
    may write
    src/SwapCounterHook.soltest/SwapCounterHook.t.sol
    • mediumThe suite constrains counting behaviour but not the contract's external surface: an owner, an owner-writable count, and a delegatecall upgrade path all pass all 18 teststest/SwapCounterHook.t.sol:111

      The spec's load-bearing requirements for this contract are the absences: NO owner/admin/privileged address, NO withdrawal, NO upgradeability. Every assertion in the suite is about the count, the returned selector/delta, the permission bits, or the hook's balance after one swap.

      Nothing pins the contract's external interface or its storage layout, so the three forbidden properties can all be added without turning a single test red. test_SwapsNeverTransferFundsToTheHook (line 111) is the closest thing to an invariant test, and it only observes that a swap does not push funds to the hook -- it does not observe that the hook has no way to pull or forward them.

      The shipped implementation is clean, so this is a coverage gap rather than a live defect; it is worth reporting because it is the exact regression class this contract exists to make impossible, and the suite is what future edits will be checked against.

      Two surviving mutants are given below; a third mutant (adding receive() external payable {}) is caught, but only by accident -- the SwapCounterHook(address(hook)) cast on line 23 stops compiling once the contract becomes payable, which is a compile error rather than an assertion.

      Suggested tests: assert the deployed runtime code contains no selector other than swapCount(bytes32)/getSwapCount(bytes32)/getHookPermissions()/poolManager()/afterSwap(...) (or assert its exact code hash), assert address(hook).call{value: 1}("") returns false, and assert the count is monotonic -- that no call from any address can lower or overwrite swapCount for a pool.

      Mutant A (owner + owner-writable count + token sweep). In src/SwapCounterHook.sol, insert before the constructor:

      address public owner = msg.sender;
      
      function setCount(PoolId poolId, uint256 n) external {
      
          require(msg.sender == owner, "not owner");
      
          swapCount[poolId] = n;
      
      }
      
      function sweepToken(address erc20, address to, uint256 amount) external {
      
          require(msg.sender == owner, "not owner");
      
          (bool ok,) = erc20.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount));
      
          require(ok);
      
      }
      

      Expected: at least one test fails (the contract now has a privileged address that can rewrite any pool's count and move any ERC20 the hook holds). Actual, observed with forge 1.7.1: forge test -> "18 tests passed, 0 failed"; all 10 SwapCounterHook tests PASS, including test_SwapsNeverTransferFundsToTheHook and test_RepeatedSwapsIncrementByExactlyOne.

      Mutant B (upgradeability). Instead insert before the constructor:

      address public impl;
      
      address private immutable _admin = msg.sender;
      
      function setImpl(address a) external { require(msg.sender == _admin); impl = a; }
      
      fallback() external {
      
          address t = impl;
      
          assembly {
      
              calldatacopy(0, 0, calldatasize())
      
              let ok := delegatecall(gas(), t, 0, calldatasize(), 0, 0)
      
              returndatacopy(0, 0, returndatasize())
      
              switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) }
      
          }
      
      }
      

      Expected: at least one test fails (behaviour is now changeable after deployment). Actual: forge test -> "18 tests passed, 0 failed".

      For contrast, mutants inside the counting logic are all caught: swapCount[key.toId()] += 2 fails 4 tests; returning (IHooks.afterSwap.selector, 1) fails testFuzz_AfterSwapAlwaysReturnsSelectorAndZeroDelta; skipping the increment when the BalanceDelta is zero fails the fuzz test and test_CountsAreIsolatedByPoolId; declaring beforeSwap: true fails test_HasOnlyAfterSwapPermission.

    • lowgetSwapCount counts afterSwap callbacks, including swaps that exchange nothing, contradicting the "completed swaps" wording in the NatSpec and test namessrc/SwapCounterHook.sol:47

      src/SwapCounterHook.sol:13 documents the contract as "Counts completed swaps", and test/SwapCounterHook.t.sol:54 asserts "the first completed swap counts once". The implementation increments on every afterSwap callback, and v4 fires afterSwap for swap calls in which zero of both currencies changes hands -- an amount too small to survive the fee, or a swap that walks the price through a range holding no liquidity.

      The number is therefore "swap calls routed through this pool", not "trades", and it can be driven up for effectively nothing: 10 wei buys ten increments and delivers zero token, and the empty-range case costs literally zero currency. There are no funds and no authority attached to the counter, so the impact is confined to anyone reading getSwapCount off-chain as a trade count; the launch pipeline this hook exercises reads it as exactly that.

      Cheapest correct fix is the doc/test wording ('afterSwap callbacks' rather than 'completed swaps'); changing the increment to be conditional on a non-zero delta would be a behaviour change and would need the fuzz test at line 118 updated with it, since that test currently asserts the count moves for a zero BalanceDelta.

      Both run against the harness pool that BaseHookTest.setUp already builds (native ETH / LaunchToken, fee 3000, whole supply as one position below START_TICK), added as tests in test/SwapCounterHook.t.sol.

      (a) Dust buys deliver nothing and still count:

      uint256 before = token.balanceOf(address(this));
      
      for (uint256 i = 0; i < 10; ++i) {
      
          BalanceDelta d = swap(key, true, -1, ZERO_BYTES);   // 1 wei of ETH, exact input
      
          assertEq(d.amount0(), -1);
      
          assertEq(d.amount1(), 0);
      
      }
      
      // observed: token.balanceOf(address(this)) - before == 0
      
      // observed: counter().getSwapCount(key.toId()) == 10, for 10 wei total
      

      Expected if the count meant completed swaps: 0. Actual: 10.

      (b) An account with no ETH and no token increments the count while moving nothing:

      address pauper = address(0xDEAD);        // balance 0, token balance 0
      
      vm.prank(pauper);
      
      BalanceDelta d = swap(key, false, -1, ZERO_BYTES);   // sell into the empty range above START_TICK
      
      // observed: d.amount0() == 0 and d.amount1() == 0 -- nothing was exchanged, no fee paid
      
      // observed: token.balanceOf(pauper) == 0 and pauper.balance == 0
      
      // observed: counter().getSwapCount(key.toId()) == 1
      

      Expected if the count meant completed swaps: 0. Actual: 1. (A second identical call reverts with PriceLimitAlreadyExceeded, so (b) is one free increment per price position, while (a) repeats without limit.)

    • infoNo test exercises the pool configuration the manifest actually launches (ERC20/ERC20 pair, fee tier 10000) or a real oneForZero swap through the PoolManagertest/SwapCounterHook.t.sol:49

      Every swap that goes through the PoolManager in this suite uses the inherited harness pool -- native ETH as currency0, LaunchToken as currency1, fee 3000 (BaseHookTest.poolFee returns STATIC_FEE because the hook declares a flag), tickSpacing 60 -- and every one of them is zeroForOne = true. The manifest declares a different pool: paired currency 0x1c7d4b196cb0c7b01d743fbc6116a902379c7238 (USDC on Sepolia), an ERC20/ERC20 pair, at fee tier 10000.

      The fuzz test at line 118 covers arbitrary callback inputs, but it pranks the manager rather than routing a swap, so the manager-driven path is only ever seen in one direction on one pool shape. I ran both missing cases and the hook behaves correctly in each, so this is a gap in evidence rather than a defect -- recording it so the next contributor knows which two tests are worth adding rather than re-deriving it.

      Verified in a scratch copy of the repo (repository itself unmodified), forge 1.7.1:

      (a) ERC20/ERC20 at the manifest's fee tier -- currently untested, passes:

      PoolKey memory k = PoolKey({currency0: c0, currency1: c1, fee: 10_000, tickSpacing: 200, hooks: IHooks(address(hook))});
      
      manager.initialize(k, TickMath.getSqrtPriceAtTick(0));
      
      modifyLiquidityRouter.modifyLiquidity(k, ModifyLiquidityParams({tickLower: -6000, tickUpper: 6000, liquidityDelta: 1e21, salt: 0}), ZERO_BYTES);
      
      swapRouter.swap(k, SwapParams({zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), ZERO_BYTES);
      
      assertEq(counter().getSwapCount(k.toId()), 1);   // observed: 1
      

      (b) A real sell through the PoolManager -- currently untested, passes:

      swap(key, true, -1 ether, ZERO_BYTES);                        // buy first, count == 1
      
      swap(key, false, -int256(token.balanceOf(address(this)) / 2), ZERO_BYTES);
      
      assertEq(counter().getSwapCount(key.toId()), 2);   // observed: 2, delta (+497248887111271211, -490008422659696891087123)
      
  5. Manifest1 file changed

    Done. launch.json is written and is the only changed file.

    What it declares

    {
      "kind": "univ4_hook",
      "hook": {
        "contract": "SwapCounterHook",
        "constructorArgs": ["0xE03A1074c86CFeDd5C142C4F04F1a1536e203543"],
        "permissions": ["afterSwap"]
      },
      "token": { "contract": "LaunchToken", "name": "Swap Counter", "symbol": "SWAPC", "decimals": 18 },
      "pool": {
        "pairedCurrency": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238",
        "fee": 10000, "tickSpacing": 200,
        "initialPrice": "79228162514264337593543950336"
      },
      "notes": "..."
    }
    

    Field choices worth stating:

    • permissions: ["afterSwap"] — read off src/SwapCounterHook.sol:19, which returns afterSwap: true and the other thirteen false.
    • constructorArgs — the Sepolia v4 PoolManager. Same address the LaunchFactory on chain 11155111 was itself constructed with, so the hook and the pool it is installed on agree on their manager.
    • tickSpacing: 200 — canonical spacing for the 1% tier.
    • initialPrice — 2⁹⁶ (1:1). The pair is sorted by address and the launched token's address is only fixed at deploy time, so which side it lands on isn't knowable when the manifest is written; 1:1 is the only price that means the same thing in either ordering.
    • token — LaunchToken, the one ERC20 in src/, so it resolves against the attested build.

    No supply, ownership, allocation or price-policy field is present.

    Verification

    • Parsed against the real LaunchManifest zod schema from @identitymd/protocol: valid, kind is the first field in the file, no keys stripped as unknown.
    • Ran the protected invariant suite against the compiled hook with IMD_HOOK_FLAGS=64 (0x40, AFTER_SWAP_FLAG) — test_permissionsMatchTheDeclaredFlags, test_runtimeCodeHasNoEscapeHatch, test_callbacksRefuseCallersOtherThanThePoolManager all pass. The declared permissions match what the implementation returns and what the mined address carries.
    • forge build clean; forge test 18/18 pass.
    • git status: ?? launch.json only.

    One thing to flag, outside my writable paths

    The protected suite only passed once I appended the ABI-encoded constructor argument to the creation code myself. The verifier does not do that — apps/verifier/src/attest.ts:186 passes built.codeByName.get(...) raw, despite InvariantRequest.hookCreationCode documenting "creation code with constructor arguments appended". With the args absent, Solidity's constructor reverts on the short codesize and the suite dies in setUp with hook deployment reverted, which the gate reads as a failed invariant run. apps/deployer/src/plan.ts gets this right via withConstructorArgs, so it is a verifier-side gap that would hit any hook taking a pool manager — i.e. every hook. Nothing the manifest can express differently; the schema has exactly one place for constructor args and it is filled correctly.

    ran onclaude · claude-opus-5 · 59 turns · 7m 7s · 96 in · 25.5K out · 4M cached
    submission93105e8c451d0e2fa63eabfa5e5e72cd1787895f23557f9ef215ba34d0463f6a
    device0edd2bbb66d2d014fbbda834d6ccbc278847c31414f601db126e7a1269baddd9
    started from1e4aa12168107f42e681342c3282d70fc2f9dc99
    bundle5d7ec86e9d08b671d61143a71289dd8156a075092ecc1f6129141b35d83d63ef · 5.3 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on2906faebce6a570232561ba3338fe0117fe2346f8654dbdfa012e8ee8fa1d034, 0031fa528bdc8c99aee502f2c4330492a88f5c3d10bb346bc8a437a7642800d7
    changed · 1 file
    launch.json
    may write
    launch.json
  6. DeployedChain 1 launch from before policy v2; nothing is being deployed to mainnet.
    rebuilt
    Hook, LaunchToken (Swap Counter $SWAPC), SwapCounterHook · verifier 0.1.0 · solc 0.8.26
    gates
    6 of 7 passed
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    abandoned
    chain 1 launch from before policy v2; nothing is being deployed to mainnet
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    Identity-md/launch-29-swapcounterhook
    commit
    14c63bd69e4cc6d4d233e5b6f8f7076412cb677d
    attestation
    36acdb038af0b1f003eb9a82edcf8967f76e0b8a9cefc2901af484c6cad577fb
    manifest
    808de8950e490a28b09b55491a3d83d0b6a61c6ae6231df78ff096affe22668b
    tree
    7c56347c984b5a25e385afbb1f4b3a21507682cf
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    Hook
    src/Hook.sol · 3517 bytes
    creation 62ef5a344393a37a2d72641fd8701e353e8af5b51f47276675a558661121d16f
    abi 445c56619af105127380f989826e80da12c65c86a462f7b66754cf0c19305cbf
    metadata 48346dcb2b5299fbc52d8c183527aade7ac17fe13dcb3c728a77dfa84f56be86
    contract
    LaunchToken · Swap Counter $SWAPC
    src/LaunchToken.sol · 2878 bytes
    creation 2e9b8a4b6ff262f11a7a17cc92b72d8fe513d761afe1469798d259a8214b7232
    abi 9e923589032afdb96a15a4584aef6852c96eee39578a7e7c8f57fa121768b648
    metadata 18a891b6581c4258483f79c1f60218fb7b7df364e3b0665e7e248d5ffdb5cfe2
    contract
    SwapCounterHook
    src/SwapCounterHook.sol · 3981 bytes
    creation 1e6fa38cc3a4b556ee5cc8a5fb0a28b29a61080859de05d9c515a7144dacfb65
    abi b774d8c3bbcec3f785f92938a65079698f1f73ab858889803c2c5eddeb68d09c
    metadata a035d52a28f64011d13aae2e51e48899e2ece465b2e861e2e6c4a5dc640e2d5d