{"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":"e70e221f-cfed-40c7-8c31-450e7e2eaa96","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"09f61729a044745674d7de7b943e704bd7c272b2918cc7189ffe634dca8bde86","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":"ab618868f2285abe5a32850c32692b6c702c9fe1f01239847023d1a510757e31","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":null,"dependsOn":["build_contract_project","write_foundry_tests","manifest","audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"waiting"},{"acceptedSubmissionHash":"53110e2fc70071da46fc93446cf990c2d11ff90706778f21cf7fdb9cffa2b1e1","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":"5e9b7a995936247ed932e4c3a60a44f74ca26e6c23577b603813034c8304ca5e","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":"e349662d55cf6756a010511941d9dc0e0c5c4622860b1cf67198412ed7333759","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"d59561da3b378461b7c749f6bd5084477d44993aa94972ddea90f542535751df","skillId":"build-contract-project","tools":[]},"key":"build_contract_project","kind":"code","role":"implement","skillHash":"d59561da3b378461b7c749f6bd5084477d44993aa94972ddea90f542535751df","skillId":"build-contract-project","state":"accepted"},{"acceptedSubmissionHash":null,"dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"tools":[]},"key":"manifest","kind":"code","role":"integrate","skillHash":null,"skillId":null,"state":"failed"},{"acceptedSubmissionHash":"2b26a5fa99608380684a00daff277cc2a799ff43ef83cb8f938724941f34bab3","dependsOn":["build_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"4c76766b00591dfe982fe85d3de589fdb2f810e5137c301155d1c5e36a1d3b23","skillId":"write-foundry-tests","tools":[]},"key":"write_foundry_tests","kind":"code","role":"tests","skillHash":"4c76766b00591dfe982fe85d3de589fdb2f810e5137c301155d1c5e36a1d3b23","skillId":"write-foundry-tests","state":"accepted"}],"objective":"Deploy one contract: WordsmithHook, a standalone Uniswap v4 hook with no token and no pool of its own, on Robinhood Chain. It must be usable by anyone initializing a new pool with it on the PoolManager passed as a constructor argument (Robinhood Chain PoolManager 0x8366a39CC670B4001A1121B8F6A443A643e40951).\nThe hook does exactly what the creator describes below, and nothing else.\nIn the creator’s own words: <description>A hook for a pool where the other currency is native ETH. Take a 0.5% fee on the ETH side of every buy and every sell, settled in ETH inside the swap that paid it; when the PoolManager cannot pay it directly, mint a claim that anyone may redeem to the recipient by calling claimFees(). The recipient is 0x5Bff2a2FEcD1d6152676813b21346238733bEAB1. For the first 30 minutes after the pool opens, no wallet may buy more than 0.5% of the pool's token supply in total; after that there is no limit; expose windowClosesAt() as a view. There are no owner functions: nothing can be paused, changed or upgraded after deployment. Do not use any other pool, token or outside service.</description>\nNo owner functions, no backend signer, no upgrade path.\nFees are always taken from the ETH (pair currency) side, never from the pool’s token; a fee the PoolManager cannot pay directly is minted as a claim the recipient can redeem.\nTake the PoolManager as a constructor argument and enable only the callbacks the behavior above needs. The deployed address must carry the correct hook permission bits (mine a CREATE2 salt). Owner, if any, is $owner.\nKeep the function names exactly as written so the storefront can show them. Composed with The Hooks Store.","parentJobId":null,"planHash":"8403e62c3e23b8cc7949af06120736206ab20c4dfee9e77ff94143d808f55fa1","previousHash":"1dc2bcb1ab722844dc46303dabd5f2286ff40b4729f163c634610a1af98368c6","projectId":"e70e221f-cfed-40c7-8c31-450e7e2eaa96","publication":{"commit":null,"deliveredAt":null,"repoUrl":"https://github.com/identity-md-launches/launch-2429-deploy-one-contract-wordsmithhook"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52615","feedbackHash":"22e174f6120fb227ec2bde3bf24ce0f97ef42fd7d59fb591c40a0b8c00e5c875","nodeKey":"audit_economics","submissionHash":"09f61729a044745674d7de7b943e704bd7c272b2918cc7189ffe634dca8bde86","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51245","feedbackHash":"c9a5c9ae942e9c776820548e9b31ede7888c125ecc9a3352ccfcb15b9d876340","nodeKey":"audit_flow","submissionHash":"ab618868f2285abe5a32850c32692b6c702c9fe1f01239847023d1a510757e31","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51540","feedbackHash":"d6541498f2e9fd7b03758e5a87e1c883ef09f4ea833cc36201b0840e73b76110","nodeKey":"audit_math","submissionHash":"53110e2fc70071da46fc93446cf990c2d11ff90706778f21cf7fdb9cffa2b1e1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52152","feedbackHash":"2db423ad8ff65edd09e92da8efffe0a31e47e83d35354e6ee7b648cff7e65831","nodeKey":"audit_permissions","submissionHash":"5e9b7a995936247ed932e4c3a60a44f74ca26e6c23577b603813034c8304ca5e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51353","feedbackHash":"49afde2fac05ddad9ab9ded4f04fac6a96b2f931581bcbb9b081b3fa314f0e8b","nodeKey":"write_foundry_tests","submissionHash":"2b26a5fa99608380684a00daff277cc2a799ff43ef83cb8f938724941f34bab3","tag1":"verification:checks","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"5a9b729055ed63dbf204bdd076ef1debefafd65941de69b56e0811e556bac239","state":"blocked","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b8fd8054ea57c5d8","findings":[{"citation":"resolved","description":"When ETH is the specified currency (exact-input buy, exact-output sell), beforeSwap fixes the fee from params.amountSpecified and takes it with a BeforeSwapDelta before the swap runs. A v4 swap stops early when it reaches sqrtPriceLimitX96 or runs out of in-range liquidity (common in a launch pool with one narrow liquidity range), so the ETH that trades can be far smaller than amountSpecified. afterSwap returns at once for these swaps (`if (ethSpecified) return (IHooks.afterSwap.selector, 0);`), so the fee is never corrected against the real BalanceDelta. The fee is then not 0.5% of the ETH side of the trade, as the brief requires. Instead it can be any multiple of it, and the excess goes to FEE_RECIPIENT. On an exact-output sell, the fee can be larger than the ETH the pool pays out. Hooks.afterSwap computes swapDelta - hookDelta, so the seller's ETH delta goes negative: the seller hands over tokens and also pays ETH. Fix while keeping the design: in afterSwap, for ETH-specified swaps, compare |delta.amount0()| with the amount after the fee. Either revert on a partial fill, or charge the fee on the filled amount and return the difference to the swapper. The exact-output buy and exact-input sell paths already use the real delta and are correct.","line":126,"path":"src/WordsmithHook.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {Hooks} from \"v4-core/src/libraries/Hooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.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 {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {WordsmithHook} from \"src/WordsmithHook.sol\";\n\ncontract PartialFillFeeTest is Test {\n    address constant RECIPIENT = 0x5Bff2a2FEcD1d6152676813b21346238733bEAB1;\n    uint160 constant SQRT_1_1 = 79228162514264337593543950336;\n    uint160 constant FLAGS = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG\n        | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n\n    PoolManager manager;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    MockERC20 token;\n    WordsmithHook hook;\n    PoolKey key;\n    address alice = makeAddr(\"alice\");\n\n    receive() external payable {}\n\n    function setUp() public {\n        manager = new PoolManager(address(this));\n        swapRouter = new PoolSwapTest(manager);\n        lpRouter = new PoolModifyLiquidityTest(manager);\n        token = new MockERC20(\"Word\", \"WORD\", 18);\n        token.mint(address(this), 100_000 ether);\n        token.approve(address(lpRouter), type(uint256).max);\n        address hookAddr = address(uint160(0x4444 << 144) | FLAGS);\n        deployCodeTo(\"WordsmithHook.sol:WordsmithHook\", abi.encode(manager), hookAddr);\n        hook = WordsmithHook(hookAddr);\n        key = PoolKey(CurrencyLibrary.ADDRESS_ZERO, Currency.wrap(address(token)), 3000, 60, IHooks(hookAddr));\n        manager.initialize(key, SQRT_1_1);\n        vm.deal(address(this), 1000 ether);\n        lpRouter.modifyLiquidity{value: 300 ether}(key, ModifyLiquidityParams(-6000, 6000, 1000 ether, 0), \"\");\n        vm.deal(alice, 100 ether);\n        token.mint(alice, 1000 ether);\n        vm.prank(alice);\n        token.approve(address(swapRouter), type(uint256).max);\n        // past the launch window so the buy cap is not involved\n        vm.warp(block.timestamp + 31 minutes);\n    }\n\n    /// Exact-input buy of 10 ETH whose price limit lets only ~0.5 ETH trade:\n    /// the fee must be 0.5% of the ETH actually swapped, not of the 10 ETH requested.\n    function test_exactInputBuyPartialFillFeeOnSwappedEthOnly() public {\n        uint256 ethBefore = alice.balance;\n        vm.prank(alice, alice);\n        try swapRouter.swap{value: 10 ether}(\n            key, SwapParams(true, -10 ether, TickMath.getSqrtPriceAtTick(-10)), PoolSwapTest.TestSettings(false, false), \"\"\n        ) {} catch {\n            return; // a fix that refuses partial fills also keeps the buyer from overpaying\n        }\n        uint256 spent = ethBefore - alice.balance;\n        uint256 fee = RECIPIENT.balance;\n        uint256 swapped = spent - fee;\n        emit log_named_uint(\"ETH that entered the pool\", swapped);\n        emit log_named_uint(\"fee taken\", fee);\n        assertLe(fee, swapped * 50 / 10_000 + 1, \"fee exceeds 0.5% of the ETH side of the trade\");\n    }\n\n    /// Exact-output sell asking for 10 ETH whose price limit lets only ~0.5 ETH out:\n    /// a seller must never pay ETH to sell tokens.\n    function test_exactOutputSellPartialFillNeverChargesSellerEth() public {\n        uint256 ethBefore = alice.balance;\n        vm.prank(alice, alice);\n        try swapRouter.swap{value: 1 ether}(\n            key, SwapParams(false, 10 ether, TickMath.getSqrtPriceAtTick(1)), PoolSwapTest.TestSettings(false, false), \"\"\n        ) {} catch {\n            return; // a fix that refuses partial fills also keeps the seller from paying ETH\n        }\n        uint256 fee = RECIPIENT.balance;\n        emit log_named_uint(\"fee taken\", fee);\n        emit log_named_uint(\"tokens sold\", 1000 ether - token.balanceOf(alice));\n        assertGe(alice.balance, ethBefore, \"seller paid ETH on a sell\");\n    }\n}","reproduction":"Pool ETH/WORD at price 1:1 with liquidity 1000e18 in ticks [-6000,6000], 31 minutes after initialization. (1) Alice calls PoolSwapTest.swap(key, SwapParams(zeroForOne=true, amountSpecified=-10e18, sqrtPriceLimitX96=getSqrtPriceAtTick(-10))). The swap stops at the limit after 0.5016 ETH enters the pool. Expected fee: 0.5% of the ETH traded, about 0.0025 ETH. Actual: 0.05 ETH (0.5% of the 10 ETH requested), which is 10% of the ETH traded. (2) Alice calls swap(zeroForOne=false, amountSpecified=+10e18 [exact 10 ETH out], sqrtPriceLimitX96=getSqrtPriceAtTick(1)). The pool sells 0.0501 WORD and pays out about 0.0462 ETH. The hook takes 0.05 ETH, so Alice's ETH balance falls by 0.00375 ETH and she also loses 0.0501 WORD. Expected: Alice receives ETH minus a 0.5% fee. The same thing happens with no price limit at all, when a buy or sell is larger than the liquidity left in range. Run: forge test --match-path test/scratch/PartialFillFee.t.sol. Both tests fail now and pass once the fee follows the filled amount, or once partial fills revert.","severity":"high","snippet":"        uint256 amount = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);\n        uint256 fee = amount * FEE_BPS / BPS;","title":"beforeSwap charges 0.5% of the requested ETH amount, not of the ETH actually swapped: a partly filled swap overpays the fee (a seller can end up paying ETH)"},{"citation":"resolved","description":"The 30-minute buy cap is anchored to beforeInitialize. PoolManager.initialize is permissionless, and the creator's PoolKey (ETH, token, fee, tickSpacing, this hook) is predictable as soon as the token exists. Anyone can therefore initialize it early with no liquidity. Uniswap's PositionManager.initializePool wraps initialize in try/catch, so the creator's usual multicall (initializePool followed by a liquidity mint) still succeeds and adds liquidity to the pre-initialized pool. When it does, the window has already run out and the per-wallet cap is never enforced. That defeats the brief's guarantee that no wallet may buy more than 0.5% of supply in the first 30 minutes after the pool opens. The same happens without an attacker whenever the creator adds liquidity more than 30 minutes after initializing. Cost to the attacker: one initialize call. Gain: unlimited sniping at launch. Fix within the current permissions: start the window on the pool's first swap (in beforeSwap, if the stored closesAt is 0, set it to block.timestamp + WINDOW), so it begins when trading is actually possible. windowClosesAt still reports the stored value.","line":114,"path":"src/WordsmithHook.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {Hooks} from \"v4-core/src/libraries/Hooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.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 {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {WordsmithHook} from \"src/WordsmithHook.sol\";\n\ncontract WindowPreInitTest is Test {\n    uint160 constant SQRT_1_1 = 79228162514264337593543950336;\n    uint160 constant FLAGS = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG\n        | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n\n    receive() external payable {}\n\n    function test_sniperPreInitializesSoWindowIsOverWhenTradingOpens() public {\n        PoolManager manager = new PoolManager(address(this));\n        PoolSwapTest swapRouter = new PoolSwapTest(manager);\n        PoolModifyLiquidityTest lpRouter = new PoolModifyLiquidityTest(manager);\n        MockERC20 token = new MockERC20(\"Word\", \"WORD\", 18);\n        token.mint(address(this), 1000 ether);\n        token.approve(address(lpRouter), type(uint256).max);\n        address hookAddr = address(uint160(0x4444 << 144) | FLAGS);\n        deployCodeTo(\"WordsmithHook.sol:WordsmithHook\", abi.encode(manager), hookAddr);\n        PoolKey memory key =\n            PoolKey(CurrencyLibrary.ADDRESS_ZERO, Currency.wrap(address(token)), 3000, 60, IHooks(hookAddr));\n\n        // sniper initializes the creator's PoolKey ahead of time (no liquidity, nothing to trade)\n        address sniper = makeAddr(\"sniper\");\n        vm.prank(sniper);\n        manager.initialize(key, SQRT_1_1);\n\n        // creator adds liquidity 31 minutes later: this is when trading actually opens\n        vm.warp(block.timestamp + 31 minutes);\n        vm.deal(address(this), 1000 ether);\n        lpRouter.modifyLiquidity{value: 300 ether}(key, ModifyLiquidityParams(-6000, 6000, 1000 ether, 0), \"\");\n\n        // first buy after opening: sniper takes 10 tokens = 1% of supply (cap is 0.5% = 5 tokens)\n        vm.deal(sniper, 100 ether);\n        vm.prank(sniper, sniper);\n        try swapRouter.swap{value: 20 ether}(\n            key, SwapParams(true, 10 ether, TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        ) {} catch {\n            return; // cap enforced: the over-limit buy is refused\n        }\n        assertLe(token.balanceOf(sniper), token.totalSupply() * 50 / 10_000, \"first buy after opening exceeded cap\");\n    }\n}","reproduction":"T0: the sniper calls manager.initialize(PoolKey(ETH, WORD, 3000, 60, hook), SQRT_1_1). windowClosesAt is T0+30min and the pool has no liquidity. At T0+31min the creator adds 1000e18 liquidity in [-6000,6000]; WORD totalSupply is 1000e18, so the cap is 5e18. The sniper (tx.origin) immediately buys an exact 10e18 WORD out (zeroForOne=true, amountSpecified=+10e18). Expected: BuyLimitExceeded, because this is the first minute of trading. Actual: the buy succeeds and the sniper holds 1% of supply. Run: forge test --match-path test/scratch/WindowPreInit.t.sol. It fails now and passes once the window starts with trading.","severity":"medium","snippet":"        _windowClosesAt[key.toId()] = block.timestamp + WINDOW;","title":"The launch window starts at initialize, which anyone can call before liquidity exists, so a sniper can make sure the 0.5% buy cap never applies once trading opens"}],"hash":"09f61729a044745674d7de7b943e704bd7c272b2918cc7189ffe634dca8bde86","nodeId":"dfc0870c-0329-4e63-8f9d-d17a46972fba","outcome":"completed","summary":"I found two defects in `WordsmithHook`'s economics. Both are confirmed with Foundry tests that fail on the current code. They are written to `.imd-findings.json` with full proofs, plus a coverage row for each of the 5 entry points and 4 invariant rows. I changed no files; the test files I used are deleted, and their full source is in the JSON as `proof`.\n\n### Findings\n1. **High: the fee is charged on the amount requested, not the amount traded** (`src/WordsmithHook.sol:126`). When the user fixes the ETH amount (exact-input buys, exact-output sells), `beforeSwap` sets the fee from `amountSpecified`. Nothing corrects it afterwards, because `afterSwap` returns early for these swaps. When a swap only partly fills, because it hits its price limit or runs out of liquidity in range, the user still pays 0.5% of the full request.\n   - **Buy:** an exact-input buy of 10 ETH that stops after 0.5016 ETH pays a 0.05 ETH fee, which is 10% of the ETH actually traded.\n   - **Sell:** an exact-output sell asking for 10 ETH that fills only about 0.046 ETH leaves the seller about 0.00375 ETH down. They pay ETH as well as giving up their tokens.\n   - **Fix:** in `afterSwap`, either refuse partial fills or charge the fee on the filled amount. The proof passes with either fix.\n2. **Medium: the 30-minute buy limit can be skipped by initializing the pool early** (`src/WordsmithHook.sol:114`). The window starts when the pool is initialized, and anyone can initialize the creator's pool. Uniswap's standard liquidity tooling (PositionManager) quietly accepts an already-initialized pool, so the creator's usual setup still goes through.\n   - **Repro:** a sniper initializes the pool, liquidity arrives 31 minutes later, and the sniper's first buy takes 1% of supply when the limit is 0.5%.\n   - **Fix:** start the window on the pool's first swap instead. That needs no extra hook permissions.\n\n### Coverage\n- **`beforeSwap`:** finding 1.\n- **`beforeInitialize`:** finding 2.\n- **`afterSwap`:** works as intended. Its own fee path uses the real swap result, and the buy limit counts actual tokens received per wallet.\n- **`claimFees` and `unlockCallback`:** work as intended. Anyone can call `claimFees`, but only the fixed recipient is paid, and the callback can only be reached through `claimFees`.\n- **Invariants checked:**\n  - The hook's ETH accounting with the PoolManager nets to zero in every swap.\n  - The fee is never taken in the pool's token.\n\n### Not reported\n- **Wallet limit bypass:** the limit is per signing wallet (`tx.origin`), so one person with several wallets can get around it. The README already acknowledges this.\n- **Small fee difference by swap type:** the fee is 0.5% or 0.4975% of the trade depending on the swap type. The difference is too small to matter.\n- **`windowClosesAt` signature:** it takes the pool's ID as an argument, while the brief writes `windowClosesAt()`. It is outside my area and the README explains the choice.\n\nThe static-analysis leads were checked: none of them is a real defect. The flagged \"uninitialized\" `paid` variable defaults to false, which is correct. The ignored return value of `unlock` is harmless. The state change after an external call is reached only from the PoolManager.","treeHash":null,"usage":{"cachedInputTokens":795798,"inputTokens":28,"model":"claude-opus-5-5","outputTokens":24661,"runtime":"claude","turns":15,"wallClockMs":260251}},{"artifacts":[],"attempt":1,"bundleHash":"0335f973e2f140e97b4857864ea65a803958d8b562ca12046460f0c01249ad1e","device":"60f85cbc35c28a7e","findings":[{"description":"beforeSwap collects 0.5% of params.amountSpecified before the actual fill is known. When v4 stops at a price limit or exhausts available liquidity, the unfilled input is refunded but its fee is not. afterSwap returns immediately for this case without reconciling the charge. A normal partially filled buy can therefore pay far more than 0.5% of its executed ETH side. This also affects the specified-ETH exact-output sell path. The proof uses a real local PoolManager and a complete unlock/swap/settlement transaction.","line":127,"path":"src/WordsmithHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {WordsmithHook} from \"src/WordsmithHook.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 {Hooks} from \"v4-core/src/libraries/Hooks.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\";\n\n// Minimal standard transfer/approval token, wholly contained in this proof file.\ncontract ProofToken {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        totalSupply += amount;\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n// A complete unlock/swap/settle transaction, with no dependency on other test files.\ncontract ProofRouter is IUnlockCallback {\n    IPoolManager immutable manager;\n\n    constructor(IPoolManager manager_) {\n        manager = manager_;\n    }\n    receive() external payable {}\n\n    function provide(PoolKey calldata key) external payable {\n        manager.unlock(abi.encode(true, key, SwapParams(false, 0, 0), msg.sender));\n        _refund();\n    }\n\n    function trade(PoolKey calldata key, SwapParams calldata params) external payable returns (BalanceDelta delta) {\n        delta = abi.decode(manager.unlock(abi.encode(false, key, params, msg.sender)), (BalanceDelta));\n        _refund();\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        (bool adding, PoolKey memory key, SwapParams memory params, address actor) =\n            abi.decode(data, (bool, PoolKey, SwapParams, address));\n        BalanceDelta delta;\n        if (adding) {\n            (delta,) = manager.modifyLiquidity(key, ModifyLiquidityParams(-6000, 6000, 1000 ether, 0), \"\");\n        } else {\n            delta = manager.swap(key, params, \"\");\n        }\n        _settle(key.currency0, delta.amount0(), actor);\n        _settle(key.currency1, delta.amount1(), actor);\n        return abi.encode(delta);\n    }\n\n    function _settle(Currency currency, int128 delta, address actor) internal {\n        if (delta > 0) manager.take(currency, actor, uint256(int256(delta)));\n        if (delta < 0) {\n            uint256 amount = uint256(-int256(delta));\n            if (Currency.unwrap(currency) == address(0)) {\n                manager.settle{value: amount}();\n            } else {\n                manager.sync(currency);\n                ProofToken(Currency.unwrap(currency)).transferFrom(actor, address(manager), amount);\n                manager.settle();\n            }\n        }\n    }\n\n    function _refund() internal {\n        if (address(this).balance > 0) {\n            (bool ok,) = msg.sender.call{value: address(this).balance}(\"\");\n            require(ok);\n        }\n    }\n}\n\ncontract WordsmithPartialFillProof is Test {\n    address constant RECIPIENT = 0x5Bff2a2FEcD1d6152676813b21346238733bEAB1;\n    PoolManager manager;\n    WordsmithHook hook;\n    ProofToken token;\n    ProofRouter router;\n    PoolKey key;\n    address buyer;\n\n    function setUp() public {\n        vm.warp(1000);\n        manager = new PoolManager(address(this));\n        router = new ProofRouter(manager);\n        token = new ProofToken();\n        token.mint(address(this), 1000 ether);\n        token.approve(address(router), type(uint256).max);\n        uint160 flags = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG\n            | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n        address hookAddress = address(uint160(0x7777 << 144) | flags);\n        deployCodeTo(\"WordsmithHook.sol:WordsmithHook\", abi.encode(manager), hookAddress);\n        hook = WordsmithHook(hookAddress);\n        key = PoolKey(CurrencyLibrary.ADDRESS_ZERO, Currency.wrap(address(token)), 3000, 60, IHooks(hookAddress));\n        manager.initialize(key, uint160(1 << 96));\n        vm.deal(address(this), 1000 ether);\n        router.provide{value: 300 ether}(key);\n        buyer = makeAddr(\"partial-fill-buyer\");\n        vm.deal(buyer, 100 ether);\n    }\n\n    receive() external payable {}\n\n    function test_partialFillMustNotChargeForUnspentInput() public {\n        uint256 ethBefore = buyer.balance;\n        uint256 recipientBefore = RECIPIENT.balance;\n        vm.prank(buyer, buyer);\n        // The limit permits only one tick of price movement. Most of the 10 ETH is refunded.\n        BalanceDelta delta =\n            router.trade{value: 10 ether}(key, SwapParams(true, -10 ether, TickMath.getSqrtPriceAtTick(-1)));\n        uint256 spent = ethBefore - buyer.balance;\n        uint256 fee = RECIPIENT.balance - recipientBefore;\n        assertGt(delta.amount1(), 0, \"a real buy must occur\");\n        assertLt(spent, 10 ether, \"the price limit must cause a partial fill\");\n        emit log_named_uint(\"actual ETH debited\", spent);\n        emit log_named_uint(\"fee paid to recipient\", fee);\n        // Even if the fee base includes the fee itself, it cannot exceed 0.5% of spent ETH.\n        assertLe(fee, spent / 200, \"fee exceeds 0.5% of the ETH actually spent\");\n    }\n}","reproduction":"Run the proof as test/WordsmithPartialFillProof.t.sol with forge test --match-path test/WordsmithPartialFillProof.t.sol -vv. Initialize an ETH/token pool at sqrtPriceX96=2^96, fee=3000, tickSpacing=60; add liquidity=1000e18 over ticks [-6000,6000]. Request an exact-input buy of 10e18 wei with sqrtPriceLimitX96=TickMath.getSqrtPriceAtTick(-1). Actual buyer debit after refund is 100149197655462483 wei, but the recipient gets 50000000000000000 wei. Expected: the fee must be at most 500745988277312 wei (0.5% of even the fee-inclusive actual debit); actual fee is almost 100 times that upper bound. test_partialFillMustNotChargeForUnspentInput fails on this inequality.","severity":"medium","title":"Partial fills pay the ETH fee on the requested amount, including unspent input"},{"description":"The cap is indexed by tx.origin, not the wallet executing and receiving the purchase. A contract wallet with multiple authorized executors gets a fresh allowance per executor although the payer, PoolManager swap caller and token recipient are the same wallet. This violates the requested per-wallet cumulative cap; conversely, shared relayers can make unrelated wallets consume one origin allowance. The existing EOA-only tests cannot establish the stronger wallet guarantee. The proof wallet talks directly to PoolManager, without an intermediary router that could obscure the wallet identity.","line":142,"path":"src/WordsmithHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {WordsmithHook} from \"src/WordsmithHook.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 {Hooks} from \"v4-core/src/libraries/Hooks.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\";\n\n// Minimal standard transfer/approval token, wholly contained in this proof file.\ncontract ProofToken {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        totalSupply += amount;\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n// A complete unlock/swap/settle transaction, with no dependency on other test files.\ncontract ProofRouter is IUnlockCallback {\n    IPoolManager immutable manager;\n\n    constructor(IPoolManager manager_) {\n        manager = manager_;\n    }\n    receive() external payable {}\n\n    function provide(PoolKey calldata key) external payable {\n        manager.unlock(abi.encode(true, key, SwapParams(false, 0, 0), msg.sender));\n        _refund();\n    }\n\n    function trade(PoolKey calldata key, SwapParams calldata params) external payable returns (BalanceDelta delta) {\n        delta = abi.decode(manager.unlock(abi.encode(false, key, params, msg.sender)), (BalanceDelta));\n        _refund();\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        (bool adding, PoolKey memory key, SwapParams memory params, address actor) =\n            abi.decode(data, (bool, PoolKey, SwapParams, address));\n        BalanceDelta delta;\n        if (adding) {\n            (delta,) = manager.modifyLiquidity(key, ModifyLiquidityParams(-6000, 6000, 1000 ether, 0), \"\");\n        } else {\n            delta = manager.swap(key, params, \"\");\n        }\n        _settle(key.currency0, delta.amount0(), actor);\n        _settle(key.currency1, delta.amount1(), actor);\n        return abi.encode(delta);\n    }\n\n    function _settle(Currency currency, int128 delta, address actor) internal {\n        if (delta > 0) manager.take(currency, actor, uint256(int256(delta)));\n        if (delta < 0) {\n            uint256 amount = uint256(-int256(delta));\n            if (Currency.unwrap(currency) == address(0)) {\n                manager.settle{value: amount}();\n            } else {\n                manager.sync(currency);\n                ProofToken(Currency.unwrap(currency)).transferFrom(actor, address(manager), amount);\n                manager.settle();\n            }\n        }\n    }\n\n    function _refund() internal {\n        if (address(this).balance > 0) {\n            (bool ok,) = msg.sender.call{value: address(this).balance}(\"\");\n            require(ok);\n        }\n    }\n}\n\ncontract WordsmithWalletCapProof is Test {\n    address constant RECIPIENT = 0x5Bff2a2FEcD1d6152676813b21346238733bEAB1;\n    PoolManager manager;\n    WordsmithHook hook;\n    ProofToken token;\n    ProofRouter router;\n    PoolKey key;\n    address buyer;\n\n    function setUp() public {\n        vm.warp(1000);\n        manager = new PoolManager(address(this));\n        router = new ProofRouter(manager);\n        token = new ProofToken();\n        token.mint(address(this), 1000 ether);\n        token.approve(address(router), type(uint256).max);\n        uint160 flags = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG\n            | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n        address hookAddress = address(uint160(0x7777 << 144) | flags);\n        deployCodeTo(\"WordsmithHook.sol:WordsmithHook\", abi.encode(manager), hookAddress);\n        hook = WordsmithHook(hookAddress);\n        key = PoolKey(CurrencyLibrary.ADDRESS_ZERO, Currency.wrap(address(token)), 3000, 60, IHooks(hookAddress));\n        manager.initialize(key, uint160(1 << 96));\n        vm.deal(address(this), 1000 ether);\n        router.provide{value: 300 ether}(key);\n        buyer = makeAddr(\"partial-fill-buyer\");\n        vm.deal(buyer, 100 ether);\n    }\n\n    receive() external payable {}\n\n    function test_sameWalletCannotBuyOverCapThroughDifferentExecutors() public {\n        address first = makeAddr(\"executor-one\");\n        address second = makeAddr(\"executor-two\");\n        ProofSmartWallet wallet = new ProofSmartWallet(manager, first, second);\n        vm.deal(address(wallet), 100 ether);\n        vm.prank(first, first);\n        wallet.buy(key, 3 ether);\n        assertEq(token.balanceOf(address(wallet)), 3 ether, \"first buy is below the five token cap\");\n        vm.prank(second, second);\n        try wallet.buy(key, 3 ether) {} catch {}\n        emit log_named_uint(\"tokens bought by the same wallet\", token.balanceOf(address(wallet)));\n        emit log_named_uint(\"wallet limit\", token.totalSupply() / 200);\n        assertLe(\n            token.balanceOf(address(wallet)),\n            token.totalSupply() / 200,\n            \"one wallet exceeded the cap by changing tx.origin\"\n        );\n    }\n}\n\ncontract ProofSmartWallet is ProofRouter {\n    address immutable executorOne;\n    address immutable executorTwo;\n\n    constructor(IPoolManager manager_, address first, address second) ProofRouter(manager_) {\n        executorOne = first;\n        executorTwo = second;\n    }\n\n    function buy(PoolKey calldata key, uint256 amount) external {\n        require(msg.sender == executorOne || msg.sender == executorTwo, \"unauthorized executor\");\n        // The wallet itself is the v4 swap caller, payer and output recipient.\n        manager.unlock(\n            abi.encode(false, key, SwapParams(true, int256(amount), TickMath.MIN_SQRT_PRICE + 1), address(this))\n        );\n    }\n}","reproduction":"Run the proof as test/WordsmithWalletCapProof.t.sol with forge test --match-path test/WordsmithWalletCapProof.t.sol -vv. With a fixed supply of 1000e18, the launch cap is 5e18 tokens. Fund one contract wallet and authorize two executors. In the same launch window executor one tells that wallet to buy 3e18 tokens, then executor two tells the SAME wallet to buy another 3e18. Expected: reject the second buy or otherwise keep the wallet total at most 5e18. Actual: both swaps succeed, and the wallet holds 6e18 tokens bought from the pool. test_sameWalletCannotBuyOverCapThroughDifferentExecutors fails with 6e18 > 5e18.","severity":"medium","title":"A single contract wallet bypasses the cumulative buy cap by changing executors"},{"description":"The cap computes totalSupply * 50 before dividing by 10000. For any valid uint256 supply above type(uint256).max / 50 this intermediate multiplication overflows even though the resulting 0.5% cap fits in uint256. Every buy is then rejected during the launch window, including purchases far below the cap. Use overflow-safe arithmetic for the ratio; this report does not assert that the current panic is acceptable.","line":143,"path":"src/WordsmithHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {WordsmithHook} from \"src/WordsmithHook.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 {Hooks} from \"v4-core/src/libraries/Hooks.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\";\n\n// Minimal standard transfer/approval token, wholly contained in this proof file.\ncontract ProofToken {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        totalSupply += amount;\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n// A complete unlock/swap/settle transaction, with no dependency on other test files.\ncontract ProofRouter is IUnlockCallback {\n    IPoolManager immutable manager;\n\n    constructor(IPoolManager manager_) {\n        manager = manager_;\n    }\n    receive() external payable {}\n\n    function provide(PoolKey calldata key) external payable {\n        manager.unlock(abi.encode(true, key, SwapParams(false, 0, 0), msg.sender));\n        _refund();\n    }\n\n    function trade(PoolKey calldata key, SwapParams calldata params) external payable returns (BalanceDelta delta) {\n        delta = abi.decode(manager.unlock(abi.encode(false, key, params, msg.sender)), (BalanceDelta));\n        _refund();\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        (bool adding, PoolKey memory key, SwapParams memory params, address actor) =\n            abi.decode(data, (bool, PoolKey, SwapParams, address));\n        BalanceDelta delta;\n        if (adding) {\n            (delta,) = manager.modifyLiquidity(key, ModifyLiquidityParams(-6000, 6000, 1000 ether, 0), \"\");\n        } else {\n            delta = manager.swap(key, params, \"\");\n        }\n        _settle(key.currency0, delta.amount0(), actor);\n        _settle(key.currency1, delta.amount1(), actor);\n        return abi.encode(delta);\n    }\n\n    function _settle(Currency currency, int128 delta, address actor) internal {\n        if (delta > 0) manager.take(currency, actor, uint256(int256(delta)));\n        if (delta < 0) {\n            uint256 amount = uint256(-int256(delta));\n            if (Currency.unwrap(currency) == address(0)) {\n                manager.settle{value: amount}();\n            } else {\n                manager.sync(currency);\n                ProofToken(Currency.unwrap(currency)).transferFrom(actor, address(manager), amount);\n                manager.settle();\n            }\n        }\n    }\n\n    function _refund() internal {\n        if (address(this).balance > 0) {\n            (bool ok,) = msg.sender.call{value: address(this).balance}(\"\");\n            require(ok);\n        }\n    }\n}\n\ncontract WordsmithSupplyOverflowProof is Test {\n    address constant RECIPIENT = 0x5Bff2a2FEcD1d6152676813b21346238733bEAB1;\n    PoolManager manager;\n    WordsmithHook hook;\n    ProofToken token;\n    ProofRouter router;\n    PoolKey key;\n    address buyer;\n\n    function setUp() public {\n        vm.warp(1000);\n        manager = new PoolManager(address(this));\n        router = new ProofRouter(manager);\n        token = new ProofToken();\n        token.mint(address(this), 1000 ether);\n        token.approve(address(router), type(uint256).max);\n        uint160 flags = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG\n            | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n        address hookAddress = address(uint160(0x7777 << 144) | flags);\n        deployCodeTo(\"WordsmithHook.sol:WordsmithHook\", abi.encode(manager), hookAddress);\n        hook = WordsmithHook(hookAddress);\n        key = PoolKey(CurrencyLibrary.ADDRESS_ZERO, Currency.wrap(address(token)), 3000, 60, IHooks(hookAddress));\n        manager.initialize(key, uint160(1 << 96));\n        vm.deal(address(this), 1000 ether);\n        router.provide{value: 300 ether}(key);\n        buyer = makeAddr(\"partial-fill-buyer\");\n        vm.deal(buyer, 100 ether);\n    }\n\n    receive() external payable {}\n\n    function test_validLargeSupplyDoesNotPreventSmallBuy() public {\n        token.mint(address(this), type(uint256).max - token.totalSupply());\n        uint256 before = token.balanceOf(buyer);\n        vm.prank(buyer, buyer);\n        router.trade{value: 2 ether}(key, SwapParams(true, 1 ether, TickMath.MIN_SQRT_PRICE + 1));\n        assertEq(token.balanceOf(buyer) - before, 1 ether);\n    }\n}","reproduction":"Run the proof as test/WordsmithSupplyOverflowProof.t.sol with forge test --match-path test/WordsmithSupplyOverflowProof.t.sol -vv. Seed the same real v4 pool, then mint the local ERC20 to a totalSupply of type(uint256).max. Request an exact-output buy of only 1e18 tokens with 2 ETH available. Expected: success, since 1e18 is far below floor(type(uint256).max / 200) and there is enough liquidity. Actual: afterSwap reverts with arithmetic Panic(0x11), wrapped by Hooks, before it can enforce the cap. test_validLargeSupplyDoesNotPreventSmallBuy fails on that revert.","severity":"low","title":"Supply multiplication overflow blocks launch-window buys for large valid token supplies"}],"hash":"2b26a5fa99608380684a00daff277cc2a799ff43ef83cb8f938724941f34bab3","nodeId":"c92f9f0a-ac55-4cef-826a-3bdf070c33e7","outcome":"completed","summary":"Added 30 unit/fuzz tests and four invariant properties.\n\n`forge build` and `forge test` pass with the required seed: **45 tests passed**, including **32,768 invariant handler calls**.\n\nReported three reproduced defects with failing proofs in [.imd-findings.json](/root/.identitymd-722/work/e70e221f-cfed-40c7-8c31-450e7e2eaa96/c92f9f0a-ac55-4cef-826a-3bdf070c33e7/.imd-findings.json):\n\n- **Medium:** partial-fill fee overcharging.\n- **Medium:** contract-wallet buy-cap bypass.\n- **Low:** large-supply arithmetic overflow.\n\nLive Robinhood Chain integration remains unverified; tests use the local v4 PoolManager.","treeHash":"17f24c5bda4ac7793707caa35b414fb15a5c1168","usage":{"cachedInputTokens":3010432,"inputTokens":142748,"model":"gpt-6-astra","outputTokens":36838,"runtime":"codex","turns":7,"wallClockMs":998770}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"589ef002581a5371","findings":[{"citation":"resolved","description":"Boundary x precision seam. When ETH is the specified currency (exact-input buy, exact-output sell) the fee is computed in beforeSwap from params.amountSpecified and returned as the specified BeforeSwapDelta. The PoolManager then charges the swapper that delta unconditionally (Hooks.afterSwap: swapDelta = swapDelta - hookDelta), while Pool.swap may stop early at params.sqrtPriceLimitX96 and move only a fraction of the specified amount. The other two cases (afterSwap, line 150-152) use delta.amount0(), the ETH the pool really moved, so the four paths are not the same formula. Assumption the code makes: the pool consumes the full specified amount. Actual: a partial fill leaves the fee at 0.5% of the unfilled request, which is a multiple of the ETH side of the trade that happened. For an exact-output sell the pre-charged fee can exceed the ETH paid out, so a router that settles ETH on the seller's behalf makes the seller pay ETH to sell tokens. Impact: swappers using a price limit (the standard v4 slippage control, used by routers and aggregators) lose ETH to the recipient far beyond the 0.5% the brief states; the hook's own invariant 'fee == 0.5% of the ETH side of the swap' breaks. Fix (author's scope decision): v4 does not let afterSwap adjust the specified currency, so either (a) in afterSwap compare the ETH the pool moved (|delta.amount0()|, plus the fee for exact-input) with the amount beforeSwap charged and revert with a dedicated error when they differ, so a partial fill never carries the pre-charged fee, or (b) refund the excess (fee - 0.5% of actual) to the swap's sender with poolManager.take(currency0, sender, excess), documenting that this pays the router. Option (a) is the minimal fix that keeps 'fee is exactly 0.5% of the ETH side'.","line":126,"path":"src/WordsmithHook.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {Hooks} from \"v4-core/src/libraries/Hooks.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.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 {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {WordsmithHook} from \"src/WordsmithHook.sol\";\n\n/// @notice When ETH is the specified currency, WordsmithHook.beforeSwap charges 0.5% of\n/// params.amountSpecified, not of the ETH the pool actually moved. A swap that stops at its\n/// sqrtPriceLimitX96 (partial fill) therefore pays a fee that is a multiple of the trade.\n///\n/// A fixed hook may either charge 0.5% of the ETH actually moved or refuse partially filled swaps\n/// whose fee was pre-charged; both tests accept either outcome and fail only on the overcharge.\ncontract PartialFillFeeTest is Test {\n    address constant RECIPIENT = 0x5Bff2a2FEcD1d6152676813b21346238733bEAB1;\n    uint160 constant SQRT_1_1 = 79228162514264337593543950336;\n    uint160 constant FLAGS = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG\n        | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n\n    PoolManager manager;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    MockERC20 token;\n    WordsmithHook hook;\n    PoolKey key;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        manager = new PoolManager(address(this));\n        swapRouter = new PoolSwapTest(manager);\n        lpRouter = new PoolModifyLiquidityTest(manager);\n        token = new MockERC20(\"Word\", \"WORD\", 18);\n        token.mint(address(this), 1000 ether);\n        token.approve(address(lpRouter), type(uint256).max);\n\n        address hookAddr = address(uint160(0x4444 << 144) | FLAGS);\n        deployCodeTo(\"WordsmithHook.sol:WordsmithHook\", abi.encode(manager), hookAddr);\n        hook = WordsmithHook(hookAddr);\n\n        key = PoolKey(CurrencyLibrary.ADDRESS_ZERO, Currency.wrap(address(token)), 3000, 60, IHooks(hookAddr));\n        manager.initialize(key, SQRT_1_1);\n        vm.deal(address(this), 1000 ether);\n        lpRouter.modifyLiquidity{value: 300 ether}(key, ModifyLiquidityParams(-6000, 6000, 1000 ether, 0), \"\");\n\n        vm.deal(alice, 1000 ether);\n        token.mint(alice, 1000 ether);\n        vm.prank(alice);\n        token.approve(address(swapRouter), type(uint256).max);\n        // the launch-window buy limit is not what is under test here\n        vm.warp(hook.windowClosesAt(key.toId()));\n    }\n\n    receive() external payable {}\n\n    /// Returns false if the swap reverted (a fixed hook may refuse partial fills with a pre-charged fee).\n    function _trySwap(bool zeroForOne, int256 amount, uint160 limit, uint256 value) internal returns (bool ok) {\n        vm.prank(alice, alice);\n        try swapRouter.swap{value: value}(\n            key, SwapParams(zeroForOne, amount, limit), PoolSwapTest.TestSettings(false, false), \"\"\n        ) {\n            ok = true;\n        } catch {\n            ok = false;\n        }\n    }\n\n    /// Exact-input buy of 100 ETH with a price limit 0.1% below spot. The pool only absorbs about\n    /// 1 ETH of the input, but the fee is 0.5 ETH (0.5% of the 100 ETH specified): ~50% of the trade.\n    function test_exactInputBuyPartialFillChargesFeeOnUnfilledInput() public {\n        uint256 managerBefore = address(manager).balance;\n        uint160 limit = SQRT_1_1 - SQRT_1_1 / 1000;\n        if (!_trySwap(true, -100 ether, limit, 100 ether)) return;\n        uint256 fee = RECIPIENT.balance;\n        // ETH the pool actually absorbed: the manager's balance grew by what alice settled minus the fee\n        // that left to the recipient, so gross ETH side = growth + fee.\n        uint256 ethToPool = address(manager).balance - managerBefore;\n        emit log_named_uint(\"eth actually swapped into pool\", ethToPool);\n        emit log_named_uint(\"fee paid\", fee);\n        assertLe(fee, (ethToPool + fee) * 50 / 10_000 + 1, \"fee exceeds 0.5% of the ETH the swap moved\");\n    }\n\n    /// Exact-output sell asking for 100 ETH with a price limit 0.1% above spot. The pool pays out\n    /// about 1 ETH, yet the hook keeps 0.5 ETH of it: the seller loses ~50% of the sale to the fee.\n    function test_exactOutputSellPartialFillTakesMoreThanTheSwapPaid() public {\n        uint256 managerBefore = address(manager).balance;\n        uint160 limit = SQRT_1_1 + SQRT_1_1 / 1000;\n        if (!_trySwap(false, 100 ether, limit, 0)) return;\n        uint256 fee = RECIPIENT.balance;\n        // gross ETH paid out by the pool = everything that left the manager (seller's share + fee)\n        uint256 grossOut = managerBefore - address(manager).balance;\n        emit log_named_uint(\"gross eth out of pool\", grossOut);\n        emit log_named_uint(\"fee paid\", fee);\n        assertLe(fee, grossOut * 50 / 10_000 + 1, \"fee exceeds 0.5% of the ETH the pool paid out\");\n    }\n}","reproduction":"State: ETH/WORD pool at price 1:1, 1000e18 liquidity in [-6000, 6000] (same setup as test/WordsmithHook.t.sol). Call 1: swap(zeroForOne=true, amountSpecified=-100 ether, sqrtPriceLimitX96 = SQRT_1_1 - SQRT_1_1/1000) with 100 ETH. Expected: fee = 0.5% of the ETH the swap moved (about 1.004 ETH reached the pool, so about 0.00752 ETH). Actual: fee = 100e18*50/10000 = 0.5 ETH, i.e. ~50% of the ETH that was swapped; the swapper pays 1.504 ETH for 1.004 ETH of input. Call 2: swap(zeroForOne=false, amountSpecified=+100 ether, sqrtPriceLimitX96 = SQRT_1_1 + SQRT_1_1/1000). Expected: fee about 0.005 ETH (0.5% of the 0.999 ETH the pool paid out). Actual: fee = 0.5 ETH, the seller keeps 0.499 ETH of a 0.999 ETH sale. With a tighter limit (e.g. SQRT_1_1 + SQRT_1_1/100000, ~0.01 ETH out) the seller's ETH delta is 0.01 - 0.5 = -0.49 ETH: a sell that costs ETH (PoolSwapTest reverts there because it cannot settle native for the seller; a router that can settle ETH pays it). Proof: test/scratch/PartialFillFee.t.sol, both tests fail on the current code.","severity":"high","snippet":"        uint256 amount = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);\n        uint256 fee = amount * FEE_BPS / BPS;","title":"beforeSwap charges 0.5% of amountSpecified, not of the ETH actually swapped: partially filled swaps are overcharged by up to the whole trade"},{"citation":"resolved","description":"Precision x invariant seam: two formulas that should give the same fee for the same ETH flow use different bases. Exact-input buy (beforeSwap, line 126-127): fee = 0.5% of the ETH the user sends, so the pool receives amount - fee and the user's gross ETH pays 0.5%. Exact-output buy (afterSwap, line 150-152): fee = 0.5% of the ETH the pool took, and the user pays that plus the fee, so the user's gross ETH pays 0.5/100.5 = 0.4975%. Exact-input sell: fee = 0.5% of the gross ETH the pool pays out. Exact-output sell: fee = 0.5% of the net ETH the user requested, so 0.4975% of the gross the pool paid. The existing test test_exactOutputBuyPaysFeeOnEthIn encodes the net base ((spent - fee) * 50 / 10_000) while testFuzz_exactInputBuyFee encodes the gross base. The brief says one rate, 0.5% of the ETH side; the rate the recipient actually receives depends on which swap mode the trader chose. Impact is small (0.0025% of volume) and not exploitable, but a storefront or integrator quoting '0.5%' is off by 0.5% of the fee on half of the paths. Fix: pick one base. To charge 0.5% of the gross ETH on the exact-output paths use fee = amount * FEE_BPS / (BPS - FEE_BPS) (rounded up if fees should round in the recipient's favour), or document that exact-output swaps pay 0.5% of the pool-side amount.","line":150,"path":"src/WordsmithHook.sol","reproduction":"Same pool as the project tests. (1) Exact-input buy: swap(true, -1 ether, MIN_SQRT_PRICE+1) with 1 ETH -> fee = 1e18*50/10000 = 5e15; user paid 1e18 gross, fee/gross = 0.5000%. (2) Exact-output buy that happens to take X = 1e18 ETH at pool level: afterSwap fee = 1e18*50/10000 = 5e15, user pays 1.005e18 gross, fee/gross = 5e15/1.005e18 = 0.4975%. Same gross ETH side (1.005e18) on path (1) would have paid 5.025e15. Expected: identical rate on every path; actual: 2.5e13 wei less per 1 ETH on exact-output paths.","severity":"low","snippet":"        int128 ethDelta = delta.amount0();\n        uint256 amount = ethDelta < 0 ? uint256(-int256(ethDelta)) : uint256(int256(ethDelta));\n        uint256 fee = amount * FEE_BPS / BPS;","title":"Fee base differs between the beforeSwap and afterSwap paths: 0.5% of gross for exact-input, 0.5% of net for exact-output (effective 0.4975%)"}],"hash":"53110e2fc70071da46fc93446cf990c2d11ff90706778f21cf7fdb9cffa2b1e1","nodeId":"452e5ade-0714-479e-a6aa-59a44b40d17c","outcome":"completed","summary":"The tight-limit sell reverts in the test router as I described, and the working tree shows no tracked changes. The findings file is complete and validated.\n\n**Review complete. Findings written to `.imd-findings.json` with 2 findings and 8 coverage rows.**\n\n**Finding 1, high: fee charged on the requested amount, not the filled amount.** When ETH is the specified currency, the fee is computed in `beforeSwap` from `params.amountSpecified` and the PoolManager charges that delta to the swapper no matter how much the pool actually filled. A swap that stops at its price limit is overcharged. In the proof, a 100 ETH exact-input buy that filled about 1 ETH paid a 0.5 ETH fee, and an exact-output sell that returned about 1 ETH lost 0.5 ETH of it. The other two paths use the pool's real delta, so the four paths disagree. The proof test under `test/scratch/PartialFillFee.t.sol` fails now and accepts either fix: refusing partial fills with a pre-charged fee, or refunding the excess.\n\n**Finding 2, low: fee base differs by swap mode.** Exact-input paths take 0.5% of gross ETH, exact-output paths take 0.5% of the pool-side amount, so the effective rate is 0.4975% on half the paths. Not exploitable, but the recipient's rate depends on which mode the trader picked.\n\n**Checked and holding** in the Math Precision, Boundary and Numerical Gap areas: the int128 narrowing casts cannot truncate silently because the PoolManager's SafeCast reverts first, the negation of `int256.min` reverts, the window boundary and timestamp arithmetic are sound, the hook's ETH delta nets to zero in every swap and in the claim path, and `unlockCallback` is reachable only through the hook's own unlock. The static-analysis leads about uninitialized locals, ignored unlock return and reentrancy did not produce a reproducible defect. Fee rounding down on sub-200-wei dust is documented and not farmable.\n\n**Not reached in depth:** totalSupply overflow is only possible above about 2.3e75 supply, so I did not pursue it. Access control, the tx.origin cap design, and the window starting at initialize rather than at first liquidity are outside my area and were left to the other specialists.","treeHash":null,"usage":{"cachedInputTokens":960585,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":32803,"runtime":"claude","turns":22,"wallClockMs":472835}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7c748c02cd2ee98f","findings":[{"citation":"resolved","description":"Asymmetry between the two fee branches. When ETH is the unspecified currency (exact-output buy, exact-input sell) afterSwap (line 150-152) computes the fee from delta.amount0(), i.e. the ETH the pool actually moved. When ETH is the specified currency (exact-input buy, exact-output sell) beforeSwap computes it from params.amountSpecified, which is only the amount the swapper *asked* for. Uniswap v4 fills a swap only as far as liquidity and sqrtPriceLimitX96 allow; when the swap exits the liquidity range (concentrated positions, which are the norm for launch pools) or hits the price limit, the pool consumes a fraction of amountSpecified but the hook has already taken, via the +fee BeforeSwapDelta and poolManager.take, 0.5% of the full requested amount. The swapper is charged that fee on ETH that never traded. The brief promises 'a 0.5% fee on the ETH side of every buy and every sell'; here the effective rate is unbounded above (8.2% and 3.5% in the reproduction). The overpayment goes to FEE_RECIPIENT, so the swapper loses and the recipient gains. Routers with a minimum-output check usually catch the symptom by reverting, but any caller that does not (direct PoolManager callers, routers using sqrtPriceLimitX96 for slippage, or a user who deliberately requests more than the range holds) pays it. Fix options that keep the design: in afterSwap, for ETH-specified swaps, compare the ETH the pool actually consumed (delta.amount0()) with the pre-charged amount and revert with a PartialFill error when they differ by more than rounding, or charge only what 0.5% of the executed ETH comes to and refund the rest. Note that afterSwap can only return a delta in the unspecified currency, so a refund on the ETH side has to be done differently (e.g. revert, or take the fee on the executed amount via a second-pass settlement).","line":126,"path":"src/WordsmithHook.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 {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {Hooks} from \"v4-core/src/libraries/Hooks.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.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 {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {WordsmithHook} from \"src/WordsmithHook.sol\";\n\n/// @notice Fee on ETH-specified swaps is computed from params.amountSpecified, not from the ETH the pool\n/// actually moved. When the swap only partially fills (liquidity range exhausted), the swapper pays 0.5% of the\n/// requested amount, which can be many times 0.5% of the executed amount. Fails now; passes once the fee\n/// charged is at most 0.5% of the ETH actually swapped (or the hook rejects the partial fill).\ncontract PartialFillFeeTest is Test {\n    address constant RECIPIENT = 0x5Bff2a2FEcD1d6152676813b21346238733bEAB1;\n    uint160 constant SQRT_1_1 = 79228162514264337593543950336;\n    uint160 constant FLAGS = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG\n        | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG;\n\n    PoolManager manager;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    MockERC20 token;\n    WordsmithHook hook;\n    PoolKey key;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        manager = new PoolManager(address(this));\n        swapRouter = new PoolSwapTest(manager);\n        lpRouter = new PoolModifyLiquidityTest(manager);\n        token = new MockERC20(\"Word\", \"WORD\", 18);\n        token.mint(address(this), 1000 ether);\n        token.approve(address(lpRouter), type(uint256).max);\n\n        address hookAddr = address(uint160(0x4444 << 144) | FLAGS);\n        deployCodeTo(\"WordsmithHook.sol:WordsmithHook\", abi.encode(manager), hookAddr);\n        hook = WordsmithHook(hookAddr);\n\n        key = PoolKey(CurrencyLibrary.ADDRESS_ZERO, Currency.wrap(address(token)), 3000, 60, IHooks(hookAddr));\n        manager.initialize(key, SQRT_1_1);\n        vm.deal(address(this), 1000 ether);\n        // concentrated position: a large swap exits the range and is only partially filled\n        lpRouter.modifyLiquidity{value: 300 ether}(key, ModifyLiquidityParams(-600, 600, 1000 ether, 0), \"\");\n\n        vm.deal(alice, 1000 ether);\n        token.mint(alice, 1000 ether);\n        vm.prank(alice);\n        token.approve(address(swapRouter), type(uint256).max);\n        vm.warp(block.timestamp + 31 minutes); // outside the launch window so the buy cap does not interfere\n    }\n\n    receive() external payable {}\n\n    function _try(bool zeroForOne, int256 amount, uint256 value) internal returns (bool ok) {\n        vm.prank(alice, alice);\n        try swapRouter.swap{value: value}(\n            key,\n            SwapParams(zeroForOne, amount, zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        ) {\n            ok = true;\n        } catch {\n            ok = false;\n        }\n    }\n\n    /// Exact-input buy of 500 ETH into a pool whose range only absorbs ~30 ETH.\n    function test_exactInputBuyFeeTracksExecutedEth() public {\n        uint256 ethBefore = alice.balance;\n        uint256 feeBefore = RECIPIENT.balance + hook.pendingFees();\n        bool ok = _try(true, -500 ether, 500 ether);\n        if (!ok) return; // rejecting the partial fill is an acceptable fix\n        uint256 fee = RECIPIENT.balance + hook.pendingFees() - feeBefore;\n        uint256 poolReceived = ethBefore - alice.balance - fee;\n        // fee must be 0.5% of the ETH the pool actually took (1 wei rounding slack)\n        assertLe(fee, poolReceived * 50 / 10_000 + 1, \"fee exceeds 0.5% of ETH actually swapped\");\n    }\n\n    /// Exact-output sell asking for 200 ETH from a pool whose range only yields ~30 ETH.\n    function test_exactOutputSellFeeTracksExecutedEth() public {\n        uint256 ethBefore = alice.balance;\n        uint256 feeBefore = RECIPIENT.balance + hook.pendingFees();\n        bool ok = _try(false, 200 ether, 0);\n        if (!ok) return;\n        uint256 fee = RECIPIENT.balance + hook.pendingFees() - feeBefore;\n        uint256 received = alice.balance - ethBefore;\n        // fee must be 0.5% of the ETH the pool actually paid out (received + fee)\n        assertLe(fee, (received + fee) * 50 / 10_000 + 1, \"fee exceeds 0.5% of ETH actually swapped\");\n    }\n}","reproduction":"State: pool (ETH, WORD, 3000, 60, hook) initialised at price 1:1, one position in ticks [-600, 600] with liquidity 1000e18 (about 30 ETH on each side). Outside the launch window. Input A: alice calls PoolSwapTest.swap with zeroForOne=true, amountSpecified=-500 ether, sqrtPriceLimitX96=MIN_SQRT_PRICE+1, value 500 ether. Actual: alice's ETH balance falls by 33.044 ETH, of which the pool received 30.544 ETH and FEE_RECIPIENT received 2.5 ETH (= 500 ether * 50 / 10_000). Effective fee 818 bps of the ETH swapped. Expected: fee of 0.5% of 30.544 ETH = 0.1527 ETH. Input B: alice calls swap with zeroForOne=false, amountSpecified=+200 ether (exact-output sell), limit MAX_SQRT_PRICE-1. Actual: alice receives 28.553 ETH, FEE_RECIPIENT receives 1.0 ETH (= 200 ether * 50 / 10_000), 350 bps of ETH received. Expected: 0.5% of the ETH the pool paid out (29.553 ETH) = 0.1478 ETH. Contrast: the same swap as exact-output buy or exact-input sell is charged on delta.amount0() and pays exactly 0.5% of the executed ETH. Proof test: test/scratch/PartialFillFee.t.sol fails on this code with 'fee exceeds 0.5% of ETH actually swapped: 2500000000000000000 > 152723111213203397' and '1000000000000000000 > 147765054395685849'.","severity":"medium","snippet":"        uint256 amount = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);\n        uint256 fee = amount * FEE_BPS / BPS;","title":"Fee on ETH-specified swaps is 0.5% of the requested amount, not of the ETH actually swapped; partial fills overcharge the swapper many times over"},{"citation":"resolved","description":"The launch-window cap attributes every buy to tx.origin. The README documents that several EOAs can split a buy (bypass), but not the converse: any two accounts whose swaps are executed in transactions signed by the same EOA are treated as one wallet. Smart-account users on Robinhood Chain go through a handful of ERC-4337 bundler EOAs or relayers, so the first user through a bundler who buys up to the cap exhausts it for every other smart-account user on that bundler for the rest of the 30 minutes, and their buys revert with BuyLimitExceeded although they bought nothing. The guard therefore neither identifies the wallet the brief names (it under-counts for multi-EOA buyers and over-counts for shared-origin users) and a 0.5% purchase is a cheap way to deny the window to everyone behind a shared origin. If the hook must stay router-agnostic this is a design limit worth stating in the brief; otherwise key the cap on the swap's beneficiary (e.g. decode it from hookData, or use sender when the sender is an EOA).","line":142,"path":"src/WordsmithHook.sol","reproduction":"State: pool as in the test suite (WORD supply 3000e18, cap = 15e18 tokens), inside the 30-minute window. Input: vm.prank(alice, bundler); swapRouter.swap(key, {zeroForOne:true, amountSpecified:14 ether}) - alice's buy executes and boughtInWindow[id][bundler] == 14e18 while boughtInWindow[id][alice] == 0. Then vm.prank(bob, bundler); swapRouter.swap(key, {zeroForOne:true, amountSpecified:2 ether}). Actual: reverts with BuyLimitExceeded(16e18, 15e18) although bob has bought nothing. Expected per brief: each wallet (bob) has its own 0.5% allowance. Scratch test test_sharedOriginSharesCap in test/scratch/Repro.t.sol shows this.","severity":"low","snippet":"            uint256 bought = boughtInWindow[id][tx.origin] + out;","title":"Buy cap is keyed by tx.origin, so unrelated users sharing a relayer or ERC-4337 bundler share one 0.5% allowance and can be locked out by each other"},{"citation":"resolved","description":"The brief says 'expose windowClosesAt() as a view' and 'keep the function names exactly as written so the storefront can show them'. The contract only has windowClosesAt(bytes32). A caller using the selector of windowClosesAt() (0xb2374158, computed from the no-argument signature) matches no function and there is no fallback, so the call reverts with empty data. The README's reason (one hook, many pools) is sound, but it is a spec deviation that the storefront integration will hit; either keep the parameterised view and also expose a windowClosesAt() that resolves to the pool the storefront shows (e.g. the first pool initialised with this hook), or have the brief updated to the parameterised form.","line":85,"path":"src/WordsmithHook.sol","reproduction":"Input: address(hook).staticcall(abi.encodeWithSignature(\"windowClosesAt()\")). Actual: (false, 0x) - revert with no data. Expected per brief: returns the uint256 timestamp at which the launch window closes. Scratch test test_windowClosesAtNoArgReverts in test/scratch/Repro.t.sol.","severity":"low","snippet":"    function windowClosesAt(PoolId id) external view returns (uint256) {","title":"windowClosesAt is exposed as windowClosesAt(PoolId), so the no-argument windowClosesAt() the brief and storefront expect reverts"},{"citation":"resolved","description":"beforeInitialize accepts any caller and starts the per-pool window at that moment. The pool key (ETH, token, fee, tickSpacing, hook) is public once the token exists, and PoolManager.initialize is permissionless, so anyone can initialise the creator's intended key before the creator does, at any price. From then on the window is counting down while the pool has no liquidity; the creator's own initialize reverts with PoolAlreadyInitialized and they either trade in a pool whose protection has partly or fully elapsed or pick another fee tier (which the same party can pre-empt again). Even without an adversary, 'the pool opens' in the brief most naturally means first liquidity, not initialisation; a creator who initialises and funds in separate transactions gets a shorter window than 30 minutes. Mitigation within the design: start the window at the first swap or first liquidity add (afterAddLiquidity is not enabled; the cheapest option is to lazily set windowClosesAt in beforeSwap when it is still zero), or document that initialize must be the same transaction as the first liquidity add.","line":114,"path":"src/WordsmithHook.sol","reproduction":"State: hook deployed, token T2 deployed, no pool yet. Input: bob calls manager.initialize((ETH, T2, 3000, 60, hook), SQRT_1_1) at time t0. Actual: windowClosesAt(id) == t0 + 1800. At t0 + 1800 the creator calls manager.initialize with the same key - reverts (already initialised) - and when they add liquidity afterwards no buy limit applies to anyone (block.timestamp < _windowClosesAt is false). Expected: the 0.5% per-wallet cap covers the first 30 minutes of trading after liquidity exists. Scratch test test_thirdPartyInitializeStartsWindow in test/scratch/Repro.t.sol.","severity":"low","snippet":"        _windowClosesAt[key.toId()] = block.timestamp + WINDOW;","title":"Launch window starts at initialize, which any third party can trigger, so the 30 minutes can run out before the creator adds liquidity"}],"hash":"5e9b7a995936247ed932e4c3a60a44f74ca26e6c23577b603813034c8304ca5e","nodeId":"47aa907e-ea18-42be-aead-5d98f1f9e30a","outcome":"completed","summary":"The review is complete. No tracked file changed; the findings file and the scratch tests are the only additions.\n\n**Outcome.** The permission model of WordsmithHook holds: every callback is PoolManager-only, the unlock callback is reachable only through the hook's own `claimFees`, the hook holds no approvals, and there is no owner, setter, or upgrade path. I found no access-control bypass. The defects are in the asymmetry and trust-gap areas, written to `.imd-findings.json` with four findings and eleven coverage rows.\n\n**Findings**\n\n1. **Medium, fee overcharge on partial fills.** When ETH is the specified currency the fee is 0.5% of the amount requested, while the other two swap shapes charge 0.5% of the ETH actually moved. A swap that exits a concentrated range pays the fee on ETH that never traded. In the reproduction a 500 ETH exact-input buy moved 30.5 ETH and paid a 2.5 ETH fee, about 8%. The exact-output sell case paid 3.5%. A proof test under test/scratch/ fails on the current code and is embedded in the finding.\n2. **Low, cap keyed by tx.origin.** Unrelated users whose swaps are signed by the same relayer or ERC-4337 bundler share one 0.5% allowance. The first buyer through that origin locks out everyone else behind it for the window.\n3. **Low, windowClosesAt signature.** The brief asks for `windowClosesAt()`; the contract only has the PoolId form, so the no-argument call the storefront would make reverts.\n4. **Low, window starts at initialize.** Anyone can initialize the creator's key before liquidity exists, so the 30-minute cap can run out before trading begins.\n\n**Coverage.** All five verifier entry points have rows: `afterSwap`, `beforeSwap` and `beforeInitialize` map to findings 1 and 4, `claimFees` and `unlockCallback` hold. Invariant rows record that the fee never touches the pool token, the hook's delta nets to zero per swap, and the address permission bits are validated in the constructor. One row is marked unreached: parity with the live Robinhood PoolManager and whether the recipient address has code on chain, since I did not use the network.\n\n**Static-analysis leads.** The slither and aderyn lines were checked and none became findings: the uninitialized `paid` local defaults correctly, the ignored `unlock` return is empty bytes, and the flagged reentrancy is a view call on the pool's own token with no exploitable state.","treeHash":null,"usage":{"cachedInputTokens":1493056,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":41542,"runtime":"claude","turns":27,"wallClockMs":600492}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"911c99a95f538613","findings":[{"citation":"resolved","description":"beforeSwap charges FEE_BPS of params.amountSpecified whenever ETH is the specified currency (exact-input buys, exact-output sells) and returns it as the specified BeforeSwapDelta. The pool then swaps only `amountSpecified +/- fee`, and a price-limited swap (sqrtPriceLimitX96 reached, or the in-range liquidity exhausted under a router's default MIN/MAX limit) fills less than that. The fee is never reconciled against the executed amount, so it is 0.5% of a number the pool never traded. For an exact-input buy the buyer pays the full 0.5% of the unfilled input (fee of 0.005 ETH on 0.001 ETH traded = 500%). For an exact-output sell the PoolManager credits the hook +fee on currency0 and subtracts it from the seller's delta (Hooks.afterSwap: swapDelta - hookDelta); when the pool delivers less ETH than the fee, the seller's ETH delta turns negative and they must *pay* ETH on a sell, in addition to the tokens they sold. The brief requires 0.5% of the ETH side of every buy and sell. Both the direct-take path and the claim-mint fallback inherit the wrong amount. Fix: take the ETH-side fee from the executed delta (compute it in afterSwap from delta.amount0() for every direction, returning it as the unspecified/specified hook delta as appropriate), or cap the beforeSwap fee against the executed amount in afterSwap and refund the difference. Because v4 only lets the specified-side hook delta be set in beforeSwap, the simplest safe fix for ETH-specified swaps is to revert in afterSwap when the executed ETH amount is smaller than the amount the fee was computed on; the proof test accepts a revert.","line":126,"path":"src/WordsmithHook.sol","reproduction":"Pool: ETH/WORD, 3000/60, price 1:1, liquidity 1000e18 over ticks [-6000, 6000] (300 ETH in range). (a) alice swaps zeroForOne=false, amountSpecified=+1e18 (exact-output sell of 1 ETH), sqrtPriceLimitX96=SQRT_1_1+8e22. Pool delivers 1_009_740_939_105_101 wei; hook charges 5e15. Expected: alice receives ~0.001 ETH minus ~5.05e12 fee. Actual: alice's PoolManager delta on ETH is -3_990_259_060_894_899 (she pays 0.00399 ETH) and FEE_RECIPIENT receives 0.005 ETH, 5x the ETH that moved. (b) Same pool, alice swaps zeroForOne=false, amountSpecified=+100_000e18 with the default limit TickMath.MAX_SQRT_PRICE-1. Pool runs out of ETH at ~259 ETH; hook charges 500 ETH (take fails, 500 ETH claim minted). Actual: alice owes 240.83 ETH after selling all her tokens. (c) alice swaps zeroForOne=true, amountSpecified=-1e18, sqrtPriceLimitX96=SQRT_1_1-8e22. Pool consumes 1_012_781_322_229_437 wei; alice pays 6_012_781_322_229_437 total; fee 5e15 instead of 5_063_906_611_147. Proof: test/scratch/PartialFill.t.sol, 3 tests, all fail on this code.","severity":"high","snippet":"        uint256 amount = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);\n        uint256 fee = amount * FEE_BPS / BPS;\n        if (fee > 0) _collect(key, fee);\n        return (IHooks.beforeSwap.selector, toBeforeSwapDelta(int128(int256(fee)), 0), 0);","title":"Fee is computed from the specified amount, not the ETH that moved: partially filled swaps overpay and an exact-output sell can make the seller pay ETH"},{"citation":"resolved","description":"The per-wallet cap identifies the wallet as tx.origin. The brief says no wallet may buy more than 0.5% of the token supply in the first 30 minutes. tx.origin does not identify a buyer: one person with two EOAs gets two caps for free, and every smart account whose UserOperations go through an ERC-4337 bundler has tx.origin equal to that bundler's EOA, so all of those users share one 0.5% allowance and are refused with BuyLimitExceeded once it is spent, while sells and EOA buys continue. The README documents the sybil side as an accepted limitation but not the bundler collision; the `sender` argument the PoolManager passes (the router) and hookData are unused. Recording this as a trust assumption with a concrete failure, not a demand to redesign: if a per-signer cap is the intent, document that AA users via public bundlers are throttled collectively during the window.","line":142,"path":"src/WordsmithHook.sol","reproduction":"Supply 3000 WORD, cap 15 WORD. (1) EOA A swaps zeroForOne=true, amountSpecified=+14e18: passes, boughtInWindow[id][A]=14e18. (2) EOA B (same owner) swaps +14e18: passes. The owner holds 28 WORD inside the window; expected: refused above 15. (3) Bundler EOA X submits UserOps for accounts S1, S2, S3, each buying 6e18 through the router: S3's op reverts BuyLimitExceeded(18e18, 15e18) although S3 has bought nothing.","severity":"low","snippet":"            uint256 bought = boughtInWindow[id][tx.origin] + out;","title":"Launch-window buy cap is keyed by tx.origin: zero-cost bypass with a second EOA, and all ERC-4337 accounts behind one bundler share a single cap"},{"citation":"resolved","description":"windowClosesAt is fixed in beforeInitialize. PoolManager.initialize is permissionless and the key is fully determined by (ETH, token, fee, tickSpacing, hook), so anyone who knows the token address can initialize the pool before the launcher does. The launcher's own initialize then reverts (PoolAlreadyInitialized), and if they add liquidity to the existing pool 30 minutes or more after that early initialize, the buy cap has already expired when trading actually begins. The same happens with no attacker when a launcher initializes and funds the pool in separate steps more than 30 minutes apart. The brief's cap is meant for 'the first 30 minutes after the pool opens'. Mitigation preserving the design: start (or restart) the window at the first swap or first liquidity add instead of at initialize, or restrict the window start to when liquidity is non-zero.","line":114,"path":"src/WordsmithHook.sol","reproduction":"T0: token WORD exists, no pool. Attacker calls manager.initialize(PoolKey(ETH, WORD, 3000, 60, hook), SQRT_1_1): succeeds, windowClosesAt(id)=T0+30min. T0+31min: launcher's initialize reverts, they add liquidity to the pool. T0+32min: attacker buys 50% of supply in one swap; expected BuyLimitExceeded, actual success because block.timestamp >= _windowClosesAt[id].","severity":"low","snippet":"        _windowClosesAt[key.toId()] = block.timestamp + WINDOW;","title":"The 30-minute window starts at pool initialization, which anyone can trigger early, not when the pool opens with liquidity"},{"citation":"resolved","description":"Hooks.validateHookPermissions reverts with HookAddressNotValid unless the deployed address carries exactly the 0x20CC permission bits. The address depends on the deployer address, salt and init code (creationCode ++ abi.encode(poolManager)). script/DeployWordsmithHook.s.sol mines against 0x4e59b44847b379578588920cA78FbF26c0B4956C and the README says the hook 'is not deployed by the launch factory', while the launch recipe deploys through the project factory with create2(0, code, salt) and the protected test rehearses exactly that. A manifest step that submits any salt not mined against the factory address and the exact constructor argument parks the launch with 'application 0 constructor failed'. Not a logic bug in the hook, but the deployment path has to mine the salt for the real deployer, and the salt must be re-mined if the PoolManager argument or bytecode changes.","line":57,"path":"src/WordsmithHook.sol","reproduction":"From any deployer with salt bytes32(0): new WordsmithHook{salt: 0}(IPoolManager(0x8366a39CC670B4001A1121B8F6A443A643e40951)) reverts HookAddressNotValid (address bits != 0x20CC) with overwhelming probability. mineSalt(0x4e59b4...956C, manager) returns a salt whose address is only valid when deployed through that proxy; using it from the factory address yields a different address and the same revert.","severity":"low","snippet":"        Hooks.validateHookPermissions(IHooks(address(this)), getHookPermissions());","title":"Constructor reverts unless the deployer's CREATE2 salt is mined for that deployer; the script mines for the Arachnid proxy, not the launch factory"},{"citation":"resolved","description":"The brief asks to 'expose windowClosesAt() as a view' and to keep function names exactly as written so the storefront can show them. The name matches but the ABI signature is windowClosesAt(bytes32); a storefront calling selector windowClosesAt() reverts (no fallback). The README explains the parameter (one window per pool). Either is defensible; flagging so the storefront integration uses the right signature.","line":85,"path":"src/WordsmithHook.sol","reproduction":"eth_call hook with calldata 0x<keccak('windowClosesAt()')[:4]> -> revert (no matching selector). eth_call with windowClosesAt(bytes32 poolId) -> returns the pool's window end.","severity":"info","snippet":"    function windowClosesAt(PoolId id) external view returns (uint256) {","title":"windowClosesAt takes a PoolId parameter, so the brief's no-argument windowClosesAt() selector does not exist"}],"hash":"ab618868f2285abe5a32850c32692b6c702c9fe1f01239847023d1a510757e31","nodeId":"f2914033-3a3f-4f23-8fb6-d869c53b8172","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds five findings and eight coverage rows.\n\n**Main finding (high, with proof).** The fee in `beforeSwap` is 0.5% of the amount the swapper *asked* to trade, not of the ETH that actually moved. On a partially filled swap (a price limit reached, or the pool's in-range ETH running out under a router's default limit) the fee is never reconciled. Three scratch tests in test/scratch/PartialFill.t.sol fail on the current code:\n\n- Exact-output sell of 1 ETH, tight price limit: the pool delivers about 0.001 ETH, the hook charges 0.005 ETH, and the seller's ETH delta goes negative. The seller hands over tokens and pays about 0.004 ETH on top.\n- Exact-output sell of 100,000 ETH with the default max price limit on a pool holding about 300 ETH: the hook mints a 500 ETH claim and the seller owes about 241 ETH.\n- Exact-input buy of 1 ETH, tight price limit: about 0.001 ETH trades but the fee is 0.005 ETH, roughly 500% of the ETH side.\n\nThe tests accept a revert, so a fix that refuses partial fills or one that reconciles the fee to the executed delta both make them pass.\n\n**Lower findings, substantiated by trace.**\n- The buy cap is keyed by `tx.origin`. A second EOA bypasses it for free, and all ERC-4337 accounts behind one bundler share a single 0.5% cap and get refused once it is spent.\n- The 30-minute window starts at permissionless pool initialization, so anyone can initialize the pool early and the cap can expire before liquidity ever arrives.\n- The constructor's permission-bit check reverts unless the salt is mined for the actual deployer. The script mines for the Arachnid proxy, while the launch recipe deploys through the project factory.\n- Info: `windowClosesAt` takes a PoolId, so the brief's no-argument selector does not exist.\n\n**Coverage.** `claimFees` and `unlockCallback` hold. The hook's transient delta nets to zero on every swap path, including the mint fallback. All slither and aderyn leads were checked and are false positives: `paid` defaults to false, the unlock return is unused by design, and `totalSupply()` is a staticcall so no reentrancy precedes the storage write. The existing 14-test suite passes but never exercises a price-limited or liquidity-exhausted swap, which is where the high finding lives.","treeHash":null,"usage":{"cachedInputTokens":1106171,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":46602,"runtime":"claude","turns":28,"wallClockMs":782902}},{"artifacts":[],"attempt":1,"bundleHash":"e6388a8d5b686610d6e01aeceac786bb25f63e009522ca69d431c420197b28c9","device":"7f1df2a55552de06","findings":[],"hash":"e349662d55cf6756a010511941d9dc0e0c5c4622860b1cf67198412ed7333759","nodeId":"8ba38037-26b2-44be-a569-53f40bb80328","outcome":"completed","summary":"I've built WordsmithHook as a standalone Uniswap v4 hook for Robinhood Chain, with tests, a deploy script and a README. `forge build`, `forge test` (14 tests pass, run with your fuzz seed) and `forge fmt --check` all pass. Nothing is committed and nothing was deployed.\n\n**What it does** (`src/WordsmithHook.sol`):\n- **Pools:** it only accepts pools on the PoolManager given at deployment where the other currency is native ETH. Anyone can open such a pool with it; any other pair is refused.\n- **Fee:** 0.5% of the ETH side of every buy and sell, never taken in the token. It is sent to `0x5Bff2a2FEcD1d6152676813b21346238733bEAB1` inside the swap that paid it. If the PoolManager can't pay it, the hook records a claim instead, and anyone can call `claimFees()` to pay all claims to that address in ETH.\n- **Buy limit:** for 30 minutes after a pool opens, no wallet can buy more than 0.5% of the token's total supply in total. Sells are never limited, and after the window there is no limit.\n- **No owner:** nothing can be paused, changed or upgraded. Only the callbacks this needs are switched on.\n\n**Choices you should check:**\n- **`windowClosesAt` takes the pool's id** (`windowClosesAt(PoolId)`), not zero arguments as the brief wrote it. Because anyone can open a pool with the hook, each pool gets its own window. A single shared window could be used up by someone opening a junk pool first.\n- **\"Wallet\" means the account that signed the transaction** (`tx.origin`), because swaps arrive through routers. Someone using several wallets can still get around the limit.\n- **Small swaps pay no fee:** the fee rounds down, so swaps under 200 wei of ETH pay nothing.\n\n**Deployment:** the launch can't deploy this contract, because a hook's address has to carry its permission bits. `script/DeployWordsmithHook.s.sol` finds a matching salt and deploys through the standard CREATE2 deployer at `0x4e59b44847b379578588920cA78FbF26c0B4956C`. I haven't confirmed that deployer exists on Robinhood Chain; whoever deploys should check that first. The test suite checks that the salt it finds gives the right address.\n\n**Tests** cover all four kinds of swap, a fee becoming a claim and being redeemed, the buy limit during and after the window, and rejected pools and callers. They are not a security audit, so get an independent review before real money goes through it.\n\nThe dependencies (v4-core, solmate, forge-std) are copied into `lib/` as ordinary files so the build works offline. The README covers the assumptions and deployment details.","treeHash":"ac0207eb31cbb75bab365ba7cc9542d44b1f63f2","usage":{"cachedInputTokens":664411,"inputTokens":28,"model":"claude-opus-5-5","outputTokens":18320,"runtime":"claude","turns":14,"wallClockMs":184877}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be85220723d9ce9d","findings":[],"hash":"ff3c9ca08048b157a522edff85becfc023bae180edea03f4c9ba18ae4946385f","nodeId":"70bf7895-5a6d-46f3-bb31-57e34df8b50f","outcome":"failed","summary":"needs input: WordsmithHook needs a mined CREATE2 salt so its address carries permission bits 0x20cc, but an evm_contracts launch deploys it at an unmined, launch-number-salted address, where its constructor reverts. The accepted source has no deployer contract, and this task may only edit launch.json and must use kind evm_contracts. — Should this launch become a univ4_hook manifest, whose deployer mines the hook salt, or should the source gain a deployer contract that CREATE2-deploys WordsmithHook with an off-chain-mined salt and then list that deployer in an evm_contracts launch.json?","treeHash":null,"usage":{"cachedInputTokens":166205,"inputTokens":12,"model":"claude-opus-5-5","outputTokens":2097,"runtime":"claude","turns":6,"wallClockMs":36323}}],"verification":[{"checks":[{"durationMs":2913,"exitCode":0,"name":"build","output":"Compiling 80 files with Solc 0.8.26\nSolc 0.8.26 finished in 2.82s\nCompiler run successful!\n","passed":true},{"durationMs":7865,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 14 tests for test/WordsmithHook.t.sol:WordsmithHookTest\n[PASS] testFuzz_exactInputBuyFee(uint256) (runs: 256, μ: 229414, ~: 229569)\n[PASS] test_buyLimitDuringWindow() (gas: 697689)\n[PASS] test_callbacksOnlyFromPoolManager() (gas: 64287)\n[PASS] test_claimFeesRevertsWhenNothingPending() (gas: 34984)\n[PASS] test_exactInputBuyPaysFeeInEth() (gas: 239343)\n[PASS] test_exactInputSellPaysFeeOnEthOut() (gas: 201307)\n[PASS] test_exactOutputBuyPaysFeeOnEthIn() (gas: 237020)\n[PASS] test_exactOutputSellPaysFee() (gas: 196905)\n[PASS] test_mineSaltMatchesFlags() (gas: 5679586)\n[PASS] test_rejectsNonEthPool() (gas: 813830)\n[PASS] test_secondPoolHasOwnWindow() (gas: 95794)\n[PASS] test_sellsNotLimited() (gas: 566461)\n[PASS] test_unpayableFeeBecomesClaimAndAnyoneRedeems() (gas: 645897)\n[PASS] test_windowClosesAt() (gas: 9930)\nSuite result: ok. 14 passed; 0 failed; 0 skipped; finished in 140.82ms (99.72ms CPU time)\n\nRan 30 tests for test/WordsmithBehavior.t.sol:WordsmithBehaviorTest\n[PASS] testFuzz_buySellRoundTripCannotCreateEthOrResetAllowance(uint256) (runs: 1000, μ: 440219, ~: 440498)\n[PASS] testFuzz_fourSwapModesConserveValueAndChargeEthOnly(uint256,uint8,bool,bytes) (runs: 1000, μ: 366884, ~: 352545)\n[PASS] testFuzz_splitPurchasesUseOneCumulativeCap(uint256) (runs: 1000, μ: 511205, ~: 513671)\n[PASS] testFuzz_unspecifiedEthFeesUseRealizedPartialFills(uint256,uint8,bool) (runs: 1000, μ: 272752, ~: 292133)\n[PASS] test_allEnabledCallbacksRejectUntrustedCallers() (gas: 140078)\n[PASS] test_capUsesRawTokenUnitsForZeroSixAndEighteenDecimals() (gas: 4853446)\n[PASS] test_claimInsideExistingUnlockFailsWithoutLosingDebt() (gas: 646190)\n[PASS] test_claimsRemainBackedAfterAllLiquidityIsRemoved() (gas: 670799)\n[PASS] test_constructorRejectsIncorrectAddressPermissions() (gas: 13633)\n[PASS] test_create2ConstructorAcceptsSpecifiedChainManagerOnEmptyChain() (gas: 5028093)\n[PASS] test_directFeesDoNotEraseOlderClaims() (gas: 488578)\n[PASS] test_exactCapAcceptedOneWeiMoreRejectedAndFullyRolledBack() (gas: 584450)\n[PASS] test_failedClaimPreservesAllDebtAndRetryPaysOnlyRecipient() (gas: 686345)\n[PASS] test_failedInitializationDoesNotOpenWindow() (gas: 176753)\n[PASS] test_failedTokenSettlementRollsBackFeeAndPoolBalances() (gas: 487945)\n[PASS] test_feeRoundingAtOneWeiAndFirstCharge() (gas: 794059)\n[PASS] test_genuineManagerEthShortfallMintsBackedClaim() (gas: 1069055)\n[PASS] test_noAdministrativeEntryPoints() (gas: 142516)\n[PASS] test_nonNativePoolRevertsWithExactHookError() (gas: 821300)\n[PASS] test_onlyRequiredPermissionsAreEnabled() (gas: 27244)\n[PASS] test_postWindowBuyDoesNotCallTotalSupply() (gas: 249524)\n[PASS] test_reentrantRecipientCannotRedeemSameClaimsTwice() (gas: 716895)\n[PASS] test_reinitializationCannotExtendWindowOrResetBuys() (gas: 315650)\n[PASS] test_rejectedExactInputBuyRollsBackAnAlreadyMintedFeeClaim() (gas: 447867)\n[PASS] test_sellingAndTransferringTokensCannotRestoreBuyAllowance() (gas: 558523)\n[PASS] test_twoPoolsHaveIndependentWindowsAndCounters() (gas: 1093424)\n[PASS] test_unusedCallbacksRevert() (gas: 132998)\n[PASS] test_windowAppliesUntilLastSecondAndEndsAtExactBoundary() (gas: 704352)\n[PASS] test_wrongHookKeyRejectedEvenFromManager() (gas: 45299)\n[PASS] test_zeroSwapRejectedWithoutFeeOrCounterChanges() (gas: 89060)\nSuite result: ok. 30 passed; 0 failed; 0 skipped; finished in 148.58ms (611.93ms CPU time)\n\nRan 1 test for test/WordsmithInvariant.t.sol:WordsmithInvariantTest\n[PASS]\nWordsmithInvariantTest invariants:\n[PASS] invariant_everyEarnedFeeIsPaidOrStillClaimable\n[PASS] invariant_nativeAndTokenValueAreConserved\n[PASS] invariant_purchaseTotalsAreGrossPerWalletPerPool\n[PASS] invariant_windowDeadlinesNeverMove\n WordsmithInvariantTest invariants (runs: 256, calls: 32768, reverts: 0)\n\n╭------------------+---------------------+-------+---------+----------╮\n| Contract         | Selector            | Calls | Reverts | Discards |\n+=====================================================================+\n| WordsmithHandler | advanceTime         | 4034  | 0       | 0        |\n|------------------+---------------------+-------+---------+----------|\n| WordsmithHandler | attemptOverCap      | 4205  | 0       | 0        |\n|------------------+---------------------+-------+---------+----------|\n| WordsmithHandler | claim               | 3988  | 0       | 0        |\n|------------------+---------------------+-------+---------+----------|\n| WordsmithHandler | donateClaim         | 4069  | 0       | 0        |\n|------------------+---------------------+-------+---------+----------|\n| WordsmithHandler | setRecipientRejects | 4100  | 0       | 0        |\n|------------------+---------------------+-------+---------+----------|\n| WordsmithHandler | swap                | 4198  | 0       | 0        |\n|------------------+---------------------+-------+---------+----------|\n| WordsmithHandler | swapDust            | 4133  | 0       | 0        |\n|------------------+---------------------+-------+---------+----------|\n| WordsmithHandler | transferTokens      | 4041  | 0       | 0        |\n╰------------------+---------------------+-------+---------+----------╯\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.76s (7.76s CPU time)\n\nRan 3 test suites in 7.76s (8.05s CPU time): 45 tests passed, 0 failed, 0 skipped (45 total tests)\n","passed":true},{"durationMs":49,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"WordsmithHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"WordsmithHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"WordsmithHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"WordsmithHook.claimFees()\",\"WordsmithHook.unlockCallback(bytes)\"],\"files\":{\".gitignore\":3,\"README.md\":46,\"foundry.toml\":13,\"remappings.txt\":3,\"script/DeployWordsmithHook.s.sol\":35,\"src/WordsmithHook.sol\":219,\"test/README.md\":38,\"test/WordsmithBehavior.t.sol\":528,\"test/WordsmithHook.t.sol\":192,\"test/WordsmithInvariant.t.sol\":373,\"test/helpers/WordsmithActions.sol\":103,\"test/helpers/WordsmithFixture.sol\":129},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"2b26a5fa99608380684a00daff277cc2a799ff43ef83cb8f938724941f34bab3","verifiedTreeHash":"17f24c5bda4ac7793707caa35b414fb15a5c1168","verifierVersion":"0.1.0+560ab6a2"},{"checks":[{"durationMs":1720,"exitCode":0,"name":"build","output":"Compiling 76 files with Solc 0.8.26\nSolc 0.8.26 finished in 1.62s\nCompiler run successful!\n","passed":true},{"durationMs":123,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 14 tests for test/WordsmithHook.t.sol:WordsmithHookTest\n[PASS] testFuzz_exactInputBuyFee(uint256) (runs: 256, μ: 229398, ~: 229569)\n[PASS] test_buyLimitDuringWindow() (gas: 697689)\n[PASS] test_callbacksOnlyFromPoolManager() (gas: 64287)\n[PASS] test_claimFeesRevertsWhenNothingPending() (gas: 34984)\n[PASS] test_exactInputBuyPaysFeeInEth() (gas: 239343)\n[PASS] test_exactInputSellPaysFeeOnEthOut() (gas: 201307)\n[PASS] test_exactOutputBuyPaysFeeOnEthIn() (gas: 237020)\n[PASS] test_exactOutputSellPaysFee() (gas: 196905)\n[PASS] test_mineSaltMatchesFlags() (gas: 5679586)\n[PASS] test_rejectsNonEthPool() (gas: 813830)\n[PASS] test_secondPoolHasOwnWindow() (gas: 95794)\n[PASS] test_sellsNotLimited() (gas: 566461)\n[PASS] test_unpayableFeeBecomesClaimAndAnyoneRedeems() (gas: 645897)\n[PASS] test_windowClosesAt() (gas: 9930)\nSuite result: ok. 14 passed; 0 failed; 0 skipped; finished in 18.36ms (29.40ms CPU time)\n\nRan 1 test suite in 19.13ms (18.36ms CPU time): 14 tests passed, 0 failed, 0 skipped (14 total tests)\n","passed":true},{"durationMs":51,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"WordsmithHook.afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),int256,bytes)\",\"WordsmithHook.beforeInitialize(address,(address,address,uint24,int24,address),uint160)\",\"WordsmithHook.beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)\",\"WordsmithHook.claimFees()\",\"WordsmithHook.unlockCallback(bytes)\"],\"files\":{\".gitignore\":3,\"README.md\":46,\"foundry.toml\":13,\"remappings.txt\":3,\"script/DeployWordsmithHook.s.sol\":35,\"src/WordsmithHook.sol\":219,\"test/WordsmithHook.t.sol\":192},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":938,"exitCode":0,"name":"slither","output":"[medium/medium] uninitialized-local at src/WordsmithHook.sol:159: WordsmithHook._collect(PoolKey,uint256).paid (src/WordsmithHook.sol#159) is a local variable never initialized\n[medium/medium] unused-return at src/WordsmithHook.sol:95: WordsmithHook.claimFees() (src/WordsmithHook.sol#95-100) ignores return value by poolManager.unlock(abi.encode(amount)) (src/WordsmithHook.sol#98)\n[low/medium] reentrancy-events at src/WordsmithHook.sol:95: Reentrancy in WordsmithHook.claimFees() (src/WordsmithHook.sol#95-100):\n[low/medium] reentrancy-events at src/WordsmithHook.sol:158: Reentrancy in WordsmithHook._collect(PoolKey,uint256) (src/WordsmithHook.sol#158-166):\n[low/medium] timestamp at src/WordsmithHook.sol:134: WordsmithHook.afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes) (src/WordsmithHook.sol#134-155) uses timestamp for comparisons","passed":true},{"durationMs":340,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/WordsmithHook.sol:143: Reentrancy: State change after external call\n[low] large-numeric-literal at src/WordsmithHook.sol:36: Large Numeric Literal\n[low] state-change-without-event at src/WordsmithHook.sol:111: State Change Without Event\n[low] unchecked-return at src/WordsmithHook.sol:98: Unchecked Return","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"e349662d55cf6756a010511941d9dc0e0c5c4622860b1cf67198412ed7333759","verifiedTreeHash":"ac0207eb31cbb75bab365ba7cc9542d44b1f63f2","verifierVersion":"0.1.0+560ab6a2"}]}