{"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":"6acefe89-1311-462a-8f78-5697050c327d","kind":"audit","nodes":[{"acceptedSubmissionHash":"909708f30a2fe76c09844163030bd3f32c5e2896f79dae1b4a58e7b6d1c83e95","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":"a2d315917278c5969197e191115c98ecbf60883788ab4d45c9ab63b0623d0212","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":"5ec64fefbe63429394fdb76663fa1a4fe0b85d07ddbbf732c175a925ff239c95","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":"fbc376cc5407098470a4859eaef2cdb7f97b9d6676f054749f8bd99287d7cdf7","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":"11223622e7f1e7df6228d6cb9a70f0e33c48b6f43f5e1e086d370178f188ddb6","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 IMDuelHook in this repository, a Uniswap v4 hook in which every buy bids for its team's next chess move, played by the live ChessEngineV5 (launch 2643, audited before; its sources are here for reference). The hook was reworked since the last swarm audit of 4e35c8e (job 3d8885fb-9576-4266-9627-c9fafdddf8ea): a bid's strength comes from its team's pot, not the pool's liquidity; a 0.75% hook fee on every swap, 0.65% into the pot worth less and 0.1% to the protocol; only a checkmate pays: a quarter of the losing team's pot to the owner of the mating move, in the loser's token, capped at twice that move's bid and at twice what the game's trading put into both pots; payouts are recorded and claimed, never sent inside a trade; a move owner is the hookData address, else the signer; a failing engine ends the game as a draw; per-player counts in the main duel only. Check solvency (held claims cover every pot and every owed payout), the three prize caps, the claim path, bid ownership and replacement inside a block, and anything a dust game, a side duel or several wallets can farm.","parentJobId":null,"planHash":"4ccd2a9a81e280b3e58a8b8f99291678862ba8599be586681c41c61623648332","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"6acefe89-1311-462a-8f78-5697050c327d","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":"52642","feedbackHash":"00c195d4055681677c823d52b821743323bd8f7e88c9ead5f4611b0373e911d2","nodeKey":"audit_economics","submissionHash":"909708f30a2fe76c09844163030bd3f32c5e2896f79dae1b4a58e7b6d1c83e95","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51128","feedbackHash":"15c53f92f43c3ffbfca2295d5e06a050c8b28384c543670a7bda8a368a277ab9","nodeKey":"audit_flow","submissionHash":"a2d315917278c5969197e191115c98ecbf60883788ab4d45c9ab63b0623d0212","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51071","feedbackHash":"33fc553c0a1966a63e8de27f971771ce9c49335cd00e8158ed90278e3369dcc8","nodeKey":"audit_judge","submissionHash":"5ec64fefbe63429394fdb76663fa1a4fe0b85d07ddbbf732c175a925ff239c95","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51486","feedbackHash":"000b7bc8421fa3e2a7add9107f40ec1c36260e1cc37c84c2caf5d36614fb9084","nodeKey":"audit_math","submissionHash":"fbc376cc5407098470a4859eaef2cdb7f97b9d6676f054749f8bd99287d7cdf7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51018","feedbackHash":"13609b4305c7461a53513dfb2f2ff6d6aa88cbb148a552caa71924eedfced51a","nodeKey":"audit_permissions","submissionHash":"11223622e7f1e7df6228d6cb9a70f0e33c48b6f43f5e1e086d370178f188ddb6","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"284b70e0900fa07da9faaf077cd22c5f9b0428d7ec3fa2f271d5c98566dff5f2","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"30a6c1a419ef4f9c","findings":[{"citation":"resolved","description":"MATE_INFLOW_CAP is the rule that 'a game played for dust pays nothing': a mate pays at most twice what the game's trading put into both pots. _inflowIn converts the winner's pot's inflow into the loser's token at the pool's spot sqrt price read by getSlot0 at the moment _payMate runs, i.e. after the mating bid's own buy and after the resolving trade have moved it, and the comment above it claims a pushed price 'gains nothing'. Spot can be pushed by whoever trades, and the conversion is quadratic in the sqrt price, so a few thousand wei of inflow in the winner's token is worth an arbitrary amount of the loser's token once the winner's token is made expensive. The caller class that can do this for free is any LP who controls a pool's liquidity: side duels are permissionless (afterInitialize accepts any pool pairing IMD or $IMDUEL) and their pots hold other traders' hook fees, which the LP cannot otherwise touch. The sole LP of a thin side duel bids with a large buy (its input returns to it as its own liquidity, so its cost is the 0.1% slice plus 0.65% of a dust output), that buy pushes the price by ~1e12 (sqrt), and the next trade resolves the mate and pays min(quarter of the loser's pot, 2x bid) instead of ~2x dust. Repeatable every game until the pot is drained a quarter at a time. In the main duel the same push costs round-trip fees on real liquidity, so there it only weakens the cap by the attacker's price impact, but the cap is still not what the spec states. A second, cheaper channel feeds the same cap: _takeFee in the resolving trade's beforeSwap/afterSwap adds that trade's 0.65% to gameIn under the ended game's number (g.gameNo is incremented only later in _play), so the resolver's own volume raises the cap of the game it ends. Fix: value the other pot's inflow at a price the mating side cannot set in the same game, e.g. a sqrt price recorded in _newGame (start of game) or the per-block sample the main duel already keeps (first observation of the block), or drop the cross-valuation and cap by the loser's own pot inflow only; and credit a trade's fee to the game its bid is for (increment gameNo before the resolving trade's fee is recorded, or record the fee in _play after _resolve).","line":382,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\n\n/// The hook with one test-only setter: puts a chosen position on a pool's board (a forced mate in one for the side\n/// to move), so the real bid / resolve / _payMate path can be driven to a mate.\ncontract Probe is IMDuelHook {\n    constructor(IPoolManager m, IChessEngine e, Currency imd_, uint256 a, uint256 b, uint256 t)\n        IMDuelHook(m, e, imd_, a, b, t)\n    {}\n\n    function setPosition(PoolId id, uint256 board, uint32 white, uint32 black, uint16 ply, uint16 quiet) external {\n        Game storage g = games[id];\n        (g.board, g.whiteState, g.blackState, g.ply, g.quiet) = (board, white, black, ply, quiet);\n    }\n}\n\n/// Finding: _inflowIn values the other pot's inflow at the pool's spot price read inside _payMate, after the trades\n/// that led to the mate moved it. In a thin pool (a side duel whose only LP is the bidder) the mating bid's own buy\n/// moves the price by many orders of magnitude, so a game whose trading put a few thousand wei into the pots pays\n/// the full 2x-bid prize out of the other team's pot.\ncontract InflowCapProof is Test {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n\n    IPoolManager manager;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    Probe hook;\n    Currency imd;\n    Currency imduel;\n    Currency side; // the side duel's own token\n\n    function sq(string memory s) internal pure returns (uint16) {\n        bytes memory b = bytes(s);\n        return uint16(uint8(b[0]) - 97) + 8 * uint16(uint8(b[1]) - 49);\n    }\n\n    function put(uint256 board, string memory s, uint256 piece) internal pure returns (uint256) {\n        return board | (piece << (uint256(sq(s)) << 2));\n    }\n\n    function sideState(string memory k) internal pure returns (uint32) {\n        return 0x80800000 | (uint32(sq(k)) << 8) | 0xff;\n    }\n\n    function token(string memory n) internal returns (Currency) {\n        MockERC20 t = MockERC20(vm.deployCode(\"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol:MockERC20\",\n            abi.encode(n, n, uint8(18))));\n        t.mint(address(this), 2 ** 200);\n        t.approve(address(swapRouter), type(uint256).max);\n        t.approve(address(lpRouter), type(uint256).max);\n        return Currency.wrap(address(t));\n    }\n\n    function key(Currency a, Currency b) internal view returns (PoolKey memory) {\n        (Currency c0, Currency c1) = a < b ? (a, b) : (b, a);\n        return PoolKey(c0, c1, 3000, 60, IHooks(address(hook)));\n    }\n\n    function setUp() public {\n        manager = IPoolManager(vm.deployCode(\"PoolManager.sol:PoolManager\", abi.encode(address(this))));\n        swapRouter = PoolSwapTest(vm.deployCode(\"PoolSwapTest.sol:PoolSwapTest\", abi.encode(manager)));\n        lpRouter = PoolModifyLiquidityTest(vm.deployCode(\"PoolModifyLiquidityTest.sol:PoolModifyLiquidityTest\", abi.encode(manager)));\n        imd = token(\"IMD\");\n        imduel = token(\"IMDUEL\");\n        side = token(\"SIDE\");\n        IChessEngine engine = IChessEngine(vm.deployCode(\"ChessEngineV5.sol:ChessEngineV5\"));\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));\n        deployCodeTo(\"InflowCapProof.t.sol:Probe\",\n            abi.encode(manager, engine, imd, type(uint256).max, type(uint256).max, uint256(1)), at);\n        hook = Probe(at);\n        MockERC20(Currency.unwrap(imd)).approve(address(hook), type(uint256).max);\n        MockERC20(Currency.unwrap(side)).approve(address(hook), type(uint256).max);\n        // the main duel must exist first (every later duel routes to it)\n        manager.initialize(key(imd, imduel), SQRT_PRICE_1_1);\n    }\n\n    function test_aDustGameInAThinSideDuelPaysTheFullTwiceTheBid() public {\n        // The side duel SIDE / IMD, opened by the attacker, who is its only LP with a dust position.\n        PoolKey memory k = key(side, imd);\n        PoolId sid = k.toId();\n        manager.initialize(k, SQRT_PRICE_1_1);\n        lpRouter.modifyLiquidity(k, ModifyLiquidityParams(-887220, 887220, 1e6, 0), \"\");\n\n        // The pots: 100 IMD that earlier traders' fees left in IMD's pot (a donation stands in for it: donations\n        // don't count as this game's inflow, so this game is a dust game), 1 SIDE in SIDE's pot.\n        hook.fundChest(k, k.currency1 == imd, 100e18);\n        hook.fundChest(k, k.currency1 == side, 1e18);\n        uint256 sideTeam = k.currency0 == side ? 0 : 1;\n        uint256 imdTeam = 1 - sideTeam;\n        assertEq(hook.chest(sid, imdTeam), 100e18);\n\n        // Game 0: SIDE's team (the token people buy) plays White. White to move, one legal move, and it mates.\n        uint256 b;\n        b = put(put(put(put(put(put(b, \"c8\", 6), \"h8\", 4), \"a8\", 14), \"a7\", 9), \"b7\", 9), \"d7\", 13);\n        hook.setPosition(sid, b, sideState(\"c8\"), sideState(\"a8\"), 0, 0);\n\n        // The attacker bids for White with 1 IMD. In a pool with 1e6 of liquidity that buy moves the price by ~1e12\n        // (sqrt); it is the only trade of this game. Its pot fee is a few thousand wei of SIDE.\n        bool zeroForOne = k.currency0 == imd;\n        swapRouter.swap(k, SwapParams(zeroForOne, -int256(1e18), zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false), abi.encodePacked(address(this)));\n        (uint256 in0, uint256 in1) = hook.gameInflow(sid, 0);\n        assertLt(in0 + in1, 1e5, \"the game's whole inflow is dust (wei)\");\n\n        // Next block: a dust trade by anyone plays the move (the mate) and pays it.\n        vm.warp(vm.getBlockTimestamp() + 12);\n        vm.roll(vm.getBlockNumber() + 1);\n        swapRouter.swap(k, SwapParams(zeroForOne, -int256(1000), zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false), \"\");\n        assertEq(hook.game(sid).gameNo, 1, \"mate: the game ended\");\n        (in0, in1) = hook.gameInflow(sid, 0);\n\n        uint256 prize = hook.owed(address(this), imd.toId());\n        emit log_named_uint(\"game inflow (wei, both pots)\", in0 + in1);\n        emit log_named_uint(\"prize recorded in IMD (wei)\", prize);\n        // Expected: at most MATE_INFLOW_CAP (2) times what the game's trading put into the pots, the two tokens\n        // valued at the 1:1 price they traded at (a factor 2 of slack for any sane valuation). Actual: the 2x-bid\n        // cap, 2 IMD, out of a pot this game put nothing into.\n        assertLe(prize, 4 * (in0 + in1), \"a dust game pays at most twice its inflow\");\n    }\n}","reproduction":"test/scratch/InflowCapProof.t.sol (below). Side duel SIDE/IMD opened by the attacker with a single full-range position of liquidity 1e6; IMD pot holds 100e18 from earlier traders (fundChest stands in: donations don't count as inflow, so the game is a dust game), SIDE pot 1e18. Position: White (SIDE's team, game 0) to move with one legal move, Kc8xd7, which mates. Block N: attacker swaps 1e18 IMD exact-input for SIDE with hookData = its address: gameInflow(sid,0) = 6,505 wei total (SIDE fee on the dust output), and the swap moves sqrtPrice by ~1e12. Block N+1: any 1,000 wei trade resolves: engine plays Kxd7, mate, _payMate computes _inflowIn = in_SIDE * price^2 ~ 6.5e27, so amount = min(25e18, 2 * 0.999e18, 6.5e27 * 2) = 1,998,000,000,000,000,000 wei of IMD recorded as owed to the attacker. Expected per the spec and the comment at line 379: at most 2 * 6,505 wei. Observed: 1.998e18 (test output: 'prize recorded in IMD (wei): 1998000000000000000', assertion fails 1998000000000000000 > 26020). The attacker then withdraws and removes its liquidity to recover the 1e18 it paid.","severity":"high","snippet":"        (uint160 sqrtPrice,,,) = manager.getSlot0(id);\n        return loser == 0\n            ? in0 + FullMath.mulDiv(FullMath.mulDiv(in1, 1 << 96, sqrtPrice), 1 << 96, sqrtPrice)\n            : in1 + FullMath.mulDiv(FullMath.mulDiv(in0, sqrtPrice, 1 << 96), sqrtPrice, 1 << 96);","title":"Mate inflow cap is valued at the manipulable spot price read inside _payMate, so a dust game in a thin pool pays the full 2x-bid prize"},{"citation":"resolved","description":"_resolve asks _hashOf for the window's last block hash; once that block is older than 8,191 blocks (about a day on Ethereum) the EIP-2935 history contract no longer serves it and h == 0. The hook then opens a fresh window with closeBlock = block.number and calls _bid with Trade(0, address(0), 0), which overwrites g.bids[mover].owner, strength and size. The bidder's trade already paid its fee and its size is the only input to the PAYOUT_CAP and to _choose's seed, yet nothing of it survives: the move is played at 0% for nobody and if it mates _payMate returns at `t.owner == address(0)`. The branch is asymmetric with every other resolution path, which keeps the bid's fields until the move is played, and with the timeout path, which only creates a nobody move when no bid is open. The bidder itself cannot avoid it except by trading again (its own trade in that block runs _resolve first, so it must pay a second bid to re-own the move). Fix: keep the open bid and only re-open the window (`g.closeBlock = uint40(block.number); return;`), so the hash of the new window's last block seeds the same bid; or, if a day-old bid is meant to lapse, document it in the Bid/Moved events and the contract notes rather than in the fallback path only.","line":650,"path":"src/IMDuelHook.sol","reproduction":"Main duel (test/Base.sol setUpMainDuel, turnBlocks 1), both pots 100e18, position: White to move, one legal move (Kc8xd7) and it mates. Block N: White's team trades 1e18 with hookData 0xB1D: bid().owner == 0xB1D, bid().size == 0.9925e18, open. No trade for 9,000 blocks. Block N+9000: any trade of the other team. Expected: the move 0xB1D paid for is played (or at least still owned by 0xB1D) with a fresh window. Actual (test/scratch/ExpiredHash.t.sol, passes on this code): bid().owner == address(0), bid().size == 0; the next block's trade plays the mate with moveOwner == address(0), MatePaid is not emitted and owed[0xB1D][loser token] == 0.","severity":"low","snippet":"            return _bid(id, g, mover, g.ply, Trade(0, address(0), 0));","title":"A move whose window hash is no longer served discards the paid top bid: owner and size are overwritten by a nobody move"},{"citation":"resolved","description":"beforeSwap and afterSwap record a trade's 0.65% pot fee under g.gameNo before _play runs; when that trade is the one whose _resolve plays a mate, g.gameNo is still the ending game's number, so its fee counts as 'what the game's trading put into the pots' and _payMate (reading gameInflow(id, g.moveGameNo) right after) sees it. The trade's bid, however, is for the next game. Whoever resolves a mating move it owns can therefore lift the inflow cap of a dust game by 2 * 0.65% of its resolving volume. Alone this costs more than it yields (a 2% total fee buys a 1.3% cap raise), but it is the cheaper half of the pair with the spot-price valuation (finding above) and makes the stated cap ('twice what the game's trading put in') untrue even in a deep pool. Fix: in _play, run _resolve before the fee is attributed to a game (e.g. record the fee's game number after _resolve, or when the trade ends a game move its fee from gameIn[ended] to the new game's slot), or increment gameNo before the resolving trade's _takeFee.","line":336,"path":"src/IMDuelHook.sol","reproduction":"Main duel, pots 100e18 each, game inflow reset to 0, forced mate for Black (test/MatePot.t.sol test_aShortGamePaysTwiceItsOwnFees shows the mechanism: 'plays the mate; its own fee counts too'). Black bids 1e18 (inflow 6.5e15), next block White resolves with an exact-input trade of 1e21 instead of 1e18. Expected cap: 2 * 6.5e15 = 1.3e16 (the mating game's only trade). Actual: the resolving trade's 6.5e18 pot fee is added under the ended game's number, so gameInflow(id, 0) ~ 6.5e18 and the prize is min(25e18, 2*0.9925e18, ~1.3e19) = 1.985e18, about 150x the cap the game's own trading supports.","severity":"low","snippet":"        if (toChest != 0) _addInflow(id, g.gameNo, idx, toChest);","title":"The resolving trade's pot fee is credited to the game it ends, so the resolver's own volume raises that game's MATE_INFLOW_CAP"},{"citation":"resolved","description":"withdrawTo exists 'for an owner that can't receive them itself'. For a recipient that reverts (no receive(), a blocklisted address) the public push also reverts, so no harm. But a contract owner that accepts transfers yet has no way to move them (a router or paymaster credited through tx.origin-less relays, or named by mistake in hookData) loses the reward the moment a third party calls withdraw(owner, c) ahead of its withdrawTo. The design comment accepts rewards being pushed by anyone; this is recorded as an asymmetry between the two claim paths rather than a vulnerability. If it matters, let an owner opt out of third-party pushes (a per-owner flag set by the owner, or require msg.sender == who unless who is an EOA).","line":394,"path":"src/IMDuelHook.sol","reproduction":"Owner = a contract C with no token-transfer function, credited by hookData = abi.encodePacked(C) on a mating bid; owed[C][token] = X. Anyone calls withdraw(C, token): X - 1 wei of the token is transferred to C and is unrecoverable. Had C called withdrawTo(token, treasury) first, it would have received the reward at treasury.","severity":"info","snippet":"    function withdraw(address who, Currency c) external {\n        _withdraw(who, c, who);\n    }","title":"withdraw(who, c) lets anyone push an owner's reward to it, defeating withdrawTo for owners that can receive but not forward tokens"},{"citation":"resolved","description":"Privileged surfaces found, none a bypass: (1) the engine is immutable and unchecked in the constructor; with the _endByEngine fallback added since 4e35c8e, an engine address without code (or a wrong one) makes _engine return (false, ...) for both calls, _enoughGas passes, and the first resolution of every game ends it as a draw (reason 6) with the paid bid lost, so the comment 'just waits' no longer describes the behaviour; launch/hook.json carries the engine 0xc21788f7... matching launch/deployed.json, so this is an operational trust assumption, not a defect in the deployed path. (2) The first pool initialized through the hook becomes the main duel (afterInitialize, imduel set once): this relies on the factory deploying the hook and opening the IMD pool in one transaction; a hook deployed in an earlier transaction could have its main duel chosen by anyone pairing IMD with a token of theirs. (3) Owner = hookData address else tx.origin credits bundlers/relayers for smart-wallet trades (documented). (4) The builder of a window's last block can grind the dice (documented). (5) Anyone can fund any pot and open side duels; side duels are isolated from the main duel's pots except that their slices feed the main $IMDUEL pot (verified: _convert only credits mainId). No role can pause, upgrade, or move pots; held claims equal chests + piles + owed on every path reviewed (fees mint what they book, _payMate moves chest to owed, _withdraw burns what it takes, _convert burns spent and mints bought).","line":204,"path":"src/IMDuelHook.sol","reproduction":"Deploy the hook with engine_ = an address with no code (the constructor accepts it), open a pool, bid, next block trade: _engine's staticcall returns ok with empty data, result.length != 224, both calls fail, _endByEngine runs: DrawTriggered(reason 6), GameOver(3), gameNo++ and the bid is closed unpaid. Expected per the comment at line 204: the trade 'just waits'.","severity":"info","snippet":"        // (hook job f7f2a606); its address is checked off chain, and a trade with no engine to answer just waits","title":"Trust assumptions and a stale constructor note: an engine address without code ends every game as a draw, not 'waits'"}],"hash":"11223622e7f1e7df6228d6cb9a70f0e33c48b6f43f5e1e086d370178f188ddb6","nodeId":"2f777e10-6461-4dfc-b89f-acf2652e8955","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds five findings, all with their path, line and snippet verified against the tree. No source or configuration file was changed; only the two scratch tests under `test/scratch/` were written.\n\n**Outcome: one High, two Low, two Info.** The permission surface itself is tight: every callback checks the manager, `convertBatch` checks self, `unlockCallback` only runs for the hook's own unlocks, the hook's own swaps skip the callbacks in the vendored Hooks library, and the claim path burns exactly what it takes. The material defect sits where access, economics and asymmetry meet: a caller class the guards allow can set the price the prize cap is valued at.\n\n- **High, inflow cap valued at manipulable spot.** `_inflowIn` converts the winner's pot inflow at the pool's spot price read inside `_payMate`, after the mating bid's own buy moved it, and quadratically in the sqrt price. The sole LP of a permissionless side duel bids with one big buy on dust liquidity, the price moves by about 1e12, and the next trade pays the full 2x-bid prize from other traders' pot. The proof test shows a game with 6,505 wei of total inflow paying 1.998 IMD. It fails on this code and asserts the documented bound, so any sane re-valuation makes it pass.\n- **Low, expired hash erases a paid bid.** After 8,191 silent blocks `_resolve` overwrites the mover's owner and size with a nobody move. Confirmed with a scratch test: the forced mate is played for nobody and the bidder is owed nothing.\n- **Low, resolver's fee counted to the ended game.** The resolving trade's pot fee lands under the old game number, so the resolver's own volume lifts the cap of the game it ends. Unprofitable alone, but it pairs with the High.\n- **Info, `withdraw(who)` push by anyone** defeats `withdrawTo` only for owners that accept but cannot forward tokens.\n- **Info, trust assumptions.** The engine address is immutable and unchecked, and with the new `_endByEngine` fallback a codeless engine ends every game as a draw rather than \"waits\" as the constructor comment says. The main-duel selection relies on same-transaction deploy and init. Solvency held on every path reviewed.\n\n**Not covered in depth:** the engine's move choice, the strength curve's rounding, and the conversion-batch price band were read but not tested, since they fall outside the assigned permissions area.","treeHash":null,"usage":{"cachedInputTokens":1942457,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":64886,"runtime":"claude","turns":36,"wallClockMs":1206351}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"39da99ded7f125c8","findings":[{"citation":"resolved","description":"Merged from all four specialists (audit_math, audit_economics, audit_flow and audit_permissions reported the same root cause; both attached proofs were run and fail as stated). _payMate (line 797) caps a prize at MATE_INFLOW_CAP times what the game's trading put into both pots; _inflowIn (lines 380-386) converts the winner's pot's inflow into the loser's token at manager.getSlot0(id) read inside _payMate. _payMate runs inside afterSwap of the trade that plays the mating move (_play -> _resolve -> _apply -> _payMate), i.e. after that swap and the bid's own swap have moved the pool price. The conversion is quadratic in the sqrt price, so making the winner's token dear in the loser's token multiplies the valued inflow without bound; only the quarter and the 2x-bid cap remain, and the bid is the attacker's own capital. The comment at line 379 (\"a pushed price gains nothing\") is wrong: the cap exists so that \"a game played for dust pays nothing\" (line 790), and the player who resolves the mate chooses the price it is valued at. Cheapest actor: the sole LP of a side duel (afterInitialize is permissionless for any pool pairing IMD or $IMDUEL), whose own buy through thin liquidity moves sqrtPrice by ~1e12 for a cost of the 0.1% slice plus 0.65% of a dust output, and whose LP fee returns to itself; it drains a quarter of a pot other traders funded, every game, for about twice its bid. In the main duel the push costs round-trip fees on the seed liquidity (profitable once a quarter of the loser's pot is about a tenth of the in-range liquidity, and pots accumulate 0.65% of all-time volume), and even an unprofitable push lets the resolving trade raise the cap by its own price impact (verified with the live ChessEngineV5: a 10,000,000e18 resolving buy on 10,000,000e18 full-range liquidity turned a 66,532e18 cap into a 200,000e18 prize). Fix (keeps the design): value the other pot's inflow at a price the resolving trade cannot set, e.g. the sqrtPrice beforeSwap already reads (stash it in transient slot 1 next to chestIdx and pass it to _payMate), or the block-start sample for the main duel plus a per-pool start-of-block sample for side duels; or take the lesser of the pre- and post-swap valuations; or drop the cross valuation and cap at MATE_INFLOW_CAP times the loser's pot's own inflow.","line":382,"path":"src/IMDuelHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\n\n/// The hook with one test-only setter: puts a chosen position on a pool's board (a forced mate in one for the side\n/// to move), so the real bid / resolve / _payMate path can be driven to a mate.\ncontract Probe is IMDuelHook {\n    constructor(IPoolManager m, IChessEngine e, Currency imd_, uint256 a, uint256 b, uint256 t)\n        IMDuelHook(m, e, imd_, a, b, t)\n    {}\n\n    function setPosition(PoolId id, uint256 board, uint32 white, uint32 black, uint16 ply, uint16 quiet) external {\n        Game storage g = games[id];\n        (g.board, g.whiteState, g.blackState, g.ply, g.quiet) = (board, white, black, ply, quiet);\n    }\n}\n\n/// Finding: _inflowIn values the other pot's inflow at the pool's spot price read inside _payMate, after the trades\n/// that led to the mate moved it. In a thin pool (a side duel whose only LP is the bidder) the mating bid's own buy\n/// moves the price by many orders of magnitude, so a game whose trading put a few thousand wei into the pots pays\n/// the full 2x-bid prize out of the other team's pot.\ncontract InflowCapProof is Test {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n\n    IPoolManager manager;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    Probe hook;\n    Currency imd;\n    Currency imduel;\n    Currency side; // the side duel's own token\n\n    function sq(string memory s) internal pure returns (uint16) {\n        bytes memory b = bytes(s);\n        return uint16(uint8(b[0]) - 97) + 8 * uint16(uint8(b[1]) - 49);\n    }\n\n    function put(uint256 board, string memory s, uint256 piece) internal pure returns (uint256) {\n        return board | (piece << (uint256(sq(s)) << 2));\n    }\n\n    function sideState(string memory k) internal pure returns (uint32) {\n        return 0x80800000 | (uint32(sq(k)) << 8) | 0xff;\n    }\n\n    function token(string memory n) internal returns (Currency) {\n        MockERC20 t = MockERC20(vm.deployCode(\"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol:MockERC20\",\n            abi.encode(n, n, uint8(18))));\n        t.mint(address(this), 2 ** 200);\n        t.approve(address(swapRouter), type(uint256).max);\n        t.approve(address(lpRouter), type(uint256).max);\n        return Currency.wrap(address(t));\n    }\n\n    function key(Currency a, Currency b) internal view returns (PoolKey memory) {\n        (Currency c0, Currency c1) = a < b ? (a, b) : (b, a);\n        return PoolKey(c0, c1, 3000, 60, IHooks(address(hook)));\n    }\n\n    function setUp() public {\n        manager = IPoolManager(vm.deployCode(\"PoolManager.sol:PoolManager\", abi.encode(address(this))));\n        swapRouter = PoolSwapTest(vm.deployCode(\"PoolSwapTest.sol:PoolSwapTest\", abi.encode(manager)));\n        lpRouter = PoolModifyLiquidityTest(vm.deployCode(\"PoolModifyLiquidityTest.sol:PoolModifyLiquidityTest\", abi.encode(manager)));\n        imd = token(\"IMD\");\n        imduel = token(\"IMDUEL\");\n        side = token(\"SIDE\");\n        IChessEngine engine = IChessEngine(vm.deployCode(\"ChessEngineV5.sol:ChessEngineV5\"));\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));\n        deployCodeTo(\"InflowCapProof.t.sol:Probe\",\n            abi.encode(manager, engine, imd, type(uint256).max, type(uint256).max, uint256(1)), at);\n        hook = Probe(at);\n        MockERC20(Currency.unwrap(imd)).approve(address(hook), type(uint256).max);\n        MockERC20(Currency.unwrap(side)).approve(address(hook), type(uint256).max);\n        // the main duel must exist first (every later duel routes to it)\n        manager.initialize(key(imd, imduel), SQRT_PRICE_1_1);\n    }\n\n    function test_aDustGameInAThinSideDuelPaysTheFullTwiceTheBid() public {\n        // The side duel SIDE / IMD, opened by the attacker, who is its only LP with a dust position.\n        PoolKey memory k = key(side, imd);\n        PoolId sid = k.toId();\n        manager.initialize(k, SQRT_PRICE_1_1);\n        lpRouter.modifyLiquidity(k, ModifyLiquidityParams(-887220, 887220, 1e6, 0), \"\");\n\n        // The pots: 100 IMD that earlier traders' fees left in IMD's pot (a donation stands in for it: donations\n        // don't count as this game's inflow, so this game is a dust game), 1 SIDE in SIDE's pot.\n        hook.fundChest(k, k.currency1 == imd, 100e18);\n        hook.fundChest(k, k.currency1 == side, 1e18);\n        uint256 sideTeam = k.currency0 == side ? 0 : 1;\n        uint256 imdTeam = 1 - sideTeam;\n        assertEq(hook.chest(sid, imdTeam), 100e18);\n\n        // Game 0: SIDE's team (the token people buy) plays White. White to move, one legal move, and it mates.\n        uint256 b;\n        b = put(put(put(put(put(put(b, \"c8\", 6), \"h8\", 4), \"a8\", 14), \"a7\", 9), \"b7\", 9), \"d7\", 13);\n        hook.setPosition(sid, b, sideState(\"c8\"), sideState(\"a8\"), 0, 0);\n\n        // The attacker bids for White with 1 IMD. In a pool with 1e6 of liquidity that buy moves the price by ~1e12\n        // (sqrt); it is the only trade of this game. Its pot fee is a few thousand wei of SIDE.\n        bool zeroForOne = k.currency0 == imd;\n        swapRouter.swap(k, SwapParams(zeroForOne, -int256(1e18), zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false), abi.encodePacked(address(this)));\n        (uint256 in0, uint256 in1) = hook.gameInflow(sid, 0);\n        assertLt(in0 + in1, 1e5, \"the game's whole inflow is dust (wei)\");\n\n        // Next block: a dust trade by anyone plays the move (the mate) and pays it.\n        vm.warp(vm.getBlockTimestamp() + 12);\n        vm.roll(vm.getBlockNumber() + 1);\n        swapRouter.swap(k, SwapParams(zeroForOne, -int256(1000), zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false), \"\");\n        assertEq(hook.game(sid).gameNo, 1, \"mate: the game ended\");\n        (in0, in1) = hook.gameInflow(sid, 0);\n\n        uint256 prize = hook.owed(address(this), imd.toId());\n        emit log_named_uint(\"game inflow (wei, both pots)\", in0 + in1);\n        emit log_named_uint(\"prize recorded in IMD (wei)\", prize);\n        // Expected: at most MATE_INFLOW_CAP (2) times what the game's trading put into the pots, the two tokens\n        // valued at the 1:1 price they traded at (a factor 2 of slack for any sane valuation). Actual: the 2x-bid\n        // cap, 2 IMD, out of a pot this game put nothing into.\n        assertLe(prize, 4 * (in0 + in1), \"a dust game pays at most twice its inflow\");\n    }\n}","reproduction":"Side duel SIDE/IMD opened by the attacker, one full-range position of liquidity 1e6, IMD pot 100e18 (earlier traders' fees, set with fundChest: donations do not count as inflow, so the game is a dust game), SIDE pot 1e18; game 0, White (SIDE's team) to move in a position whose only legal move Kc8xd7 mates. Block N: attacker swaps 1e18 IMD exact-input for SIDE with hookData = attacker: gameInflow(sid, 0) = 6,505 wei in total and sqrtPrice moves by ~1e12. Block N+1: a 1,000 wei trade resolves; _payMate computes amount = min(100e18/4, 2 * 0.999e18, 2 * _inflowIn) where _inflowIn values the 6,505 wei of SIDE at the pushed price (~6.5e27). Expected (line 790 and the comment at line 379): at most 2 * 6,505 = 13,010 wei of IMD. Actual: owed[attacker][IMD] = 1,998,000,000,000,000,000 wei, out of a pot this game put nothing into. Run: forge test --match-path test/scratch/InflowCapProof.t.sol (fails on this code: \"1998000000000000000 > 26020\"). The main-duel variant (audit_math's proof, run as test/scratch/InflowSpotPrice.t.sol) fails too: loser pot 1,000,000e18, bid 100,000e18, resolving buy 10,000,000e18 on 10,000,000e18 liquidity; prize 200,000e18 against an intended cap of 66,532e18 at the price the game was played at.","severity":"high","snippet":"        (uint160 sqrtPrice,,,) = manager.getSlot0(id);","title":"Mate inflow cap values the other pot's inflow at the spot price after the resolving swap, so a dust game pays the full quarter / 2x-bid prize once the price is pushed"},{"citation":"resolved","description":"Merged from audit_math, audit_economics and audit_flow (same arithmetic). The inflow cap is the only prize cap tied to what a game cost its players (the quarter is a share of other people's pot, the 2x-bid cap only needs a bid of an eighth of the pot). A game's pot inflow is CHEST_BPS = 0.65% of its swap volume V (line 335), so a mate may pay MATE_INFLOW_CAP * 0.65% V = 1.3% V, while the hook charges the trader CHEST_BPS + SLICE_BPS = 0.75% V. The remaining cost, the 1.25% LP fee (launch pool fee 12500), is paid only by a trader who is not the LP: Uniswap v4 LP fees go pro rata to in-range liquidity, so the sole LP of a side duel (permissionless to open) recovers all of it and a concentrated position at the current tick in the main duel recovers most of it. Such a player, trading a whole game against nobody (the other side times out at 0% every TURN_TIMEOUT, its own side bids at 90% strength) or against its second wallet, pays 0.75% V and takes back up to 1.3% V from a pot that other traders funded: +0.55% V per game, with the pot losing 0.65% V. With a single resolving trade of the needed volume at the mate (see the low finding on line 336) no long game is even needed. The stated purpose at line 790 (\"the pot can't be harvested by fast games\") holds only while the farmer pays the LP fee. This is a parameter decision for the requester: the constant satisfies the design only if MATE_INFLOW_CAP * CHEST_BPS <= CHEST_BPS + SLICE_BPS (i.e. 1x), or the cap counts only the loser's pot's own inflow, or the LP-fee-independent slice is the bound.","line":75,"path":"src/IMDuelHook.sol","reproduction":"Constants read from the code: CHEST_BPS 65, SLICE_BPS 10, MATE_INFLOW_CAP 2, MATE_SHARE_BPS 2500, PAYOUT_CAP 2. State: a pool whose loser pot holds P = 100e18 from earlier trading; the farmer is the dominant in-range LP (sole LP of a side duel). Inputs: the farmer's swaps in one game total V = 1920e18 (bids for its own moves, the other side timing out), the mating bid 12.5e18, then the mate. gameInflow sums to 0.65% * 1920e18 = 12.48e18 over both pots, so _payMate pays min(25e18, 2 * 12.5e18, 2 * 12.48e18) = 24.96e18. The farmer's hook fees were 0.75% * 1920e18 = 14.4e18 (12.48e18 of which landed in the pots, 1.92e18 in the slice) and its LP fees returned to itself. Expected under the cap's stated purpose: a game the farmer alone traded pays it no more than it put in. Actual: +10.56e18 net per game out of the pot other traders built. The existing harness path shows the payout directly: test/MatePot.t.sol mateAgainstIn(loser, owner, 12.5e18, in0, in1) with in0 + in1 = 12.48e18 records 24.96e18 owed.","severity":"medium","snippet":"    uint256 public constant MATE_INFLOW_CAP = 2; // and at most twice what the game's trading put into the pots","title":"MATE_INFLOW_CAP of 2x pays up to 1.3% of a game's volume against a 0.75% hook fee, so a player who is also the in-range LP farms other traders' pots at a profit"},{"citation":"resolved","description":"From audit_permissions; confirmed by reading the call order. beforeSwap (line 299) and afterSwap (line 317) call _takeFee, which records the 0.65% pot fee under g.gameNo (line 336), before _play (line 322) runs _resolve. When that trade plays a mating move, g.gameNo is still the ending game's number, so the resolver's fee counts as \"what the game's trading put into the pots\" and _payMate reads it at once through gameInflow(id, g.moveGameNo). The trade's own bid, however, is for the next game. Whoever resolves a mate it owns (or a second wallet) therefore raises the dust-game cap by 1.3% of its resolving volume for a 0.75% hook fee plus the LP fee; alone that is a loss for an ordinary trader but it is the cheap lever behind the medium finding above (one large resolving trade replaces a long game's volume), and it makes the cap \"twice what the game's trading put in\" untrue even in a deep pool. The project's own test test/MatePot.t.sol::test_aShortGamePaysTwiceItsOwnFees documents the behaviour (\"plays the mate; its own fee counts too\"), so the requester may consider it intended; if so the comment at line 790 should say so. Fix otherwise: attribute the resolving trade's fee to the game its bid is for (record inflow after _resolve in _play, or move the fee from gameIn[ended] to the new game when a trade ends a game).","line":336,"path":"src/IMDuelHook.sol","reproduction":"Main duel (test/Base.sol setUpMainDuel, pots 100e18 each), game inflow 0, Black's only move mates (forcedMate(true)). Block N: Black-team trade of 1e18 with hookData 0xB (pot fee 6.5e15). Block N+1: a White-team exact-input trade of 1e21 resolves. Expected cap: 2 * 6.5e15 = 1.3e16, the mating game's only trade. Actual: the resolving trade's 6.5e18 pot fee is added under game 0 before _play, so gameInflow(id, 0) is about 6.5e18 and the prize is min(25e18, 2 * 0.9925e18, about 1.3e19) = 1.985e18, about 150x what the game's own trading supports. With a 1e18 resolving trade the same path gives about 2 * 1.3e16 (test_aShortGamePaysTwiceItsOwnFees).","severity":"low","snippet":"        if (toChest != 0) _addInflow(id, g.gameNo, idx, toChest);","title":"The resolving trade's pot fee is credited to the game it ends, so the resolver's own volume raises that game's inflow cap"},{"citation":"resolved","description":"Merged from audit_economics (stranding) and audit_permissions (third-party push). withdrawTo refuses address(0), the hook and the manager as destinations (line 402), but withdraw(who, c) sends to `who` itself, and `who` is whatever address a trade named in hookData (_owner, line 622). A mate owned by the PoolManager records owed[manager][c]; anyone then calls withdraw(manager, c): _send burns the hook's ERC-6909 claims and manager.take(c, manager, amount) transfers the token from the manager to itself, so the hook's claims fall while the manager's reserves do not, and the amount belongs to nobody. For the hook as owner the tokens land in the hook's own balance, which has no sweep. The same public push also lets a third party deliver a reward to a contract owner that can receive tokens but not forward them, ahead of its withdrawTo. The loss is the bidder's own choice of owner, so this is hygiene, not theft. Fix: apply withdrawTo's three-address check in withdraw (or in _owner, treating those addresses as none so the signer owns the bid).","line":394,"path":"src/IMDuelHook.sol","reproduction":"Main duel (test/Base.sol setUpMainDuel), both pots 100e18, Black's only move mates. Block N: Black-team trade of 1e18 with hookData = abi.encodePacked(address(manager)). Block N+1: a dust trade resolves the mate: owed[manager][loserToken] = 2e18. Anyone calls withdraw(address(manager), loserToken). Expected: a refused or no-op claim. Actual (test/scratch/Judge.t.sol::test_withdrawToManagerStrands, local): owed drops to 1, manager.balanceOf(hook, id) drops by 1,999,999,999,999,999,999, the manager's ERC-20 balance is unchanged, and no account can claim the 2e18.","severity":"low","snippet":"    function withdraw(address who, Currency c) external {","title":"withdraw(who, c) pushes a reward to any owner, including the PoolManager or the hook, where the tokens are stranded"},{"citation":"resolved","description":"From audit_flow; the mechanism reproduces, the economics are narrow. _reference takes the most hook-favourable start price of the three latest sampled blocks; a block is sampled only when the main duel trades (beforeSwap) or convert() runs, and a 100 wei swap pays no hook fee (65 * 100 / 10000 = 0), so the attacker chooses which blocks are sampled. Pushing the price in block N-1 (that block's sample is pre-push) and dust-swapping in N, N+1, N+2 gives three samples at the pushed price; the trade in N+2 then runs convertBatch, which sells BATCH_MINS * minImd of IMD for about a quarter of the $IMDUEL it would fetch at the pre-push price, and one more batch per following block while the price is held. The contract notes (lines 147-148) treat the two-block hold as the defence; it is a defence only if holders sell into the pushed price in time. Economics: a 4x price push costs ~2% round-trip fees on an input of about the pool's IMD reserves R (0.75% hook + 1.25% LP, 0.75% for an LP), and each batch overpays by ~75% of 10 minImd (100 IMD at launch), so the attack profits only when the pile holds more than about 5% of R worth of batches; batches run inside every trade once the pile reaches minImd, so that pile rarely exists. The loss falls on the $IMDUEL pot (half) and the burn (half). Fix if wanted: require the three samples to come from blocks with at least one fee-paying trade, or use a longer window, or skip a batch when the reference differs from the pre-push sample by more than a band.","line":532,"path":"src/IMDuelHook.sol","reproduction":"Main duel, 1e24 full-range liquidity at 1:1, minImd = 1e18 (a batch spends 10e18). Block 1: trade 30,000e18 IMD for $IMDUEL (pile = 30e18; one sample, no batch). Block 2: sell 1e24 IMD for $IMDUEL: sqrtPrice 7.69e28 -> 3.91e28 (price of $IMDUEL in IMD ~4x); this block's sample is pre-push. Blocks 3, 4, 5: one 100 wei swap each (no fee). Expected (notes at lines 147-148): a pushed price cannot sell the hook $IMDUEL dear. Actual (test/scratch/Judge.t.sol::test_batchReferenceHold, local): the block-5 trade's convertBatch spends 10e18 IMD and buys 2,429,671,842,219,291,286 wei of $IMDUEL (about 9.4e18 at the pre-push price), burning 1.21e18; the next blocks repeat it once each while the price is held.","severity":"low","snippet":"    function _reference(uint256 s, bool zeroForOne) internal view returns (uint256 from) {","title":"A conversion batch buys at a price pushed in an earlier block if the pusher is the only one trading for three sampled blocks (dust swaps sample for free)"},{"citation":"resolved","description":"From audit_permissions; reproduced, kept as information because the contract notes (lines 642-644) and launch/hook.json (\"a hash no longer served gives a 0% timeout turn\") document it. _resolve calls _bid with Trade(0, address(0), 0) when _hashOf returns 0, overwriting g.bids[mover].owner, strength and size although the bidder's trade paid its fee. If the move then mates, _payMate returns at t.owner == address(0) and the bidder gets nothing; its size no longer bounds the prize or seeds the dice. Every other path keeps the bid until it is played. A gentler option that keeps the design: only re-open the window (g.closeBlock = block.number) and keep the bid, so the new window's hash seeds the same bid.","line":650,"path":"src/IMDuelHook.sol","reproduction":"Main duel (test/Base.sol setUpMainDuel, turnBlocks 1), both pots 100e18, White's only move Kc8xd7 mates. Block N: White-team trade 1e18 with hookData 0xB1D: bid().owner == 0xB1D, size 0.9925e18. No trade for 9,000 blocks. Block N+9000: any trade of the other team. Actual (test/scratch/Judge.t.sol::test_expiredHashDiscardsPaidBid, local, where the history contract is absent as it is for a block older than 8,191): bid().owner == address(0), bid().size == 0; the next block's dust trade plays the mate with moveOwner == address(0) and owed[0xB1D][loser token] == 0.","severity":"info","snippet":"            return _bid(id, g, mover, g.ply, Trade(0, address(0), 0));","title":"A window hash no longer served (after 8,191 blocks) discards the paid top bid: owner and size are overwritten by a nobody move (documented behaviour)"},{"citation":"resolved","description":"From audit_economics; a feature interaction of the requested design, not a defect. Strength.fromShare(got, chest(id, team)) scores a buy by its share of its own team's pot (0% below 0.1% of the pot), and fundChest (line 417) lets anyone add to either pot. A player of team A can donate to team B's pot so that B's buys of the usual size play at 0%. The donation stays in B's pot and is reachable only a quarter per mate under the inflow cap, so it is mostly sunk for the donor; in a side duel with small pots it is cheap. Worth a line in the design notes.","line":616,"path":"src/IMDuelHook.sol","reproduction":"Main duel, $IMDUEL pot 1e21. fundChest(pool, imduelIsCurrency1, 1e24) by an IMD-team player, then a $IMDUEL buy receiving 1e21. Before: Strength.fromShare(1e21, 1e21) = 9000 (90%). After: fromShare(1e21, 1.001e24): paid * FLOOR = 1e24 < depth, so up <= down and the strength is 0.","severity":"info","snippet":"        return Trade(paid, _owner(hookData), Strength.fromShare(got, chest(id, team)));","title":"Funding the opponent's pot weakens the opponent: strength is a trade's share of its own pot, and anyone may fund either pot"},{"citation":"resolved","description":"From audit_permissions; trust assumption and a stale comment, not a defect in the deployed path (launch/hook.json carries the audited ChessEngineV5 address). With the _endByEngine fallback added since 4e35c8e, a staticcall to an address without code returns ok with empty data, _engine returns (false, ...) for both calls (result.length != 224), _enoughGas passes, and _resolve runs _endByEngine: DrawTriggered(reason 6), GameOver(3), gameNo++ and the paid bid is closed unpaid. Other privileged surfaces reviewed and found as designed: no owner, pause or upgrade; the first pool opened becomes the main duel (relies on the factory opening it in the deployment transaction); side duels are permissionless and isolated from the main duel's pots except that their slices feed the main $IMDUEL pot; held claims equal chests + piles + owed on every path (fees mint what they book, _payMate moves chest to owed, _withdraw burns what it takes, _convert burns what it spends and mints what it buys).","line":204,"path":"src/IMDuelHook.sol","reproduction":"Deploy the hook with engine_ = an address with no code (the constructor accepts it), open a pool, bid in block N, trade in block N+1. Expected per the comment: the trade \"just waits\". Actual: DrawTriggered(reason 6) and GameOver(3) are emitted, gameNo becomes 1 and the bid is closed without payout.","severity":"info","snippet":"        // (hook job f7f2a606); its address is checked off chain, and a trade with no engine to answer just waits","title":"Stale constructor note: an engine address without code ends every game as a draw at its first resolution, it does not \"wait\""}],"hash":"5ec64fefbe63429394fdb76663fa1a4fe0b85d07ddbbf732c175a925ff239c95","nodeId":"81e1e6db-ecea-499b-bc79-412017622916","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 8 findings, every snippet verified against its line, with one Foundry proof attached.\n\n**Specialist findings, settled**\n\n- **Spot-price inflow cap** (4 specialists, line 382): reproduced and merged into one **high**. Both attached proofs fail on this code as stated. The side-duel proof is attached: a dust game with 6,505 wei of inflow records a 1.998e18 IMD prize. The main-duel variant with the live engine turned a 66,532e18 cap into 200,000e18.\n- **MATE_INFLOW_CAP 2x against the 0.75% hook fee** (3 specialists, line 75): kept as one **medium**. The arithmetic follows from the constants and the launch's 1.25% LP fee. A sole LP pays 0.75% of volume and can take back 1.3% from a pot others funded. Flagged as the requester's parameter decision.\n- **Resolver's fee credited to the ended game** (line 336): kept as **low**, confirmed by the call order in beforeSwap/afterSwap before `_play`. The project's own test documents it, so it may be intended.\n- **withdraw to the manager strands tokens** and **third-party push** (line 394): merged into one **low**. My test shows owed dropping to 1, hook claims falling by 2e18, and the manager's token balance unchanged.\n- **Batch at a pushed price** (line 532): kept as **low**. My test shows a batch spending 10e18 IMD for 2.43e18 $IMDUEL after a push held through three dust-sampled blocks. The finding states plainly that it profits only when the pile exceeds about 5% of reserves.\n- **Expired window hash discards the paid bid** (line 650): reproduced, downgraded to **info**. The contract notes and launch notes document it as a 0% timeout turn.\n- **fundChest weakens the opponent** and **stale engine comment**: kept as **info**. No defect, a feature interaction and a comment that no longer matches `_endByEngine`.\n\n**My own checks** found nothing more. Solvency holds on every path read: fees mint what they book, mates move chest to owed, withdraws burn what they take, conversions burn what they spend and mint what they buy. The bid replacement rule, the PartialFill guard and the reentrancy lock behave as described.\n\nScratch tests live under `test/scratch/` and are not kept. The proof file must run under its own name, `test/scratch/InflowCapProof.t.sol`, because it deploys its probe by artifact name.","treeHash":null,"usage":{"cachedInputTokens":1227904,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":31496,"runtime":"claude","turns":21,"wallClockMs":711885}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d73af718d55ba788","findings":[{"citation":"resolved","description":"The third prize cap (MATE_INFLOW_CAP, line 797) is meant to make a game played for dust pay nothing, so a pot built by other traders' fees cannot be harvested by fast games. _inflowIn values the winner's pot's inflow in the loser's token at manager.getSlot0(id), read inside _payMate, which runs in afterSwap of the trade that plays the mating move, after that swap has already moved the pool's price. The comment at line 379 says a pushed price 'can only lower a prize', but pushing the price in the direction that makes the winner's token dearer multiplies the other pot's inflow by the square of the sqrt-price move, with no bound. The mating trade is the attacker's own swap, so the attacker chooses that price. In a side duel the attacker seeded (it holds the pool's liquidity, and may remove it in the same block), a swap of 0.01 token through 1e12 of liquidity moves the sqrt price 10,000x, so 0.0001 token of dust inflow is valued at 1e8 times more than it is worth, far above a quarter of a 100-token pot. The only remaining caps are the quarter and twice the mating bid; the bid is a normal swap made while liquidity is deep, and the attacker as the pool's LP recaptures its LP fee, so the whole quarter of a pot other traders funded is taken for a hook fee of 0.75% of 12.5 tokens, every game. The same amplification applies in any pool whose in-range liquidity is shallow relative to its pots, including the main duel if its full-range liquidity thins. Fix: value the other pot's inflow at a price the mating trade cannot set, for example the price read in beforeSwap of the resolving trade (store it in the transient stash next to the fee), or the block-start sample where one exists, or simply cap at MATE_INFLOW_CAP times the loser's pot's own inflow plus the other pot's inflow valued at the lesser of the pre- and post-swap price. Any of these preserves the design (a mate still pays up to twice what the game's trading put into the pots) while removing the attacker's control of the valuation.","line":382,"path":"src/IMDuelHook.sol","reproduction":"State: side duel X/IMD on the hook (X's team plays White in game 0), 1e24 full-range liquidity from the attacker, the X pot holding 100e18 (other traders' fees; set here with fundChest), gameIn = 1e14 in each pot (a dust game), position: Black king c1, Black rook h1, White king a1, White pawns a2 b2, White queen d2, Black to move (ply 1): the only legal move Kc1xd2 mates. Input 1: attacker's swap buying the winner's (IMD) token with exact input 12.5e18 X, hookData = attacker, in the open window (a bid of 12.5e18 so twice the bid equals the quarter). Input 2: remove 1e24 - 1e12 liquidity. Input 3: next block, a swap selling exact input 1e16 X (zeroForOne when X is currency0) with the default price limit; it plays the move. Expected: the prize is at most twice the game's inflow at the price the game traded at, about 1.62e17 wei of X (2 x (1e14 + 1e14 + 0.65% of 12.5e18)). Actual: owed[attacker][X] = 25e18, the full quarter, about 154x the cap; gameInflow(side, 0) still reports 1e14 + 8.1e16. The test test/scratch/InflowCapPrice.t.sol reproduces it with a one-move engine and a harness that only sets the position and gameIn.","severity":"high","snippet":"        (uint160 sqrtPrice,,,) = manager.getSlot0(id);\n        return loser == 0\n            ? in0 + FullMath.mulDiv(FullMath.mulDiv(in1, 1 << 96, sqrtPrice), 1 << 96, sqrtPrice)\n            : in1 + FullMath.mulDiv(FullMath.mulDiv(in0, sqrtPrice, 1 << 96), sqrtPrice, 1 << 96);","title":"Mate inflow cap values the winner's pot inflow at the spot price set by the mating trade itself: a dust game in a thin pool pays the full quarter"},{"citation":"resolved","description":"The inflow cap is the only cap tied to what a game cost its players: the quarter is a share of other people's pot and the 2x bid cap only needs a bid of an eighth of the pot. A mate pays up to MATE_INFLOW_CAP x the game's pot inflow, and the pot inflow is CHEST_BPS = 0.65% of the game's swap volume, so a mate pays up to 1.3% of the volume traded in the game. The hook's whole take on that volume is 0.75% (0.65% to a pot, 0.1% to the slice). A player who trades the whole game against nobody (the other side times out at 0% every 600 seconds, so the engine at 30-90% strength wins) and supplies the in-range liquidity its own swaps cross (a tight position around the current tick captures nearly all of the 1.25% LP fee in the main duel, all of it in a side duel it seeded) pays 0.75% of its volume and takes back up to 1.3% of it from the loser's pot. For a loser's pot P, volume V = 19.2 P in one game pays 0.144 P in hook fees (of which 0.125 P lands in the two pots) and takes 0.25 P out: a net 0.106 P per game, and the pot shrinks by about a fifth each game, so the pot other traders built is harvested by someone who added no net money to the game. With a cap of 1x the inflow the payout (0.65% of volume) stays below the farmer's 0.75% cost and self-dealing loses money, which is the stated purpose of the cap. Fix: set MATE_INFLOW_CAP so that CAP x CHEST_BPS < CHEST_BPS + SLICE_BPS (1x), or cap at the loser's pot's own inflow only (half the volume's fee), or charge the chest share on the winner's bid when it is paid. This is a parameter change, so it is the requester's call; the numbers above are what the current constant allows.","line":75,"path":"src/IMDuelHook.sol","reproduction":"Constants: CHEST_BPS 65, SLICE_BPS 10, MATE_INFLOW_CAP 2, MATE_SHARE_BPS 2500, PAYOUT_CAP 2. State: a pool whose loser pot holds P = 100e18 from earlier trading, the farmer the dominant in-range LP. Inputs: during one game the farmer's swaps total V = 1920e18 (e.g. 38 bids of 50e18 for its moves at ~1% of a 100e18 pot, 30% strength, the other side timing out), the last a bid of 12.5e18, and the mate. Expected under the cap's stated purpose: a game the farmer alone traded cannot pay it more than it put in. Actual: gameInflow sums to 0.65% x 1920e18 = 12.48e18 across both pots, so _payMate pays min(25e18, 25e18, 2 x 12.48e18) = 24.96e18 while the farmer's hook fees were 0.75% x 1920e18 = 14.4e18 (and its LP fees returned to itself): +10.56e18 per game from the pot. The harness path in test/MatePot.t.sol (mateAgainstIn with in0 + in1 = 12.48e18 and paid = 12.5e18) shows the 24.96e18 payout directly.","severity":"medium","snippet":"    uint256 public constant MATE_INFLOW_CAP = 2; // and at most twice what the game's trading put into the pots","title":"Inflow cap of 2x the pot fee (1.3% of volume) exceeds the 0.75% hook fee: a self-dealing LP farmer nets 0.55% of its own volume from other traders' pots"},{"citation":"resolved","description":"withdrawTo refuses address(0), the hook and the manager as destinations (line 402), but withdraw(who, c) sends to `who` itself and `who` can be any address a trade named in hookData, including the PoolManager or the hook. A mate owned by such an address records owed[manager][c]; anyone then calls withdraw(manager, c): _send burns the hook's claims and manager.take(c, manager, amount) transfers the token from the manager to itself, so the ERC-6909 claims fall while the manager's reserves do not: the amount is stranded in the PoolManager, belonging to nobody. For the hook as owner the tokens land in the hook's own balance, which has no sweep. The loss is the bidder's own (it chose the owner), so this is a hygiene issue, not a theft; the fix is to apply the same three-address check in withdraw (or in _owner, treating these addresses as none so the signer owns the bid).","line":394,"path":"src/IMDuelHook.sol","reproduction":"State: main duel with 100e18 in the losing pot and a forced mate for the team to move. Input: a bid swap with hookData = abi.encodePacked(address(manager)), then the resolving trade; then withdraw(address(manager), loserCurrency). Expected: a refused or no-op claim. Actual: owed[manager] drops to 1, manager.balanceOf(hook, id) drops by the prize, the manager's token balance is unchanged and no account can claim the prize.","severity":"low","snippet":"    function withdraw(address who, Currency c) external {","title":"A reward owed to the PoolManager or the hook itself (hookData naming them) is stranded by withdraw(): tokens leave the hook's books without reaching anyone"},{"citation":"resolved","description":"A bid's strength is what its trade received as a share of its own team's pot, and fundChest lets anyone add to either pot. A player of team A can therefore donate to team B's pot: every B buy then receives a smaller share of a larger pot and plays weaker (0% below 0.1% of the pot). The donation stays in B's pot and is only reachable as a quarter per mate, capped by the inflow rule, so it is mostly a sunk cost for the donor; but it is a documented-feature interaction the design notes do not mention, and in a side duel whose pots are small it is cheap. Noted as a trust assumption of the pot-share strength rule rather than a defect.","line":616,"path":"src/IMDuelHook.sol","reproduction":"State: main duel, $IMDUEL pot 1e21. Input: fundChest(pool, imduelIsCurrency1, 1e24) by an IMD-team player, then a $IMDUEL buy receiving 1e21. Expected (by the brief's wording, 'a bid's strength comes from its team's pot'): the buy plays at the strength 1e21 of pot earned before. Actual: Strength.fromShare(1e21, 1.001e24) = 0 (below 0.1% of the pot), where before the donation it was fromShare(1e21, 1e21) = 9000.","severity":"info","snippet":"        return Trade(paid, _owner(hookData), Strength.fromShare(got, chest(id, team)));","title":"Funding a team's pot weakens that team: strength is a trade's share of its own pot, so a donation to the opponent's pot buys 0% moves for the opponent"}],"hash":"909708f30a2fe76c09844163030bd3f32c5e2896f79dae1b4a58e7b6d1c83e95","nodeId":"6939e20c-130b-4a72-a6d7-539dbde1c0c7","outcome":"completed","summary":"The review is complete and the findings file is written. Summary of what I found and did:\n\n**Scope covered:** `src/IMDuelHook.sol` (fees, pots, the three prize caps, claim path, bidding and replacement, timeouts, slice conversion) plus `Strength.sol`, `Rules.sol` and the token, read against the Economic Security, Invariant and Flow Gap guides. Solvency (held claims = pots + pile + owed) holds on every path I traced, including fee minting, mate recording, claims, chest funding, conversion batches and the 1-wei residue. The quarter cap and the 2x bid cap are correct and paid in the loser's token. Bid ownership (hookData, else signer), the 10% raise rule, same-block replacement, late windows, timeouts and cross-game carry all behave as documented. Reentrancy through claims is blocked by the manager's lock.\n\n**Findings written to `.imd-findings.json`:**\n\n- **High. Inflow cap valued at the spot price the mating trade sets.** `_inflowIn` reads `getSlot0` inside `_payMate`, which runs in afterSwap of the trade that plays the mate, after that swap moved the price. The attacker owns that trade. In a side duel whose liquidity it controls, a 0.01-token swap through 1e12 liquidity moves the sqrt price 10,000x, so dust inflow is valued 1e8 times higher and a dust game pays the full quarter of a pot other traders built. Proof at `test/scratch/InflowCapPrice.t.sol` fails on current code with payout 25e18 against an honest cap of about 1.62e17.\n- **Medium. The cap factor exceeds the hook's take.** Two times the 0.65% pot fee is 1.3% of volume, while the hook takes 0.75%. A farmer who plays against timeouts and supplies the in-range liquidity its own swaps cross nets about 0.55% of its volume from the loser's pot, roughly a tenth of the pot per game. Parameter choice, with numbers.\n- **Low. `withdraw(who, c)` with `who` set to the manager or the hook strands the prize.** `withdrawTo` blocks those destinations but `withdraw` does not. Self-inflicted only.\n- **Info. Donating to a team's pot weakens that team**, since strength is a share of its own pot.\n\n**Not done:** no proof test for the medium finding, since it is a constant's economics rather than a reachable code defect, and the low finding is reasoned from v4's `take` semantics rather than executed. Scratch files remain only under `test/scratch/`; no source or configuration file was changed.","treeHash":null,"usage":{"cachedInputTokens":1279546,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":59758,"runtime":"claude","turns":31,"wallClockMs":1231992}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"48cf2e19b6c54386","findings":[{"citation":"resolved","description":"_payMate (line 797) caps a prize at MATE_INFLOW_CAP times what the game's trading put into both pots, with the winner's pot's inflow converted into the loser's token by _inflowIn. _inflowIn reads manager.getSlot0(id) inside afterSwap, i.e. after the swap that resolves the mating move has already moved the pool. The comment at line 379 claims a pushed price 'can only lower a prize, never raise it above the quarter, so a pushed price gains nothing'. The second half is the problem: the inflow cap exists so that a game played for dust pays nothing, but it is bound only by the quarter of the pot, and the resolver chooses the price it is valued at. Control flow: _play -> _resolve -> _apply -> _payMate -> _inflowIn, all inside the resolving trade's afterSwap, so the resolver's own swap size sets sqrtPrice. The attacker (owner of the top bid for the mating move, which it can hold on both teams with two wallets) makes the resolving swap large in the direction that makes the winner's token dear in the loser's token, the cap inflates by the squared sqrt-price factor, the quarter is paid, and the attacker unwinds the price with a reverse swap in the same block. Cost is the LP+hook fee on the push (about 2%, or 0.75% if the attacker is the pool's LP); gain is up to a quarter of the loser's pot regardless of how little the game was traded. The 2x-bid cap does not help: the bid is the attacker's own capital, returned by the swap. Thin pools (any side duel, or the main duel early on) make the push cheap. Fix: value the other pot's inflow at a price the resolving trade cannot set, e.g. the pre-swap sqrtPrice read in beforeSwap (stash it next to chestIdx) or, for the main duel, the block-start sample; or take the lesser of the pre- and post-swap valuations. Alternatively count only the loser's pot's own inflow (in_loser) in the cap, which needs no price at all.","line":382,"path":"src/IMDuelHook.sol","reproduction":"Pool c0/c1 with full-range liquidity 1000e18, both pots funded 100e18, game 0 with team 1 (c1) as White. Recorded game inflow: in0 = 0, in1 = 1e18. (a) Unpushed, price 1:1: a mate by White (Ra1-a8, bid 1e30) records owed[owner][c0] = 2e18 = 2 * (0 + 1e18 / 1). (b) First swap 3000e18 of c0 for c1 (sqrtPrice falls below half of 1:1), then the same mate with the same recorded inflow: owed[owner][c0] = 29.875e18, the full quarter of the loser's pot (119.5e18 after the swap's fee), 15x the unpushed cap. Expected: at most 2e18. Actual: 29.875e18. Run: forge test --match-path test/scratch/InflowCapPrice.t.sol (test_aPushedPriceDoesNotRaiseTheMatesCap fails on this code). Real path, confirmed with the live ChessEngineV5 (test/scratch/InflowCapRealPath.t.sol, local only): main duel with full-range liquidity 1000e18, both pots 1000e18, Black's only move mates (forcedMate position), Black-team trade of 500e18 bids for it, next block a Black-team trade of 3000e18 plays it. Game inflow recorded: in0 = 2.16e18, in1 = 19.5e18 (the resolving trade's own fee), so twice the inflow at the unpushed 1:1 price is 43.3e18. Prize recorded for the bidder: 125.4e18 (2.9x the honest cap); the attacker's fees on the 3000e18 push were about 60e18 (0.75% hook + 1.25% LP), of which 19.5e18 went into the pot it took from. With a 1e18 resolving trade the same mate pays 9.7e18.","severity":"high","snippet":"        (uint160 sqrtPrice,,,) = manager.getSlot0(id);\n        return loser == 0\n            ? in0 + FullMath.mulDiv(FullMath.mulDiv(in1, 1 << 96, sqrtPrice), 1 << 96, sqrtPrice)\n            : in1 + FullMath.mulDiv(FullMath.mulDiv(in0, sqrtPrice, 1 << 96), sqrtPrice, 1 << 96);","title":"Mate inflow cap is valued at the price the resolving swap itself just set, so a mating move raises its own cap by pushing the pool price"},{"citation":"resolved","description":"The inflow cap is meant to make harvesting the pot by fast self-played games unprofitable. Per game of volume V the pots receive 0.65% V (CHEST_BPS) and the mate may pay MATE_INFLOW_CAP * 0.65% V = 1.3% V (also bounded by pot/4 and 2x the mating bid, both of which the attacker can satisfy with its own capital). A trader paying the LP fee loses 0.75% + 1.25% = 2% V > 1.3% V, so the design holds for ordinary traders. It fails for an attacker who is also an in-range LP of the pool: v4 LP fees go pro rata to in-range liquidity, so a concentrated position around the current tick returns almost all of the 1.25% to the attacker. Its net cost is the 0.75% hook fee, of which 0.65% lands in the pot it mates and 0.1% is the slice. Payout 1.3% V minus cost 0.75% V = +0.55% V per game, drawn from the pre-existing pot (which loses 0.65% V per game). With two wallets the attacker holds both teams' top bids (any outside bid must beat it by 10%), gives its mating side 90% strength (a trade receiving at least its team's pot) and the other side 0% (a trade receiving under 0.1% of its pot), so the engine mates in tens of moves, each move two trades in consecutive blocks. The 2x multiplier is the lever: with k = MATE_INFLOW_CAP, farming is unprofitable for an LP only if k * 0.65% < 0.75%, i.e. k <= 1.15 (or cap at 1x the loser pot's own inflow). Severity medium: needs capital for liquidity plus trades of V ~ 19 * pot per game, and real players can outbid during the game.","line":75,"path":"src/IMDuelHook.sol","reproduction":"Main duel, pot P = 1000e18 IMDUEL on the IMD team... concretely: attacker adds concentrated liquidity around the current tick dwarfing the seed, then with wallets A (IMDUEL team) and B (IMD team) trades V = 19,000e18 of volume across one game (bids on both sides, every trade a round trip). Pot inflow for the game: 0.65% * 19,000e18 = 123.5e18 into the smaller pot. Mating move owned by A with bid 500e18 (2x = 1000e18). Prize = min(P/4 = 250e18, 2 * 500e18, 2 * 123.5e18 = 247e18) = 247e18. Attacker cost: hook fee 0.75% * 19,000e18 = 142.5e18, of which 123.5e18 entered the pot; LP fee 1.25% * 19,000e18 = 237.5e18 returned to the attacker as LP (less the share of other in-range liquidity). Net: +247e18 - 142.5e18 = +104.5e18 per game; the pot that others funded falls by 247e18 - 123.5e18 = 123.5e18. Expected (design): a self-played game cannot net-extract from the pot. Actual: it can whenever LP fees are internalised.","severity":"medium","snippet":"    uint256 public constant MATE_INFLOW_CAP = 2; // and at most twice what the game's trading put into the pots","title":"MATE_INFLOW_CAP of 2x lets a trader who is also the pool's liquidity provider farm the pot: payout up to 1.3% of volume against a 0.75% hook fee"},{"citation":"resolved","description":"A batch buys $IMDUEL within 0.1% of the most hook-favourable start price of the three latest sampled blocks; the contract notes say a pushed price 'must be held through two public blocks' and so cannot sell the hook $IMDUEL dear. Blocks are sampled only when the main duel trades (beforeSwap) or convert() runs, and dust swaps of 100 wei pay no hook fee, so the attacker chooses which blocks are sampled. Holding a pushed price is only costly if arbitrageurs can trade against it, and $IMDUEL trades nowhere else: nobody is forced to sell into the pushed price. Sequence: block N-1 buy $IMDUEL with IMD to halve sqrtPrice (price of IMD in $IMDUEL down 4x); blocks N, N+1, N+2 one 100-wei swap each (three samples at the pushed price); from N+2 call convert(imd) once per block: each batch sells BATCH_MINS * minImd = 100 IMD for about a quarter of the $IMDUEL it would fetch at the pre-push price; afterwards sell the $IMDUEL back. The hook's overpayment (about 75 IMD per batch) accrues to the attacker's unwind as in any sandwich. Cost: 0.75% hook + 1.25% LP each way on the push size, about 4% of R for a full-range pool with R IMD of reserves (2x sqrtPrice move needs R of IMD in); 1.5% of R if the attacker is the LP. Breakeven: pile >= 100 IMD * (0.04 R / 75 IMD), e.g. R = 1,000 IMD needs a pile of 60 IMD (volume 60,000 IMD since the last batch). Low: batches run inside every trade once the pile reaches minImd, so the pile rarely holds many batches, and the loss falls on the $IMDUEL pot (half) and the burn (half) rather than on a user.","line":532,"path":"src/IMDuelHook.sol","reproduction":"Main duel IMD/$IMDUEL, full range, 1000 IMD and 1000 $IMDUEL reserves, pile[imd] = 300 IMD (minImd 10 so a batch is 100). Block N-1: swap 1000 IMD for ~500 $IMDUEL (sqrtPrice -> 1/2). Blocks N..N+2: swap -100 wei zeroForOne each (samples). Block N+2 after the sample: convert(imd): _reference returns the highest of the three pushed samples (all ~0.5 * 2^96), limit = 0.999 * that, the batch sells 100 IMD and gets ~25 $IMDUEL instead of ~100 at 1:1. Blocks N+3, N+4: two more batches. Unwind: sell ~500 $IMDUEL back. Expected (notes): a pushed price cannot sell the hook $IMDUEL dear. Actual: three batches buy at a quarter of the fair price; the attacker's unwind captures about 3 * 75 = 225 IMD of overpayment against ~40 IMD of fees on the push and unwind.","severity":"low","snippet":"    function _reference(uint256 s, bool zeroForOne) internal view returns (uint256 from) {\n        if (s >> 208 == block.number || (s >> (2 * SAMPLE_BITS)) & ((1 << SAMPLE_BITS) - 1) == 0) return 0;","title":"Conversion batch reference price can be held for the three sampled blocks at no arbitrage cost because $IMDUEL has no other venue"}],"hash":"a2d315917278c5969197e191115c98ecbf60883788ab4d45c9ab63b0623d0212","nodeId":"65c07d53-7a89-4b9c-b855-38298cb77549","outcome":"completed","summary":"Review complete. Findings are in `.imd-findings.json` (three entries, one with a Foundry proof).\n\n**What I audited.** The hook's control flow end to end: beforeSwap and afterSwap fee and delta accounting, the transient stash and lock, `_play` bid and replacement logic, `_resolve` through engine failure and hash expiry, `_payMate` and its three caps, the claim path, fundChest and the unlock callback, conversion batches, and the cross-pool state (piles, samples, main-duel flags). Solvency paths all balance: every mint of claims has a matching chest, pile or owed entry, and every burn has a matching decrement. Bid ownership, same-block replacement, timeout turns, engine-draw endings and the pre-bid carrying into the next game behave as documented.\n\n**Findings.**\n\n- **High. The mate's inflow cap is valued at the price the resolving swap just set** (`_inflowIn`, `src/IMDuelHook.sol:382`). The cap converts the winner's pot's inflow into the loser's token using slot0 read inside afterSwap, after the resolving swap moved the pool. The owner of the mating move makes that swap large, inflating the cap toward the full quarter of the pot, then unwinds. Confirmed two ways: a harness test (the proof, fails now) where the same mate pays 29.9e18 instead of 2e18 after one price-moving swap, and a real-path run with the live engine in a thin pool where a 3000e18 resolving trade paid 125.4e18 against an honest cap of 43.3e18, for roughly 60e18 in fees. Fix: value the other pot's inflow at a price the resolving trade cannot set (the pre-swap price from beforeSwap, or the block-start sample), or count only the loser pot's own inflow.\n- **Medium. The 2x inflow multiplier lets a trader who is also the pool's LP farm the pot.** Payout up to 1.3% of game volume against a net cost of 0.75% once LP fees return to the attacker. Arithmetic in the finding; the multiplier would need to be about 1.15 or less to close it.\n- **Low. The conversion batch's three-block reference can be held at no arbitrage cost** because $IMDUEL trades nowhere else, so the stated mitigation is weaker than described. Marginal profitability, loss falls on the $IMDUEL pot and the burn.\n\n**Not found.** No solvency break, no path that sends value inside a trade, no re-entrancy that changes books, no bid-ownership or replacement defect, and no way for a dust game or side duel to reach the main duel's pots other than through the price lever above.\n\n**Limits.** Economics passes (Pashov math, permissions and economics groups) were covered only as they intersected control flow. The full test suite was not run for time; the two scratch suites and the build were.","treeHash":null,"usage":{"cachedInputTokens":1840219,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":61083,"runtime":"claude","turns":32,"wallClockMs":1381235}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0e78515c4d95885a","findings":[{"citation":"resolved","description":"_inflowIn (src/IMDuelHook.sol:380-386) converts the winner's pot's game inflow into the loser's token at the pool's current sqrt price, read from slot0 inside _payMate. _payMate runs inside afterSwap of the trade that resolves the mating move, i.e. after that trade has already moved the pool price. The resolving trade can be any size and direction and is chosen by the bid owner (it is the first trade after the window). A large buy of the winning team's token raises the price of that token in the loser's token, which multiplies the valued inflow of the winner's pot by the price factor F, and MATE_INFLOW_CAP * inflow grows with it until the quarter of the pot or twice the bid binds. The code's own comment ('a pushed price gains nothing') is wrong: the quarter and twice-the-bid caps are left, and twice the bid is nearly free to raise (the bid trade is a swap whose cost is only ~2% fees). Economics in the main duel with full-range liquidity L and a loser pot quarter Q: honest farming needs wash volume V = Q/(2*0.65%) = 77 Q at 2% fees = 1.54 Q cost (unprofitable, as intended). With a price push by F=4 (a swap of about L tokens, round-tripped for about 4% L in fees) the volume needed drops to 19 Q, total cost about 0.38 Q + 0.4 L. When the pot's quarter is ~10% of the liquidity or more this is profitable, and pots accumulate 0.65% of all-time volume. In a side duel where the attacker holds the liquidity (any token paired with IMD, afterInitialize is open) the push costs only the 0.75% hook fee and the attacker drains a quarter of the organically funded IMD pot per game. The test below drives the real path (bid, then a price-pushing resolving swap into a forced mate) and shows the recorded prize is 200,000e18 against a 66,500e18 cap at the price the game was played at. Fix: value the other pot's inflow at a price the resolving trade cannot move, e.g. the sqrt price read in beforeSwap of the resolving trade stashed in transient storage, or better the start-of-block sample already kept for the main duel (and a per-pool start-of-block sample for side duels); or record inflow in the loser's token at the time each fee is taken (pre-swap price) so no conversion happens at payout; or take the minimum of the pre- and post-swap valuations.","line":382,"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 {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Currency, CurrencyLibrary} from \"v4-core/src/types/Currency.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {IChessEngine} from \"src/IChessEngine.sol\";\nimport {IMDuelHook, IMDUEL_HOOK_FLAGS} from \"src/IMDuelHook.sol\";\n\n/// Test-only subclass: sets an exact position (the mate's inflow cap is what is under test, not the chess).\ncontract InflowProbe is IMDuelHook {\n    constructor(IPoolManager m, IChessEngine e, Currency imd_, uint256 a, uint256 b, uint256 t)\n        IMDuelHook(m, e, imd_, a, b, t)\n    {}\n\n    function setPosition(PoolId id, uint256 board, uint32 white, uint32 black, uint16 ply, uint16 quiet) external {\n        Game storage g = games[id];\n        (g.board, g.whiteState, g.blackState, g.ply, g.quiet) = (board, white, black, ply, quiet);\n    }\n}\n\n/// The mate's inflow cap (_inflowIn) values the other pot's inflow at the pool's spot price read AFTER the resolving\n/// swap. The trade that plays the mate can therefore push the price and raise its own prize far above twice what the\n/// game's trading put into the pots at the price the game was played at.\ncontract InflowSpotPriceTest is Test {\n    using CurrencyLibrary for Currency;\n    using PoolIdLibrary for PoolKey;\n    using StateLibrary for IPoolManager;\n\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n\n    IPoolManager manager;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    IChessEngine engine;\n    InflowProbe hook;\n    Currency currency0;\n    Currency currency1;\n    PoolKey pool;\n    PoolId id;\n\n    function setUp() public {\n        manager = IPoolManager(address(new PoolManager(address(this))));\n        swapRouter = new PoolSwapTest(manager);\n        lpRouter = new PoolModifyLiquidityTest(manager);\n        MockERC20 a = new MockERC20(\"A\", \"A\", 18);\n        MockERC20 b = new MockERC20(\"B\", \"B\", 18);\n        (currency0, currency1) = address(a) < address(b)\n            ? (Currency.wrap(address(a)), Currency.wrap(address(b)))\n            : (Currency.wrap(address(b)), Currency.wrap(address(a)));\n        for (uint256 i; i < 2; i++) {\n            MockERC20 t = MockERC20(Currency.unwrap(i == 0 ? currency0 : currency1));\n            t.mint(address(this), 2 ** 200);\n            t.approve(address(swapRouter), type(uint256).max);\n            t.approve(address(lpRouter), type(uint256).max);\n        }\n        engine = IChessEngine(vm.deployCode(\"ChessEngineV5.sol:ChessEngineV5\"));\n        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));\n        // currency0 stands in for IMD; conversions never (max minimums); one-block turns\n        vm.etch(at, hex\"00\"); // placeholder so deployCodeTo overwrites a known-empty address\n        deployCodeTo(\"InflowSpotPrice.t.sol:InflowProbe\",\n            abi.encode(manager, engine, currency0, type(uint256).max, type(uint256).max, uint256(1)), at);\n        hook = InflowProbe(at);\n        pool = PoolKey(currency0, currency1, 3000, 60, IHooks(at));\n        id = pool.toId();\n        manager.initialize(pool, SQRT_PRICE_1_1);\n        lpRouter.modifyLiquidity(pool, ModifyLiquidityParams(-887220, 887220, 10_000_000e18, 0), \"\");\n        MockERC20(Currency.unwrap(currency1)).approve(address(hook), type(uint256).max);\n    }\n\n    function sq(string memory s) internal pure returns (uint16) {\n        bytes memory b = bytes(s);\n        return uint16(uint8(b[0]) - 97) + 8 * uint16(uint8(b[1]) - 49);\n    }\n\n    function put(uint256 board, string memory s, uint256 piece) internal pure returns (uint256) {\n        return board | (piece << (uint256(sq(s)) << 2));\n    }\n\n    function side(string memory k) internal pure returns (uint32) {\n        return 0x80800000 | (uint32(sq(k)) << 8) | 0xff;\n    }\n\n    function swap(bool zeroForOne, int256 amount, bytes memory data) internal {\n        swapRouter.swap(pool,\n            SwapParams(zeroForOne, amount, zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false), data);\n    }\n\n    function next() internal {\n        vm.warp(vm.getBlockTimestamp() + 12);\n        vm.roll(vm.getBlockNumber() + 1);\n    }\n\n    /// Game 0 with IMD as currency0: team 1 (currency1) is White, team 0 is Black. Black's only move mates, so the\n    /// loser is team 1 and the prize is paid in currency1. The loser's pot is 1,000,000 (quarter 250,000). The mating\n    /// bid paid 100,000 of currency1 (cap 200,000). The game's pot inflow is only the two trades' 0.65% fees, so the\n    /// inflow cap should hold the prize near 2 x inflow. The resolving trade buys 10,000,000 of currency0 with\n    /// currency1, quadrupling the price of currency0, and _inflowIn values pot 0's inflow at that pushed price.\n    function test_resolvingSwapRaisesTheInflowCapThroughTheSpotPrice() public {\n        hook.fundChest(pool, true, 1_000_000e18); // the loser's (team 1's) pot; donations don't count as inflow\n        uint256 b;\n        b = put(put(put(put(put(put(b, \"c1\", 14), \"h1\", 12), \"a1\", 6), \"a2\", 1), \"b2\", 1), \"d2\", 5);\n        hook.setPosition(id, b, side(\"a1\"), side(\"c1\"), 1, 0); // Black (team 0) to move: Kc1xd2 then Rh1 mates Ka1\n\n        address attacker = address(0xA77);\n        next();\n        swap(false, -100_000e18, abi.encodePacked(attacker)); // Black's bid: buys currency0, pays 100,000 currency1\n        (uint160 priceBefore,,,) = manager.getSlot0(id);\n        next();\n        // the resolving trade (any trade in the next block): a huge buy of currency0 that pushes its price up\n        swap(false, -10_000_000e18, abi.encodePacked(attacker));\n        (uint160 priceAfter,,,) = manager.getSlot0(id);\n        assertGt(priceAfter, priceBefore * 19 / 10, \"the resolving swap pushed the sqrt price up\");\n\n        IMDuelHook.Game memory g = hook.game(id);\n        assertEq(g.gameNo, 1, \"the mate ended game 0\");\n        (uint256 in0, uint256 in1) = hook.gameInflow(id, 0);\n        // what the game's trading put into both pots, in the loser's token, at the price the game was played at\n        // (sqrt price 1:1 before the push, within 1% after the bid): twice that is the intended cap\n        uint256 priceBeforeX96 = (uint256(priceBefore) * uint256(priceBefore)) >> 96; // currency1 per currency0, Q96\n        uint256 inflowAtPlayPrice = in1 + (in0 * priceBeforeX96 >> 96);\n        uint256 intendedCap = 2 * inflowAtPlayPrice;\n        uint256 quarter = 1_000_000e18 / 4;\n        assertLt(intendedCap, quarter, \"the inflow cap, not the quarter, should bind\");\n        assertLt(intendedCap, 2 * 100_000e18, \"the inflow cap, not twice the bid, should bind\");\n\n        uint256 won = hook.owed(attacker, currency1.toId());\n        emit log_named_uint(\"inflow cap at the price the game was played at\", intendedCap);\n        emit log_named_uint(\"prize recorded\", won);\n        // Fails on this code: the prize is min(quarter, 2 x bid) = 200,000e18, far above twice the game's inflow,\n        // because the other pot's inflow was valued at the price the resolving swap itself pushed to.\n        assertLe(won, intendedCap * 101 / 100, \"a mate pays at most twice the game's inflow at the played price\");\n    }\n}","reproduction":"Main duel, currency0 = IMD, 10,000,000e18 full-range liquidity at 1:1, loser (team 1) pot 1,000,000e18 (quarter 250,000e18), game inflow before the attack 0. Black (team 0) to move in a position with one legal move that mates. 1) Block N: attacker buys currency0 paying 100,000e18 currency1 with hookData = attacker (bid size 100,000e18, PAYOUT_CAP 200,000e18); fee 650e18 enters pot 0 as inflow. 2) Block N+1: attacker buys currency0 paying 10,000,000e18 currency1; this resolves the mate, pushes sqrt price to ~2x (price 4x), and its 0.65% fee (~32,600e18 of currency0) also enters pot 0. gameInflow(id,0) = (in0 ~ 33,266e18, in1 = 0). Expected (cap at the played price ~1:1): prize = min(250,000e18, 200,000e18, 2 * 33,266e18) ~ 66,500e18. Actual: _inflowIn values in0 at the pushed price (x4) = ~133,000e18, cap = 266,000e18, prize = min(250,000e18, 200,000e18, 266,000e18) = 200,000e18 recorded in owed[attacker][currency1]. Run: forge test --match-path test/scratch/InflowSpotPrice.t.sol (fails on this code with 200000000000000000000000 > 67198151251058583287240).","severity":"high","snippet":"        (uint160 sqrtPrice,,,) = manager.getSlot0(id);","title":"Mate inflow cap is valued at the spot price after the resolving swap, so the trade that plays the mate can inflate its own prize"},{"citation":"resolved","description":"The inflow cap is meant to stop fast or dust games harvesting a pot ('a game played for dust pays nothing: the pot can't be harvested by fast games'). A game's pot inflow is CHEST_BPS = 0.65% of its volume V, so the cap lets a mate pay up to 2 * 0.65% = 1.3% V, while the hook charges the trader only CHEST_BPS + SLICE_BPS = 0.75% V (0.65% of which lands in the pots the player drains). The remaining cost, the 1.25% LP fee, is only a cost to a trader who is not the LP. Anyone can open a side duel (afterInitialize is permissionless for any token paired with IMD or $IMDUEL) and be its sole LP; in the main duel a concentrated position at the current tick captures most of the LP fee of one's own swaps. For such a player, a game played against themselves (one side bid big for 90% strength, the other dust for 0%) extracts min(P/4, 2*bid, 1.3% V) from a pot P that other traders funded, at a hook-fee cost of 0.75% V, net +0.55% V per game before gas, plus 0.65% V that lands in the pots and partially returns. Setting V = P/0.052 so that 1.3% V = P/4 extracts a quarter of the pot per game for 0.144 P in hook fees: +0.106 P net. Cost is gas (~1M gas per resolving trade, ~100 trades per game) and the 0.1% protocol slice. Fix (design decision): make the cap no larger than what the hook fee costs the player, e.g. MATE_INFLOW_CAP = 1 (1x inflow = 0.65% V < 0.75% V), or count only the loser's pot's inflow, or add the LP fee-independent slice as the bound. The owner's intended cap should satisfy MATE_INFLOW_CAP * CHEST_BPS <= CHEST_BPS + SLICE_BPS.","line":75,"path":"src/IMDuelHook.sol","reproduction":"State: side duel X/IMD opened by the attacker, attacker is the only LP, IMD pot P = 100,000 IMD accumulated from other traders' fees, X pot priced higher so fees of X-buys and IMD-buys route 0.65% of each trade's IMD side into the IMD pot. Input: one game of wash trades totalling V = 1,923,000 IMD of volume (bid trades for the attacker's team large, opponent dust), ending in a mate by the attacker's team against the IMD team. Expected by the design note: a game funded only by the attacker cannot harvest the pot. Actual: gameInflow = 0.65% V = 12,500 IMD, cap = 25,000 IMD = P/4; _payMate records 25,000 IMD to owed[attacker]; the attacker paid 0.75% V = 14,423 IMD in hook fees of which 12,500 went into the IMD pot it drains and 1,923 to the protocol pile; LP fees returned to the attacker as LP. Net +10,577 IMD plus the 12,500 IMD now in the pot for the next game; repeat. (In the main duel the same arithmetic holds for a player whose concentrated liquidity captures the LP fee of its own swaps.)","severity":"medium","snippet":"    uint256 public constant MATE_INFLOW_CAP = 2; // and at most twice what the game's trading put into the pots","title":"Twice the game's pot inflow exceeds the hook fee a self-played game pays, so a liquidity-holding player farms pots at a profit"}],"hash":"fbc376cc5407098470a4859eaef2cdb7f97b9d6676f054749f8bd99287d7cdf7","nodeId":"472eac8b-505e-421b-a80d-eaf404d3ba11","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds two substantiated findings. Nothing else in the tree was modified (the scratch test is in the directory reserved for it).\n\n**What I audited.** The math of IMDuelHook against the Math Precision, Boundary and Numerical Gap checklists: the fee split and its claim minting, both pots' accounting, the three prize caps, the inflow packing, the claim path, strength from pot share, bid comparison and replacement inside a block, and the price sampling for conversion batches. Solvency holds on every path I traced: each pot credit, pile credit, owed credit and claim mint or burn are balanced, casts are guarded by the PoolManager's own SafeCast, and the inflow and chest fields saturate rather than wrap.\n\n**Findings**\n\n- **High. The mate's inflow cap is valued at the spot price after the resolving swap.** `_inflowIn` reads slot0 inside `_payMate`, which runs in the afterSwap of the trade that plays the mating move. That trade is chosen by the bid owner, so a large buy of the winning team's token multiplies the valued inflow of the other pot and lifts the cap toward the quarter of the pot and twice the bid. The comment that \"a pushed price gains nothing\" is wrong. I proved it through the real path (bid, then a price-pushing resolving swap into a forced mate): the prize recorded was 200,000e18 against a 66,500e18 cap at the price the game was played at. The proof is in `test/scratch/InflowSpotPrice.t.sol` and fails on the current code. Suggested fix: value inflow at a price the resolving trade cannot move, such as the start-of-block sample or the pre-swap price, or record inflow in the loser's token when each fee is taken.\n\n- **Medium. Twice the inflow exceeds what a self-played game pays in hook fees.** A game's inflow is 0.65% of its volume, so the cap allows 1.3% of volume while the hook charges 0.75%. A player who also holds the liquidity (any permissionlessly opened side duel, or a concentrated position in the main duel) recovers the LP fee and nets about 0.55% of wash volume from a pot others funded, up to a quarter of it per game. Fix is a parameter decision: make the cap no larger than the hook fee implies, for example one times inflow, or count only the loser's pot's inflow.\n\n**Not found.** No precision loss or overflow in the strength curve, fee rounding, pot packing, or sample encoding that is reachable with int128-bounded swap amounts. Bid ownership and in-block replacement behave as specified; the only in-block advantage is the accepted block-builder case.\n\n**Coverage limits.** The chess engine, repetition memory and player statistics were outside the assigned math area and were read only where the money path depends on them. Static analyzers and long fuzz runs were not run; only forge build and the proof test were.","treeHash":null,"usage":{"cachedInputTokens":809456,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":62417,"runtime":"claude","turns":20,"wallClockMs":1081844}}],"verification":[]}