{"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":"0baee611-180e-4e5d-9c3a-a23ba1245fbb","kind":"audit","nodes":[{"acceptedSubmissionHash":"625788be70b77b8427becff3becc4aba588b088972c3160b9c613083f3a7b961","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":"ae29d10e9492aedae1023b89d9342159ee84eeb3a814404b7f8a3d1949c4aaf0","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":"ce6aea8a15cccdca64ce4f46980d69decd9f52ed622f67d8e51b5021f2ec3b76","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":"ad3347289e7a45277916ef1ecd8701cb7c133a33b8d00b6a08219c662d710c36","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":"35ef06c4057481d318f6ddce59c838870f2550d3faff1f30b1573360419c89dd","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":"Focus on the standalone v4 hook and CREATE2 factory: exact callback permission bits, mined versus deployed address, salt/front-running risks, constructor and PoolManager/chain binding, bytecode verification, swap-limit bypasses, denial-of-service, and behavior across two new pools. Reproduce material findings with Foundry tests. Distinguish local evidence from untested public-chain deployment.","parentJobId":null,"planHash":"b723c6e5a5bac0199fe8c29ceffbffa36e0e9860654bf3ab2f81cf12932e7d2b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"0baee611-180e-4e5d-9c3a-a23ba1245fbb","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":"52121","feedbackHash":"715603b9ac3e4061b474f738df6b0360358aec3a5adc8356725a8b5fba82ef1d","nodeKey":"audit_economics","submissionHash":"625788be70b77b8427becff3becc4aba588b088972c3160b9c613083f3a7b961","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51876","feedbackHash":"168d86599aa7faafb66be3e453c1becc01667554a701cf6b8ec7f0f9d20140bc","nodeKey":"audit_flow","submissionHash":"ae29d10e9492aedae1023b89d9342159ee84eeb3a814404b7f8a3d1949c4aaf0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51534","feedbackHash":"ac8facb48414a4ae5948bd8b37d9995aee458be0d4902ac5a3df3da666c000ad","nodeKey":"audit_judge","submissionHash":"ce6aea8a15cccdca64ce4f46980d69decd9f52ed622f67d8e51b5021f2ec3b76","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51298","feedbackHash":"cc9dcf34c5bc6755d7cc75eff0dd2935ba8ec9a30872f4890f578e6a8d383fc4","nodeKey":"audit_math","submissionHash":"ad3347289e7a45277916ef1ecd8701cb7c133a33b8d00b6a08219c662d710c36","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50976","feedbackHash":"957816c51479cb068c364e013520611bac90760b26ed25b79631ce088230138f","nodeKey":"audit_permissions","submissionHash":"35ef06c4057481d318f6ddce59c838870f2550d3faff1f30b1573360419c89dd","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"098e8f59bd9e9aa806c2eef63bafec6a2bdaa511875cc95639794d5a97f4c024","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9b06782c7559b54c","findings":[{"citation":"resolved","description":"Under the Foundry toolchain this profile runs (forge 1.8.3), a `new` expression inside a test contract is rewritten into the `vm.deployCode` cheatcode. When that cheatcode reverts while `vm.expectRevert` is armed, the revert propagates out of the test function itself; Foundry then matches the test-level revert against the expectation and marks the test PASSED, but every statement after line 248 is skipped. In `testAllNamedMainnetsAndUnknownChainsRejected` the loop therefore executes only for `ids[0] == 1`. The second expectation in the loop body (line 250, `factory.deploy(...)` must revert with UnsupportedDeploymentChain) and the iterations for Base mainnet 8453, Robinhood mainnet 4663 and the unknown id 42 never run. docs/VERIFICATION.md and docs/THREAT_MODEL.md cite this test as the local evidence that 'Named mainnets and unknown chain IDs rejected' and that `deploy` is blocked off-allowlist; that evidence is currently a single constructor check on chain id 1. The underlying production code does behave as claimed (verified separately in this review by calling deploy on chain 8453 through an external call), so this is a test-coverage defect, not a policy bypass. Fix: perform the creation through an external call frame (a small deployer helper contract, or `address(this).call(abi.encodeCall(this.deployFactory, ()))`) so `expectRevert` consumes the inner revert and the loop continues; alternatively test the `deploy` rejection first and the constructor rejection last, one chain id per test.","line":248,"path":"test/Deployment.t.sol","reproduction":"Input: `forge test --match-test testAllNamedMainnetsAndUnknownChainsRejected -vvvv`. Expected: four iterations, each with two reverts (constructor via `new HooksStoreFactory()` and `factory.deploy(spec, bytes32(0), address(this), bytes32(0))`), i.e. eight UnsupportedDeploymentChain reverts for ids 1, 8453, 4663, 42. Actual: the trace shows `VM::chainId(1)`, `VM::expectRevert(UnsupportedDeploymentChain(1))`, `VM::deployCode(\"src/HooksStoreFactory.sol:HooksStoreFactory\")` reverting, then the test function itself returning `[Revert] UnsupportedDeploymentChain(1)`; reported gas is 4440 for the whole test, and no `HooksStoreFactory::deploy` call and no chainId(8453/4663/42) cheatcode appear. The same mechanism makes a sibling scratch test that placed assertions after an expected-revert `new` report PASS with 16862 gas while none of its assertions executed.","severity":"low","snippet":"            new HooksStoreFactory();","title":"Mainnet-rejection test aborts after its first iteration; chain IDs 8453, 4663, 42 and deploy() rejection are never exercised"},{"citation":"resolved","description":"README.md line 48 states that for this release 'Ethereum, Base and Robinhood mainnets are explicitly rejected' and docs/DEPLOYMENT.md says 'Mainnet deployment is disabled in the release'. The allowlist in `DeploymentPolicy.requireTestChain()` is only invoked by `HooksStoreFactory` (constructor and `deploy`), `HookDeployment.checkFactory`, `DeployStore.run`, and the Python adapter. The hook's own constructor only requires `spec.chainId == block.chainid` (HookSpecLib.validateChain) plus the callback-bit check. Any account can therefore run the CREATE2 deployment itself (or through any generic CREATE2 deployer) on chain 1, 8453 or 4663 with a spec whose chainId matches, and obtain a fully functional BoundedSwapHook bound to a mainnet PoolManager, carrying the official template id, spec hash and runtime bytecode (the runtime is identical to a factory deployment because the settings live in storage). The resulting hook is indistinguishable by bytecode from a Store deployment; only `HooksStoreFactory.deployedSpecHash` (which cannot exist on those chains) distinguishes it. THREAT_MODEL.md does scope the mainnet control to the factory, integration and adapter, so this is a gap between the headline release claim and the code rather than an access-control bypass of a factory invariant. If the release policy is meant to hold for the template itself, call `DeploymentPolicy.requireTestChain()` in the hook constructor (one line, no behaviour change on the allowed chains; note `testWrongSaltRejectedByFactoryAndConstructor` still passes because it runs on 31337). If the policy is intentionally factory-only, correct README.md line 48 and docs/DEPLOYMENT.md so operators do not rely on the hook rejecting mainnets.","line":41,"path":"src/BoundedSwapHook.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\";\nimport {HookSpec, HookSpecLib} from \"src/HookSpec.sol\";\nimport {HooksStoreFactory} from \"src/HooksStoreFactory.sol\";\nimport {BoundedSwapHook} from \"src/BoundedSwapHook.sol\";\nimport {HookMiner} from \"src/deployment/HookMiner.sol\";\nimport {DeploymentPolicy} from \"src/deployment/DeploymentPolicy.sol\";\n\n/// @dev Separate deployer so `new` runs inside a real external call frame (Foundry rewrites `new`\n///      inside test contracts into a cheatcode, which aborts the test under expectRevert).\ncontract Deployer {\n    function factory() external returns (HooksStoreFactory) {\n        return new HooksStoreFactory();\n    }\n\n    function hook(bytes32 salt, HookSpec memory spec) external returns (BoundedSwapHook) {\n        return new BoundedSwapHook{salt: salt}(spec);\n    }\n}\n\n/// @notice The release says mainnets are rejected, but only the factory enforces DeploymentPolicy.\n///         A direct `new BoundedSwapHook{salt: s}(spec)` with spec.chainId == 1 on chain 1 succeeds.\n///         Fails on the current code; passes once the hook constructor enforces the same allowlist.\ncontract HookChainPolicyTest is Test {\n    function testHookConstructorRejectsMainnetLikeFactory() public {\n        vm.chainId(1);\n        Deployer d = new Deployer();\n        PoolManager manager = new PoolManager(address(this));\n        HookSpec memory spec = HookSpec({\n            schemaVersion: 1,\n            templateId: HookSpecLib.TEMPLATE_ID,\n            chainId: 1,\n            poolManager: address(manager),\n            poolManagerCodeHash: address(manager).codehash,\n            permissions: HookSpecLib.PERMISSIONS,\n            maxSpecifiedAmount: 1_000_000\n        });\n\n        // The factory path is closed on chain 1, as documented.\n        vm.expectRevert(abi.encodeWithSelector(DeploymentPolicy.UnsupportedDeploymentChain.selector, uint256(1)));\n        d.factory();\n\n        // The standalone constructor path: mine a valid salt for the deployer and deploy directly.\n        bytes32 initHash = keccak256(bytes.concat(type(BoundedSwapHook).creationCode, abi.encode(spec)));\n        HookMiner.Result memory r = HookMiner.mine(address(d), initHash, HookSpecLib.PERMISSIONS, 0, 131072);\n        assertTrue(r.found, \"no salt found\");\n\n        vm.expectRevert(abi.encodeWithSelector(DeploymentPolicy.UnsupportedDeploymentChain.selector, uint256(1)));\n        d.hook(r.salt, spec);\n    }\n}","reproduction":"State: `vm.chainId(1)`; a PoolManager deployed at M; `spec = HookSpec(1, TEMPLATE_ID, 1, M, M.codehash, 0x10c0, 1_000_000)`; a salt mined with `HookMiner.mine(deployer, keccak256(creationCode ++ abi.encode(spec)), 0x10c0, 0, 131072)`. Input: `new HooksStoreFactory()` from the deployer, then `new BoundedSwapHook{salt: salt}(spec)` from the same deployer. Expected (per README 'mainnets are explicitly rejected'): both revert with `UnsupportedDeploymentChain(1)`. Actual: the factory creation reverts, the hook creation succeeds at the mined address with `deploymentChainId() == 1`, `poolManager() == M` and all callback bits valid. The attached Foundry test fails on the current code with 'next call did not revert as expected' and passes once the constructor enforces the allowlist.","severity":"low","snippet":"        HookSpecLib.validateChain(spec);","title":"BoundedSwapHook constructor does not enforce DeploymentPolicy; a standalone deployment with spec.chainId == 1 succeeds on Ethereum mainnet"},{"citation":"resolved","description":"`mine` is documented as a read-only eth_call with `attempts` up to 131072 (docs/STORE_API.md line 25; the DeployHook example in docs/DEPLOYMENT.md line 40 passes `0 131072`). Each candidate costs roughly 320 gas (keccak of 85 bytes plus loop overhead; the `candidate.code.length` EXTCODESIZE is short-circuited and only paid on a bit match, about once per 16384 candidates). A page that finds no match therefore needs about 42M gas. Nodes that cap eth_call at the block gas limit (30M on Ethereum-mainnet-sized chains) or lower will reject the maximum page with an out-of-gas or cap error, which the Python adapter surfaces only as 'eth_call failed'. geth's default rpc.gascap of 50M admits it, so the limit is provider-dependent and untested on any public chain here. Not a safety issue: no state is involved and the SDK default page size is 4096 (~1.35M gas). Consider lowering MAX_ATTEMPTS or documenting a recommended page size (for example 65536, ~21M gas) for hosted RPCs, and let the adapter retry with a smaller page on a gas-cap failure.","line":7,"path":"src/deployment/HookMiner.sol","reproduction":"Local Anvil (chain 31337), vendored PoolManager at 0x5FbD...0aa3 and HooksStoreFactory at 0xe7f1...0512, spec (1, keccak('hooks-store/bounded-swap/v1'), 31337, manager, manager.codehash, 4288, 1000000). `cast estimate factory 'mine((uint32,bytes32,uint256,address,bytes32,uint160,uint128),uint256,uint256)' spec 7000 N` gives N=1: 33,318 gas; N=1024: 360,678; N=4096: 1,343,718; N=16384: 5,275,878 (no match in that range), i.e. ~320 gas per candidate, so N=131072 with no match extrapolates to ~42.0M gas. Expected per docs: any page size from 1 to 131072 is usable from an eth_call. Actual: the maximum page exceeds a 30M eth_call cap. The forge test `testMineGasPerCandidate` in test/scratch/Probe.t.sol reproduces 324 gas per candidate. Public RPC gas caps were not exercised.","severity":"info","snippet":"    uint256 internal constant MAX_ATTEMPTS = 131072;","title":"A full 131072-attempt mining page costs about 42M gas in one eth_call, above a 30M-gas RPC cap"},{"citation":"resolved","description":"README.md line 5 says the hook 'rejects swaps whose requested amountSpecified is zero'. The vendored PoolManager.swap (lib/v4-core/src/PoolManager.sol line 190) reverts with `SwapAmountCannotBeZero` before `Hooks.beforeSwap` is invoked, so the hook never observes a zero amount from its only authorised caller; the branch is exercised in the suite only by direct `vm.prank(manager)` calls (testIntMinIntMaxAndZeroFailClearly, testFuzzSignedBoundaryAndNeutralReturn). Keeping the check as defence in depth is harmless; the documentation should attribute zero-amount rejection to the PoolManager so integrators do not expect `AmountOutsideLimit(0, limit)` in their error handling.","line":78,"path":"src/BoundedSwapHook.sol","reproduction":"State: hook deployed through the factory, pool (t0, t1, 3000, 60, hook) initialised at price 1<<96 with liquidity. Input: inside `manager.unlock`, call `manager.swap(key, SwapParams(true, 0, MIN_SQRT_PRICE + 1), '')`. Expected per README: revert wrapped around the hook's `AmountOutsideLimit(0, 1000000)`. Actual: revert `SwapAmountCannotBeZero()` from PoolManager; the hook is never called (test `testZeroAmountRejectedByManagerBeforeHook` in test/scratch/Probe.t.sol).","severity":"info","snippet":"        if (params.amountSpecified == 0 || params.amountSpecified < -limit || params.amountSpecified > limit) {","title":"The hook's amountSpecified == 0 rejection is unreachable through the PoolManager, contrary to README wording"},{"citation":"resolved","description":"Recorded for the swap-limit-bypass part of the review scope. The bound applies to the `amountSpecified` of each individual `beforeSwap` callback; there is no per-transaction, per-block, per-sender or per-pool cumulative accounting, and `swapCount` is only incremented after each accepted swap. A router can therefore chain any number of limit-sized swaps inside a single `unlock`, and exact-output swaps leave the input side unbounded. README.md line 7 and docs/THREAT_MODEL.md state this explicitly ('A user can split trades'; 'not a cumulative limit'), so this is a documented design property and not a code defect; it is listed so the acceptance reviewer sees that the bypass was tested rather than assumed. Any application relying on the hook for economic protection needs its own cumulative control.","line":78,"path":"src/BoundedSwapHook.sol","reproduction":"State: hook with maxSpecifiedAmount 1,000,000 and an initialised pool with 1e24 liquidity. Input: one `manager.unlock` whose callback performs three `manager.swap(key, SwapParams(true, -1_000_000, MIN_SQRT_PRICE + 1), '')` calls, settling each. Expected if the limit were per transaction: the second swap reverts with AmountOutsideLimit. Actual: all three succeed; the caller's token0 balance decreases by exactly 3,000,000 raw units and `swapCount(poolId)` is 3 (test `testLimitSplitInsideOneUnlock` in test/scratch/Probe.t.sol).","severity":"info","snippet":"        if (params.amountSpecified == 0 || params.amountSpecified < -limit || params.amountSpecified > limit) {","title":"The limit is per swap callback only: three limit-sized swaps inside one unlock move 3x the limit in one transaction"}],"hash":"35ef06c4057481d318f6ddce59c838870f2550d3faff1f30b1573360419c89dd","nodeId":"fdfe0bb5-d796-4c91-9837-128f9f1a96a7","outcome":"completed","summary":"The pgrep matches were only the shell's own command line; no Anvil process remains. The findings file is written and the only failing test in the tree is my intended proof.\n\n## Review outcome\n\nNo critical or high defects. The hook and factory hold up under every area in scope. I wrote five findings to `.imd-findings.json`, two low and three info, each with a concrete failing input.\n\n**What I checked and found sound**\n\n- **Callback bits.** The address mask 0x10c0 matches afterInitialize, beforeSwap and afterSwap in the vendored Hooks library. Both the factory and the constructor require full 14-bit equality, so no return-delta bit can slip in.\n- **Mined versus deployed address.** The factory recomputes the CREATE2 address from its own address, the salt and the full init code, then asserts the actual `new` result matches. The address binds to this one template, so a front-runner can only deploy the identical hook, gaining nothing.\n- **Chain and manager binding.** The spec commits chain id and manager code hash, and both are rechecked at deploy and verify. The hook authenticates the manager by stored address on every enabled callback.\n- **Bytecode verification.** Settings live in storage, metadata is disabled, so the runtime hash is constant and comparable with `extcodehash`.\n- **Denial of service.** No path lets the hook's `initialized` map diverge from the manager. A pool initialized against a not-yet-deployed hook address reverts in the manager, so nothing can be pre-registered.\n- **Two pools.** State is keyed by full PoolId. The split-limit, zero-amount and exact-mode behaviours were reproduced with real manager swaps.\n\n**Findings**\n\n1. **Low, test coverage.** The mainnet-rejection test in `test/Deployment.t.sol` stops after its first iteration. Under this Foundry version a `new` under `expectRevert` aborts the test and it is still marked passed, so chain ids 8453, 4663, 42 and the `deploy` rejection are never exercised. The production code does reject them. I verified that separately.\n2. **Low, policy gap.** The hook constructor does not call the chain allowlist, so a direct CREATE2 deployment with chain id 1 succeeds on mainnet with the official template id and bytecode. This contradicts the README's release claim. A proof test is attached that fails now and passes with a one-line constructor check.\n3. **Info.** A full 131072-attempt mining page costs about 42M gas in one call, above a 30M RPC cap. Measured on local Anvil only.\n4. **Info.** The hook's zero-amount branch is unreachable through the manager, which rejects zero first.\n5. **Info.** Three limit-sized swaps in one unlock move three times the limit. This is documented design, recorded for the bypass scope.\n\n**Evidence boundary.** All reproductions ran on a local Foundry EVM and a throwaway Anvil node. No public chain, fork or live PoolManager was touched. Trust assumptions stand as documented: no owner or setter exists, and the operator alone vouches for the PoolManager identity.","treeHash":null,"usage":{"cachedInputTokens":2237122,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":50273,"runtime":"claude","turns":49,"wallClockMs":723673}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bdd9b74dce66953d","findings":[{"citation":"resolved","description":"beforeSwap compares only params.amountSpecified against maxSpecifiedAmount. For an exact-output swap (positive amountSpecified) the unspecified input side is unbounded, so a swap that satisfies the limit can still move far more value through the pool than the limit suggests. The README states this is intended (not an input cap for exact-output swaps). Reported as confirmed behaviour so pool creators and the Store UI treat the number as a specified-side cap only; no contract change is required unless the product wants a two-sided bound, which would be a design change.","line":78,"path":"src/BoundedSwapHook.sol","reproduction":"Local Foundry (test/scratch/Economics.t.sol testExactOutputInputUncapped): fresh PoolManager, hook deployed through HooksStoreFactory with maxSpecifiedAmount = 1_000_000, pool initialised at sqrtPrice 2^96 with full-range liquidity 1_100_000. Swap zeroForOne=false, amountSpecified=+1_000_000 (exactly the limit). Expected if the limit were a value bound: at most ~1_000_000 raw units on either side. Actual: delta.amount0 = +1_000_000 (output), delta.amount1 = -11_033_324 (input, 11.03x the limit); swapCount becomes 1.","severity":"info","snippet":"        if (params.amountSpecified == 0 || params.amountSpecified < -limit || params.amountSpecified > limit) {","title":"Per-swap limit bounds only the specified side: exact-output swap pulls 11x the limit as input"},{"citation":"resolved","description":"The hook keeps no per-transaction, per-block or cumulative accounting, so a router can call PoolManager.swap repeatedly inside one unlock, each call at the limit. The README says splitting is allowed; this confirms it and quantifies it. Any product copy that calls the hook a 'trade size cap' should say 'per-call cap'.","line":78,"path":"src/BoundedSwapHook.sol","reproduction":"Local Foundry (test/scratch/Economics.t.sol testSplitTradesInOneTransaction): hook with maxSpecifiedAmount = 1_000_000, pool with full-range liquidity 1e24. One unlock performing 50 swaps of zeroForOne=true, amountSpecified=-1_000_000 each. Expected under a per-transaction reading of the limit: revert after the first. Actual: all 50 succeed, total amount0 moved = 50_000_000, hook.swapCount(poolId) = 50.","severity":"info","snippet":"        if (params.amountSpecified == 0 || params.amountSpecified < -limit || params.amountSpecified > limit) {","title":"Limit is per swap() call, not per transaction: 50 swaps in one unlock move 50x the limit"},{"citation":"resolved","description":"afterSwap increments the per-pool counter for every completed manager callback, including swaps in a pool with zero liquidity that transfer nothing. The threat model already says the counter is not a volume or fill metric; recorded so downstream consumers do not use swapCount as activity evidence.","line":93,"path":"src/BoundedSwapHook.sol","reproduction":"Local Foundry (test/scratch/Economics.t.sol testZeroFillCounted): hook pool initialised at 2^96 with no liquidity added. Swap zeroForOne=true, amountSpecified=-1000. Expected by a reader treating swapCount as fills: 0. Actual: delta = (0, 0) and hook.swapCount(poolId) = 1.","severity":"info","snippet":"        uint256 count = ++swapCount[id];","title":"swapCount increments for swaps that fill nothing"},{"citation":"resolved","description":"Salts are plain nonces with no sender binding, so the first matching plan for a given spec is public and identical for everyone. If any party (including a mempool front-runner copying the calldata) submits deploy() first, the operator's broadcast of the same plan reverts in HooksStoreFactory.deploy with AddressOccupied(hook) and the gas for the failed transaction is lost. No ownership is at stake and the identical hook is immediately usable, which STORE_API.md documents, but the script has no branch that detects an existing identical deployment and returns verify() instead of reverting.","line":35,"path":"script/DeployHook.s.sol","reproduction":"Local Foundry (test/scratch/FrontRun.t.sol): prepare(factory, spec, 0, 131072) yields plan P. A different address calls factory.deploy(spec, P.salt, P.hook, P.initCodeHash) first. Then script.execute(factory, spec, P) reverts with AddressOccupied(P.hook) while factory.verify(spec, P.salt, P.hook) succeeds and returns the same hook. Expected for an operator script: adopt the verified identical deployment; actual: revert.","severity":"info","snippet":"        receipt = execute(factory, spec, plan);","title":"DeployHook.run reverts with AddressOccupied when an identical plan was deployed first and offers no verify-and-adopt path"}],"hash":"625788be70b77b8427becff3becc4aba588b088972c3160b9c613083f3a7b961","nodeId":"d6aedbf6-c8a0-4262-93e2-f72fc313f1bd","outcome":"completed","summary":"The review is complete and the findings file is written. Nothing in the repository was modified; the scratch tests live under `test/scratch/` only.\n\n**Outcome: no material defects found in the hook or factory.** All four recorded findings are informational and confirm documented limitations with concrete reproductions. The file `.imd-findings.json` holds them.\n\n**What was verified locally**\n- The existing 39 Foundry tests and the 10 Python adapter tests (on a disposable Anvil) pass. `forge fmt --check` passes.\n- Build fingerprints in `docs/build-fingerprints.json` match a fresh build byte for byte, including the 3,431 and 13,879 byte runtime sizes. Neither contract has immutable references, so the runtime-hash comparison in `verify` is sound.\n- Callback permission bits: the constant equals `0x10c0` (afterInitialize bit 12, beforeSwap bit 7, afterSwap bit 6), the factory checks full low-14-bit equality, and the constructor runs the vendored `validateHookPermissions`. All four return-delta bits are clear, so the manager ignores the zero deltas the hook returns.\n- Mined versus deployed address: the factory recomputes the plan from the full creation code plus ABI constructor args and rechecks the created address. A pool cannot be initialised against the predicted address before deployment, because the manager rejects an empty hook response.\n- PoolManager and chain binding: callbacks require the exact stored manager and `key.hooks == this`, so a pool on any other manager cannot initialise with this hook. Spec chain ID must equal `block.chainid` at planning, deployment and verification.\n- Two new pools: independent registration and counters confirmed, plus the dynamic-fee rejection rolling back manager state.\n- The deployment policy's Robinhood IDs (4663 mainnet, 46630 testnet) are correct per public references.\n- Mining cost: an exhausted 131,072-candidate page costs about 43M gas on Anvil. That fits a default geth `eth_call` cap, so it is not a defect, but some hosted RPCs cap lower. The SDK default page of 4,096 costs about 1.3M.\n\n**Findings recorded (all info)**\n- Exact-output swap at exactly the limit drew 11.03× the limit as input in a thin pool.\n- Fifty swaps at the limit inside one unlock moved 50× the limit in one transaction.\n- A zero-liquidity swap fills nothing yet increments `swapCount`.\n- If an identical public plan is deployed first, `DeployHook.run` reverts with `AddressOccupied` instead of adopting the verified identical hook.\n\n**Evidence boundary**\nAll evidence is local Foundry and Anvil execution. No public-chain deployment, fork, or live PoolManager was exercised, consistent with the project's own statement that Ethereum, Base and Robinhood execution is untested.\n\nSources for the chain IDs: [BitGo Robinhood Chain docs](https://developers.bitgo.com/docs/robinhood-chain), [Chainstack Robinhood tooling](https://docs.chainstack.com/docs/robinhood-tooling), [GetBlock eth_chainId for Robinhood](https://docs.getblock.io/api-reference/robinhood/eth_chainid-robinhood).","treeHash":null,"usage":{"cachedInputTokens":1254272,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":40772,"runtime":"claude","turns":40,"wallClockMs":592987}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2d027bc56749d95c","findings":[{"citation":"resolved","description":"beforeSwap bounds only params.amountSpecified. For an exact-output swap (positive amountSpecified) that is the output currency, so the input currency the trader settles is never compared to the limit, and afterSwap (which receives the full BalanceDelta) does not check it either. The bound a creator sets as the per-swap ceiling therefore caps the input side only when the router chooses exact-input. As liquidity thins the ratio grows without bound: an exact-output request for exactly the limit against a full-range pool with L = 1.1e6 moves 11,033,210 raw units of input for 1,000,000 of output, while an exact-input request of 1,000,001 is rejected one line earlier. The README documents that the limit is 'not an input cap for exact-output swaps', so this is a documented design limit rather than a code bug, but it means the only enforceable statement is 'the requested specified amount is at most the limit', and any integration that relies on the hook to cap per-swap exposure of either currency (the natural reading of a swap limit) is unprotected. Local evidence only: reproduced on a fresh vendored PoolManager in Foundry; no public-chain pool was exercised. Fixing it is a scope decision: if the intent is a per-swap cap on both currencies, afterSwap can additionally revert when |delta.amount0()| or |delta.amount1()| exceeds maxSpecifiedAmount (the hook already has the afterSwap permission and the delta in hand); if the current semantics are intended, HOOK_SPEC.md and the schema description of maxSpecifiedAmount should state the exact-output asymmetry with a worked number so creators do not size the limit as an exposure cap.","line":78,"path":"src/BoundedSwapHook.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\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {ERC20} from \"solmate/src/tokens/ERC20.sol\";\nimport {HookSpec, HookSpecLib} from \"src/HookSpec.sol\";\nimport {HooksStoreFactory} from \"src/HooksStoreFactory.sol\";\nimport {IHooksStore} from \"src/interfaces/IHooksStore.sol\";\nimport {HookMiner} from \"src/deployment/HookMiner.sol\";\nimport {BoundedSwapHook} from \"src/BoundedSwapHook.sol\";\n\ncontract BypassToken is ERC20 {\n    constructor() ERC20(\"T\", \"T\", 18) {}\n\n    function mint(address to, uint256 a) external {\n        _mint(to, a);\n    }\n}\n\ncontract BypassRouter is IUnlockCallback {\n    IPoolManager public immutable manager;\n\n    struct Action {\n        address payer;\n        PoolKey key;\n        bool isSwap;\n        IPoolManager.SwapParams swapParams;\n        IPoolManager.ModifyLiquidityParams liquidityParams;\n    }\n\n    constructor(IPoolManager m) {\n        manager = m;\n    }\n\n    function swap(PoolKey memory key, IPoolManager.SwapParams memory p) external returns (BalanceDelta) {\n        Action memory a;\n        a.payer = msg.sender;\n        a.key = key;\n        a.isSwap = true;\n        a.swapParams = p;\n        return abi.decode(manager.unlock(abi.encode(a)), (BalanceDelta));\n    }\n\n    function liquidity(PoolKey memory key, int24 lo, int24 hi, int256 amount) external returns (BalanceDelta) {\n        Action memory a;\n        a.payer = msg.sender;\n        a.key = key;\n        a.liquidityParams = IPoolManager.ModifyLiquidityParams(lo, hi, amount, bytes32(0));\n        return abi.decode(manager.unlock(abi.encode(a)), (BalanceDelta));\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        Action memory a = abi.decode(data, (Action));\n        BalanceDelta d;\n        if (a.isSwap) d = manager.swap(a.key, a.swapParams, \"\");\n        else (d,) = manager.modifyLiquidity(a.key, a.liquidityParams, \"\");\n        _settle(a.key.currency0, d.amount0(), a.payer);\n        _settle(a.key.currency1, d.amount1(), a.payer);\n        return abi.encode(d);\n    }\n\n    function _settle(Currency c, int128 delta, address payer) private {\n        if (delta < 0) {\n            manager.sync(c);\n            require(BypassToken(Currency.unwrap(c)).transferFrom(payer, address(manager), uint256(-int256(delta))));\n            manager.settle();\n        } else if (delta > 0) {\n            manager.take(c, payer, uint256(int256(delta)));\n        }\n    }\n}\n\n/// @notice Fails on the current hook: an exact-output swap for exactly `maxSpecifiedAmount` pulls 11x the\n/// limit of the input currency through a pool whose exact-input swaps are capped at the limit.\n/// Passes once the hook also bounds the unspecified (settled) side of the swap, e.g. in afterSwap.\ncontract ExactOutputBypassTest is Test {\n    uint128 constant LIMIT = 1_000_000;\n    PoolManager manager;\n    BoundedSwapHook hook;\n    BypassRouter router;\n    PoolKey key;\n\n    function setUp() public {\n        vm.chainId(31337);\n        manager = new PoolManager(address(this));\n        HooksStoreFactory factory = new HooksStoreFactory();\n        HookSpec memory spec = HookSpec(\n            1, HookSpecLib.TEMPLATE_ID, 31337, address(manager), address(manager).codehash, HookSpecLib.PERMISSIONS, LIMIT\n        );\n        HookMiner.Result memory r = factory.mine(spec, 0, 131072);\n        require(r.found, \"mine\");\n        IHooksStore.Plan memory plan = factory.preview(spec, r.salt);\n        factory.deploy(spec, plan.salt, plan.hook, plan.initCodeHash);\n        hook = BoundedSwapHook(plan.hook);\n        router = new BypassRouter(manager);\n        BypassToken a = new BypassToken();\n        BypassToken b = new BypassToken();\n        (BypassToken t0, BypassToken t1) = address(a) < address(b) ? (a, b) : (b, a);\n        t0.mint(address(this), 1e30);\n        t1.mint(address(this), 1e30);\n        t0.approve(address(router), type(uint256).max);\n        t1.approve(address(router), type(uint256).max);\n        key = PoolKey(Currency.wrap(address(t0)), Currency.wrap(address(t1)), 3000, 60, IHooks(address(hook)));\n        manager.initialize(key, 1 << 96);\n        // Full-range liquidity L = 1.1e6 at price 1: about 1.1e6 raw units of each currency in reserve.\n        router.liquidity(key, -887220, 887220, 1_100_000);\n    }\n\n    function testExactOutputAtLimitPullsElevenTimesTheLimitOfInput() public {\n        // Exact input one unit over the limit is rejected by the hook ...\n        vm.expectRevert();\n        router.swap(key, IPoolManager.SwapParams(true, -int256(uint256(LIMIT)) - 1, TickMath.MIN_SQRT_PRICE + 1));\n\n        // ... but exact output of exactly the limit is accepted and settles 11,033,210 units of input.\n        BalanceDelta d = router.swap(key, IPoolManager.SwapParams(true, int256(uint256(LIMIT)), TickMath.MIN_SQRT_PRICE + 1));\n        uint256 input = uint256(-int256(d.amount0()));\n        assertEq(uint256(int256(d.amount1())), LIMIT);\n        assertLe(input, LIMIT, \"input currency moved in one swap exceeds maxSpecifiedAmount\");\n    }\n}","reproduction":"State: hook deployed with maxSpecifiedAmount = 1_000_000; pool (two 18-decimal tokens, fee 3000, tickSpacing 60, this hook) initialized at sqrtPrice 2^96 with full-range liquidity 1_100_000 (ticks -887220..887220). Input A: swap zeroForOne, amountSpecified = -1_000_001 (exact input). Expected: revert; actual: revert AmountOutsideLimit (consistent). Input B: swap zeroForOne, amountSpecified = +1_000_000 (exact output), sqrtPriceLimit = MIN_SQRT_PRICE+1. Expected if the limit were a per-swap ceiling: at most 1_000_000 raw units of currency0 leave the trader; actual: BalanceDelta.amount0 = -11_033_210 and amount1 = +1_000_000, swapCount increments to 1. With limit = type(uint128).max an exact-output request of 2^128-1 is also accepted by the hook and the manager partially fills it, settling 1.8e28 of input for 999_999_987 of output (same fixture, liquidity 1e9). Run: forge test --match-path test/scratch/ExactOutputBypass.t.sol (fails on current code with 'input currency moved in one swap exceeds maxSpecifiedAmount: 11033210 > 1000000').","severity":"low","snippet":"        if (params.amountSpecified == 0 || params.amountSpecified < -limit || params.amountSpecified > limit) {","title":"Exact-output swaps move unbounded input through the 'limit': one swap at the cap settles 11x maxSpecifiedAmount of the other currency"},{"citation":"resolved","description":"HookMiner.mine is documented as 'paginated and bounded... run via eth_call', and MAX_ATTEMPTS is presented as the bound that makes a page safe. Each attempt costs about 321 gas (keccak of the 85-byte CREATE2 preimage plus loop bookkeeping), so a page of 131072 attempts with no early match costs roughly 42M gas; a run of 115,085 misses measured 36,840,380 gas in Foundry. Anvil and Hardhat grant eth_call the block gas limit (30,000,000) when the request carries no gas field, which is exactly what sdk/hooks_store.py sends (Rpc.call passes only to and data). Runs of 110k+ consecutive non-matching salts occur naturally (probability per 131072-page about e^-8, but the expected gap distribution is geometric with mean 16384 and the scan found gaps of 110,159 and 118,834 within the first 12M salts), so any caller who uses the maximum page size (the SDK accepts --page-size up to 131072 and DEPLOYMENT.md's example uses 131072 attempts) hits a page that cannot be computed on those nodes and gets 'eth_call failed' with no hint that the page size is the cause. A smaller page (the SDK default of 4096, ~1.3M gas) works. On geth's default 50M eth_call cap the worst page passes, so this is an availability/usability defect, not a loss of funds. Local evidence: measured on disposable Anvil 1.8.3 and in Foundry; not measured against a public RPC provider. Fix: lower MAX_ATTEMPTS so a full page fits comfortably in 30M (65,536 attempts ≈ 21M gas) and cap the SDK page_size to the same constant, or document the per-attempt gas and require callers to pass an explicit gas field.","line":7,"path":"src/deployment/HookMiner.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\";\nimport {HookSpec, HookSpecLib} from \"src/HookSpec.sol\";\nimport {HooksStoreFactory} from \"src/HooksStoreFactory.sol\";\nimport {HookMiner} from \"src/deployment/HookMiner.sol\";\n\n/// @notice Fails on the current miner: a maximum-size page (MAX_ATTEMPTS = 131072) that has no match for\n/// 115,085 consecutive salts costs about 37M gas, above the 30M gas that Anvil (and Hardhat) grant an\n/// eth_call by default. Passes once MAX_ATTEMPTS is reduced so that a full page fits (65,536 ≈ 21M gas).\ncontract MinerPageGasTest is Test {\n    // With this deployment order the manager is at 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f and the\n    // factory at 0x2e234DAe75C793f67A35089C9d99245E1C58470b; nonces 3441596..3556680 produce no match.\n    uint256 constant NO_MATCH_START = 3441596;\n\n    function testMaxPageFitsDefaultEthCallGas() public {\n        vm.chainId(31337);\n        PoolManager manager = new PoolManager(address(this));\n        HooksStoreFactory factory = new HooksStoreFactory();\n        HookSpec memory spec = HookSpec(\n            1, HookSpecLib.TEMPLATE_ID, 31337, address(manager), address(manager).codehash, HookSpecLib.PERMISSIONS, 1_000_000\n        );\n        uint256 before = gasleft();\n        HookMiner.Result memory r = factory.mine(spec, NO_MATCH_START, HookMiner.MAX_ATTEMPTS);\n        uint256 used = before - gasleft();\n        // Sanity: the page really is a long miss run (a match, if any, comes after 110k+ attempts).\n        assertTrue(!r.found || r.nextNonce - NO_MATCH_START > 110_000, \"page unexpectedly matched early\");\n        assertLe(used, 30_000_000, \"a maximum miner page exceeds the default eth_call gas allowance\");\n    }\n}","reproduction":"Local Anvil (chain 31337), account 0 deploys PoolManager (nonce 0 -> 0x5FbDB2315678afecb367f032d93F642f64180aa3) then HooksStoreFactory (nonce 1 -> 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512); spec = (1, keccak('hooks-store/bounded-swap/v1'), 31337, manager, keccak(manager code), 4288, 1000000). Salts 1586984..1697142 produce no address with low 14 bits 0x10c0 for that factory/spec. Input: cast call <factory> 'mine(spec,uint256,uint256)' <spec> 1586984 131072 with no gas field. Expected: Result(found=true, salt=0x19e576, hook=0x425593444dbB1970D36ad64FB39a6C17B451d0C0, nextNonce=1697143). Actual: 'EVM error OutOfGas'; cast estimate reports 'gas required exceeds allowance: 30000000'; the identical eth_call with gas 0x3938700 (60M) returns the expected tuple. python3 sdk/hooks_store.py plan spec.json --rpc <anvil> --factory <factory> --start-nonce 1586984 --page-size 131072 --max-pages 1 prints 'hooks-store: eth_call failed; inspect the RPC response privately for revert details' while the same command with the default page size succeeds. Foundry: forge test --match-path test/scratch/MinerPageGas.t.sol fails with '36840380 > 30000000' for a 115,085-miss page starting at nonce 3441596.","severity":"low","snippet":"    uint256 internal constant MAX_ATTEMPTS = 131072;","title":"A maximum miner page (131072 attempts) needs ~37-42M gas and fails under the default 30M eth_call allowance, so the advertised bounded page is not evaluable on Anvil/Hardhat and the SDK surfaces an op"},{"citation":"resolved","description":"Salts are plain counters mined from 0 and the only creator-chosen field in HookSpec is maxSpecifiedAmount, so two creators on the same chain who pick the same limit (and the Store's reviewed manager) obtain byte-identical plans: same salt, same predicted hook. Whoever lands second reverts with AddressOccupied and pays for a failed transaction, and the Store adapter's plan() rejects the predicted address as 'already occupied' if it re-plans after the first deployment, forcing a re-mine for a hook that is provably identical to one already deployed and registered under the same specHash. The same path lets any mempool observer front-run every deployment with copied calldata purely to make the creator's transaction fail (no profit, only griefing; cost to the griefer is the full deployment gas). The threat model lists this as accepted ('duplicate deploy fails; caller can verify existing identical instance'), so this is reported as an operational sharp edge, not a security defect. A minimal change that preserves the design: when plan.hook already has code and deployedSpecHash[plan.hook] == plan.specHash, run _verify and return the plan (optionally emitting nothing) instead of reverting, so the Store's unsigned transaction succeeds whether or not someone else deployed the same plan first. Local evidence only.","line":71,"path":"src/HooksStoreFactory.sol","reproduction":"Fixture from test/Deployment.t.sol: plan = _plan(spec); factory.deploy(spec, plan.salt, plan.hook, plan.initCodeHash) from address A succeeds. Then any address B (e.g. a second creator with the same spec, or the original creator whose transaction was front-run) sends the identical call. Expected (idempotent): success or a receipt-equivalent return for the already-verified hook; actual: revert AddressOccupied(plan.hook) (existing testDuplicateDeploymentRejectedAndMinerSkipsOccupiedAddress asserts this behaviour). SDK: StoreClient.plan(spec) after the first deployment with the same start_nonce raises 'predicted address is already occupied' rather than returning the existing hook.","severity":"info","snippet":"        if (plan.hook.code.length != 0) revert AddressOccupied(plan.hook);","title":"deploy() is not idempotent: identical plans collide deterministically and the second caller's transaction reverts with AddressOccupied even though the identical hook already exists"}],"hash":"ad3347289e7a45277916ef1ecd8701cb7c133a33b8d00b6a08219c662d710c36","nodeId":"b4dec47d-e248-4a10-a8a6-bcb820ecf33a","outcome":"completed","summary":"The review is complete and the findings file is written. Here is the recap.\n\n**Outcome.** No critical, high or medium defect was found in the hook or the factory. Three substantiated findings are recorded in `.imd-findings.json`, two low and one info, each with a concrete failing input. The two low findings carry Foundry proof tests under `test/scratch/` that fail on the current code.\n\n**Findings**\n\n- **Low, exact-output swaps bypass the limit as an input cap.** With the limit at 1,000,000 and a full-range pool of liquidity 1.1e6, exact input of 1,000,001 reverts but exact output of 1,000,000 settles 11,033,210 raw units of input. The README documents this asymmetry, so it is reported as a documented limitation that integrators may misread, with a scope decision on whether afterSwap should also bound the unspecified side.\n- **Low, a maximum miner page cannot run under a default eth_call.** A 131072-attempt page with a long miss run costs about 37M to 42M gas. On a disposable Anvil, the call with no gas field fails with OutOfGas against the 30M allowance, the same call with 60M gas succeeds, and the SDK with page size 131072 prints only an opaque \"eth_call failed\". The SDK default page of 4096 works. Fix is to lower the page bound to about 65,536 and cap the SDK the same way.\n- **Info, deploy is not idempotent.** Salts are a public counter from zero and the only creator-chosen field is the limit, so two creators with the same limit get the same plan and the second transaction reverts with AddressOccupied. The threat model accepts this; a same-spec early return would remove the sharp edge.\n\n**Verified clean, with local evidence only.** Callback bits are exactly 0x10c0 and checked by full 14-bit equality in the factory, the miner and the vendored constructor validation. Mined and deployed addresses agree with an independent CREATE2 formula and the deploy path rechecks the address after creation. Constructor settings commit chain id, manager address and manager code hash, which also binds the manager's immutable self-address. Runtime bytecode is immutable-free and the recorded build fingerprints match the current artifacts. Pool state and counters are isolated by PoolId across two pools, settlement failure rolls back the counter, and no DoS path exists for a pool or the factory beyond the two items above. The chain ids 4663 and 46630 match public Robinhood Chain references. The existing 39 Foundry tests and 10 Python tests pass.\n\n**Not covered.** No public chain, fork or live PoolManager was exercised, so public-chain deployment remains untested. No Slither or Mythril run was performed because the tools are not provided.","treeHash":null,"usage":{"cachedInputTokens":2513183,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":51505,"runtime":"claude","turns":36,"wallClockMs":763142}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d03fc84904b54d9e","findings":[{"citation":"resolved","description":"docs/VERIFICATION.md line 39 says `lib/SHA256SUMS` provides file-level checksums of the retained dependency sources, and docs/DEPENDENCIES.md says the files are copied unchanged from the pinned upstream commits. Running the manifest from the repository root fails for 10 of the 80 entries: lib/v4-core/src/PoolManager.sol, lib/v4-core/src/libraries/Hooks.sol, lib/solmate/src/tokens/ERC20.sol, lib/forge-std/src/StdAssertions.sol, StdJson.sol, StdToml.sol, Vm.sol, console.sol, interfaces/IERC7540.sol and interfaces/IMulticall3.sol. I fetched the same paths from the pinned upstream commits (v4-core e50237c4, forge-std 77041d2c, solmate 4b47a190): the upstream files hash to exactly the values in SHA256SUMS, so the manifest was generated from pristine upstream and the committed copies were re-formatted afterwards (line re-wrapping and added braces, forge-fmt style). A whitespace-and-brace-insensitive diff shows no semantic change, so the vendored PoolManager and Hooks library used by the tests still implement upstream v4.0.0 logic. The defect is that the repository's own integrity evidence fails: an independent reviewer following VERIFICATION.md cannot distinguish this benign reformatting from a hostile edit to PoolManager.sol or Hooks.sol, and the 'copied unchanged' statement is false. This is a documentation and evidence-chain defect, not an on-chain vulnerability.","line":11,"path":"docs/DEPENDENCIES.md","reproduction":"From the repository root run `sha256sum -c lib/SHA256SUMS | grep -v ': OK$'`. Expected: no output (all 80 entries OK). Actual: 10 lines ending in FAILED, including lib/v4-core/src/PoolManager.sol and lib/v4-core/src/libraries/Hooks.sol. Cross-check: `curl -sL https://raw.githubusercontent.com/Uniswap/v4-core/e50237c43811bd9b526eff40f26772152a42daba/src/libraries/Hooks.sol | sha256sum` prints f6012d7f627259f8..., which equals the manifest entry, while `sha256sum lib/v4-core/src/libraries/Hooks.sol` prints eff2e7ca826442ca.... `diff -w` of the two files shows only line re-wrapping and added braces around a one-line `if` return (upstream line 292). Fix: either restore the byte-exact upstream files (recommended, keeps 'unchanged' true) or regenerate SHA256SUMS from the committed files and change DEPENDENCIES.md to say the files were re-formatted without semantic change; in both cases keep lib/** out of forge fmt (foundry.toml already ignores it, so the reformat happened before commit).","severity":"low","snippet":"Source files are copied unchanged from the named upstream commits. Our test router is project-authored; no Uniswap periphery or external BaseHook dependency is needed. The hook implements the pinned `IHooks` interface directly, with the unused callback rejection separated into `DisabledCallbacks`.","title":"lib/SHA256SUMS does not match the committed vendored sources; provenance claim 'copied unchanged' is not verifiable"},{"citation":"resolved","description":"The miner is described as 'bounded' and docs/STORE_API.md and docs/DEPLOYMENT.md advertise attempts up to 131072 (the DEPLOYMENT example passes `0 131072`). Measured cost is about 323 gas per non-matching candidate (keccak plus mask compare; the `code.length` check is short-circuited and only paid on a mask hit), so an exhausted or late-matching maximum page needs roughly 42-43M gas in a single eth_call. Local measurements on Foundry and a default Anvil: empty 4096-attempt page = 1,323,808 gas (forge) / 1,343,694 (anvil eth_estimateGas); empty 16384-attempt page = 5,275,854 gas. A page that first matches at candidate 93,000 or later exceeds 30M gas. Anvil itself does not cap eth_call gas (an explicit 60M gas eth_call was accepted), and Geth's default RPC gas cap is 50M, so the cap is only hit on providers that cap eth_call at or below about 40M. When it is hit, sdk/hooks_store.py `Rpc.request` converts any JSON-RPC error into the generic message 'eth_call failed; inspect the RPC response privately', so a Store operator cannot tell an out-of-gas page from a revert and the retry guidance in STORE_API.md (resume at nextNonce) does not apply. No Foundry or Python test exercises an exhausted maximum page. Impact is availability only; the SDK default page_size of 4096 (about 1.3M gas) is safe.","line":7,"path":"src/deployment/HookMiner.sol","reproduction":"Local evidence: test/scratch/MineGas.t.sol style measurement, `factory.mine(spec, start, 4096)` on a start with no match consumes 1,323,808 gas (323 per attempt); on Anvil `eth_estimateGas` for mine(spec,0,16384) with no match in that range returns 5,275,854. Linear extrapolation for attempts=131072 with no match: about 42.4M gas. Failing input: `mine(spec, start, 131072)` on a page whose first match lies beyond about candidate 93,000, issued through an RPC that caps eth_call below about 43M gas; expected per the docs: a `found=false` result with `nextNonce = start + 131072`; actual: the eth_call is rejected for gas and the SDK reports only 'eth_call failed'. I could not construct a late-matching maximum page within the time budget (probability about 0.3 percent per page), so the failing RPC case is extrapolated from the three measured page sizes, not observed. Suggested fix: document the per-attempt gas and set the recommended maximum page (and the SDK's upper bound for page_size) near 16384, or lower MAX_ATTEMPTS to a value that fits common 30M eth_call caps, and make the SDK surface the RPC error code so gas failures are distinguishable from reverts.","severity":"info","snippet":"    uint256 internal constant MAX_ATTEMPTS = 131072;","title":"Documented maximum mining page (131072 attempts) costs about 43M gas when exhausted; SDK hides the cause"},{"citation":"resolved","description":"README and THREAT_MODEL correctly state that maxSpecifiedAmount is a per-call cap on the specified currency only: a user can split trades, and an exact-output swap's input is uncapped. No test demonstrates either property, so a future change that accidentally makes the cap cumulative, or a reviewer who assumes the cap bounds per-transaction volume, has no executable evidence. Every swap in the suite is a single `manager.swap` inside its own `unlock`, and the fuzz test only asserts the specified side. I confirmed the behavior with a scratch test (test/scratch/SplitSwap.t.sol): ten exact-input swaps of exactly maxSpecifiedAmount (1,000,000 raw units) inside one `unlock` are all accepted by `beforeSwap`, 10,000,000 units of currency0 leave the trader in one transaction, and swapCount increments to 10; an exact-output swap of 1,000,000 units costs 1,003,012 input units at the fixture's liquidity, and would cost arbitrarily more in a thinner pool. These are documented design properties, not defects, but they are the primary way the 'limit' is bypassed economically and should be pinned by tests so the documentation claim is verifiable. Also untested: a second manager address sharing a hook (OnlyPoolManager at initialization), native-currency pools, and the miner's exhausted maximum page.","line":85,"path":"test/HookIntegration.t.sol","reproduction":"State: hook with maxSpecifiedAmount = 1_000_000 on a pool with 1e12 liquidity (same as the fixture). Call `manager.unlock` once and inside the callback call `manager.swap(key, SwapParams(true, -1_000_000, MIN_SQRT_PRICE+1), \"\")` ten times, then settle. Expected if the limit were a per-transaction bound: revert AmountOutsideLimit on the second swap. Actual: all ten succeed, trader's currency0 balance drops by exactly 10,000,000 and hook.swapCount(poolId) == 10. The existing suite would pass unchanged if beforeSwap were edited to track cumulative volume per unlock, which shows the property is unpinned. Fix: add a test asserting multiple limit-sized swaps in one unlock succeed (or that they fail, if the design is changed) and a test asserting the exact-output input side is uncapped.","severity":"info","snippet":"    function testFuzzRealSwapsSettleAndConserve(uint128 raw, bool zeroForOne, bool exactInput, bool secondPool) public {","title":"Tests only ever perform one swap per unlock and never measure the unspecified side, so the documented limit-splitting and exact-output input behaviors are untested"}],"hash":"ae29d10e9492aedae1023b89d9342159ee84eeb3a814404b7f8a3d1949c4aaf0","nodeId":"a51067eb-81f2-4d6c-aec6-589503e147ab","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds three substantiated findings. No source files were changed. The scratch tests live only under `test/scratch/`, which is gitignored.\n\n**Outcome.** The hook and factory are correct within their stated design. I found no critical or high defect, so no finding carries a proof file. The three recorded findings are one low-severity evidence-chain defect and two informational notes.\n\n**What I checked in the assigned area, all against the vendored v4-core v4.0.0:**\n- **Callback permission bits.** The constant is `0x10c0` (afterInitialize bit 12, beforeSwap bit 7, afterSwap bit 6). The constructor runs the Uniswap validator, the factory checks full 14-bit equality, and all four return-delta bits are clear, so the manager ignores the returned deltas.\n- **Mined versus deployed address.** The assembly CREATE2 prediction matches the reference formula under fuzzing. The deploy path checks prediction, init-code hash, bits, vacancy, and the actual created address, then verifies runtime hash and every stored setting.\n- **Salt and front-running.** Address depends on factory, salt, and the full spec. A front-runner can only deploy the identical hook and gains nothing. The miner skips occupied addresses, so a victim re-mines to a redundant but harmless second instance.\n- **Constructor, PoolManager and chain binding.** The hook pins the manager address and rejects any other caller. Chain ID is checked at construction and committed in the spec hash, so the same spec yields a different address per chain. Dynamic-fee pools are rejected atomically.\n- **Bytecode verification.** No immutables and no metadata, so runtime code is deterministic. The build fingerprints in docs match the compiled artifacts exactly.\n- **Swap-limit bypasses.** None beyond the documented ones. Splitting into several limit-sized swaps inside one unlock works, and exact-output input is uncapped. I reproduced both locally and recorded them as a test-coverage note.\n- **Denial of service.** No liquidity or donate callbacks, so withdrawals are never blocked. All hook reverts roll back atomically with manager state. The redundant checks in the hook are unreachable through the manager and harmless.\n- **Two new pools.** State is keyed by full PoolId. Counters stay independent in the existing tests and in my scratch test.\n\n**Findings recorded:**\n1. **Low.** Running `sha256sum -c lib/SHA256SUMS` fails for 10 of 80 files, including the vendored PoolManager and Hooks library. The manifest hashes match upstream byte-for-byte, so the committed copies were re-formatted after hashing. I diffed them against upstream and the changes are whitespace and braces only, but the \"copied unchanged\" claim is false and the integrity evidence does not verify.\n2. **Info.** An exhausted maximum mining page costs about 43M gas. Measured and extrapolated, not observed failing. Anvil has no eth_call cap, so this only matters on providers capping below that.\n3. **Info.** The suite never performs two swaps in one unlock or measures the unspecified side, leaving the documented limit semantics unpinned.\n\n**Local versus public chain.** Everything above is local Foundry and Anvil evidence. No public-chain deployment, fork, or live PoolManager was touched. The Robinhood testnet chain ID 46630 in the policy matches Robinhood's published network config, but Cancun opcode support on that chain remains unverified by this review.\n\nSources: [Robinhood Chain connecting docs](https://docs.robinhood.com/chain/connecting), [chainid.network entry 46630](https://chainid.network/chain/46630/)","treeHash":null,"usage":{"cachedInputTokens":1519605,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":43914,"runtime":"claude","turns":48,"wallClockMs":631549}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"739094f0e08668ee","findings":[{"citation":"resolved","description":"README.md line 48 says 'Ethereum, Base and Robinhood mainnets are explicitly rejected', and docs/DEPLOYMENT.md says 'Mainnet deployment is disabled in the release'. Only HooksStoreFactory (constructor and deploy), HookDeployment.checkFactory, the scripts and the SDK call DeploymentPolicy.requireTestChain(). The BoundedSwapHook constructor checks only spec validity, spec.chainId == block.chainid and the callback bits. Anyone can therefore deploy the template through any CREATE2 deployer on chain 1, 8453 or 4663 and get a working hook with the official templateId, specHash and identical runtime bytecode, bound to a mainnet PoolManager. Only factory.deployedSpecHash (which cannot exist on those chains) tells it apart from a Store deployment. THREAT_MODEL.md line 30 scopes the control to the factory, integration and adapter, so this is a gap between the headline claim and the code, not a bypass of a factory invariant. Fix: call DeploymentPolicy.requireTestChain() in the hook constructor (no change on allowed chains), or correct the README and DEPLOYMENT.md claim. Local evidence only: vm.chainId(1) on a fresh vendored PoolManager, not a public chain.","line":41,"path":"src/BoundedSwapHook.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\";\nimport {HookSpec, HookSpecLib} from \"src/HookSpec.sol\";\nimport {HooksStoreFactory} from \"src/HooksStoreFactory.sol\";\nimport {BoundedSwapHook} from \"src/BoundedSwapHook.sol\";\nimport {HookMiner} from \"src/deployment/HookMiner.sol\";\nimport {DeploymentPolicy} from \"src/deployment/DeploymentPolicy.sol\";\n\n/// @dev Separate deployer so `new` runs inside a real external call frame (Foundry rewrites `new`\n///      inside test contracts into a cheatcode, which aborts the test under expectRevert).\ncontract Deployer {\n    function factory() external returns (HooksStoreFactory) {\n        return new HooksStoreFactory();\n    }\n\n    function hook(bytes32 salt, HookSpec memory spec) external returns (BoundedSwapHook) {\n        return new BoundedSwapHook{salt: salt}(spec);\n    }\n}\n\n/// @notice The release says mainnets are rejected, but only the factory enforces DeploymentPolicy.\n///         A direct `new BoundedSwapHook{salt: s}(spec)` with spec.chainId == 1 on chain 1 succeeds.\n///         Fails on the current code; passes once the hook constructor enforces the same allowlist.\ncontract HookChainPolicyTest is Test {\n    function testHookConstructorRejectsMainnetLikeFactory() public {\n        vm.chainId(1);\n        Deployer d = new Deployer();\n        PoolManager manager = new PoolManager(address(this));\n        HookSpec memory spec = HookSpec({\n            schemaVersion: 1,\n            templateId: HookSpecLib.TEMPLATE_ID,\n            chainId: 1,\n            poolManager: address(manager),\n            poolManagerCodeHash: address(manager).codehash,\n            permissions: HookSpecLib.PERMISSIONS,\n            maxSpecifiedAmount: 1_000_000\n        });\n\n        // The factory path is closed on chain 1, as documented.\n        vm.expectRevert(abi.encodeWithSelector(DeploymentPolicy.UnsupportedDeploymentChain.selector, uint256(1)));\n        d.factory();\n\n        // The standalone constructor path: mine a valid salt for the deployer and deploy directly.\n        bytes32 initHash = keccak256(bytes.concat(type(BoundedSwapHook).creationCode, abi.encode(spec)));\n        HookMiner.Result memory r = HookMiner.mine(address(d), initHash, HookSpecLib.PERMISSIONS, 0, 131072);\n        assertTrue(r.found, \"no salt found\");\n\n        vm.expectRevert(abi.encodeWithSelector(DeploymentPolicy.UnsupportedDeploymentChain.selector, uint256(1)));\n        d.hook(r.salt, spec);\n    }\n}","reproduction":"vm.chainId(1); deploy PoolManager M; spec = HookSpec(1, TEMPLATE_ID, 1, M, M.codehash, 0x10c0, 1_000_000); a helper contract D first runs `new HooksStoreFactory()` (reverts UnsupportedDeploymentChain(1), as documented), then runs `new BoundedSwapHook{salt: s}(spec)` with s from HookMiner.mine(D, initHash, PERMISSIONS, 0, 131072). Expected per README: revert UnsupportedDeploymentChain(1). Actual: the hook deploys. Ran `forge test --match-path test/scratch/Proof_edf77f1d61ad.t.sol`: FAIL 'next call did not revert as expected'.","severity":"low","snippet":"        HookSpecLib.validateChain(spec);","title":"Hook constructor does not enforce DeploymentPolicy: a standalone CREATE2 deployment of the official template succeeds on Ethereum mainnet (chain 1)"},{"citation":"resolved","description":"Under forge 1.8.3, `new HooksStoreFactory()` inside the test contract is executed as the vm.deployCode cheatcode. With expectRevert armed, its revert propagates out of the test function itself. Foundry matches that revert against the expectation and reports PASS, but nothing after line 248 runs. testAllNamedMainnetsAndUnknownChainsRejected therefore checks only the constructor on chain 1. The factory.deploy rejection (line 250) and the iterations for 8453, 4663 and 42 never execute, yet VERIFICATION.md and THREAT_MODEL.md cite this test as evidence for them. The production code does reject them (the policy is a plain chain-id comparison and is called first in deploy). This is a test-evidence defect. Fix: create the factory through an external helper contract (or this.call) so expectRevert consumes the inner revert, or split the test into one chain per test with deploy checked first.","line":248,"path":"test/Deployment.t.sol","reproduction":"`forge test --match-test testAllNamedMainnetsAndUnknownChainsRejected -vvvv`. Expected: 8 UnsupportedDeploymentChain reverts across ids 1, 8453, 4663, 42. Actual (observed): PASS with gas 4440. The trace shows only VM::chainId(1), expectRevert, deployCode reverting, then the test function itself returning [Revert] UnsupportedDeploymentChain(1). No HooksStoreFactory::deploy call and no chainId(8453/4663/42) appear.","severity":"low","snippet":"            new HooksStoreFactory();","title":"Mainnet-rejection test stops after its first expected revert; chains 8453/4663/42 and the deploy() rejection are never exercised"},{"citation":"resolved","description":"VERIFICATION.md presents lib/SHA256SUMS as file-level integrity evidence. `sha256sum -c lib/SHA256SUMS` reports 18 FAILED entries: 7 forge-std files, solmate ERC20.sol, and 10 v4-core files (PoolManager, Hooks, Pool, SqrtPriceMath, StateLibrary, SwapMath, TickBitmap, TransientStateLibrary, Currency, Slot0). One specialist counted 10; this tree has 18. I fetched six of the v4-core files from upstream commit e50237c4. They hash exactly to the manifest values, and after removing whitespace and braces they are identical to the committed copies. diff -w of Hooks.sol shows only line re-wrapping and braces added around a one-line `if` return. So the vendored code is reformatted (forge fmt style), not semantically changed, as far as checked. But the repository's own integrity check fails, and a reviewer cannot tell this apart from a hostile edit without fetching upstream. Fix: restore the byte-exact upstream files, or regenerate SHA256SUMS and document the reformatting.","line":11,"path":"docs/DEPENDENCIES.md","reproduction":"From the repository root: `sha256sum -c lib/SHA256SUMS | grep -v ': OK$'`. Expected: no output. Actual: 18 lines ending in FAILED, including lib/v4-core/src/PoolManager.sol and lib/v4-core/src/libraries/Hooks.sol. Cross-check: upstream raw.githubusercontent.com/Uniswap/v4-core/e50237c4.../src/libraries/Hooks.sol hashes to f6012d7f627259f8..., which equals the manifest entry, while the committed file does not.","severity":"low","snippet":"Source files are copied unchanged from the named upstream commits. Our test router is project-authored; no Uniswap periphery or external BaseHook dependency is needed. The hook implements the pinned `IHooks` interface directly, with the unused callback rejection separated into `DisabledCallbacks`.","title":"lib/SHA256SUMS fails for 18 vendored files, including PoolManager.sol and Hooks.sol, contradicting 'copied unchanged'"},{"citation":"resolved","description":"Merged from three specialists. Each non-matching candidate costs about 320 gas (keccak of 85 bytes plus loop overhead). A full 131072-attempt page with a late match or no match therefore costs about 37-42M gas. mine is meant to run via eth_call (docs/STORE_API.md allows attempts up to 131072, and sdk/hooks_store.py accepts --page-size up to 131072). Nodes that cap eth_call gas at the block gas limit (30M, the Anvil/Hardhat default when no gas field is sent, and the SDK sends none) reject such a page. Rpc.request turns this into the generic 'eth_call failed', so the operator cannot tell it from a revert. geth's default 50M rpc.gascap admits it. Forge scripts (the DEPLOYMENT.md example) run locally and are unaffected. Availability only, no funds at risk; the SDK default page of 4096 (~1.3M gas) is fine. Fix: lower MAX_ATTEMPTS (65536 ≈ 21M gas) and the SDK page_size bound to match, or document per-attempt gas and send an explicit gas field. Local evidence: Foundry measurement only. No public RPC provider was exercised.","line":7,"path":"src/deployment/HookMiner.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\";\nimport {HookSpec, HookSpecLib} from \"src/HookSpec.sol\";\nimport {HooksStoreFactory} from \"src/HooksStoreFactory.sol\";\nimport {HookMiner} from \"src/deployment/HookMiner.sol\";\n\n/// @notice Fails on the current miner: a maximum-size page (MAX_ATTEMPTS = 131072) that has no match for\n/// 115,085 consecutive salts costs about 37M gas, above the 30M gas that Anvil (and Hardhat) grant an\n/// eth_call by default. Passes once MAX_ATTEMPTS is reduced so that a full page fits (65,536 ≈ 21M gas).\ncontract MinerPageGasTest is Test {\n    // With this deployment order the manager is at 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f and the\n    // factory at 0x2e234DAe75C793f67A35089C9d99245E1C58470b; nonces 3441596..3556680 produce no match.\n    uint256 constant NO_MATCH_START = 3441596;\n\n    function testMaxPageFitsDefaultEthCallGas() public {\n        vm.chainId(31337);\n        PoolManager manager = new PoolManager(address(this));\n        HooksStoreFactory factory = new HooksStoreFactory();\n        HookSpec memory spec = HookSpec(\n            1, HookSpecLib.TEMPLATE_ID, 31337, address(manager), address(manager).codehash, HookSpecLib.PERMISSIONS, 1_000_000\n        );\n        uint256 before = gasleft();\n        HookMiner.Result memory r = factory.mine(spec, NO_MATCH_START, HookMiner.MAX_ATTEMPTS);\n        uint256 used = before - gasleft();\n        // Sanity: the page really is a long miss run (a match, if any, comes after 110k+ attempts).\n        assertTrue(!r.found || r.nextNonce - NO_MATCH_START > 110_000, \"page unexpectedly matched early\");\n        assertLe(used, 30_000_000, \"a maximum miner page exceeds the default eth_call gas allowance\");\n    }\n}","reproduction":"Ran `forge test --match-path test/scratch/Proof_9a464c293127.t.sol`: a fresh manager and factory at test-contract nonces, spec(limit 1_000_000), factory.mine(spec, 3441596, MAX_ATTEMPTS) over a 115,085-salt miss run. Expected: at most 30,000,000 gas. Actual: FAIL '36840380 > 30000000'. A page with no match extrapolates to ~42M gas.","severity":"low","snippet":"    uint256 internal constant MAX_ATTEMPTS = 131072;","title":"A maximum mining page (MAX_ATTEMPTS = 131072) can need ~37-42M gas, above a 30M default eth_call allowance; the SDK reports only 'eth_call failed'"},{"citation":"resolved","description":"Merged from six specialist findings (audit_math, audit_economics x2, audit_flow, audit_permissions). beforeSwap compares only params.amountSpecified, and keeps no cumulative per-transaction or per-block state; afterSwap ignores the BalanceDelta. (1) An exact-output swap at the limit settles input bounded only by pool liquidity. (2) Any number of limit-sized swaps can run inside one unlock. README line 7 and THREAT_MODEL line 20 state both properties, so this is not a code defect. It is recorded because the review scope asks about limit bypasses. The test suite pins neither property: every test does one swap per unlock and asserts only the specified side. If the product wants a two-sided cap, afterSwap could revert when |delta.amount0()| or |delta.amount1()| exceeds the limit; that is a design decision. Local evidence only.","line":78,"path":"src/BoundedSwapHook.sol","reproduction":"Ran `forge test --match-path test/scratch/Proof_f29ad4ab994a.t.sol`: hook limit 1_000_000; pool fee 3000, tickSpacing 60, sqrtPrice 2^96, full-range liquidity 1_100_000; swap zeroForOne, amountSpecified=+1_000_000. Actual delta.amount0 = -11_033_210 (input 11x the limit); the test fails with '11033210 > 1000000'. An exact-input swap of -1_000_001 reverts AmountOutsideLimit. Splitting follows directly from the code: beforeSwap is `view` and reads no per-call state, so N swaps of -1_000_000 in one unlock each pass.","severity":"info","snippet":"        if (params.amountSpecified == 0 || params.amountSpecified < -limit || params.amountSpecified > limit) {","title":"Swap limit bounds only the per-call specified amount: exact-output input is uncapped and limit-sized swaps can repeat within one unlock (documented design; untested)"},{"citation":"resolved","description":"Merged from audit_math and audit_economics. Salts are sender-independent counters, and the only creator choice in a spec is the limit. Two creators with the same spec get the same plan, and anyone who copies calldata from the mempool can land first. The second transaction reverts and pays gas, though the identical hook exists and gives no one ownership. STORE_API.md line 27 and THREAT_MODEL line 26 document this ('call verify'), but script/DeployHook.s.sol execute() always calls deploy and reverts. This is griefing or an operational sharp edge, with no loss of funds. Optional fix: in deploy (or the script), if the address has code and deployedSpecHash[plan.hook] == plan.specHash, run _verify and return the plan instead of reverting.","line":71,"path":"src/HooksStoreFactory.sol","reproduction":"The existing testDuplicateDeploymentRejectedAndMinerSkipsOccupiedAddress (passes) shows it: factory.deploy(spec, plan.salt, plan.hook, plan.initCodeHash) succeeds, then the identical call from any address reverts AddressOccupied(plan.hook), while factory.verify(spec, plan.salt, plan.hook) succeeds.","severity":"info","snippet":"        if (plan.hook.code.length != 0) revert AddressOccupied(plan.hook);","title":"Duplicate deploy of an identical public plan reverts AddressOccupied; DeployHook.run has no verify-and-adopt path"},{"citation":"resolved","description":"lib/v4-core/src/PoolManager.sol line 190 reverts SwapAmountCannotBeZero before Hooks.beforeSwap is called, so integrators never see AmountOutsideLimit(0, limit) from a real swap. The hook check is harmless defence in depth and is reached only by the tests' direct vm.prank(manager) calls. Fix: attribute zero-amount rejection to the PoolManager in the README.","line":5,"path":"README.md","reproduction":"manager.swap(key, SwapParams(true, 0, MIN_SQRT_PRICE+1), '') inside unlock reverts SwapAmountCannotBeZero() at PoolManager.sol:190 (`if (params.amountSpecified == 0) SwapAmountCannotBeZero.selector.revertWith();`), which comes before key.hooks.beforeSwap on line 199.","severity":"info","snippet":"`BoundedSwapHook` serves any number of **new static-fee pools on one PoolManager**. It rejects swaps whose requested `amountSpecified` is zero or outside an immutable raw-unit limit, then counts successful swap callbacks separately by PoolId. It neither holds swap funds nor changes fees or accounting deltas. Anyone may use it when initializing a new pool. A pool's hook is part of its PoolKey; this does not retrofit hooks onto existing pools.","title":"README claims the hook rejects zero amountSpecified, but PoolManager rejects zero first; the hook branch is unreachable via the manager"}],"hash":"ce6aea8a15cccdca64ce4f46980d69decd9f52ed622f67d8e51b5021f2ec3b76","nodeId":"80b06119-b638-44f9-b6a0-8126d244ed38","outcome":"completed","summary":"I found no high or critical issue. I merged the 15 specialist findings into 7 and wrote them to `.imd-findings.json`. Four are low and three are info. All 7 snippets match the tree, and I made no changes to the repository apart from deleting my scratch tests.\n\n**What the code gets right:**\n- **Callback bits:** the low 14 address bits must match exactly in both the factory and the hook's constructor.\n- **Mined vs deployed address:** the factory checks the address the miner predicted against the address the deploy actually produces.\n- **Chain and PoolManager binding:** the spec's chain ID and the PoolManager's code hash are both checked.\n- **Bytecode verification:** after deploying, the factory checks the hook's runtime code and constructor settings.\n- **Callbacks and pools:** callbacks only accept the PoolManager as caller, unused callbacks always revert, and the two new pools keep separate state.\n\n**Kept findings:**\n1. **Low (proof attached):** the hook's constructor doesn't apply the mainnet block. Anyone can deploy the official hook directly with CREATE2 on chain 1, which contradicts the README's \"mainnets are explicitly rejected\". The proof fails as described.\n2. **Low:** `testAllNamedMainnetsAndUnknownChainsRejected` stops after its first expected revert. The trace shows it passing on 4,440 gas, with chains 8453, 4663 and 42 and the `deploy()` rejection never run. The production code does reject them; the test doesn't show it.\n3. **Low:** `lib/SHA256SUMS` fails for 18 vendored files, not the 10 one specialist reported, including `PoolManager.sol` and `Hooks.sol`. I fetched six of those files from the pinned upstream commit: they match the manifest exactly, and differ from the committed copies only in formatting. The repo's own integrity check still fails, and `DEPENDENCIES.md` wrongly says the files are unchanged.\n4. **Low (proof attached):** a full mining page of 131,072 attempts measured 36.8M gas, above a 30M default `eth_call` allowance. When that happens, the SDK only says \"eth_call failed\". This merges three specialist reports. The proof fails as described.\n5. **Info (proof attached):** the swap limit caps only the amount the trader specifies in each call. An exact-output swap at the limit pulled 11× the limit as input, and limit-sized swaps can repeat inside one transaction. The README documents both, but no test covers either. This merges six reports. The proof fails as described.\n6. **Info:** a second deploy of an identical public plan reverts with `AddressOccupied`, and `DeployHook.run` has no option to adopt the existing hook. This merges two reports and is documented.\n7. **Info:** the README says the hook rejects a zero swap amount, but the PoolManager rejects it first, so the hook's check is never reached through a real swap.\n\n**Dropped:** \"swapCount increments for swaps that fill nothing\". It reproduces, but `THREAT_MODEL.md` already says the counter doesn't track fills, so it isn't a defect.\n\nAll evidence is local: Foundry on a fresh vendored PoolManager, plus the upstream file fetch. I didn't exercise any public chain or public RPC provider, and I didn't re-run the specialists' Anvil measurements.","treeHash":null,"usage":{"cachedInputTokens":885381,"inputTokens":30,"model":"claude-opus-5-5","outputTokens":11861,"runtime":"claude","turns":15,"wallClockMs":130127}}],"verification":[]}