# Audit report

> 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.

| | |
|---|---|
| Repository | https://github.com/nitro-slav/imduel.git |
| Commit | `8a46d287a38eee3381c572a4fb184534f8a569a7` |
| Job | `6acefe89-1311-462a-8f78-5697050c327d` |
| Judged | 2026-11-01 00:01 UTC |
| Findings | 1 high · 1 medium · 3 low · 3 info |

Four agents audited the code as it is at `8a46d28`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: 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

`src/IMDuelHook.sol:382`

```
        (uint160 sqrtPrice,,,) = manager.getSlot0(id);
```

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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {Test} from "forge-std/Test.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";
import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {IChessEngine} from "src/IChessEngine.sol";
import {IMDuelHook, IMDUEL_HOOK_FLAGS} from "src/IMDuelHook.sol";

/// The hook with one test-only setter: puts a chosen position on a pool's board (a forced mate in one for the side
/// to move), so the real bid / resolve / _payMate path can be driven to a mate.
contract Probe is IMDuelHook {
    constructor(IPoolManager m, IChessEngine e, Currency imd_, uint256 a, uint256 b, uint256 t)
        IMDuelHook(m, e, imd_, a, b, t)
    {}

    function setPosition(PoolId id, uint256 board, uint32 white, uint32 black, uint16 ply, uint16 quiet) external {
        Game storage g = games[id];
        (g.board, g.whiteState, g.blackState, g.ply, g.quiet) = (board, white, black, ply, quiet);
    }
}

/// Finding: _inflowIn values the other pot's inflow at the pool's spot price read inside _payMate, after the trades
/// 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
/// moves the price by many orders of magnitude, so a game whose trading put a few thousand wei into the pots pays
/// the full 2x-bid prize out of the other team's pot.
contract InflowCapProof is Test {
    using CurrencyLibrary for Currency;
    using PoolIdLibrary for PoolKey;

    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;

    IPoolManager manager;
    PoolSwapTest swapRouter;
    PoolModifyLiquidityTest lpRouter;
    Probe hook;
    Currency imd;
    Currency imduel;
    Currency side; // the side duel's own token

    function sq(string memory s) internal pure returns (uint16) {
        bytes memory b = bytes(s);
        return uint16(uint8(b[0]) - 97) + 8 * uint16(uint8(b[1]) - 49);
    }

    function put(uint256 board, string memory s, uint256 piece) internal pure returns (uint256) {
        return board | (piece << (uint256(sq(s)) << 2));
    }

    function sideState(string memory k) internal pure returns (uint32) {
        return 0x80800000 | (uint32(sq(k)) << 8) | 0xff;
    }

    function token(string memory n) internal returns (Currency) {
        MockERC20 t = MockERC20(vm.deployCode("lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol:MockERC20",
            abi.encode(n, n, uint8(18))));
        t.mint(address(this), 2 ** 200);
        t.approve(address(swapRouter), type(uint256).max);
        t.approve(address(lpRouter), type(uint256).max);
        return Currency.wrap(address(t));
    }

    function key(Currency a, Currency b) internal view returns (PoolKey memory) {
        (Currency c0, Currency c1) = a < b ? (a, b) : (b, a);
        return PoolKey(c0, c1, 3000, 60, IHooks(address(hook)));
    }

    function setUp() public {
        manager = IPoolManager(vm.deployCode("PoolManager.sol:PoolManager", abi.encode(address(this))));
        swapRouter = PoolSwapTest(vm.deployCode("PoolSwapTest.sol:PoolSwapTest", abi.encode(manager)));
        lpRouter = PoolModifyLiquidityTest(vm.deployCode("PoolModifyLiquidityTest.sol:PoolModifyLiquidityTest", abi.encode(manager)));
        imd = token("IMD");
        imduel = token("IMDUEL");
        side = token("SIDE");
        IChessEngine engine = IChessEngine(vm.deployCode("ChessEngineV5.sol:ChessEngineV5"));
        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));
        deployCodeTo("InflowCapProof.t.sol:Probe",
            abi.encode(manager, engine, imd, type(uint256).max, type(uint256).max, uint256(1)), at);
        hook = Probe(at);
        MockERC20(Currency.unwrap(imd)).approve(address(hook), type(uint256).max);
        MockERC20(Currency.unwrap(side)).approve(address(hook), type(uint256).max);
        // the main duel must exist first (every later duel routes to it)
        manager.initialize(key(imd, imduel), SQRT_PRICE_1_1);
    }

    function test_aDustGameInAThinSideDuelPaysTheFullTwiceTheBid() public {
        // The side duel SIDE / IMD, opened by the attacker, who is its only LP with a dust position.
        PoolKey memory k = key(side, imd);
        PoolId sid = k.toId();
        manager.initialize(k, SQRT_PRICE_1_1);
        lpRouter.modifyLiquidity(k, ModifyLiquidityParams(-887220, 887220, 1e6, 0), "");

        // The pots: 100 IMD that earlier traders' fees left in IMD's pot (a donation stands in for it: donations
        // don't count as this game's inflow, so this game is a dust game), 1 SIDE in SIDE's pot.
        hook.fundChest(k, k.currency1 == imd, 100e18);
        hook.fundChest(k, k.currency1 == side, 1e18);
        uint256 sideTeam = k.currency0 == side ? 0 : 1;
        uint256 imdTeam = 1 - sideTeam;
        assertEq(hook.chest(sid, imdTeam), 100e18);

        // Game 0: SIDE's team (the token people buy) plays White. White to move, one legal move, and it mates.
        uint256 b;
        b = put(put(put(put(put(put(b, "c8", 6), "h8", 4), "a8", 14), "a7", 9), "b7", 9), "d7", 13);
        hook.setPosition(sid, b, sideState("c8"), sideState("a8"), 0, 0);

        // The attacker bids for White with 1 IMD. In a pool with 1e6 of liquidity that buy moves the price by ~1e12
        // (sqrt); it is the only trade of this game. Its pot fee is a few thousand wei of SIDE.
        bool zeroForOne = k.currency0 == imd;
        swapRouter.swap(k, SwapParams(zeroForOne, -int256(1e18), zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),
            PoolSwapTest.TestSettings(false, false), abi.encodePacked(address(this)));
        (uint256 in0, uint256 in1) = hook.gameInflow(sid, 0);
        assertLt(in0 + in1, 1e5, "the game's whole inflow is dust (wei)");

        // Next block: a dust trade by anyone plays the move (the mate) and pays it.
        vm.warp(vm.getBlockTimestamp() + 12);
        vm.roll(vm.getBlockNumber() + 1);
        swapRouter.swap(k, SwapParams(zeroForOne, -int256(1000), zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),
            PoolSwapTest.TestSettings(false, false), "");
        assertEq(hook.game(sid).gameNo, 1, "mate: the game ended");
        (in0, in1) = hook.gameInflow(sid, 0);

        uint256 prize = hook.owed(address(this), imd.toId());
        emit log_named_uint("game inflow (wei, both pots)", in0 + in1);
        emit log_named_uint("prize recorded in IMD (wei)", prize);
        // Expected: at most MATE_INFLOW_CAP (2) times what the game's trading put into the pots, the two tokens
        // valued at the 1:1 price they traded at (a factor 2 of slack for any sane valuation). Actual: the 2x-bid
        // cap, 2 IMD, out of a pot this game put nothing into.
        assertLe(prize, 4 * (in0 + in1), "a dust game pays at most twice its inflow");
    }
}
```

### 2. Medium: 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

`src/IMDuelHook.sol:75`

```
    uint256 public constant MATE_INFLOW_CAP = 2; // and at most twice what the game's trading put into the pots
