# Audit report

> Audit the contracts in this repository: IMDuelHook, a Uniswap v4 hook in which every swap bids for its team's next chess move and pays bounties from per-pool reward pools, and ChessEngineV5 with its Rules, Bitboard, Chess and Strength libraries, which choose and apply legal moves. This commit answers an earlier audit of bc2d23c (job 513e6726-a763-4dd1-90aa-e9c5cd63b02c): a swap that its price limit stops before its whole order traded now reverts (PartialFill in afterSwap), and a pool whose slice has no route to the main duel's token can no longer open (NoRoute in afterInitialize). Check those changes as well as the rest.

| | |
|---|---|
| Repository | https://github.com/nitro-slav/imduel.git |
| Commit | `4e35c8e2cbadd10830ca74c34858876fc4b5753d` |
| Job | `3d8885fb-9576-4266-9627-c9fafdddf8ea` |
| Judged | 2026-10-27 23:19 UTC |
| Findings | 4 low · 1 info |

Four agents audited the code as it is at `4e35c8e`, 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. Low: ETH slice batch feeds amountIn = 0 into the main-duel leg when the IMD/ETH route leg buys nothing, so convert(ETH) reverts and every trade of an ETH-sliced duel re-runs a failing batch

`src/IMDuelHook.sol:442`

```
            (imdSpent, bought) = _swapClaims(_mainKey(), imd, imdIn, true);
```

_convert's native-ETH branch sells `batch` ETH for IMD in the hookless IMD/ETH route pool (main=false: the limit is 0.1% past that pool's current price, so there is no early return) and then unconditionally sells `imdIn` IMD in the main duel. When no liquidity sits in that 0.1% band (the route pool is public, its LPs can leave, or the price sits in a gap) the first leg returns spent = 0, got = 0, and the second leg calls manager.swap with amountSpecified = -int256(0). PoolManager.swap rejects that with SwapAmountCannotBeZero (lib/v4-core/src/PoolManager.sol:193). Effects: (1) the public convert(ETH) reverts instead of returning with the pile untouched, which is what the IMD branch does in the same situation (_swapClaims' early return); (2) inside _play every trade of every duel whose slice is ETH sees pile[ETH] >= minEth, calls this.convertBatch{gas: 800_000}, runs the route swap, reverts, and the try/catch swallows it, so the same wasted work repeats on every trade until someone adds route liquidity. No funds are lost: the ETH pile stays accounted. Reported by two specialists (math and flow); merged. Fix: skip the second leg when imdIn == 0 (e.g. `if (imdIn != 0) (imdSpent, bought) = _swapClaims(_mainKey(), imd, imdIn, true);`) or guard _swapClaims with `if (amountIn == 0) return (0, 0);`.

**Reproduction**

Hook with ethFee 3000 / ethSpacing 60 / ethHooks 0, minEth 1e15; main duel IMD/IMDUEL with 1e24 liquidity at 1:1; route pool PoolKey(ETH, IMD, 3000, 60, 0) initialized at 1:1 with no liquidity; an ETH/X duel on the hook with liquidity. (1) swapNativeInput(ethX, true, -10e18): the 0.01 ETH slice reaches the minimum, the in-trade batch fails inside convertBatch's catch, pile[0] stays 0.01e18. (2) hook.convert(Currency.wrap(address(0))). Expected: returns with pile[0] still 0.01e18 (nothing to sell the ETH for yet). Actual: reverts with SwapAmountCannotBeZero() from PoolManager.swap for the main-duel leg. test/scratch/EthRouteZeroLeg.t.sol (the specialist's proof) fails on this code with exactly that error.

