{"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":"513e6726-a763-4dd1-90aa-e9c5cd63b02c","kind":"audit","nodes":[{"acceptedSubmissionHash":"157244379587bf9be0124b51780c5d9cba4c183993a083b0dfbbc8fa03ebf86b","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"c4712a02bcb50ec53959a17ed2dd6e2e9b9202ddf3d9166fb7bf99fd9ba48559","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0a78adcfe9e18dbd192be3a94d712fe56a345418a341d692186b9fa63fec4a64","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"141ac03bb3fb14d1926a1470dca4568d74955c596430eef93aa8ce4274a4adc5","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"f07515cb94d456b43d41702633797c190a0faceecf36268a295c62e89b49db18","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the contracts in this repository: IMDuelHook, a Uniswap v4 hook in which every swap bids for its team's next chess move and pays bounties from per-pool reward pools, and ChessEngineV5 with its Rules, Bitboard, Chess and Strength libraries, which choose and apply legal moves.","parentJobId":null,"planHash":"e03087a8aecdda4637fa2b902c1c45a6ebbc384892965d634af598fd270ed19a","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"513e6726-a763-4dd1-90aa-e9c5cd63b02c","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52621","feedbackHash":"a5856b742f6c5bcf5f460b3a3acdf0fbf611cd1caf568315c1075775425665c3","nodeKey":"audit_economics","submissionHash":"157244379587bf9be0124b51780c5d9cba4c183993a083b0dfbbc8fa03ebf86b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52618","feedbackHash":"47ff37874a13878915831524852cad2cc2354e84b135abe1e3b629a8015f1ec0","nodeKey":"audit_flow","submissionHash":"c4712a02bcb50ec53959a17ed2dd6e2e9b9202ddf3d9166fb7bf99fd9ba48559","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51196","feedbackHash":"83adf2be19a12407f094a2ba905d2effe533ac3bbc5542c088ddc843385e1bfe","nodeKey":"audit_judge","submissionHash":"0a78adcfe9e18dbd192be3a94d712fe56a345418a341d692186b9fa63fec4a64","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52622","feedbackHash":"e4fa7ae97482191ae1294dd5d3a26b01f7788b0d1ea29ba9a3bccb297be144bb","nodeKey":"audit_math","submissionHash":"141ac03bb3fb14d1926a1470dca4568d74955c596430eef93aa8ce4274a4adc5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52627","feedbackHash":"fd787fa7cb605248f9f0dd369b404e79902497a4b3ce8f0fc65ff2992ee5789e","nodeKey":"audit_permissions","submissionHash":"f07515cb94d456b43d41702633797c190a0faceecf36268a295c62e89b49db18","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"8554ec113c8523b7f203047073d486fcfd0253a5022ad36d5b715c29126c54b6","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6f30d6341cbff249","findings":[{"citation":"resolved","description":"Merged from all four specialists (permissions 1d44b12b, math 46408ffc, economics 9ac24c61, flow 4f032754). beforeSwap computes the chest share and the protocol slice that fall in the specified currency from |params.amountSpecified|, then returns them as a fixed BeforeSwapDelta. v4 (Hooks.beforeSwap) adds that delta to amountToSwap and charges it to the caller in full (swapDelta - hookDelta). When a swap stops at its sqrtPriceLimitX96, or runs out of in-range liquidity (thin or single-sided launch pools, or routers' MIN/MAX limits), the pool fills only part of the order, but the trader still pays the fee on the unfilled part. That money goes into the chest and the pile. For exact-output swaps the trader receives fill minus a fee sized on the requested output. afterSwap's half is correct because it uses the realised delta. In the main duel's first OPENING seconds _chestBps reaches 2,540 bps, so a limited swap can lose about 25.5% of the requested input while trading almost nothing. Fix (keeping the 0.4%/0.1% rates and the surcharge): base the specified-side fee on the realised amount, for example by taking all fee parts in afterSwap on the unspecified side from the actual delta, or revert in afterSwap when the realised specified amount is smaller than amountToSwap.","line":264,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {Deployers} from \"v4-core/test/utils/Deployers.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\n\ncontract StubEngine {\n    function noop() external {}\n}\n\ncontract PartialFillFeeTest is Test, Deployers {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n\n    IMDuelHook hook;\n    PoolKey pool;\n\n    function setUp() public {\n        deployFreshManagerAndRouters();\n        deployMintAndApprove2Currencies();\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));\n        deployCodeTo(\n            \"src/IMDuelHook.sol:IMDuelHook\",\n            abi.encode(\n                manager, IChessEngine(address(new StubEngine())), currency0, uint24(0), uint24(0), IHooks(address(0)),\n                type(uint256).max, type(uint256).max, type(uint256).max, uint256(1)\n            ),\n            at\n        );\n        hook = IMDuelHook(at);\n        // the main duel: currency0 stands in for IMD, currency1 for $IMDUEL\n        (pool,) = initPool(currency0, currency1, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);\n        modifyLiquidityRouter.modifyLiquidity(pool, ModifyLiquidityParams(-60, 60, 1_000e18, 0), \"\");\n        vm.warp(block.timestamp + 301); // past the opening surcharge: the plain 0.5%\n    }\n\n    /// An exact-input trade stopped early by its price limit pays the hook's fee on the whole specified amount,\n    /// not on what was swapped.\n    function test_partialFillPaysFeeOnlyOnWhatWasSwapped() public {\n        uint160 limit = TickMath.getSqrtPriceAtTick(-1); // stops almost at once\n        uint256 before0 = currency0.balanceOf(address(this));\n        try swapRouter.swap(\n            pool,\n            SwapParams(true, -100e18, limit),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        ) {\n            uint256 paid = before0 - currency0.balanceOf(address(this));\n            uint256 hookFee = manager.balanceOf(address(hook), currency0.toId());\n            uint256 swapped = paid - hookFee;\n            emit log_named_uint(\"paid\", paid);\n            emit log_named_uint(\"swapped into the pool\", swapped);\n            emit log_named_uint(\"hook fee\", hookFee);\n            // the hook's fee is 0.5% of a trade: at most 0.5% of what entered the pool (+1 wei rounding)\n            assertLe(hookFee, swapped * 50 / 9_950 + 1, \"fee charged on the unfilled part of the trade\");\n        } catch {\n            // refusing a partial fill is also a fix\n        }\n    }\n}","reproduction":"Main duel with currency0 standing in for IMD, so chest 0 and slice 0 are both taken on the input. Fee 3000, 1:1 price, liquidity 1,000e18 on ticks [-60,60], 301 s after opening (plain 0.5%). swapRouter.swap(pool, SwapParams(zeroForOne=true, amountSpecified=-100e18, sqrtPriceLimitX96=getSqrtPriceAtTick(-1)), ...). Expected: hook fee <= 0.5% of what entered the pool (~2.52e14). Actual (forge test below, run here): hook fee 500000000000000000 (0.5e18, 0.5% of the full 100e18) against ~0.05e18 swapped, about 10x what was swapped. The test fails with 'fee charged on the unfilled part of the trade: 500000000000000000 > 252006018369159'. The specialists' other three proofs reproduce the same failure in other setups: Deployers liquidity with limit SQRT_PRICE_1_1*9999/10000 gives 5e17 > 5.04e11, and 1,000,000e18 full-range with an order of 1,000e18 gives 5.001e18 > 1.27e15. The test accepts a revert on partial fill as a fix.","severity":"medium","snippet":"        int128 taken = _takeFee(id, key, _specified(params), uint256(a < 0 ? -a : a), chestIdx, g);","title":"Specified-side hook fee (0.5%, up to 25.5% in the opening) is charged on the full amountSpecified, not on what the pool actually swapped, so partial fills overpay"},{"citation":"resolved","description":"Merged from permissions cd55e6ed, economics eb4369a5 and flow ce85a745. The seed is keccak256(blockhash(closeBlock), id, gameNo, ply, owner, size), with h = _hashOf(g.closeBlock) in _resolve (line 558). Bids are still accepted in block closeBlock itself, because _play line 507 sets `late = block.number > g.closeBlock`. The builder or proposer of block closeBlock, or a searcher paying one, can include its own top bid in that block. It can then produce several variants of the block (extraData, tx order, or the hookData owner, which also enters the seed), evaluate previewMove(id, h_i) for each (it returns ready once block.number >= closeBlock), and publish the variant whose move captures, checks or mates. This breaks the contract's stated property (lines 33-34, 553) that 'nobody can know a move while it can still be bid for'. It moves bounty value (PIECE_BPS/CHECK_BPS/MATE_BPS of the team chest, capped at 2x the bid's output) from honest bidders and chests to builders. Severity is low because it requires producing the closing block and gains are capped per move. Fix: draw the dice from a block whose contents are fixed only after bidding closes, e.g. blockhash(closeBlock + 1) with resolution from closeBlock + 2, or stop accepting bids in closeBlock.","line":581,"path":"src/IMDuelHook.sol","reproduction":"Side to move has closeBlock = N, current top bid size S. The builder of block N includes a swap buying the mover's token paying > 1.1*S. It is accepted as top bid at line 512, since block.number == N is not late. For variants N_1..N_k (different extraData or hookData owner) it computes h_i and calls previewMove(id, h_i), getting different moves m_i (the engine uses the seed for awareness, mistakes and second looks). It publishes the N_j whose m_j captures the most material or mates. Any trade in N+1 calls _resolve with h = blockhash(N) = h_j, plays m_j and pays the builder's address bountyBps * chest / 10,000 (capped at 2x its output). Expected: no party that can still bid can choose the move. Actual: block N's builder chooses among k moves.","severity":"low","snippet":"        uint256 seed = uint256(keccak256(abi.encode(h, id, g.gameNo, g.ply, t.owner, t.size)));","title":"Builder of a move's closing block can bid in that block and grind its hash, which is the move's dice"},{"citation":"resolved","description":"From math 486b1ef7. _repeated compares the current position only with the 8 same-parity keys left in the 16-lane ring (plus a flag for the start position). Positions older than 16 half-moves are overwritten. The quiet-move draw only fires at MAX_QUIET_PLY = 60, so a position can occur for the third time with no capture or pawn move in between and the game goes on, against the documented draw rule (Chess.sol line 14: 'the third repetition'). Later moves keep paying bounties in a game that should have ended. The engine's _history (8 keys) has the same window. Fix: keep keys back to the last irreversible move, up to MAX_QUIET_PLY of them (a 64-key ring, 16 slots), or lower the quiet limit to fit the ring.","line":722,"path":"src/IMDuelHook.sol","reproduction":"Verified with a forge test using the repo's test harness (test/Base.sol, hook.applyMove) from the start of game 0: Nf3 Nf6 Ng1 Ng8 (start position, 2nd time at ply 4), then Nc3 Nc6 Nf3 Nf6 Nb5 Nb4 Ng5 Ng4 Na3 Na6 Nh3 Nh6 Nb1 Nb8 Nf4 Nf5 Nh3 Nh6 Ng1 Ng8. Afterwards board == Chess.START_BOARD (3rd occurrence, ply 24, quiet 24). Expected: draw by repetition (GameOver, gameNo 1, ply 0). Actual: gameNo == 0 and ply == 24 (assertions passed), because the second occurrence's lane (half-move 3) was overwritten by half-move 19.","severity":"low","snippet":"        (uint256 lo, uint256 hi) = _lanes(id, g.ply + 1);","title":"Repetition memory spans only 16 half-moves, so a third repetition more than 16 plies after the second is not drawn (quiet limit is 60)"},{"citation":"resolved","description":"Merged from permissions c1feb0e3, economics c9e0752f and flow 4aac7e1c. The hook is open: any pool can initialize on it. afterInitialize (line 245) sets g.slice = 0 for a pool with neither IMD nor $IMDUEL. _takeFee then mints 0.1% of every swap's currency0 to the hook and adds it to pile. If currency0 is an ERC20 other than IMD/$IMDUEL (or native ETH while ethFee == 0), _minBatch returns type(uint256).max, so _play never converts. convert(c) reaches `return; // no route: it waits` and pile never decreases. imd, imduel and the ETH route are immutable or set once, and no other function spends pile claims (_payBounty spends only chest accounting). Traders pay a fee that funds nothing, and the header's claim that the slice 'is converted into $IMDUEL' fails for these pools. Fix (keeps the fee level for routed pools): take no slice where the slice currency has no route (mark it in afterInitialize), or add it to that pool's chest in _takeFee.","line":434,"path":"src/IMDuelHook.sol","reproduction":"After the main duel exists, initialize a pool of two fresh ERC20s A < B (neither IMD nor $IMDUEL) on the hook (fee 3000, 1:1), add liquidity, swap 1,000e18 A exact-input zeroForOne. pile(A.toId()) == 1e18 and the hook holds 1e18 more A claims. Call hook.convert(A): it returns at line 434 and pile(A) stays 1e18. Expected: the slice is converted or not taken. Actual: it grows by 0.1% of every swap and no function can move it.","severity":"low","snippet":"            return; // no route: it waits","title":"Protocol slice taken in a currency with no conversion route accrues in pile forever and can never be spent"},{"citation":"resolved","description":"Merged from permissions f0126fc5, economics 2b7ee786 and flow 92a2f4d0. _trade falls back to tx.origin ('the signing wallet') when hookData is not exactly 20 bytes. For ERC-4337 smart accounts, relayed Safe or meta transactions, and intent fillers (UniswapX, CoW, aggregator solvers) tx.origin is the bundler, relayer or filler, not the trader whose tokens paid for the bid. _resolve records it as the move's owner, and _payBounty sends the bounty (up to 2x the trade's output from the team chest) there. Most routers do not forward hookData, so these traders can never be paid. Fix: require the 20-byte owner for a bid to carry a bounty (treat a missing owner as owner-less, paying nobody), or use the swap's `sender` argument when it is not a known router, and document that integrators must pass hookData.","line":536,"path":"src/IMDuelHook.sol","reproduction":"In a main duel setup, vm.prank(SA, BUN) with SA the paying account and BUN the bundler EOA as tx.origin, then swapRouter.swap(pool, SwapParams(true, -1e18, MIN_SQRT_PRICE + 1), settings, \"\") buying team 1's token. hook.game(id).bids[1].owner == BUN (line 536). When that move is played and captures a rook, _payBounty pays 150 bps of the team chest (capped at 2x the output) to BUN. Expected: SA, who paid, is the owner. Actual: BUN is.","severity":"low","snippet":"        address owner = hookData.length == 20 ? address(bytes20(hookData)) : tx.origin;","title":"Without 20-byte hookData the bid owner is tx.origin, so relayed or ERC-4337 trades pay the move's bounty to the bundler or relayer"}],"hash":"0a78adcfe9e18dbd192be3a94d712fe56a345418a341d692186b9fa63fec4a64","nodeId":"b2f22081-c7b4-4ee0-bda2-925f34f7e035","outcome":"completed","summary":"I merged the 14 specialist findings into 5, reproduced all 5 against the code, and dropped none. They're in `.imd-findings.json`. No source files were changed, and I deleted the scratch tests.\n\n| # | Severity | Finding | Where | How it was reproduced |\n|---|---|---|---|---|\n| 1 | medium | The hook's fee is charged on the full requested amount even when the swap only partly fills. This is up to about 25.5% during the main duel's first 300 seconds. | `src/IMDuelHook.sol:264` | All four specialist proofs fail on the current code. I attached the one that also accepts a revert on partial fill as a valid fix. In its run a trader paid 0.55e18, only 0.0501e18 reached the pool, and the hook took 0.5e18. The test fails with `500000000000000000 > 252006018369159`. |\n| 2 | low | The builder of the last block in a move's bidding window can still bid in that block and pick among block hashes, which seed the move. This breaks the contract's own claim that \"nobody can know a move while it can still be bid for\". | `src/IMDuelHook.sol:581` (bids still accepted at line 507) | Traced in the code: line 507 still accepts bids in the closing block, and `previewMove` gives the move for any candidate hash. |\n| 3 | low | A position repeated for the third time is not drawn if more than 16 half-moves separate it from the second time. The memory holds 16 half-moves, but the no-capture, no-pawn-move draw only triggers at 60. | `src/IMDuelHook.sol:722` | A Forge run of the 24-move knight shuffle through the repo's test harness returns to the start position a third time, and the game keeps going (game number 0, half-move 24). |\n| 4 | low | The 0.1% protocol slice taken in pools with no conversion route (for example two unrelated ERC20s) builds up forever and can never be spent. | `src/IMDuelHook.sol:434` | Traced in the code: those currencies have no conversion route, `convert` returns without spending, and nothing else ever lowers the pile. |\n| 5 | low | When a trade is sent without the 20-byte owner address, the bid's owner becomes `tx.origin`. Trades sent through a bundler, relayer or solver therefore pay their bounty to that bundler, relayer or solver instead of the trader. | `src/IMDuelHook.sol:536` | Traced in the code: line 536 falls back to `tx.origin`, which in these flows is the submitting account. |\n\n**Merges:** finding 1 combines four reports, findings 2, 4 and 5 combine three each, and finding 3 had one.\n\n**Corrections to the specialists' reports:**\n- Proof `1d44b12b` does fail, but with different numbers from its own description (50,000e18 against 153e18, where the text says 5e18), so I didn't use it.\n- The specialists rated the block-hash finding medium once and low twice. I rated it low because it requires producing the block, and each move's bounty is capped at twice what the trade received.\n\n**What this review didn't cover:** with about 84 minutes and a 5.5-minute build, I put the time into checking the specialists' work. My own pass covered only the hook itself (fees, chests, payouts, conversion, the lock, how the main duel is set up). I did not independently review the chess engine or the Rules, Bitboard, Chess and Strength libraries.","treeHash":null,"usage":{"cachedInputTokens":768116,"inputTokens":28,"model":"claude-opus-5-5","outputTokens":13298,"runtime":"claude","turns":15,"wallClockMs":513645}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"91d3f9816008ce9f","findings":[{"citation":"resolved","description":"When the chest share and/or protocol slice fall in the specified currency, beforeSwap computes them from |params.amountSpecified| and returns them as a fixed BeforeSwapDelta. v4 then runs the swap with amountSpecified+fee, and a sqrtPriceLimitX96 (or running out of in-range liquidity) can stop it far short of that. The swapper is still charged the full fee on the part that never traded (swapper delta = swapDelta - hookDelta), and that money goes into the chest and the pile. In the main duel's first OPENING seconds _chestBps reaches 2,540 bps, so a limited swap there can lose up to ~25.5% of the requested input while trading almost nothing. The same holds for exact-output swaps, where the fee on the specified output is computed from the requested output. The afterSwap half is correct because it uses the realised delta.","line":264,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Deployers} from \"v4-core/test/utils/Deployers.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {IERC20Minimal} from \"v4-core/src/interfaces/external/IERC20Minimal.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\n\ncontract PartialFillFeeTest is Test, Deployers {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n\n    IMDuelHook hook;\n\n    function setUp() public {\n        deployFreshManagerAndRouters();\n        deployMintAndApprove2Currencies();\n        address engine = deployCode(\"ChessEngineV5.sol:ChessEngineV5\");\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));\n        deployCodeTo(\n            \"IMDuelHook.sol:IMDuelHook\",\n            abi.encode(manager, engine, currency0, uint24(0), uint24(0), address(0), type(uint256).max,\n                type(uint256).max, type(uint256).max, uint256(1)),\n            at\n        );\n        hook = IMDuelHook(at);\n        (key,) = initPool(currency0, currency1, IHooks(at), 3000, SQRT_PRICE_1_1);\n        modifyLiquidityRouter.modifyLiquidity(key, LIQUIDITY_PARAMS, ZERO_BYTES);\n        vm.warp(block.timestamp + 1 days); // past the opening surcharge: plain 0.4% chest + 0.1% slice\n    }\n\n    /// An exact-input swap that its price limit stops early is charged the hook fee on the whole requested input.\n    function test_feeOnUnfilledInput() public {\n        uint256 requested = 100e18;\n        // a limit ~0.01% below the price: the pool fills only a small part of the request\n        uint160 limit = uint160(uint256(SQRT_PRICE_1_1) * 9_999 / 10_000);\n        uint256 before0 = currency0.balanceOf(address(this));\n        BalanceDelta d = swapRouter.swap(\n            key, SwapParams(true, -int256(requested), limit), PoolSwapTest.TestSettings(false, false), ZERO_BYTES\n        );\n        uint256 paid = before0 - currency0.balanceOf(address(this));\n        uint256 hookFee = hook.chest(key.toId(), 0) + hook.pile(currency0.toId());\n        uint256 swapped = paid - hookFee; // what actually went into the pool\n        emit log_named_uint(\"requested\", requested);\n        emit log_named_uint(\"paid\", paid);\n        emit log_named_uint(\"swapped\", swapped);\n        emit log_named_uint(\"hook fee\", hookFee);\n        assertEq(uint256(uint128(-d.amount0())), paid);\n        // the hook advertises 0.5% of the trade: at most 0.5% of what was swapped (+1 for rounding)\n        assertLe(hookFee, swapped * 50 / 9_950 + 1, \"hook fee exceeds 0.5% of the executed input\");\n    }\n}","reproduction":"Main duel (currency0 = IMD, so chest 0 and slice 0 are both taken on the input), the default Deployers liquidity, time past OPENING. Swap zeroForOne with amountSpecified = -100e18 and sqrtPriceLimitX96 = SQRT_PRICE_1_1*9999/10000. Expected: hook fee <= 0.5% of the input that executed (~5.04e11). Actual (forge test): paid 500100310933801506, of which 100310933801506 was swapped and 500000000000000000 was hook fee, about 4,985% of the executed input. Fix: take the specified-side fee in afterSwap from the realised delta. For exact-input, take it from the unspecified/output side, or size the beforeSwap fee from an amount that is known to execute. Alternatively, reject swaps whose realised specified amount differs from the requested one when a specified-side fee was taken.","severity":"medium","snippet":"        int128 taken = _takeFee(id, key, _specified(params), uint256(a < 0 ? -a : a), chestIdx, g);","title":"beforeSwap charges the 0.5% hook fee (and the 25.4% opening surcharge) on the requested amount, not on what the price limit lets the pool fill"},{"citation":"resolved","description":"_repeated compares the current position only with the 8 same-parity keys still in the 16-lane ring (positions after half-moves g.ply-17 .. g.ply-3), plus a flag for the start position. A position that occurs twice and then again more than 16 half-moves after its second occurrence is not drawn, even though no capture or pawn move came between them. The quiet-move draw only fires at MAX_QUIET_PLY = 60, so such sequences can be played. The game then goes on past a threefold repetition, against the documented draw rule ('the third repetition'), and later moves and bounties are paid in a game that should be over. The engine's _history window (8 keys) has the same gap.","line":722,"path":"src/IMDuelHook.sol","reproduction":"With the test Harness's applyMove from the start (game 0): Nf3 Nf6 Ng1 Ng8 (ply 4 = start position, 2nd time). Then Nc3 Nc6 Nf3 Nf6 Nb5 Nb4 Ng5 Ng4 Na3 Na6 Nh3 Nh6 Nb1 Nb8 Nf4 Nf5 Nh3 Nh6 Ng1 Ng8. At ply 24 the board equals Chess.START_BOARD with the start states for the 3rd time. Expected: a draw (GameOver, gameNo 1). Actual: gameNo stays 0 and ply is 24, because lane 3 (half-move 3) was overwritten by half-move 19. Fix: store keys back to the last irreversible move, up to MAX_QUIET_PLY of them (e.g. a ring of 64 keys, which needs 16 slots), or make the quiet-move limit no longer than the ring.","severity":"low","snippet":"        (uint256 lo, uint256 hi) = _lanes(id, g.ply + 1);","title":"Threefold repetition is missed when the occurrences span more than 16 half-moves, although the quiet-move draw only fires at 60"}],"hash":"141ac03bb3fb14d1926a1470dca4568d74955c596430eef93aa8ce4274a4adc5","nodeId":"f9fe3ac1-ca72-4edf-ac5e-b19a0ba171ec","outcome":"completed","summary":"I found two problems in the math, one medium and one low. Both reproduce in Foundry and are written to `.imd-findings.json`. No repository files were changed, and I deleted my scratch tests afterwards.\n\n**1. Medium: the swap fee is charged on the amount asked for, not the amount traded** (`src/IMDuelHook.sol:264`)\n- `beforeSwap` sets its fee from the full requested amount. If the trader's price limit stops the swap early, they still pay the whole fee.\n- **Test:** main pool, past the opening window, an exact-input swap of 100e18 with a limit 0.01% below the price. Only about 1.003e14 actually traded, but the hook took 5e17 — about 50 times what was traded, where 0.5% was expected.\n- **Opening window:** in the main pool's first 300 seconds the chest share is 2,540 bps (25.4%), so a limited swap could lose about 25.5% of its input while trading almost nothing.\n- **Exact-output swaps:** the same applies to the output fee.\n- **Fix:** take this fee in `afterSwap` from the amount that actually traded.\n- The finding includes a self-contained test that fails now and should pass once this is fixed.\n\n**2. Low: a threefold repetition can be missed** (`src/IMDuelHook.sol:722`)\n- The hook only remembers positions from the last 16 half-moves, but the no-capture, no-pawn-move draw doesn't fire until 60. A position that comes back a third time more than 16 half-moves after its second appearance is not drawn.\n- **Test:** knights only. Nf3 Nf6 Ng1 Ng8 brings back the starting position at half-move 4. A 20-half-move knight tour then returns to it at half-move 24. The board matched the start for the third time, but the game went on (the game counter stayed 0).\n- The engine's own 8-position repetition memory has the same blind spot.\n- **Fix:** store positions back to the last capture or pawn move, up to 60, or cap the no-progress draw at the memory size.\n\n**Checked with nothing to report:**\n- **Pricing and fee math:** comparing the two chests by value, the fee split, the opening surcharge's decline, and the per-chest size cap.\n- **Bids and payouts:** the bid strength curve and its log approximation, how pool depth is measured, the 10% raise rule, the uint128 casts on bid size and cap, the bounty rates and the 2x cap.\n- **Conversion:** the 0.1% price band, including near the maximum price, and the ETH batch cap.\n- **Timing:** block-hash timing, the gas checks around the engine call, and the order the repetition memory is read in.\n- **Chess libraries:** I read the move-generation, check, pin, castling, en passant and board-conversion code without finding a fault.\n\n**Limits:**\n- I did no fuzzing of my own; the chess libraries already have the repo's chess.js comparison tests, which I didn't run.\n- I didn't cover the engine's move-scoring heuristics in depth.\n- Leads outside the math area were noted but not pursued, for example strength gamed with thin concentrated liquidity, or multi-block price pushes against conversions.","treeHash":null,"usage":{"cachedInputTokens":1069557,"inputTokens":26,"model":"claude-opus-5-5","outputTokens":39986,"runtime":"claude","turns":16,"wallClockMs":506822}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bfeaa965edf57623","findings":[{"citation":"resolved","description":"beforeSwap works out the chest share and the protocol slice from |params.amountSpecified| and returns it as the specified-currency hook delta. Uniswap v4 stops a swap at sqrtPriceLimitX96 (or when in-range liquidity runs out) without refunding the hook's beforeSwap delta. As a result the trader pays the hook's fee on input that never entered the pool (exact input), or on output that was never delivered (exact output). The documented fee is '0.5% of every swap', but a partly filled swap can pay many times what it swapped. In the main duel's first 300 seconds the chest share is up to 25.4% (OPENING_BPS), so a buyer whose exact-input trade fills only partly can lose up to about 25.5% of the full specified amount. Launch-time pools are often thin and one-sided, which makes partial fills likely then. Fix: charge the specified-side share on what actually filled. For example, take it in afterSwap from |delta| of the specified currency, converted into the unspecified currency, because afterSwap can only return an unspecified delta. Alternatively, revert in afterSwap when |specified delta| plus the fee is less than |amountSpecified|.","line":264,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {Deployers} from \"v4-core/test/utils/Deployers.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\n\ncontract StubEngine {\n    function noop() external {}\n}\n\ncontract PartialFillFeeTest is Test, Deployers {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n\n    IMDuelHook hook;\n    PoolKey pool;\n\n    function setUp() public {\n        deployFreshManagerAndRouters();\n        deployMintAndApprove2Currencies();\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));\n        deployCodeTo(\n            \"src/IMDuelHook.sol:IMDuelHook\",\n            abi.encode(\n                manager, IChessEngine(address(new StubEngine())), currency0, uint24(0), uint24(0), IHooks(address(0)),\n                type(uint256).max, type(uint256).max, type(uint256).max, uint256(1)\n            ),\n            at\n        );\n        hook = IMDuelHook(at);\n        // the main duel: currency0 stands in for IMD, currency1 for $IMDUEL\n        (pool,) = initPool(currency0, currency1, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);\n        modifyLiquidityRouter.modifyLiquidity(pool, ModifyLiquidityParams(-60, 60, 1_000e18, 0), \"\");\n        vm.warp(block.timestamp + 301); // past the opening surcharge: the plain 0.5%\n    }\n\n    /// An exact-input trade stopped early by its price limit pays the hook's fee on the whole specified amount,\n    /// not on what was swapped.\n    function test_partialFillPaysFeeOnlyOnWhatWasSwapped() public {\n        uint160 limit = TickMath.getSqrtPriceAtTick(-1); // stops almost at once\n        uint256 before0 = currency0.balanceOf(address(this));\n        try swapRouter.swap(\n            pool,\n            SwapParams(true, -100e18, limit),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        ) {\n            uint256 paid = before0 - currency0.balanceOf(address(this));\n            uint256 hookFee = manager.balanceOf(address(hook), currency0.toId());\n            uint256 swapped = paid - hookFee;\n            emit log_named_uint(\"paid\", paid);\n            emit log_named_uint(\"swapped into the pool\", swapped);\n            emit log_named_uint(\"hook fee\", hookFee);\n            // the hook's fee is 0.5% of a trade: at most 0.5% of what entered the pool (+1 wei rounding)\n            assertLe(hookFee, swapped * 50 / 9_950 + 1, \"fee charged on the unfilled part of the trade\");\n        } catch {\n            // refusing a partial fill is also a fix\n        }\n    }\n}","reproduction":"Main duel (fee 3000, 1:1 price), liquidity 1,000e18 on ticks [-60,60], 301 s after opening so the plain 0.5% applies. Call swapRouter.swap(pool, SwapParams(zeroForOne=true, amountSpecified=-100e18, sqrtPriceLimitX96=getSqrtPriceAtTick(-1)), ...). Actual: the trader pays 0.550149e18 of currency0, only 0.050149e18 of which enters the pool, and the hook mints 0.5e18 of fee claims (0.4e18 to chest 0, 0.1e18 to the IMD pile). That is about 997% of the amount swapped. Expected: a fee of at most 0.5% of the amount swapped, about 2.52e14. At opening age 0 the same trade pays 25.5e18.","severity":"medium","snippet":"        int128 taken = _takeFee(id, key, _specified(params), uint256(a < 0 ? -a : a), chestIdx, g);","title":"Hook fee is charged on the full specified amount even when the swap only partly fills (price limit or exhausted liquidity)"},{"citation":"resolved","description":"The hook is open, so anyone can initialize a pool on it. afterInitialize sets g.slice to 0 when neither currency is IMD or $IMDUEL (line 245). For two arbitrary ERC20s, every swap then adds 0.1% of currency0 to pile[currency0]. _minBatch returns type(uint256).max for that currency, so _play never converts it. convert(c) reaches the `!(c == imduel)` branch and returns ('no route: it waits'). No route can ever exist, because imd, imduel and the ETH route are immutable or set once, and nothing else reads pile. These fees are taken from traders as ERC-6909 claims that can never be spent, burned or sent to a chest. The contract header says the slice 'is converted into $IMDUEL', which does not happen here. The same applies to native ETH when the hook has no ETH route (ethFee 0) and IMD is not ETH. Fix (keeps the fee level): in pools whose slice currency has no route, do not take the slice, or add it to that pool's chest in _takeFee.","line":433,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {Deployers} from \"v4-core/test/utils/Deployers.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\n\ncontract StubEngine {\n    function noop() external {}\n}\n\ncontract RoutelessSliceTest is Test, Deployers {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n\n    IMDuelHook hook;\n    PoolKey pool;\n\n    function setUp() public {\n        deployFreshManagerAndRouters();\n        deployMintAndApprove2Currencies();\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));\n        deployCodeTo(\n            \"src/IMDuelHook.sol:IMDuelHook\",\n            abi.encode(\n                manager, IChessEngine(address(new StubEngine())), currency0, uint24(0), uint24(0), IHooks(address(0)),\n                type(uint256).max, type(uint256).max, type(uint256).max, uint256(1)\n            ),\n            at\n        );\n        hook = IMDuelHook(at);\n        // the main duel: currency0 stands in for IMD, currency1 for $IMDUEL\n        (pool,) = initPool(currency0, currency1, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);\n        modifyLiquidityRouter.modifyLiquidity(pool, ModifyLiquidityParams(-60, 60, 1_000e18, 0), \"\");\n        vm.warp(block.timestamp + 301); // past the opening surcharge: the plain 0.5%\n    }\n\n    /// In a pool on the hook whose currencies have no conversion route (neither IMD, $IMDUEL nor native ETH), the 0.1%\n    /// protocol slice is piled up and can never leave the hook.\n    function test_sliceInARoutelessPoolCanBeConverted() public {\n        MockERC20 a = new MockERC20(\"A\", \"A\", 18);\n        MockERC20 b = new MockERC20(\"B\", \"B\", 18);\n        a.mint(address(this), 1e30);\n        b.mint(address(this), 1e30);\n        a.approve(address(swapRouter), type(uint256).max);\n        b.approve(address(swapRouter), type(uint256).max);\n        a.approve(address(modifyLiquidityRouter), type(uint256).max);\n        b.approve(address(modifyLiquidityRouter), type(uint256).max);\n        (Currency c0, Currency c1) = address(a) < address(b)\n            ? (Currency.wrap(address(a)), Currency.wrap(address(b)))\n            : (Currency.wrap(address(b)), Currency.wrap(address(a)));\n        (PoolKey memory k,) = initPool(c0, c1, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);\n        modifyLiquidityRouter.modifyLiquidity(k, ModifyLiquidityParams(-600, 600, 1_000e18, 0), \"\");\n\n        swap(k, true, -10e18, \"\");\n        uint256 piled = hook.pile(c0.toId());\n        assertEq(piled, 1e16, \"0.1% slice of 10e18 taken in currency0\");\n\n        hook.convert(c0); // the only exit for a pile\n        assertEq(hook.pile(c0.toId()), 0, \"the slice is stuck: no route and no other way out\");\n    }\n}","reproduction":"Initialize a pool of two fresh tokens A/B on the hook (fee 3000, 1:1), add liquidity, swap 10e18 exact-input zeroForOne. pile(currency0) is now 1e16. Call hook.convert(currency0). Expected: the pile is converted or otherwise usable (pile 0). Actual: pile(currency0) stays 1e16, and no function can ever move it. It grows by 0.1% of every swap in every such pool.","severity":"low","snippet":"        } else if (!(c == imduel)) {\n            return; // no route: it waits","title":"Protocol slice taken in pools without a conversion route is locked in the hook forever"},{"citation":"resolved","description":"The move's randomness is seed = keccak256(blockhash(closeBlock), id, gameNo, ply, owner, size) (_choose, line 581). The engine uses the seed for whether it is 'aware', for seeded mistakes, for second looks and for mate tests (ChessEngineV5.sol lines 162, 170, 224, 323, 333). Bids are accepted in block closeBlock itself, because `late` is block.number > closeBlock (line 507). Whoever builds block closeBlock can therefore add its own top bid and, before sealing, try different contents (tx order, extraData, or the owner it puts in hookData, which also enters the seed). For each candidate hash it calls previewMove(id, h) or re-runs the engine, and publishes a block whose hash makes its move a capture, check or mate. This breaks the guarantee in the contract header (lines 33-34) that 'nobody can know a move while it can still be bid for'. The builder captures bounties (up to PIECE_BPS/CHECK_BPS/MATE_BPS of the team chest, capped at 2x its trade) more often than honest bidders. Fix: take the dice from a block after the last biddable block, for example blockhash(closeBlock + 1), resolving only when block.number > closeBlock + 1 (or use prevrandao from a later block). Then no party that can still bid can see or choose the dice.","line":581,"path":"src/IMDuelHook.sol","reproduction":"State: the White team's move is open, closeBlock = N, the current top bid is S, and in the current position a seeded engine choice at the bid's strength sometimes takes the black queen. Builder B builds block N with its own buy of White's token paying > 1.1*S and hookData = owner_k. For k = 1..n it computes h_k = hash(block N with owner_k) and the result of previewMove(id, h_k), and publishes the first block where the move captures the queen. The next block's trade resolves it and pays owner_k 2.7% of White's chest (capped at 2x its trade). Expected: the dice are unknowable to every party that can still bid for the move. Actual: block N's builder both bids in block N and picks among hashes.","severity":"low","snippet":"        uint256 seed = uint256(keccak256(abi.encode(h, id, g.gameNo, g.ply, t.owner, t.size)));","title":"The builder of a window's last block can bid in that block and choose its dice, so it knows the move while it can still bid"},{"citation":"resolved","description":"_trade sets the bid owner to tx.origin whenever hookData is not exactly 20 bytes. For ERC-4337 smart accounts tx.origin is the bundler's EOA. For intent and relayer flows (UniswapX-style fillers, meta-transaction relayers, Safe transactions executed by a relayer) it is the filler or relayer. The trader whose tokens paid for the bid has no claim on the bid, and the move's bounty from the team chest (up to 25.3% of it for a mate, capped at 2x the trade's output) goes to whoever submitted the transaction. msg.sender (the router) is not usable either, so for these flows a buyer cannot get paid unless the integrator passes the owner in hookData. Generic aggregators do not do this. Fix: use the swap's `sender` argument from beforeSwap/afterSwap when it is not a known router, or require the 20-byte owner and treat a trade without one as a non-owner bid (or pay the bounty to the chest). Either keeps 'the address our site sends' working.","line":536,"path":"src/IMDuelHook.sol","reproduction":"A smart account SA (ERC-4337) buys currency1 in the main duel through a v4 router with empty hookData. The bundler EOA BUN submits the handleOps transaction, so tx.origin = BUN. The trade becomes White's top bid, and the Bid event logs owner = BUN. If the resolved move captures a rook, _payBounty sends 1.5% of the team chest (capped at 2x the trade's output) to BUN. Expected: the reward goes to SA, which paid for the bid. Actual: BUN is paid and SA gets nothing. In a test: vm.prank(SA, BUN) followed by swapRouter.swap(pool, SwapParams(true, -1e18, MIN_SQRT_PRICE + 1), settings, \"\") gives hook.game(id).bids[1].owner == BUN.","severity":"low","snippet":"        address owner = hookData.length == 20 ? address(bytes20(hookData)) : tx.origin;","title":"Without hookData the bounty owner is tx.origin, so relayed and ERC-4337 trades pay bounties to the relayer or bundler instead of the buyer"}],"hash":"157244379587bf9be0124b51780c5d9cba4c183993a083b0dfbbc8fa03ebf86b","nodeId":"70a87425-0f8b-4d8a-a614-302094facde3","outcome":"completed","summary":"I found four economic defects: one medium and three low. Two are proven by Foundry tests that fail on the current code. They are written to `.imd-findings.json`, with each proof test's source embedded in its finding. I changed no repository files, and I deleted the scratch tests after running them. There are no critical or high findings.\n\n**Findings**\n\n1. **Medium: the hook charges its fee on the part of a swap that never fills** (`src/IMDuelHook.sol:264`). `beforeSwap` works out the fee from the full requested amount. If the swap stops early at its price limit or runs out of liquidity, that fee is not refunded. In the test, a 100e18 exact-input trade limited to tick −1 put 0.050149e18 into the pool but paid the hook 0.5e18, about ten times what it swapped. In the main pool's first 300 seconds, when the chest share can reach 25.4%, the same trade would have paid 25.5e18. The fix is to charge on the filled amount, or to revert on a partial fill. Proof: fails as described.\n\n2. **Low: the 0.1% protocol slice is locked forever in pools with no conversion route** (`:433`, with the choice of currency at `:245`). Anyone can open a pool on this hook. For a pool of two tokens that are neither IMD, $IMDUEL nor ETH, the slice piles up and nothing can ever convert or move it. In the test, the pile stayed at 1e16 after `convert(c0)`. The fix is to skip the slice in such pools, or add it to that pool's chest. Proof: fails as described.\n\n3. **Low: the builder of a bid window's last block can see and choose the dice** (`:581`). Bids are still accepted in the window's last block, and that block's hash is the dice. Its builder can add their own top bid, try different block hashes against `previewMove`, and publish one that makes their move a capture or mate. That contradicts the stated rule that nobody can know a move while it can still be bid for. The fix is to take the dice from the block after the window closes.\n\n4. **Low: bounties go to `tx.origin` when the trade carries no owner address** (`:536`). For ERC-4337 smart accounts and relayed or intent-based trades, that is the bundler or relayer, not the buyer who paid for the bid. I did not run a test for this one; it follows directly from the code.\n\n**What I checked without finding a defect:** where every fee, chest, pile and payout goes (the hook's token balance always equals the chests plus the pile plus unpaid rewards), the cases of a full chest and of `fundChest` with fee-on-transfer tokens, the rule that a new bid must beat the old one by 10%, timeout moves, and failed payouts that stay owed. I also checked the strength formula, the conversion price limits, and gas-starving the engine.\n\n**Limits:**\n- I worked through the Economic Security, Invariant and Flow Gap guides as my own review passes; no helpers were used.\n- I did not review the chess engine's rules and move generation for correctness. I only checked how its random seed affects payouts.\n- These were judged to be intended game design or unprofitable, so I did not report them as findings:\n  - Winning a move by buying and selling back in one transaction (it costs only the fees).\n  - Sandwiching the batch that converts the ETH slice.\n  - Moving the price across a block boundary to affect the main pool's conversion limit.","treeHash":null,"usage":{"cachedInputTokens":1028550,"inputTokens":30,"model":"claude-opus-5-5","outputTokens":44075,"runtime":"claude","turns":16,"wallClockMs":533920}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"faf271d05ee8ea0f","findings":[{"citation":"resolved","description":"beforeSwap takes the chest share and the protocol slice that fall in the specified currency on |params.amountSpecified|, then returns them as the BeforeSwapDelta. v4 subtracts that delta from the amount the pool swaps (Hooks.beforeSwap: amountToSwap += hookDeltaSpecified) and charges it to the caller in full (swapDelta - hookDelta). If the swap reaches params.sqrtPriceLimitX96 before amountToSwap is used up, the pool trades only part of the order, but the trader still pays the fee on the whole order. The fee should follow what traded. For exact input, the trader pays 0.5% of the order (25.4% + 0.1% in the main duel's first 300 seconds) on top of a fill that can be tiny. For exact output, the pool must produce amountSpecified + fee, so on a partial fill the trader receives fill - fee. The afterSwap half is correct because it uses the realised delta. Fix: base the specified-side fee on the realised amount. One option is to take every part of the fee in afterSwap, on the unspecified side, worked out from the actual delta. Another is to revert in afterSwap when the realised specified amount is smaller than amountToSwap, which rejects partial fills. Both keep the 0.4% / 0.1% split and the opening surcharge.","line":264,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\n\ncontract Tok {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a; balanceOf[to] += a; return true;\n    }\n    function transferFrom(address f, address to, uint256 a) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a; balanceOf[to] += a; return true;\n    }\n}\n\n/// beforeSwap charges the hook fee on the full amountSpecified. When the swap stops early at its sqrtPriceLimit, the\n/// trader still pays the whole fee: here 0.5% of 1,000 tokens for a fill of well under one token.\ncontract PartialFillFeeTest is Test {\n    PoolManager manager;\n    PoolSwapTest swapper;\n    PoolModifyLiquidityTest lp;\n    IMDuelHook hook;\n    PoolKey key;\n    Tok t0;\n    Tok t1;\n\n    function setUp() public {\n        manager = new PoolManager(address(this));\n        swapper = new PoolSwapTest(manager);\n        lp = new PoolModifyLiquidityTest(manager);\n        Tok a = new Tok();\n        Tok b = new Tok();\n        (t0, t1) = address(a) < address(b) ? (a, b) : (b, a);\n        IChessEngine engine = IChessEngine(vm.deployCode(\"ChessEngineV5.sol:ChessEngineV5\"));\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0xABCDE) << 40));\n        // imd = token0, no ETH route, conversions never, 3-block turns\n        deployCodeTo(\n            \"IMDuelHook.sol:IMDuelHook\",\n            abi.encode(manager, engine, Currency.wrap(address(t0)), uint24(0), uint24(0), address(0),\n                type(uint256).max, type(uint256).max, type(uint256).max, uint256(3)),\n            at\n        );\n        hook = IMDuelHook(at);\n        key = PoolKey(Currency.wrap(address(t0)), Currency.wrap(address(t1)), 3000, 60, IHooks(at));\n        manager.initialize(key, 79228162514264337593543950336); // 1:1\n        t0.mint(address(this), 1e30);\n        t1.mint(address(this), 1e30);\n        t0.approve(address(swapper), type(uint256).max);\n        t1.approve(address(swapper), type(uint256).max);\n        t0.approve(address(lp), type(uint256).max);\n        t1.approve(address(lp), type(uint256).max);\n        lp.modifyLiquidity(key, ModifyLiquidityParams(-887220, 887220, 1_000_000e18, 0), \"\");\n        vm.warp(block.timestamp + 1 hours); // past the opening surcharge: the plain 0.5%\n        vm.roll(block.number + 1);\n    }\n\n    function test_feeFollowsExecutedAmountOnPartialFill() public {\n        uint160 limit = 79228162514264337593543950336 - 1e20; // a hair below the current price\n        uint256 before0 = t0.balanceOf(address(this));\n        BalanceDelta d = swapper.swap(\n            key, SwapParams(true, -1_000e18, limit), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n        uint256 paid = before0 - t0.balanceOf(address(this));\n        uint256 got = uint256(int256(d.amount1()));\n        emit log_named_uint(\"token0 paid\", paid);\n        emit log_named_uint(\"token1 received\", got);\n        assertGt(got, 0, \"partial fill\");\n        assertLt(got, 1e18, \"far less than one token filled\");\n        // a 0.5% fee on what actually traded: at most ~1.006x the fill (LP fee 0.3% plus the hook's 0.5%)\n        assertLe(paid, got * 1_010 / 1_000, \"hook fee charged on the unfilled part of the order\");\n    }\n}","reproduction":"Main duel token0 (IMD) / token1, fee 3000, spacing 60, 1:1, full-range liquidity 1,000,000e18, one hour after the opening (plain 0.5%). Chests are empty, so chestIdx = 0 and slice = 0, and the whole hook fee falls on token0. Swap zeroForOne with amountSpecified = -1,000e18 and sqrtPriceLimitX96 = 2^96 - 1e20 (just below the current price). Expected: the trader pays about the fill plus 0.3% LP fee and 0.5% hook fee, roughly 0.00127e18 token0. Actual (test below): the trader pays 5.001265975376074936e18 token0 for 0.001262177448353618e18 token1. That is 5e18 of hook fee (0.5% of the unfilled 1,000e18), about 4,000 times what was received. In the first 300 seconds the same order would lose 255e18.","severity":"medium","snippet":"        int128 taken = _takeFee(id, key, _specified(params), uint256(a < 0 ? -a : a), chestIdx, g);","title":"Hook fee is charged on the full amountSpecified even when the swap stops early at its price limit"},{"citation":"resolved","description":"afterInitialize lets any pool use the hook and sets g.slice to currency0 when neither currency is IMD or $IMDUEL (line 245). Every swap in such a pool mints 0.1% of the slice side to the hook as ERC6909 claims and adds it to pile[currency]. Only _convert lowers pile. For a currency that is not IMD, not $IMDUEL and not native ETH with an ETH route, _convert returns with the comment \"no route: it waits\", and _minBatch returns type(uint256).max, so _play never tries. Every routing input (imd, ethFee, ethSpacing, ethHooks, imduel) is immutable or set once, so a route can never be added and nothing else spends or withdraws pile. The waiting is permanent: traders in these pools pay a 0.1% fee that funds nothing, and those claims can never leave the PoolManager. The same applies to native ETH when the hook is deployed with ethFee = 0 and IMD is an ERC20. Fix, keeping the open hook: take no slice where there is no route (in afterInitialize, mark such pools so _takeFee skips toPile), or send it to the pool's chests instead.","line":434,"path":"src/IMDuelHook.sol","reproduction":"Use the launch hook (imd = 0xd34a..., ethFee 10000). Initialise a pool of two ERC20s A < B, neither IMD nor $IMDUEL, with this hook: afterInitialize sets slice = 0. Swap 1,000e18 of A for B: pile[A] rises by 1e18 and the hook holds 1e18 more A claims. Call convert(A): it returns at line 434 and pile[A] stays at 1e18. _play only tries a conversion when pile[A] >= _minBatch(A) = type(uint256).max. No function can move those claims, so after N such swaps pile[A] = 0.1% of all A that traded, locked for good. Expected: the slice is converted or not taken. Actual: it accrues forever.","severity":"low","snippet":"            return; // no route: it waits","title":"Protocol slice taken in pools with no conversion route is locked in the hook forever"},{"citation":"resolved","description":"_trade makes the bid's owner tx.origin whenever hookData is not exactly 20 bytes, and the comment calls this 'the signing wallet'. That holds only for an EOA calling the router itself. For an ERC-4337 smart account, a Safe run by a relayer, a meta-transaction, or an intent filler (UniswapX, CoW, aggregator solvers), tx.origin is the bundler, relayer or filler, not the trader who paid. _resolve then records that address as the move's owner, and _payBounty sends the bounty (up to 2x what the trade received, from the team chest) to it. The trader who paid for the bid gets nothing. Fix: make the 20-byte hookData mandatory for a bid to count (no fallback), or fall back to an address the trader controls, not tx.origin. Either way, document that integrators must pass hookData.","line":536,"path":"src/IMDuelHook.sol","reproduction":"In the scratch setup (main duel token0/token1, full-range liquidity), call PoolSwapTest.swap(key, SwapParams(false, -10e18, MAX_SQRT_PRICE - 1), settings, \"\") with msg.sender = account (the smart account or router caller) and tx.origin = 0xB0B (the bundler), using vm.prank(account, 0xB0B). Expected: the bid for team 0 is owned by the trader. Actual: hook.game(id).bids[0].owner == 0xB0B (verified with forge). When that move is played and captures, checks or mates, _payBounty pays 0xB0B.","severity":"low","snippet":"        address owner = hookData.length == 20 ? address(bytes20(hookData)) : tx.origin;","title":"Without hookData a bid (and its bounty) belongs to tx.origin, so relayed or ERC-4337 trades pay the relayer"},{"citation":"resolved","description":"The move is seeded by keccak(blockhash(closeBlock), id, gameNo, ply, owner, size). _play still accepts bids in closeBlock itself (line 507: late is only true once block.number > closeBlock). The contract's claim that 'nobody can know a move while it can still be bid for' therefore fails for whoever builds closeBlock. A builder or proposer, or a searcher paying one, puts its own top bid in closeBlock. It builds candidate versions of that block (different ordering, an extra transaction, different extraData), each with its own hash h_i, and for each runs previewMove(id, h_i), which is exactly how the next trade will play. It then publishes the version whose move captures, checks or mates. That steers the bounty (up to 2.7% of the team chest for a queen, 25.3% plus capture for a mate, capped at 2x the bid's received amount) and the game. Builders already make many versions of each block, so a choice among k hashes costs little. Fix: take the dice from a block that is final only after bidding closes, for example the hash of closeBlock + 1, with resolution allowed from closeBlock + 2. Alternatively stop accepting bids in closeBlock, so its builder can no longer add a bid after seeing which hashes are available. Either way the bid set is fixed before the hash's block exists.","line":558,"path":"src/IMDuelHook.sol","reproduction":"State: team T to move, closeBlock = C, current top bid size S. The builder of block C includes its own trade for T paying more than 1.1*S (it becomes the top bid at line 512, since block.number == C is not late). It then builds versions C_1..C_k of block C. For each it computes h_i = hash(C_i) and calls previewMove(id, h_i), which returns (true, m_i). It publishes the C_j whose m_j captures, checks or mates. Any trade in block C+1 runs _resolve with h = blockhash(C) = h_j, plays m_j and pays the builder's bid the matching bounty. Expected: the move is unknown to everyone who could still bid. Actual: the bidder who builds C chooses among k moves.","severity":"low","snippet":"        bytes32 h = _hashOf(g.closeBlock);","title":"The dice are the hash of a block that still accepts bids, so the builder of closeBlock can choose a bid's move"}],"hash":"c4712a02bcb50ec53959a17ed2dd6e2e9b9202ddf3d9166fb7bf99fd9ba48559","nodeId":"8fc2db5d-5351-425e-a005-0943461fdaec","outcome":"completed","summary":"The review found four defects, one medium and three low, and they're in `.imd-findings.json` in the repository root. I changed no source files and deleted my scratch tests afterwards, so the working tree is clean. The findings file doesn't show in `git status`, probably because it's gitignored, but it's there and parses.\n\n| # | Severity | Where | Problem |\n|---|---|---|---|\n| 1 | medium | `src/IMDuelHook.sol:264` (`beforeSwap`) | The hook fee is charged on the whole order even when the swap stops early at its price limit. |\n| 2 | low | `src/IMDuelHook.sol:434` (`_convert`) | The 0.1% protocol slice from pools with no conversion route is locked in the hook forever. |\n| 3 | low | `src/IMDuelHook.sol:536` (`_trade`) | Without `hookData`, a bid and its bounty belong to `tx.origin`, which can be a relayer rather than the trader. |\n| 4 | low | `src/IMDuelHook.sol:558` (`_resolve`) | The dice come from a block that still takes bids, so whoever builds that block can pick the move. |\n\n**1. Fee on the unfilled part of an order.** This is the only finding with a Foundry proof attached; the proof fails on the current code. A 1,000-token exact-input swap that fills only 0.00126 tokens before hitting its price limit still pays the full 5-token fee, about 4,000 times what it received. In the main duel's first 300 seconds the same order would lose about 255 tokens. On an exact-output swap, a partial fill hands the trader the fill minus the fee. The fix is to charge the fee on what actually traded, or to reject partly filled swaps.\n\n**2. Slice locked in pools with no route.** In any pool on this hook whose tokens are neither IMD, $IMDUEL, nor native ETH with an ETH route, the slice builds up and can never be spent or withdrawn. The settings that would provide a route are all fixed at deployment.\n\n**3. Bid owner falls back to `tx.origin`.** I checked this with a test: when a smart account trades through a bundler, the bundler's address is recorded as the bid's owner. Any bounty then goes to the bundler, not the trader. The same applies to relayers and order fillers.\n\n**4. Builder can steer the dice.** Bids are still accepted in the window's last block, and that block's hash is the dice. Its builder can place a top bid, try several versions of the block, preview each resulting move with `previewMove`, and publish the one that captures or mates. There's no test for this one; the reproduction in the file walks through the steps.\n\n**Coverage:** I did the Execution Trace, Periphery and First Principles passes myself, with no helpers. They covered every hook entry point and callback, the bidding, timeout and resolve sequence, engine gas handling, repetition memory and conversion. I also traced the legality code in `Bitboard` (pins, checks, en passant, castling) and the `Rules`, `Strength` and `ChessEngineV5` logic, and found no defects there. Beyond reading the code, the only checks I ran were the two scratch tests behind findings 1 and 3. I did not run the project's own test suite, Slither or a fuzzer. This doesn't show that nothing else is wrong.","treeHash":null,"usage":{"cachedInputTokens":1773574,"inputTokens":32,"model":"claude-opus-5-5","outputTokens":53200,"runtime":"claude","turns":21,"wallClockMs":632107}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aefe32dd2527eb68","findings":[{"citation":"resolved","description":"Asymmetry between the two halves of the fee. beforeSwap computes the chest share and slice that fall in the specified currency on |params.amountSpecified| and takes them as a BeforeSwapDelta, while afterSwap computes the unspecified side's share on the pool's actual delta. When a swap is partly filled (it reaches its sqrtPriceLimit, or it runs out of in-range liquidity, which happens with the V4Router's default MIN/MAX limits on any thin pool and on the empty side of the single-sided launch position), the pool consumes only part of amountToSwap. The swapper still owes the hook the whole before-swap fee, worked out on input that was never swapped. Plain v4 (with no hook) charges only for what was swapped, and the hook's own afterSwap half does the same. With the main duel's opening surcharge (up to 25.4% chest share) the overcharge is 25.4% of the unfilled amount. The extra goes into the chest or pile, so the swapper's loss becomes other people's bounty money. Fix: charge the specified-side fee on the amount actually swapped. For example, take every fee in afterSwap from the actual delta, and use the unspecified/hook delta there. Or, in beforeSwap, cap the fee basis using a quote of the fillable amount. Keep 0.4% + 0.1% as the rate.","line":264,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Deployers} from \"v4-core/test/utils/Deployers.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams, SwapParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\n\ncontract PartialFeeTest is Test, Deployers {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n\n    IMDuelHook hook;\n    PoolKey pool;\n\n    function setUp() public {\n        deployFreshManagerAndRouters();\n        deployMintAndApprove2Currencies();\n        IChessEngine e = IChessEngine(vm.deployCode(\"ChessEngineV5.sol:ChessEngineV5\"));\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0xabcde) << 40));\n        deployCodeTo(\"IMDuelHook.sol:IMDuelHook\",\n            abi.encode(manager, e, currency0, uint24(0), uint24(0), IHooks(address(0)), type(uint256).max,\n                type(uint256).max, type(uint256).max, uint256(1)), at);\n        hook = IMDuelHook(at);\n        (pool,) = initPool(currency0, currency1, IHooks(at), 3000, SQRT_PRICE_1_1);\n        // liquidity only between ticks -60 and 60: a big trade runs out of it\n        modifyLiquidityRouter.modifyLiquidity(pool, ModifyLiquidityParams(-60, 60, 1_000e18, 0), \"\");\n        vm.warp(block.timestamp + 1000); // past the opening surcharge: the plain 0.4% + 0.1%\n        vm.roll(block.number + 1);\n    }\n\n    /// An exact-input trade of 1,000 tokens that the pool can only fill ~3 of pays the hook's fee on all 1,000.\n    function test_feeOnUnfilledInput() public {\n        uint256 amountIn = 1_000e18;\n        uint256 before0 = currency0.balanceOfSelf();\n        uint256 before1 = currency1.balanceOfSelf();\n        BalanceDeltaLike memory d;\n        swapRouter.swap(pool, SwapParams(true, -int256(amountIn), TickMath.MIN_SQRT_PRICE + 1),\n            PoolSwapTest.TestSettings(false, false), \"\");\n        d.paid = before0 - currency0.balanceOfSelf();\n        d.got = currency1.balanceOfSelf() - before1;\n        uint256 fee = hook.chest(pool.toId(), 0) + hook.pile(currency0.toId());\n        emit log_named_uint(\"paid in\", d.paid);\n        emit log_named_uint(\"got out\", d.got);\n        emit log_named_uint(\"hook fee in currency0\", fee);\n        // the hook's fees are 0.5% of a trade: on what actually traded (paid - fee) they should be ~0.5%\n        assertLe(fee * 10_000, (d.paid - fee) * 51, \"hook fee is more than 0.51% of what was swapped\");\n    }\n\n    struct BalanceDeltaLike {\n        uint256 paid;\n        uint256 got;\n    }\n}","reproduction":"Main duel IMD(c0)/IMDUEL(c1), fee 3000, liquidity 1,000e18 only in ticks [-60,60], past the 300 s opening. A swapper does an exact-input zeroForOne swap of amountSpecified = -1,000e18 with limit MIN_SQRT_PRICE+1. The pool fills only ~3.01e18. Expected: hook fee ~0.5% of 3.01e18 (~0.015e18). Actual: the swapper pays 8.013e18 in currency0 and receives 2.995e18, and the hook takes 5.0e18 (0.4% chest + 0.1% slice of the full 1,000e18), which is ~166% of the swapped amount. The proof test fails with 'hook fee is more than 0.51% of what was swapped: 50000000000000000000000 > 153683106519396397587'.","severity":"medium","snippet":"        int128 taken = _takeFee(id, key, _specified(params), uint256(a < 0 ? -a : a), chestIdx, g);","title":"Before-swap fee is charged on the full amountSpecified, not on what the pool actually swapped: partial fills overpay up to 100%+ (25.4%+ of unfilled input during the opening)"},{"citation":"resolved","description":"Trust gap (access × economics). The seed for every move comes from the hash of the window's last block, g.closeBlock (_resolve -> _hashOf). The builder or proposer of that block is also allowed to place the last top bid in it (bids are accepted while block.number <= closeBlock). Header fields such as extraData can be changed without touching state, so the builder can work out many candidate hashes for the same block contents, run previewMove(id, h) off-chain on each candidate, and publish the variant whose move earns the most from bountyBps (a capture, a check or mate, up to 25% + piece bps of the team chest, capped at 2x what the bid received). Honest bidders cannot do this, so value moves from them and from the chests to block builders. Fix (within the design): use randomness the closing-block builder cannot choose. One option is a commit that sets the seed from the hash of a block after closeBlock (e.g. closeBlock + k, with the move resolvable only from closeBlock + k + 1). Another is to mix in prevrandao from a later block. Either way, document the residual trust in proposers.","line":581,"path":"src/IMDuelHook.sol","reproduction":"Main duel, side to move has turnBlocks = 3. A builder B builds block N = g.closeBlock and includes its own swap that becomes the top bid (owner B, size S). Before publishing, B tries extraData values x1..xk, giving hashes h1..hk for the same transactions, and evaluates previewMove(mainId, hi) for each (previewMove proceeds once block.number >= closeBlock). B publishes the variant whose move captures the most material or mates. In block N+1 any trade calls _resolve with h = blockhash(N) = B's chosen hash and pays B bountyBps(captured, check, mate) * chest / 10,000 (up to 2x B's output). Expected per the contract's own NatSpec ('nobody can know a move while it can still be bid for'): no bidder can choose the outcome. Actual: the closing block's builder chooses among k outcomes.","severity":"medium","snippet":"        uint256 seed = uint256(keccak256(abi.encode(h, id, g.gameNo, g.ply, t.owner, t.size)));","title":"The builder of a move's closing block can pick its dice: blockhash(closeBlock) is grindable, so the claim that nobody knows the move while it can still be bid for does not hold for that block's builde"},{"citation":"resolved","description":"Asymmetry by caller class. A trader who signs the transaction directly owns the move their trade bid for. A trader whose swap is submitted by someone else (an ERC-4337 bundler for a smart-account wallet, a UniswapX/CoW filler or solver, a meta-transaction relayer) pays the same price, but the move's owner, and so the receiver of _payBounty, becomes the relayer's EOA. Routers that do not forward hookData (most aggregators) always hit this path. Fix: take the owner from a field the router controls (e.g. its `sender` argument when it is a known router forwarding the user), or require hookData for attribution and treat missing hookData as owner-less (address(0)) rather than tx.origin.","line":536,"path":"src/IMDuelHook.sol","reproduction":"A smart-account user U submits, through bundler EOA R, a UserOperation whose call swaps 10e18 currency0 for currency1 in the main duel with empty hookData, and becomes the top bid of the side to move. In the next block the move is played and captures a queen. Expected: U (who paid) receives the bounty of 270 bps of the team chest, capped at 2x U's output. Actual: _trade sets owner = tx.origin = R, and _payBounty sends the bounty to R (Reward event with owner R).","severity":"low","snippet":"        address owner = hookData.length == 20 ? address(bytes20(hookData)) : tx.origin;","title":"Without 20-byte hookData the bid owner is tx.origin: bounties for trades relayed by solvers, bundlers or aggregators go to the relayer, not to the trader who paid"},{"citation":"resolved","description":"Anyone can open a pool on this hook. A pool pairing two tokens that are neither IMD, $IMDUEL nor (with an ETH route) native ETH gets g.slice = 0 (afterInitialize), so 0.1% of every swap in currency0 goes into pile[currency0]. _minBatch returns type(uint256).max for that currency, so _play never converts it, convert(c) returns without spending, and no function withdraws or reassigns the pile. Traders pay the slice and the hook keeps the ERC-6909 claims forever without using them. Fix: skip the slice (toPile = 0) when the slice currency has no route, or send it to the pool's chest.","line":434,"path":"src/IMDuelHook.sol","reproduction":"Initialize a pool TOKA/TOKB (neither IMD, IMDUEL nor ETH) with this hook and add liquidity. Swap 1,000e18 TOKA exact-input. pile[TOKA] grows by 1e18. Calling convert(TOKA) leaves pile[TOKA] at 1e18 (the 'no route' return). No function can move those claims. Expected: the 0.1% slice is either converted or not charged. Actual: it is charged and stuck.","severity":"low","snippet":"            return; // no route: it waits","title":"Protocol slices taken in a currency with no conversion route are locked in the hook forever"}],"hash":"f07515cb94d456b43d41702633797c190a0faceecf36268a295c62e89b49db18","nodeId":"8dca4716-f30a-414d-ba1b-af0c21facdb0","outcome":"completed","summary":"I found four defects and wrote them to `.imd-findings.json`. The first is confirmed by a failing Foundry test; the other three are traced through the code but have no test. I changed no project files; the only other file I wrote is the scratch test in `test/scratch/`.\n\n| Severity | Where | Problem |\n|---|---|---|\n| Medium | `src/IMDuelHook.sol:264` | **Fee on input that never traded.** The fee taken before a swap is worked out on the full amount the trader asks to swap, not the amount the pool actually fills. In the test, an exact-input swap of 1,000e18 could only fill about 3.01e18. The trader paid 8.013e18 and got 2.995e18; the hook took 5.0e18, about 166% of what was swapped. During the main duel's first 300 seconds the overcharge is up to 25.4% of the unfilled amount. The fee taken after the swap correctly uses the real amount. The test is attached as the proof. |\n| Medium | `src/IMDuelHook.sol:581` | **The closing block's builder can choose the move.** Each move's randomness is the hash of the last block of its bidding window. Whoever builds that block can also place the top bid in it. They can try many versions of the block, check the move for each with `previewMove`, and publish the one whose move pays them the most. That breaks the contract's own promise that nobody can know a move while it can still be bid for. |\n| Low | `src/IMDuelHook.sol:536` | **Bounties can go to the wrong person.** If a swap carries no owner address in its hook data, the owner defaults to `tx.origin`. When a bundler, solver or aggregator submits the trade, that relayer gets the bounty instead of the trader who paid. |\n| Low | `src/IMDuelHook.sol:434` | **Some protocol fees are locked forever.** In pools pairing two tokens that are not IMD, $IMDUEL or ETH, the 0.1% protocol share is still charged. It can never be converted, and no function can move it out. |\n\nThe access-control inventory found no unguarded entry points:\n- **Hook callbacks** (pool setup, before/after swap) and the unlock callback accept only the PoolManager.\n- **`payOut` and `convertBatch`** accept only the hook itself.\n- **`withdraw`** always sends to the address that is owed.\n- **`fundChest`** only pulls tokens from the caller.\n- **Re-entering** `withdraw`, `fundChest` or `convert` during a swap fails, because the PoolManager is already unlocked.\n\nThe rule that the first pool becomes the main duel is safe only because the launch factory deploys the hook and opens that pool in one transaction. I treated that as a trust assumption, not a defect. Starving the engine, payout or conversion calls of gas does not get around anything: the trade reverts or the payout is kept for later withdrawal.\n\n**Coverage gap:** I reviewed `IMDuelHook` in depth. I covered `ChessEngineV5`, `Rules`, `Chess` and `Strength` only at the permission level: they hold no state and no funds and have no privileged entry points. Their move-legality logic and the `Bitboard` library were not audited.","treeHash":null,"usage":{"cachedInputTokens":803716,"inputTokens":26,"model":"claude-opus-5-5","outputTokens":38761,"runtime":"claude","turns":17,"wallClockMs":452604}}],"verification":[]}