{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"b58971f0-f89e-49cb-9d55-16b30b3f1237","kind":"audit","nodes":[{"acceptedSubmissionHash":"9d88b6c5cc4cf2260341e56854947499d3418306e66b5dfe0882486e5f548212","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0e9ac09fda4c09d72228fea1b63ed3fcea812331677d0c967625678cfc7fcabc","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"63c0dd433e27aca96a32bcc5bbf4f540f77c7a0f9bc299ce2346e70116758d2c","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"68029f2f4c5df24c1b60dcd0914210a3f42e8d2e980e7279be6246aca55b6bbe","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"ee3788964fc1f1fa7e3bf348196c223a6b1117768a322b5f34edbe8ceec79365","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the contracts in 0xtenang/Runner as they are.","parentJobId":null,"planHash":"43a5670e06ebbc701686bd6ce62f8e02998ece57ddad8c20edb482741dc380dc","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"b58971f0-f89e-49cb-9d55-16b30b3f1237","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51162","feedbackHash":"01723c21ca56ee19e47af23eff4b0a44e4e02046c15cc690fc0962903ea360e3","nodeKey":"audit_economics","submissionHash":"9d88b6c5cc4cf2260341e56854947499d3418306e66b5dfe0882486e5f548212","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52604","feedbackHash":"b182cde1e6aa8c428743754645d9e857e9077a47377685ea0919a0f8c1462212","nodeKey":"audit_flow","submissionHash":"0e9ac09fda4c09d72228fea1b63ed3fcea812331677d0c967625678cfc7fcabc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51488","feedbackHash":"d81e8007b3844d7f76ae8c04e61746185a3ab6b66a5b8f0dd05cfa2298978666","nodeKey":"audit_judge","submissionHash":"63c0dd433e27aca96a32bcc5bbf4f540f77c7a0f9bc299ce2346e70116758d2c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52207","feedbackHash":"1769bb2d43bf3b27b8a0e52cf90802434a9748bee9c97cf490566b10f1db56d9","nodeKey":"audit_math","submissionHash":"68029f2f4c5df24c1b60dcd0914210a3f42e8d2e980e7279be6246aca55b6bbe","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52679","feedbackHash":"6e9bc1f34f13ca42d141cada9ebdd6a603e16996c965c63672d6c60512540780","nodeKey":"audit_permissions","submissionHash":"ee3788964fc1f1fa7e3bf348196c223a6b1117768a322b5f34edbe8ceec79365","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"1ccb814ed3876c5a79016c2cb58349225231bf4667b0dca6c8dda8334826eb47","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9e17b97b8ac7c97a","findings":[{"citation":"resolved","description":"The NatSpec on claimable() says it returns \"What claim() would pay right now\" and lists every condition under which claim() would pay nothing (paused, no emission, no signer, daily cap, empty pool). It omits the two remaining divergences between the view and claim(): (1) an instant claim (claim(..., instant=true)) pays amount - amount*INSTANT_BURN_BPS/BPS = 80% and burns 20% (lines 388-391), while claimable() returns the full 100%; (2) a locked claim reverts TooManyLocks when the account already has MAX_ACTIVE_LOCKS (30) unfinished locks (line 394), while claimable() still returns the full amount. The in-app Earn tab does not call this view (js/earn-view.js computes the figure from the server), so the impact is limited to wallets, explorers and integrators that read claimable() on chain and show it to runners as the amount that will arrive. Fix: either give claimable() an `instant` parameter and apply the same burn (and return 0 when the lock slots are full), or reword the NatSpec to say it returns the gross difference before the instant burn. Run the proof with `FOUNDRY_TEST=test/scratch forge test --match-path test/scratch/ClaimableInstant.t.sol` (foundry.toml sets test = \"contracts/test\", so test/scratch is not compiled without the override).","line":347,"path":"contracts/RunnerToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\n\n/// @dev RunnerToken is compiled with solc 0.8.24, so it is reached through this interface and deployCode.\ninterface IRunnerToken {\n    function balanceOf(address) external view returns (uint256);\n    function totalSupply() external view returns (uint256);\n    function setSigner(address) external;\n    function startEmission() external;\n    function domainSeparator() external view returns (bytes32);\n    function CLAIM_TYPEHASH() external view returns (bytes32);\n    function claimable(address account, uint256 totalEarned) external view returns (uint256);\n    function claim(address account, uint256 totalEarned, uint256 deadline, uint8 v, bytes32 r, bytes32 s, bool instant) external;\n}\n\n/// @notice claimable() promises \"what claim() would pay right now\", but an instant claim pays only 80%\n///         (20% is burned). Fails on the current code; passes once claimable() accounts for the instant burn\n///         (e.g. takes the `instant` flag, or documents/returns the gross and net separately).\ncontract ClaimableInstantTest is Test {\n    IRunnerToken token;\n    uint256 signerKey = 0xA11CE;\n    address owner = makeAddr(\"owner\");\n    address runner = makeAddr(\"runner\");\n\n    function setUp() public {\n        vm.warp(20_500 days + 3600);\n        token = IRunnerToken(deployCode(\n            \"RunnerToken.sol:RunnerToken\",\n            abi.encode(owner, makeAddr(\"hook\"), makeAddr(\"team\"), makeAddr(\"imd\"), makeAddr(\"poolManager\"))\n        ));\n        vm.startPrank(owner);\n        token.setSigner(vm.addr(signerKey));\n        token.startEmission();\n        vm.stopPrank();\n    }\n\n    function test_claimable_matchesInstantClaimPayout() public {\n        uint256 totalEarned = 1_000e18;\n        uint256 deadline = block.timestamp + 3600;\n        bytes32 digest = keccak256(abi.encodePacked(\n            \"\\x19\\x01\", token.domainSeparator(),\n            keccak256(abi.encode(token.CLAIM_TYPEHASH(), runner, totalEarned, deadline, true))\n        ));\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest);\n\n        uint256 promised = token.claimable(runner, totalEarned);\n        assertEq(promised, totalEarned, \"view reports the full difference\");\n\n        uint256 before = token.balanceOf(runner);\n        vm.prank(runner);\n        token.claim(runner, totalEarned, deadline, v, r, s, true);\n        uint256 paid = token.balanceOf(runner) - before;\n\n        // 800 RUNNER arrive, 200 are burned: the view over-promised by 25%.\n        assertEq(paid, promised, \"claimable() must equal what the instant claim pays\");\n    }\n}","reproduction":"State: emission started, signer set, claimed[runner] = 0, run-reward pool full. Input: totalEarned = 1000e18, a valid signer signature with instant = true, msg.sender = runner. Expected (per NatSpec): claimable(runner, 1000e18) equals what claim() pays. Actual: claimable() returns 1_000e18; claim(runner, 1000e18, deadline, v, r, s, true) moves 800e18 to runner and burns 200e18 (RewardsBurned), so the runner receives 800e18, not the 1000e18 the view reported. Second input: an account with 30 unfinished locks and the same signature with instant = false: claimable() returns 1000e18, claim() reverts TooManyLocks.","severity":"low","snippet":"    function claimable(address account, uint256 totalEarned) external view returns (uint256 amount) {","title":"claimable() over-reports instant claims by 25% and ignores the TooManyLocks revert"},{"citation":"resolved","description":"The refund after the unlock sends address(this).balance to msg.sender instead of msg.value - ethIn. The router has no receive() function, so in normal operation the two are equal and nothing is lost; but any ETH that reaches the router by other means (SELFDESTRUCT beneficiary, block-reward coinbase, or a future change that lets the router hold ETH) is handed to whichever buyer calls next. This is not exploitable for profit by the caller beyond that windfall and causes no loss to users of the protocol, so it is reported as a hygiene note. Fix: compute the refund as msg.value minus the ETH actually settled (return ethIn from the callback alongside tokensOut, or snapshot address(this).balance - msg.value before the unlock) and refund only that.","line":83,"path":"contracts/RunnerEthRouter.sol","reproduction":"State: a contract holding 1 wei self-destructs with the RunnerEthRouter as beneficiary (or any 1 wei otherwise credited to the router). Input: alice calls buyWithEth{value: 1 ether}(RUNNER, 0, deadline) and the IMD/ETH pool fully fills the 1 ether. Expected: alice's refund is 0 (msg.value - ethIn). Actual: the final `if (address(this).balance > 0)` branch sends alice the stray 1 wei, so alice receives tokensOut plus 1 wei of ETH that was never hers.","severity":"info","snippet":"        if (address(this).balance > 0) address(0).transferOut(msg.sender, address(this).balance);","title":"buyWithEth refunds the router's whole ETH balance, not only the caller's unspent msg.value"}],"hash":"0e9ac09fda4c09d72228fea1b63ed3fcea812331677d0c967625678cfc7fcabc","nodeId":"e61d2a05-4dfb-422a-bfc9-9360c08f91ac","outcome":"completed","summary":"Review complete. The findings file is written at `.imd-findings.json` with two entries, and the proof test sits in `test/scratch/ClaimableInstant.t.sol`.\n\n**Outcome.** After six passes over the contracts I found no critical, high or medium defect. The hook's fee accounting, the locked launch liquidity, the holder-reward checkpointing and the two routers behave as the design documents say, including at the edges I probed empirically.\n\n**Findings recorded**\n\n- **Low: `claimable()` over-reports instant claims.** The view promises \"what claim() would pay\" but returns the full difference, while an instant claim pays 80% and burns 20%. It also returns the full amount when a locked claim would revert with `TooManyLocks`. The proof test fails on the current code as required. The in-app Earn tab does not use this view, so only on-chain integrators are affected.\n- **Info: `buyWithEth` refunds the router's whole ETH balance** rather than the caller's unspent value. Only stray ETH from a self-destruct could reach the router, so this is hygiene, not a loss path.\n\n**What I verified and found sound**\n\n- All four swap directions against the pinned v4-core delta accounting, including partial fills, oversells past the launch position, and tiny trades with rounded-up fees.\n- Holder-reward share freezing, min-balance checkpoints, exclusion of a pool after an intra-day balance drop, and the paid-day freeze in `_exclude`.\n- Run-reward claims, signature domain, half-order check, daily cap, lock release paths and the emission schedule promotion logic.\n- The routers' settlement flows, the mid-unlock flush restriction, and the Genesis NFT's mint, transfer and enumeration paths.\n- The live IMD token on Robinhood Chain has 18 decimals, no EIP-1967 proxy slot, and no ERC-777 or ERC-1363 callback selectors in its bytecode.\n\n**One thing the author and verifier need to know.** The project's foundry.toml sets the test directory to `contracts/test`, so a file under `test/scratch` is not compiled by a plain `forge test`. The proof runs with:\n\n```\nFOUNDRY_TEST=test/scratch forge test --match-path test/scratch/ClaimableInstant.t.sol\n```\n\n**Limits of this review.** I did not run the fork tests against the live chain, and I did not run Slither or long fuzz campaigns. The server was read only as far as it signs claims, and the signing code matches the contract's digest.","treeHash":null,"usage":{"cachedInputTokens":2945990,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":82616,"runtime":"claude","turns":43,"wallClockMs":1197796}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"79373c79d1351eba","findings":[],"hash":"310b0601c81ac6e8762ca4f7120eb2086e6b186d41ecb6cd46646d3d936bbeb0","nodeId":"fc1cdd2a-49fc-4769-b23e-1d1174f53336","outcome":"failed","summary":"Failed to authenticate: OAuth session expired and could not be refreshed","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":1478}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5835e48821d8827d","findings":[{"citation":"resolved","description":"setFeeRecipient() (contracts/RunnerHook.sol:429-434) refuses address(0), address(this) and address(poolManager) because IMD taken to the hook is stuck (the hook has no function that moves its own ERC-20 balance) and IMD taken to the PoolManager vanishes (a PoolManager->PoolManager transfer leaves no delta or claim). The constructor only checks feeRecipient_ != address(0), so the same two values are accepted at deployment. Both are plausible deploy-script mistakes: poolManager_ is the first constructor argument and the hook's own address is known before deployment because the CREATE2 salt is mined for it. collectProtocolFees() is permissionless, so once a swap has produced a protocol fee anyone can trigger the loss before the owner notices: _collect() zeroes pendingProtocolFees, burns the hook's ERC-6909 claims and take()s the IMD to the bad recipient. The owner can repair feeRecipient afterwards with setFeeRecipient(), but every IMD collected before that is unrecoverable. Reviewer note: the same constructor/setter asymmetry exists in RunnerToken (constructor sets protocolWallet = initialOwner at contracts/RunnerToken.sol:192 without the this/poolManager/hook checks of setProtocolWallet() at line 503), but there the mis-deployment also puts the owner role on a contract that can never call anything, so it is immediately visible and is noted here rather than reported separately. Fix: apply the setter's check in the constructor, e.g. `if (feeRecipient_ == address(this) || feeRecipient_ == address(poolManager_)) revert ZeroAddress();` (address(this) is available in a constructor). Reproduced by running the attached proof: the first test fails on the current code (deployment succeeds with feeRecipient == poolManager) and the second test demonstrates the stranded 20 IMD.","line":176,"path":"contracts/RunnerHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\n\nimport {RunnerHook} from \"contracts/RunnerHook.sol\";\nimport {RunnerRouter} from \"contracts/RunnerRouter.sol\";\n\ncontract CtorIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amt) external { balanceOf[to] += amt; }\n    function approve(address s, uint256 amt) external returns (bool) { allowance[msg.sender][s] = amt; return true; }\n    function transfer(address to, uint256 amt) external returns (bool) { balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true; }\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt; balanceOf[to] += amt; return true;\n    }\n}\n\n/// @dev RunnerHook's constructor accepts a feeRecipient that setFeeRecipient() refuses (the PoolManager or the hook\n///      itself). With feeRecipient == poolManager, collectProtocolFees() `take`s IMD to the PoolManager, where it is\n///      unaccounted and lost. The constructor should apply the same check as the setter.\ncontract HookFeeRecipientCtorTest is Test {\n    uint256 constant DAY = 1 days;\n    address owner = makeAddr(\"owner\");\n    address team = makeAddr(\"team\");\n    address alice = makeAddr(\"alice\");\n\n    PoolManager pm;\n    CtorIMD imd;\n\n    function setUp() public {\n        vm.warp(20_500 * DAY + 3600);\n        pm = new PoolManager(address(this));\n        imd = new CtorIMD();\n    }\n\n    function _flags() internal pure returns (uint160) {\n        return uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 5) | (1 << 3) | (1 << 2));\n    }\n\n    function _mine(bytes memory initCode) internal view returns (bytes32 salt, address hook) {\n        bytes32 h = keccak256(initCode);\n        for (uint256 i = 0; i < 2_000_000; i++) {\n            hook = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));\n            if (uint160(hook) & 0x3FFF == _flags()) return (bytes32(i), hook);\n        }\n        revert(\"no salt\");\n    }\n\n    function _deploy(address feeRecipient_) internal returns (address deployed) {\n        bytes memory initCode = abi.encodePacked(\n            type(RunnerHook).creationCode,\n            abi.encode(pm, address(imd), owner, feeRecipient_, int24(142_600), RunnerHook.ImdEthPool(10_000, 100, address(0)))\n        );\n        (bytes32 salt,) = _mine(initCode);\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n    }\n\n    /// Fails on the current code: the constructor accepts feeRecipient == poolManager, which setFeeRecipient rejects\n    /// with ZeroAddress(). Passes once the constructor applies the same check (create2 then returns address(0)).\n    function test_constructor_rejectsPoolManagerAsFeeRecipient() public {\n        address deployed = _deploy(address(pm));\n        assertEq(deployed, address(0), \"constructor must refuse feeRecipient == poolManager like setFeeRecipient does\");\n    }\n\n    /// Shows the harm on the current code: with feeRecipient == poolManager, protocol IMD is sent to the PoolManager\n    /// and is unreachable by anyone (no delta, no claims), while pendingProtocolFees is zeroed. Once the constructor\n    /// is fixed the deploy reverts and there is nothing to demonstrate, so the test returns early.\n    function test_harm_protocolFeesLostToPoolManager() public {\n        address deployed = _deploy(address(pm));\n        if (deployed == address(0)) return;\n        RunnerHook hook = RunnerHook(deployed);\n        RunnerRouter router = RunnerRouter(hook.router());\n        address runner = deployCode(\"RunnerToken.sol:RunnerToken\", abi.encode(owner, address(hook), team, address(imd), address(pm)));\n        vm.prank(owner);\n        hook.openPool(runner);\n\n        imd.mint(alice, 10_000e18);\n        vm.startPrank(alice);\n        imd.approve(address(router), type(uint256).max);\n        router.buy(runner, 1_000e18, 0, block.timestamp);\n        vm.stopPrank();\n        assertEq(hook.pendingProtocolFees(address(imd)), 20e18);\n\n        uint256 pmBefore = imd.balanceOf(address(pm));\n        uint256 claimsBefore = pm.balanceOf(address(hook), uint256(uint160(address(imd))));\n        hook.collectProtocolFees(address(imd));\n        assertEq(hook.pendingProtocolFees(address(imd)), 0, \"fees marked as paid\");\n        assertEq(imd.balanceOf(address(pm)), pmBefore, \"IMD never left the PoolManager\");\n        assertEq(pm.balanceOf(address(hook), uint256(uint160(address(imd)))), claimsBefore - 20e18, \"hook's claims burned\");\n        assertEq(pm.balanceOf(address(pm), uint256(uint160(address(imd)))), 0, \"nobody holds a claim on the 20 IMD\");\n    }\n}","reproduction":"Deploy RunnerHook via CREATE2 with feeRecipient_ = address(poolManager), all other arguments valid (start tick 142_600, salt mined for HOOK_FLAGS). Expected: the deployment reverts, as setFeeRecipient(address(poolManager)) does with ZeroAddress(). Actual: it succeeds and feeRecipient() == address(poolManager). Then: owner calls openPool(runner); alice buys with 1,000 IMD through RunnerRouter, so pendingProtocolFees(IMD) == 20e18; anyone calls collectProtocolFees(IMD). Expected: 20 IMD reach a recipient. Actual: pendingProtocolFees becomes 0, the hook's IMD claims drop by 20e18, the PoolManager's IMD ERC-20 balance is unchanged (transfer to itself) and no address holds a claim or delta for those 20 IMD. Verified with `FOUNDRY_TEST=test/scratch forge test --match-path test/scratch/HookFeeRecipientCtor.t.sol` (foundry.toml points `test` at contracts/test, so the override is needed): test_constructor_rejectsPoolManagerAsFeeRecipient FAILS (deployed address != 0), test_harm_protocolFeesLostToPoolManager PASSES, confirming the loss.","severity":"low","snippet":"        if (address(poolManager_) == address(0) || imd == address(0) || owner_ == address(0) || feeRecipient_ == address(0)) {","title":"RunnerHook constructor accepts a feeRecipient that setFeeRecipient() rejects (the PoolManager or the hook itself); protocol IMD collected before the owner corrects it is lost"},{"citation":"resolved","description":"The NatSpec at contracts/RunnerToken.sol:343-346 says claimable() returns what claim() would pay right now and lists the conditions under which it returns 0 (paused, no emission, no signer, daily cap, empty pool). Two divergences remain: (1) an instant claim (claim(..., instant=true)) moves amount - amount*INSTANT_BURN_BPS/BPS = 80% to the runner and burns 20% (lines 388-391), while claimable() returns 100%; (2) a locked claim reverts TooManyLocks when the account already has MAX_ACTIVE_LOCKS (30) unfinished locks (line 394), while claimable() still returns the full amount. The in-app Earn tab computes its figure server-side, so the impact is limited to wallets, explorers and integrators that read claimable() on chain and show it as the amount that will arrive. Fix: give claimable() an `instant` parameter and apply the same burn (and return 0 when the account's active locks are at MAX_ACTIVE_LOCKS for the locked path), or reword the NatSpec to say it returns the gross difference before the instant burn and before the lock-slot check.","line":347,"path":"contracts/RunnerToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\n\n/// @dev RunnerToken is compiled with solc 0.8.24, so it is reached through this interface and deployCode.\ninterface IRunnerToken {\n    function balanceOf(address) external view returns (uint256);\n    function totalSupply() external view returns (uint256);\n    function setSigner(address) external;\n    function startEmission() external;\n    function domainSeparator() external view returns (bytes32);\n    function CLAIM_TYPEHASH() external view returns (bytes32);\n    function claimable(address account, uint256 totalEarned) external view returns (uint256);\n    function claim(address account, uint256 totalEarned, uint256 deadline, uint8 v, bytes32 r, bytes32 s, bool instant) external;\n}\n\n/// @notice claimable() promises \"what claim() would pay right now\", but an instant claim pays only 80%\n///         (20% is burned). Fails on the current code; passes once claimable() accounts for the instant burn\n///         (e.g. takes the `instant` flag, or documents/returns the gross and net separately).\ncontract ClaimableInstantTest is Test {\n    IRunnerToken token;\n    uint256 signerKey = 0xA11CE;\n    address owner = makeAddr(\"owner\");\n    address runner = makeAddr(\"runner\");\n\n    function setUp() public {\n        vm.warp(20_500 days + 3600);\n        token = IRunnerToken(deployCode(\n            \"RunnerToken.sol:RunnerToken\",\n            abi.encode(owner, makeAddr(\"hook\"), makeAddr(\"team\"), makeAddr(\"imd\"), makeAddr(\"poolManager\"))\n        ));\n        vm.startPrank(owner);\n        token.setSigner(vm.addr(signerKey));\n        token.startEmission();\n        vm.stopPrank();\n    }\n\n    function test_claimable_matchesInstantClaimPayout() public {\n        uint256 totalEarned = 1_000e18;\n        uint256 deadline = block.timestamp + 3600;\n        bytes32 digest = keccak256(abi.encodePacked(\n            \"\\x19\\x01\", token.domainSeparator(),\n            keccak256(abi.encode(token.CLAIM_TYPEHASH(), runner, totalEarned, deadline, true))\n        ));\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest);\n\n        uint256 promised = token.claimable(runner, totalEarned);\n        assertEq(promised, totalEarned, \"view reports the full difference\");\n\n        uint256 before = token.balanceOf(runner);\n        vm.prank(runner);\n        token.claim(runner, totalEarned, deadline, v, r, s, true);\n        uint256 paid = token.balanceOf(runner) - before;\n\n        // 800 RUNNER arrive, 200 are burned: the view over-promised by 25%.\n        assertEq(paid, promised, \"claimable() must equal what the instant claim pays\");\n    }\n}","reproduction":"State: emission started, signer set, claimed[runner] = 0, run-reward pool full. Input: totalEarned = 1000e18 with a valid signer signature for instant = true, msg.sender = runner. Expected (per NatSpec): claimable(runner, 1000e18) equals what claim() pays. Actual: claimable() returns 1000e18; claim(runner, 1000e18, deadline, v, r, s, true) moves 800e18 to the runner and burns 200e18 (RewardsBurned). Verified with the attached proof: `FOUNDRY_TEST=test/scratch forge test --match-path test/scratch/ClaimableInstant.t.sol` fails with `800000000000000000000 != 1000000000000000000000`. Second input: submit 30 locked claims for alice (totalEarned 1e18, 2e18, ... 30e18), so locksOf(alice).activeLocks == 30; then claimable(alice, 31e18) returns 1e18 while claim(alice, 31e18, ..., false) reverts TooManyLocks(). Verified in test/scratch/Repro.t.sol::test_claimable_ignoresTooManyLocks (passes, i.e. the divergence is observed).","severity":"low","snippet":"    function claimable(address account, uint256 totalEarned) external view returns (uint256 amount) {","title":"claimable() promises \"what claim() would pay\" but ignores the 20% instant burn and the TooManyLocks revert"},{"citation":"resolved","description":"_feeOf rounds the 4% fee up, so every swap with an IMD leg of 2..25 wei pays a fee of exactly 1 wei. _chargeFee then splits it as protocolFee = fee * 200 / 400, which floors 1/2 to 0, and credits fee - protocolFee = 1 wei to holders. For every odd fee the extra wei goes to holders; for fee == 1 the protocol receives nothing, so the documented 2% protocol / 2% holder split is 0% / 4% for that trade. The total fee is conserved (pendingProtocolFees + pendingHolderFees == minted claims), the deviation is bounded by 1 wei per swap, and a 1-wei exact-in buy cannot execute (fee == input, the pool swaps 0, afterSwap reverts EmptySwap). Economic impact is nil: spamming 25-wei swaps through a third-party router only burns gas to move 1 wei per swap from the protocol to holders. Reported as a concrete mismatch between the stated split and the arithmetic. Fix if exact parity matters: round the protocol half up on alternate swaps, or document that holders receive the rounding wei.","line":358,"path":"contracts/RunnerHook.sol","reproduction":"Setup as in contracts/test/RunnerHook.t.sol (hook at startTick 142,600, pool opened, local v4 PoolManager). Through PoolSwapTest, perform an exact-in buy with amountSpecified = -24 (24 wei IMD) and zeroForOne = launches(runner).quoteIsCurrency0. Expected per the NatSpec: fee = 1 wei split 2%/2% between protocol and holders (or a documented rounding direction). Actual: hook.pendingProtocolFees(IMD) increases by 0 and hook.pendingHolderFees(RUNNER) increases by 1. Observed in test/scratch/Repro.t.sol::test_oddWeiFee_splitGoesEntirelyToHolders: amount 24 -> protocol 0 / holders 1; 25 -> 0 / 1; 26 -> 1 / 1; 51 -> 1 / 2. amountSpecified = -1 reverts (test_oneWeiBuy_revertsEmptySwap). The afterSwap path (exact-in sell / exact-out buy) calls the same _chargeFee.","severity":"info","snippet":"        uint256 protocolFee = (fee * PROTOCOL_FEE_BPS) / FEE_BPS;","title":"Odd-wei fees split protocol 0 / holders 1 instead of 2%/2%: the protocol share floors to zero on dust-sized IMD legs"},{"citation":"resolved","description":"The refund after the unlock sends address(this).balance to msg.sender instead of msg.value - ethIn. The router has no receive() function, so in normal operation the two are equal and nothing is lost; but any ETH that reaches the router by other means (SELFDESTRUCT beneficiary, coinbase reward, or a future change that lets the router hold ETH) is handed to whichever buyer calls next. Not exploitable for profit beyond that windfall and no loss to protocol users; hygiene note. Fix: return ethIn from the callback alongside tokensOut (or snapshot address(this).balance - msg.value before the unlock) and refund only msg.value - ethIn.","line":83,"path":"contracts/RunnerEthRouter.sol","reproduction":"State: 1 wei credited to the RunnerEthRouter outside buyWithEth (vm.deal(address(ethRouter), 1 wei) in the test; on chain, a contract self-destructing with the router as beneficiary). Input: alice calls buyWithEth{value: 1 ether}(RUNNER, 0, deadline) against an IMD/ETH pool deep enough to fill the full 1 ether. Expected: alice's refund is 0 (msg.value - ethIn) and her ETH balance drops by exactly 1 ether. Actual: the `if (address(this).balance > 0)` branch sends her the stray wei, so her balance drops by 1 ether - 1 wei and the router ends at 0. Verified in test/scratch/Repro.t.sol::test_buyWithEth_refundsStrayBalanceToCaller.","severity":"info","snippet":"        if (address(this).balance > 0) address(0).transferOut(msg.sender, address(this).balance);","title":"buyWithEth refunds the router's whole ETH balance, not only the caller's unspent msg.value"}],"hash":"63c0dd433e27aca96a32bcc5bbf4f540f77c7a0f9bc299ce2346e70116758d2c","nodeId":"7d0a74ce-c185-4b67-9b86-2995c8259a97","outcome":"completed","summary":"All four specialist findings reproduced against the pinned code and are recorded in `.imd-findings.json`. No tracked file was modified; only `test/scratch/` reproductions and the findings file were written.\n\n**What I verified**\n\n- **Low, RunnerHook constructor accepts a feeRecipient the setter rejects.** The attached proof fails on the current code (deploying with the PoolManager as recipient succeeds) and its second test shows 20 IMD stranded in the PoolManager after a permissionless collection. Kept with the specialist's proof. The RunnerToken constructor has the same asymmetry for protocolWallet, folded into this finding as a note rather than a separate entry, because that mis-deployment leaves the owner role on a contract that cannot act and is visible at once.\n- **Low, claimable() over-reports.** The attached proof fails as stated, with the view returning the full amount while an instant claim pays 80%. I also reproduced the second half with my own test: after 30 locked claims the view still returns a positive amount while claim() reverts with TooManyLocks. Kept with the specialist's proof.\n- **Info, odd-wei fee split.** Reproduced through PoolSwapTest at the production start tick. A 24 or 25 wei exact-in buy credits the protocol 0 and holders 1. A 1 wei buy reverts. Kept at info since the deviation is bounded to one wei per swap.\n- **Info, buyWithEth refunds the router's entire balance.** Reproduced by crediting the router 1 stray wei. The next buyer's balance drops by one ether minus one wei. Kept at info.\n\n**Merging.** The four findings sit in different functions and contracts with distinct fixes, so none were merged. Nothing was dropped.\n\n**Independent pass.** I also read the hook's beforeSwap/afterSwap delta accounting, the fee transient slot, the token's checkpoint and share arithmetic, the emission schedule, both routers, the deploy library, and the Genesis NFT contract that no specialist covered. I found nothing further I could substantiate with a failing input. The project's own suite of 35 tests passes, with the fork test skipped.\n\n**Limitations.** No static analyzer ran, as the task profile is Foundry only. Severity assessments assume the documented trust model, where the owner is a multisig and the IMD issuer blocklist is an accepted external dependency.","treeHash":null,"usage":{"cachedInputTokens":627265,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":23561,"runtime":"claude","turns":23,"wallClockMs":421592}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f009de0ce52c7b3a","findings":[{"citation":"resolved","description":"_feeOf rounds the 4% fee up (fourth audit, #7), so every swap with an IMD leg of 2..25 wei pays a fee of exactly 1 wei. _chargeFee then splits that fee as protocolFee = fee * 200 / 400, which floors 1/2 to 0, and credits fee - protocolFee = 1 wei to holders. For every odd fee the extra wei goes to holders; for fee == 1 the protocol receives nothing at all, so the documented 2% protocol / 2% holder split becomes 0% / 4% for that trade. The total fee is still conserved (pendingProtocolFees + pendingHolderFees == minted claims; the existing fuzz confirms this), the deviation is bounded by 1 wei per swap, and a 1-wei exact-in buy cannot execute at all (fee == input, the pool swaps 0, afterSwap reverts EmptySwap). Economic impact is nil: an attacker spamming 25-wei swaps through a third-party router only burns gas to move 1 wei per swap from the protocol to holders. Reported because it is a concrete, reproducible mismatch between the stated split and the arithmetic; if exact parity matters, alternate the odd wei (e.g. protocolFee = fee - fee / 2 on alternate swaps) or document that holders receive the rounding wei. Verified with the local v4 PoolManager at the production start tick 142,600.","line":358,"path":"contracts/RunnerHook.sol","reproduction":"Setup as in contracts/test/RunnerHook.t.sol (hook at startTick 142,600, openPool done). Through PoolSwapTest, perform an exact-in buy with amountSpecified = -24 (24 wei IMD) and the IMD-side zeroForOne. Expected per the NatSpec: fee = 1 wei split 2%/2%, i.e. protocol and holders each credited the same (or, with rounding, a documented direction). Actual: hook.pendingProtocolFees(IMD) increases by 0 and hook.pendingHolderFees(RUNNER) increases by 1. Observed table for exact-in IMD amounts (wei): amount 1 -> swap reverts EmptySwap (fee 1 == input); 2..25 -> fee 1, protocol 0, holders 1; 26..50 -> fee 2, protocol 1, holders 1; 51..75 -> fee 3, protocol 1, holders 2. The same split applies to the afterSwap path (exact-in sell / exact-out buy) since both call _chargeFee.","severity":"info","snippet":"        uint256 protocolFee = (fee * PROTOCOL_FEE_BPS) / FEE_BPS;","title":"Odd-wei fees split 0/1 instead of 2%/2%: protocol share floors to zero on dust-sized IMD legs"}],"hash":"68029f2f4c5df24c1b60dcd0914210a3f42e8d2e980e7279be6246aca55b6bbe","nodeId":"fc1cdd2a-49fc-4769-b23e-1d1174f53336","outcome":"completed","summary":"The math audit is complete. The findings file holds one info-level item; no medium or higher defects were substantiated.\n\n**What I reviewed.** All six in-scope contracts at the pinned commit, read against the Math Precision, Boundary and Numerical Gap guides, with the v4 core swap and hook libraries traced for the exact delta semantics the fee math relies on. The existing 35-test suite passes. I added probe tests under `test/scratch/` (not kept) and ran them against the real local PoolManager.\n\n**What held up under concrete inputs:**\n- **Hook fee math in all four directions.** Exact-in buy, exact-out sell, exact-in sell and exact-out buy each charge exactly 4% of the IMD side after rounding up. The PartialFill check is exact because the pool's specified delta equals the hook-adjusted amount on a full fill. Draining the reserve with an exact-out sell reverts PartialFill as designed, and an exact-in sell of everything leaves 1 wei and tick 142,599, after which buys and the market cap view behave correctly.\n- **Launch liquidity.** Floored liquidity in both pair orderings never asks for more than the allocation minus the buffer, so the burn stays within 2e9 wei at tick 142,600, and the constructor's max-liquidity-per-tick check covers both sort orders.\n- **Token day-share accounting.** A 200-run randomized model with transfers, buys, sells, day warps, claims, sweeps and a mid-window `excludePool` kept three invariants: per-day shares equal the sum of eligible day-minimum balances, paid-out never exceeds rewards, and IMD held equals the bookkeeping. Narrow casts (uint112, uint128, uint64, uint32) are all bounded by supply or emission caps.\n- **Genesis and deploy helper.** The app's BigInt rarity formula matches the contract bit for bit, and the realised rarity split over all 9,999 ids is 70.98 / 19.04 / 7.95 / 2.03 percent. The start-tick helper's \"at most about 2% above target\" claim holds (641.6 IMD for a 635 IMD target).\n\n**The one finding (info).** For any swap whose IMD leg is 2 to 25 wei, the rounded-up fee is 1 wei, and `fee * 200 / 400` floors the protocol share to zero, so holders get the whole wei. Every odd fee gives holders the extra wei. It is bounded by 1 wei per swap, conserved in total, and has no economic lever, but it is a reproducible mismatch with the stated 2%/2% split. The reproduction and a table of inputs are in `.imd-findings.json`.\n\n**Edges not exercised.** The Robinhood Chain fork tests were not run (no RPC in this session). I did not exercise uint128 overflow of a single day's rewards, which would need about 3.4e20 IMD in one day, nor v4 governance protocol fees on the pool, which the hook's accounting tolerates but which the tests do not cover.","treeHash":null,"usage":{"cachedInputTokens":2025442,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":57232,"runtime":"claude","turns":38,"wallClockMs":1006429}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"771f83f312eace21","findings":[],"hash":"9d88b6c5cc4cf2260341e56854947499d3418306e66b5dfe0882486e5f548212","nodeId":"e1cd174d-e039-431a-bbea-1cd8dc78c9f2","outcome":"completed","summary":"The economic review of 0xtenang/Runner at commit 556c920 is complete. I found no reproducible defect, and `.imd-findings.json` holds the empty list `{\"findings\":[]}`. The tree is otherwise unchanged.\n\n**What was reviewed.** All six in-scope contracts under `contracts/`, the two routers' unlock flows against v4-core at the pinned commit, and the server's claim-signing path in `api/rewards.js`. Checks ran in the foreground: the project builds, its 35 Foundry tests pass (the fork test skips without an RPC), and two scratch probes I wrote and then removed also passed.\n\n**Economic Security pass.** The 4% IMD fee was traced in all four swap directions through the v4 `BeforeSwapDelta` and `afterSwap` accounting. Each direction credits the hook exactly the fee, the ERC-6909 claim mint settles that credit to zero, and the protocol/holder split conserves the fee. The `PartialFill` guard makes the two \"IMD specified\" directions all-or-nothing, so the fee can never exceed 4% of what traded. The launch position is single-sided at the start tick and cannot be removed. On live Robinhood Chain data, IMD is a plain non-proxy token with code and an owner, and the PoolManager has code at the stated address.\n\n**Invariant pass.** A 256-run fuzz over random transfers, donations, claims and sweeps across several UTC days confirmed that each day's share total equals the sum of eligible lowest balances, that `holderRewardsHeld` always equals the IMD the token holds, and that after the 7-day window everything is either claimed or swept with nothing stuck. The run-reward pool invariants (balance ≥ locked, daily cap fixed for the current day, instant burn and early release both 20%) hold on every path.\n\n**Flow Gap pass.** A buy after a sell has drained the pool's IMD and parked the price at the tick limit fills identically to a launch buy and pays the exact fee at 418k gas. Router flushes are wrapped in try/catch, so IMD's blocklist cannot block trading. The ETH router's ETH refund and IMD remainder paths resolve all deltas.\n\n**Not findings, but worth knowing.** Three behaviours are documented as accepted in IMD-REAUDIT.md and were confirmed as described: holder fees from third-party routers are booked on whichever day someone calls `flush`; a hookless RUNNER/IMD v4 pool pays no fee; and holder claims depend on IMD transfers succeeding. The mock IMD gives test users 1,000,000 IMD each, while live IMD supply is about 65,430, so suite trade sizes are far larger than mainnet allows. The NFT rarity formula yields 70.98/19.04/7.95/2.03%, matching its documentation.\n\n**Limits.** This was a single reviewer's pass within the time budget, not a formal proof; signature-driven run-claim flows were read but exercised only through the project's existing JavaScript tests, not re-fuzzed here.","treeHash":null,"usage":{"cachedInputTokens":2208178,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":51137,"runtime":"claude","turns":37,"wallClockMs":948921}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9812c5faf226f7c7","findings":[{"citation":"resolved","description":"Asymmetry between the constructor and its admin-setter variant. setFeeRecipient() (contracts/RunnerHook.sol:429-431) refuses address(0), address(this) and address(poolManager) because, as its own comment says, \"IMD sent to the hook would be stuck, and sent to the PoolManager it would vanish\" (fourth audit, #3). The constructor validates only feeRecipient_ != address(0), so the same two values that the setter forbids are accepted at deployment, and feeRecipient is immutable until the owner notices and changes it. Both are plausible deploy-script mistakes: poolManager_ is the first constructor argument, and the hook's own address is known before deployment because it is CREATE2-mined. Because collectProtocolFees() is permissionless, anyone can trigger the loss as soon as a swap has produced a protocol fee: _collect() zeroes pendingProtocolFees, burns the hook's ERC-6909 claims and `take`s the IMD to the recipient. With the PoolManager as recipient the transfer is PoolManager -> PoolManager, so the IMD stays inside the PoolManager with no delta and no claim attached to it (stranded forever); with the hook as recipient the IMD sits in the hook, which has no function that moves its own ERC-20 balance (documented as unrecoverable, fifth audit #7). Fix: apply the setter's check in the constructor, e.g. `if (feeRecipient_ == address(this) || feeRecipient_ == address(poolManager_)) revert ZeroAddress();` (address(this) is available in a constructor). The same constructor/setter asymmetry exists in RunnerToken: the constructor sets protocolWallet = initialOwner without the hook/poolManager checks of setProtocolWallet() (contracts/RunnerToken.sol:192 vs 503), though there the mis-deployment also leaves the owner role on a contract that cannot call anything, so it is less likely to go unnoticed.","line":176,"path":"contracts/RunnerHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\n\nimport {RunnerHook} from \"contracts/RunnerHook.sol\";\nimport {RunnerRouter} from \"contracts/RunnerRouter.sol\";\n\ncontract CtorIMD {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amt) external { balanceOf[to] += amt; }\n    function approve(address s, uint256 amt) external returns (bool) { allowance[msg.sender][s] = amt; return true; }\n    function transfer(address to, uint256 amt) external returns (bool) { balanceOf[msg.sender] -= amt; balanceOf[to] += amt; return true; }\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt; balanceOf[to] += amt; return true;\n    }\n}\n\n/// @dev RunnerHook's constructor accepts a feeRecipient that setFeeRecipient() refuses (the PoolManager or the hook\n///      itself). With feeRecipient == poolManager, collectProtocolFees() `take`s IMD to the PoolManager, where it is\n///      unaccounted and lost. The constructor should apply the same check as the setter.\ncontract HookFeeRecipientCtorTest is Test {\n    uint256 constant DAY = 1 days;\n    address owner = makeAddr(\"owner\");\n    address team = makeAddr(\"team\");\n    address alice = makeAddr(\"alice\");\n\n    PoolManager pm;\n    CtorIMD imd;\n\n    function setUp() public {\n        vm.warp(20_500 * DAY + 3600);\n        pm = new PoolManager(address(this));\n        imd = new CtorIMD();\n    }\n\n    function _flags() internal pure returns (uint160) {\n        return uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 5) | (1 << 3) | (1 << 2));\n    }\n\n    function _mine(bytes memory initCode) internal view returns (bytes32 salt, address hook) {\n        bytes32 h = keccak256(initCode);\n        for (uint256 i = 0; i < 2_000_000; i++) {\n            hook = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));\n            if (uint160(hook) & 0x3FFF == _flags()) return (bytes32(i), hook);\n        }\n        revert(\"no salt\");\n    }\n\n    function _deploy(address feeRecipient_) internal returns (address deployed) {\n        bytes memory initCode = abi.encodePacked(\n            type(RunnerHook).creationCode,\n            abi.encode(pm, address(imd), owner, feeRecipient_, int24(142_600), RunnerHook.ImdEthPool(10_000, 100, address(0)))\n        );\n        (bytes32 salt,) = _mine(initCode);\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n    }\n\n    /// Fails on the current code: the constructor accepts feeRecipient == poolManager, which setFeeRecipient rejects\n    /// with ZeroAddress(). Passes once the constructor applies the same check (create2 then returns address(0)).\n    function test_constructor_rejectsPoolManagerAsFeeRecipient() public {\n        address deployed = _deploy(address(pm));\n        assertEq(deployed, address(0), \"constructor must refuse feeRecipient == poolManager like setFeeRecipient does\");\n    }\n\n    /// Shows the harm on the current code: with feeRecipient == poolManager, protocol IMD is sent to the PoolManager\n    /// and is unreachable by anyone (no delta, no claims), while pendingProtocolFees is zeroed. Once the constructor\n    /// is fixed the deploy reverts and there is nothing to demonstrate, so the test returns early.\n    function test_harm_protocolFeesLostToPoolManager() public {\n        address deployed = _deploy(address(pm));\n        if (deployed == address(0)) return;\n        RunnerHook hook = RunnerHook(deployed);\n        RunnerRouter router = RunnerRouter(hook.router());\n        address runner = deployCode(\"RunnerToken.sol:RunnerToken\", abi.encode(owner, address(hook), team, address(imd), address(pm)));\n        vm.prank(owner);\n        hook.openPool(runner);\n\n        imd.mint(alice, 10_000e18);\n        vm.startPrank(alice);\n        imd.approve(address(router), type(uint256).max);\n        router.buy(runner, 1_000e18, 0, block.timestamp);\n        vm.stopPrank();\n        assertEq(hook.pendingProtocolFees(address(imd)), 20e18);\n\n        uint256 pmBefore = imd.balanceOf(address(pm));\n        uint256 claimsBefore = pm.balanceOf(address(hook), uint256(uint160(address(imd))));\n        hook.collectProtocolFees(address(imd));\n        assertEq(hook.pendingProtocolFees(address(imd)), 0, \"fees marked as paid\");\n        assertEq(imd.balanceOf(address(pm)), pmBefore, \"IMD never left the PoolManager\");\n        assertEq(pm.balanceOf(address(hook), uint256(uint160(address(imd)))), claimsBefore - 20e18, \"hook's claims burned\");\n        assertEq(pm.balanceOf(address(pm), uint256(uint160(address(imd)))), 0, \"nobody holds a claim on the 20 IMD\");\n    }\n}","reproduction":"Deploy RunnerHook via CREATE2 with feeRecipient_ = address(poolManager) (all other arguments valid, start tick 142_600, salt mined for HOOK_FLAGS). Expected: the deployment reverts, as setFeeRecipient(address(poolManager)) does with ZeroAddress(). Actual: it succeeds and feeRecipient() == poolManager. Then: owner calls openPool(runner); alice buys with 1,000 IMD through RunnerRouter (pendingProtocolFees[IMD] == 20e18); anyone calls collectProtocolFees(IMD). Expected: 20 IMD reach a recipient. Actual: pendingProtocolFees becomes 0, the hook's IMD claims drop by 20e18, the PoolManager's IMD ERC-20 balance is unchanged (the transfer was to itself) and no address holds a claim or delta for those 20 IMD. The scratch test test/scratch/HookFeeRecipientCtor.t.sol fails on the current code on the first assertion and demonstrates the stranded fee in the second test. Run it with: FOUNDRY_TEST=test/scratch forge test --match-path test/scratch/HookFeeRecipientCtor.t.sol (foundry.toml points `test` at contracts/test, so the directory override is needed to compile a file under test/scratch/).","severity":"low","snippet":"        if (address(poolManager_) == address(0) || imd == address(0) || owner_ == address(0) || feeRecipient_ == address(0)) {","title":"RunnerHook constructor accepts a feeRecipient that setFeeRecipient() rejects (PoolManager or the hook itself): protocol IMD is then lost on collection"}],"hash":"ee3788964fc1f1fa7e3bf348196c223a6b1117768a322b5f34edbe8ceec79365","nodeId":"25e2f42f-1b92-4dff-a88a-db99b80befe9","outcome":"completed","summary":"The review is complete and the findings file is written. Here is the recap.\n\n**Outcome.** One substantiated finding, severity low, with a Foundry proof that fails on the current code. No access-control bypass, privilege escalation, or exploitable trust gap was found in the token, hook, routers or NFT. The findings file at `.imd-findings.json` holds that single entry.\n\n**The finding.** The hook constructor only rejects a zero fee recipient, while `setFeeRecipient` also rejects the hook itself and the PoolManager. A deployment with the PoolManager as fee recipient succeeds. Because fee collection is permissionless, anyone can then trigger `collectProtocolFees` and the IMD is transferred PoolManager to PoolManager with no delta or claim, so it is stranded. The scratch test under test/scratch/ deploys such a hook, performs one buy, collects, and shows the fee gone. It fails today and passes once the constructor applies the setter's check. The same constructor-versus-setter gap exists for the token's protocol wallet, noted in the description but not reported separately because that misdeployment also leaves the owner role unusable and would be noticed.\n\n**What was checked and held.**\n- Every state-changing entry point was inventoried with its caller and guard. All owner functions are guarded, both ownership handovers are two-step and cancellable, and the unlock callbacks can only be reached by the PoolManager within the contract's own unlock.\n- The hook's self-call exemption in v4-core was confirmed in the pinned library, so only the hook can initialise its pool or add liquidity, and donations are refused.\n- Mid-unlock flush and collect are limited to the hook's two routers. A user contract receiving ETH from the ETH router mid-unlock cannot flush, collect, or re-enter the router.\n- Claim signatures bind account, cumulative total, deadline and payout choice to the chain and contract. Instant claims are restricted to the runner. Locked claims submitted by third parties have the same effect as the runner's own submission.\n- Holder-reward exclusion only shrinks share totals for days nobody has been paid from, so no day can pay out more than it received. Fee formulas are symmetric across all four swap directions.\n- Flash-borrowed pool RUNNER starts the day before entering eligible supply, so it cannot inflate a day's share total.\n\n**Leads rejected.** Admin setters that redirect in-flight sweeps or fees are documented owner powers with no unprivileged amplifier. Pause not covering lock release is design, since instant claims already pay most value immediately. Flush timing for non-router swaps is an accepted documented behaviour.\n\n**Limits.** The existing suite of 35 Foundry tests passed. The JS token tests and the rewards server were not re-run. No fork tests were run.","treeHash":null,"usage":{"cachedInputTokens":2186329,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":50708,"runtime":"claude","turns":37,"wallClockMs":816496}}],"verification":[]}