**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 {Deployers} from "v4-core/test/utils/Deployers.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol";
import {ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.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";

/// An ETH slice batch whose first leg (ETH -> IMD in the route pool) buys nothing (no liquidity within the 0.1%
/// band of the route pool's price) feeds amountIn = 0 into the second leg (IMD -> $IMDUEL in the main duel), and the
/// pool manager reverts that swap with SwapAmountCannotBeZero. convert(ETH) then reverts instead of being a no-op,
/// and every trade in an ETH-sliced duel whose pile is over the minimum burns gas on the same failing batch.
contract EthRouteZeroLegTest is Test, Deployers {
    using CurrencyLibrary for Currency;

    uint256 constant MIN = 1e15;
    Currency imd;
    Currency duel;
    Currency x;
    IMDuelHook hook;
    PoolKey main;

    function token(string memory name) internal returns (Currency c) {
        MockERC20 t = new MockERC20(name, name, 18);
        t.mint(address(this), 1e36);
        t.approve(address(swapRouter), type(uint256).max);
        t.approve(address(modifyLiquidityRouter), type(uint256).max);
        c = Currency.wrap(address(t));
    }

    function setUp() public {
        deployFreshManagerAndRouters();
        (imd, duel, x) = (token("IMD"), token("IMDUEL"), token("X"));
        IChessEngine engine = IChessEngine(vm.deployCode("ChessEngineV5.sol:ChessEngineV5"));
        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));
        // the IMD / ETH route: fee 3000, spacing 60, no hook; every pile converts from 1e15 up
        deployCodeTo(
            "IMDuelHook.sol:IMDuelHook",
            abi.encode(manager, engine, imd, uint24(3000), uint24(60), IHooks(address(0)), MIN, MIN, MIN, uint256(1)),
            at
        );
        hook = IMDuelHook(at);
        // the main duel, with liquidity at 1:1
        (Currency c0, Currency c1) = imd < duel ? (imd, duel) : (duel, imd);
        (main,) = initPool(c0, c1, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);
        modifyLiquidityRouter.modifyLiquidity(main, ModifyLiquidityParams(-887220, 887220, 1_000_000e18, 0), "");
    }

    function test_anEmptyRouteLegMakesConvertRevertInsteadOfWaiting() public {
        // the route pool exists but holds no liquidity (its LPs are not ours: they can leave at any time)
        PoolKey memory route = PoolKey(CurrencyLibrary.ADDRESS_ZERO, imd, 3000, 60, IHooks(address(0)));
        manager.initialize(route, SQRT_PRICE_1_1);
        // an ETH / X duel on the hook, its slice in ETH
        vm.deal(address(this), 1e30);
        (PoolKey memory ethX,) = initPool(CurrencyLibrary.ADDRESS_ZERO, x, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);
        modifyLiquidityRouter.modifyLiquidity{value: 1e27}(ethX, ModifyLiquidityParams(-887220, 887220, 1e24, 0), "");
        // a 10 ETH buy of X: slice 0.01 ETH, over the minimum, so the trade runs a batch (and swallows its failure)
        swapNativeInput(ethX, true, -10e18, "", 10e18);
        assertEq(hook.pile(CurrencyLibrary.ADDRESS_ZERO.toId()), 0.01e18, "nothing converted: the route has no depth");
        // expected: a batch that has nothing to sell for IMD leaves the pile waiting and does not revert.
        // actual: the second leg is asked to swap 0 IMD and the pool manager reverts (SwapAmountCannotBeZero)
        hook.convert(CurrencyLibrary.ADDRESS_ZERO);
        assertEq(hook.pile(CurrencyLibrary.ADDRESS_ZERO.toId()), 0.01e18, "the ETH still waits");
    }
}
```

### 2. Low: Bid strength is measured against in-range liquidity that an LP can pull and restore in the same block, so a small buy plays at the 90% cap

`src/IMDuelHook.sol:268`

```
        uint256 depth = manager.getLiquidity(id); // sets a bid's strength: every buy bids
```

beforeSwap reads the pool's current in-range liquidity and _trade turns it into the depth a bid's share is computed against (Strength.fromShare). The hook registers no liquidity callbacks and v4 lets one unlock remove liquidity, swap and add it back, so whoever controls most of a duel's in-range liquidity (the pool creator in a fresh duel, the launch seeder in the main duel) can remove it, buy, and re-add it atomically. The bid then plays at a far higher strength than its size against the pool's real depth gives, winning more captures, checks and mates and with them more bounties from the team chest (each capped at 2x what the trade received), while the other team's bidders face the same disadvantage. The design says strength is set 'by its share of the pool', but the share is taken against a depth the bidder controls at that moment. Fix: measure depth against something one transaction cannot move, e.g. record the pool's liquidity at the block's first touch (as mainStart does for price) and use the larger of that and the current liquidity; or add liquidity hooks that refuse in-range removal in a block where the pool has an open bid.

**Reproduction**

Base.setUpMainDuel() (1,000,000e18 full-range liquidity, 1:1). A White buy of 1e20 bids at strength 3047 bps. From the same snapshot: modifyLiquidityRouter.modifyLiquidity(pool, (-887220, 887220, -999_000e18, 0)), then the same White buy of 1e20, then re-add the 999_000e18. Expected: about the same strength as 1e20 against the pool's normal depth (~30%). Actual: hook.game(id).bids[white].strength == 9000, the cap. Verified in test/scratch/Judge.t.sol test_strengthAgainstPulledLiquidity (logs 3047 vs 9000).

### 3. Low: Exact-output swaps pay the input-side chest share and slice on the pool's net input, escaping about a fifth of the main duel's opening surcharge (20.3% instead of 25.5%)

`src/IMDuelHook.sol:305`

```
        uint256 toChest = chestIdx == idx ? _addChest(id, idx, amount * _chestBps(g) / 10_000) : 0;