```

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.

**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.

### 3. Low: 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

`src/IMDuelHook.sol:336`

```
        if (toChest != 0) _addInflow(id, g.gameNo, idx, toChest);
```

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).

**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).

### 4. Low: withdraw(who, c) pushes a reward to any owner, including the PoolManager or the hook, where the tokens are stranded

`src/IMDuelHook.sol:394`

```
    function withdraw(address who, Currency c) external {
```

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).

**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.

### 5. Low: 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)

`src/IMDuelHook.sol:532`

```
    function _reference(uint256 s, bool zeroForOne) internal view returns (uint256 from) {
```

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.

**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.

### 6. Info: 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)

`src/IMDuelHook.sol:650`

```
            return _bid(id, g, mover, g.ply, Trade(0, address(0), 0));
```

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.

**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.

### 7. Info: Funding the opponent's pot weakens the opponent: strength is a trade's share of its own pot, and anyone may fund either pot

`src/IMDuelHook.sol:616`

```
        return Trade(paid, _owner(hookData), Strength.fromShare(got, chest(id, team)));
```

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.

**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.

### 8. Info: Stale constructor note: an engine address without code ends every game as a draw at its first resolution, it does not "wait"

`src/IMDuelHook.sol:204`

```
        // (hook job f7f2a606); its address is checked off chain, and a trade with no engine to answer just waits
```

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).

**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.

---

Judge's submission `5ec64fefbe63429394fdb76663fa1a4fe0b85d07ddbbf732c175a925ff239c95`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