```

_takeFee charges amount * bps / 10_000 and the base depends on how the swap is specified. On exact input the base is the gross trade: the whole input in beforeSwap (line 271) or the pool's gross output in afterSwap (line 289). On exact output the base is the net trade: the requested output X (the pool then swaps X + fee), or the pool's input I in afterSwap (the user then pays I + fee), so exact output pays bps/(1+bps) of the gross trade instead of bps. At the normal 0.5% the gap is ~0.0025% of volume. In the main duel's opening (OPENING_BPS 2,500 on top of CHEST_BPS 40 plus SLICE_BPS 10, meant to deter snipers) an exact-output buyer pays 0.255/1.255 = 20.3% of what it hands over instead of 25.5%, and the chest receives about 25% less from it than from an exact-input buyer of the same tokens. Exact-output routes (e.g. V4Router exact-out) therefore get a weaker surcharge than documented. Fix: on exact-output swaps gross the fee up to the exact-input base, fee = amount * bps / (10_000 - bps) with the combined chest and slice bps for the side; or document that exact-output trades pay bps/(1+bps).

**Reproduction**

Base.setUpMainDuel(); hook.setOpening(true) so the real _chestBps applies at age 0 (chest share 2,540 bps + 10 bps slice, both in currency0 since both chests are empty). Path A: swap(pool, true, -1e18, "") pays 1.000000e18 currency0, receives 742764448300564558 currency1, hook claims in currency0 grow by 0.255e18 (25.5%). Path B from the same snapshot: swap(pool, true, 742764448300564558, "") receives the same 742764448300564558 currency1, pays 0.934975e18 currency0, hook claims grow by 0.189975e18 (20.3% of what was paid). Expected: the same surcharge rate for the same trade. Actual: the exact-output buyer pays 6.5% less and the chest gets 0.065e18 less. Verified in test/scratch/Judge.t.sol test_exactOutputPaysLessSurcharge (logs above).

### 4. Low: A bid without 20-byte hookData is credited to tx.origin, so for ERC-4337, relayed, Safe-executed and solver-routed swaps the bundler or relayer owns the move and its bounty

`src/IMDuelHook.sol:547`

```
        address owner = hookData.length == 20 ? address(bytes20(hookData)) : tx.origin;
```

_trade names the bid owner from hookData when exactly 20 bytes are passed and otherwise falls back to tx.origin, described in the notes as 'the signing wallet'. Every swap in a hook pool bids (there is no opt-out), so any swap that is not sent directly by an EOA and does not carry 20-byte hookData (an ERC-4337 account through a bundler, a Gelato/meta-tx relayer, a CoW or UniswapX solver, the executor of a Safe transaction, any integrating contract) makes the externally-originating EOA the owner of the move. _payBounty then pays that address from the team chest, up to PAYOUT_CAP = 2x what the trade received, and if the push fails owed[] stays credited to it. The trader whose tokens paid gets nothing and cannot reclaim it. This is the documented default and not exploitable by a third party, so it is a trust-gap/integration foot-gun rather than a theft path. Reported by two specialists (permissions and flow); merged. Fix: require 20-byte hookData for a bid to carry an owner (a bare swap bids for nobody, like a timeout move), or use the swap's `sender` argument (the router) instead of tx.origin; either way document that contract and relayed traders must pass hookData, and do not silently fall back to tx.origin when msg.sender of the swap is not tx.origin.

**Reproduction**

Base.setUpMainDuel(). A contract C (not an EOA) calls swapRouter.swap(pool, SwapParams(true, -1e18, MIN_PRICE_LIMIT), settings, "") in a transaction whose tx.origin is E != C (e.g. vm.prank(C, E)). Expected from the trader's point of view: the bid is C's. Actual: hook.game(id).bids[1].owner == E; when the move later captures, _payBounty pays E. The repository's own test asserts exactly this fallback: test/Launch.t.sol:80 `assertEq(bid().owner, tx.origin, "the first buy owns White's turn")` and test/Game.t.sol:65.

### 5. Info: previewMove reports a move as fixed in block closeBlock, where a later transaction can still change the top bid and the seed

`src/IMDuelHook.sol:611`

```
        if (!b.open || block.number < g.closeBlock) return (false, 0);
```

The doc says previewMove answers false while the window still takes bids. _play accepts a mover-team bid while block.number > g.closeBlock is false, so a trade in block closeBlock itself still takes the top spot, and the seed in _choose includes t.owner and t.size. previewMove uses the strict block.number < g.closeBlock, so an on-chain caller (or an eth_call against the pending block) in block closeBlock gets ready = true with a move computed from the current top bid, which a later transaction in the same block can replace. Note the tradeoff: an eth_call against the latest block executes with block.number == closeBlock after all of that block's transactions, which is the documented off-chain use ('fixed as soon as that block is in'); changing the check to <= would make that call answer false until the next block. So this is a doc/code mismatch for in-block callers only; no funds move. Fix: either change the comparison to block.number <= g.closeBlock and have the site query at closeBlock + 1, or document that the answer is final only once block closeBlock has no more transactions.

**Reproduction**

Base.setUpMainDuel() (turnBlocks 1, so closeBlock is the current block). white(1e18, 0xA); previewMove(id, bytes32(7)) returns (true, 666). Still in the same block, white(3e18, 0xB): _beats passes (3e18*100 > 1e18*110), the bid's owner becomes 0xB, and previewMove(id, bytes32(7)) now returns (true, 80). Expected per the doc: (false, 0) while the window still takes bids. Verified in test/scratch/Judge.t.sol test_previewInCloseBlock.

---

Judge's submission `18fa0b8ed087708038e4b389875b53bf51e51e163544db88958b938a899973f8`, 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.
