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

| | |
|---|---|
| Repository | https://github.com/nitro-slav/imduel.git |
| Commit | `bc2d23c53e4d37326db3580a1683ac03d7c22172` |
| Job | `513e6726-a763-4dd1-90aa-e9c5cd63b02c` |
| Judged | 2026-10-27 21:50 UTC |
| Findings | 1 medium · 4 low |

Four agents audited the code as it is at `bc2d23c`, 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. Medium: Specified-side hook fee (0.5%, up to 25.5% in the opening) is charged on the full amountSpecified, not on what the pool actually swapped, so partial fills overpay

`src/IMDuelHook.sol:264`

```
        int128 taken = _takeFee(id, key, _specified(params), uint256(a < 0 ? -a : a), chestIdx, g);
```

Merged from all four specialists (permissions 1d44b12b, math 46408ffc, economics 9ac24c61, flow 4f032754). beforeSwap computes the chest share and the protocol slice that fall in the specified currency from |params.amountSpecified|, then returns them as a fixed BeforeSwapDelta. v4 (Hooks.beforeSwap) adds that delta to amountToSwap and charges it to the caller in full (swapDelta - hookDelta). When a swap stops at its sqrtPriceLimitX96, or runs out of in-range liquidity (thin or single-sided launch pools, or routers' MIN/MAX limits), the pool fills only part of the order, but the trader still pays the fee on the unfilled part. That money goes into the chest and the pile. For exact-output swaps the trader receives fill minus a fee sized on the requested output. afterSwap's half is correct because it uses the realised delta. In the main duel's first OPENING seconds _chestBps reaches 2,540 bps, so a limited swap can lose about 25.5% of the requested input while trading almost nothing. Fix (keeping the 0.4%/0.1% rates and the surcharge): base the specified-side fee on the realised amount, for example by taking all fee parts in afterSwap on the unspecified side from the actual delta, or revert in afterSwap when the realised specified amount is smaller than amountToSwap.

**Reproduction**

Main duel with currency0 standing in for IMD, so chest 0 and slice 0 are both taken on the input. Fee 3000, 1:1 price, liquidity 1,000e18 on ticks [-60,60], 301 s after opening (plain 0.5%). swapRouter.swap(pool, SwapParams(zeroForOne=true, amountSpecified=-100e18, sqrtPriceLimitX96=getSqrtPriceAtTick(-1)), ...). Expected: hook fee <= 0.5% of what entered the pool (~2.52e14). Actual (forge test below, run here): hook fee 500000000000000000 (0.5e18, 0.5% of the full 100e18) against ~0.05e18 swapped, about 10x what was swapped. The test fails with 'fee charged on the unfilled part of the trade: 500000000000000000 > 252006018369159'. The specialists' other three proofs reproduce the same failure in other setups: Deployers liquidity with limit SQRT_PRICE_1_1*9999/10000 gives 5e17 > 5.04e11, and 1,000,000e18 full-range with an order of 1,000e18 gives 5.001e18 > 1.27e15. The test accepts a revert on partial fill as a fix.

**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 "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 {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol";
import {ModifyLiquidityParams, SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {IMDuelHook, IMDUEL_HOOK_FLAGS} from "src/IMDuelHook.sol";
import {IChessEngine} from "src/IChessEngine.sol";

contract StubEngine {
    function noop() external {}
}

contract PartialFillFeeTest is Test, Deployers {
    using CurrencyLibrary for Currency;
    using PoolIdLibrary for PoolKey;

    IMDuelHook hook;
    PoolKey pool;

    function setUp() public {
        deployFreshManagerAndRouters();
        deployMintAndApprove2Currencies();
        address at = address(IMDUEL_HOOK_FLAGS | (uint160(0x1D0E1) << 40));
        deployCodeTo(
            "src/IMDuelHook.sol:IMDuelHook",
            abi.encode(
                manager, IChessEngine(address(new StubEngine())), currency0, uint24(0), uint24(0), IHooks(address(0)),
                type(uint256).max, type(uint256).max, type(uint256).max, uint256(1)
            ),
            at
        );
        hook = IMDuelHook(at);
        // the main duel: currency0 stands in for IMD, currency1 for $IMDUEL
        (pool,) = initPool(currency0, currency1, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);
        modifyLiquidityRouter.modifyLiquidity(pool, ModifyLiquidityParams(-60, 60, 1_000e18, 0), "");
        vm.warp(block.timestamp + 301); // past the opening surcharge: the plain 0.5%
    }

    /// An exact-input trade stopped early by its price limit pays the hook's fee on the whole specified amount,
    /// not on what was swapped.
    function test_partialFillPaysFeeOnlyOnWhatWasSwapped() public {
        uint160 limit = TickMath.getSqrtPriceAtTick(-1); // stops almost at once
        uint256 before0 = currency0.balanceOf(address(this));
        try swapRouter.swap(
            pool,
            SwapParams(true, -100e18, limit),
            PoolSwapTest.TestSettings(false, false),
            ""
        ) {
            uint256 paid = before0 - currency0.balanceOf(address(this));
            uint256 hookFee = manager.balanceOf(address(hook), currency0.toId());
            uint256 swapped = paid - hookFee;
            emit log_named_uint("paid", paid);
            emit log_named_uint("swapped into the pool", swapped);
            emit log_named_uint("hook fee", hookFee);
            // the hook's fee is 0.5% of a trade: at most 0.5% of what entered the pool (+1 wei rounding)
            assertLe(hookFee, swapped * 50 / 9_950 + 1, "fee charged on the unfilled part of the trade");
        } catch {
            // refusing a partial fill is also a fix
        }
    }
}
```

### 2. Low: Builder of a move's closing block can bid in that block and grind its hash, which is the move's dice

`src/IMDuelHook.sol:581`

```
        uint256 seed = uint256(keccak256(abi.encode(h, id, g.gameNo, g.ply, t.owner, t.size)));
```

Merged from permissions cd55e6ed, economics eb4369a5 and flow ce85a745. The seed is keccak256(blockhash(closeBlock), id, gameNo, ply, owner, size), with h = _hashOf(g.closeBlock) in _resolve (line 558). Bids are still accepted in block closeBlock itself, because _play line 507 sets `late = block.number > g.closeBlock`. The builder or proposer of block closeBlock, or a searcher paying one, can include its own top bid in that block. It can then produce several variants of the block (extraData, tx order, or the hookData owner, which also enters the seed), evaluate previewMove(id, h_i) for each (it returns ready once block.number >= closeBlock), and publish the variant whose move captures, checks or mates. This breaks the contract's stated property (lines 33-34, 553) that 'nobody can know a move while it can still be bid for'. It moves bounty value (PIECE_BPS/CHECK_BPS/MATE_BPS of the team chest, capped at 2x the bid's output) from honest bidders and chests to builders. Severity is low because it requires producing the closing block and gains are capped per move. Fix: draw the dice from a block whose contents are fixed only after bidding closes, e.g. blockhash(closeBlock + 1) with resolution from closeBlock + 2, or stop accepting bids in closeBlock.

**Reproduction**

Side to move has closeBlock = N, current top bid size S. The builder of block N includes a swap buying the mover's token paying > 1.1*S. It is accepted as top bid at line 512, since block.number == N is not late. For variants N_1..N_k (different extraData or hookData owner) it computes h_i and calls previewMove(id, h_i), getting different moves m_i (the engine uses the seed for awareness, mistakes and second looks). It publishes the N_j whose m_j captures the most material or mates. Any trade in N+1 calls _resolve with h = blockhash(N) = h_j, plays m_j and pays the builder's address bountyBps * chest / 10,000 (capped at 2x its output). Expected: no party that can still bid can choose the move. Actual: block N's builder chooses among k moves.

### 3. Low: Repetition memory spans only 16 half-moves, so a third repetition more than 16 plies after the second is not drawn (quiet limit is 60)

`src/IMDuelHook.sol:722`

```
        (uint256 lo, uint256 hi) = _lanes(id, g.ply + 1);
```

From math 486b1ef7. _repeated compares the current position only with the 8 same-parity keys left in the 16-lane ring (plus a flag for the start position). Positions older than 16 half-moves are overwritten. The quiet-move draw only fires at MAX_QUIET_PLY = 60, so a position can occur for the third time with no capture or pawn move in between and the game goes on, against the documented draw rule (Chess.sol line 14: 'the third repetition'). Later moves keep paying bounties in a game that should have ended. The engine's _history (8 keys) has the same window. Fix: keep keys back to the last irreversible move, up to MAX_QUIET_PLY of them (a 64-key ring, 16 slots), or lower the quiet limit to fit the ring.

**Reproduction**

Verified with a forge test using the repo's test harness (test/Base.sol, hook.applyMove) from the start of game 0: Nf3 Nf6 Ng1 Ng8 (start position, 2nd time at ply 4), then Nc3 Nc6 Nf3 Nf6 Nb5 Nb4 Ng5 Ng4 Na3 Na6 Nh3 Nh6 Nb1 Nb8 Nf4 Nf5 Nh3 Nh6 Ng1 Ng8. Afterwards board == Chess.START_BOARD (3rd occurrence, ply 24, quiet 24). Expected: draw by repetition (GameOver, gameNo 1, ply 0). Actual: gameNo == 0 and ply == 24 (assertions passed), because the second occurrence's lane (half-move 3) was overwritten by half-move 19.

### 4. Low: Protocol slice taken in a currency with no conversion route accrues in pile forever and can never be spent

`src/IMDuelHook.sol:434`

```
            return; // no route: it waits
```

Merged from permissions c1feb0e3, economics c9e0752f and flow 4aac7e1c. The hook is open: any pool can initialize on it. afterInitialize (line 245) sets g.slice = 0 for a pool with neither IMD nor $IMDUEL. _takeFee then mints 0.1% of every swap's currency0 to the hook and adds it to pile. If currency0 is an ERC20 other than IMD/$IMDUEL (or native ETH while ethFee == 0), _minBatch returns type(uint256).max, so _play never converts. convert(c) reaches `return; // no route: it waits` and pile never decreases. imd, imduel and the ETH route are immutable or set once, and no other function spends pile claims (_payBounty spends only chest accounting). Traders pay a fee that funds nothing, and the header's claim that the slice 'is converted into $IMDUEL' fails for these pools. Fix (keeps the fee level for routed pools): take no slice where the slice currency has no route (mark it in afterInitialize), or add it to that pool's chest in _takeFee.

**Reproduction**

After the main duel exists, initialize a pool of two fresh ERC20s A < B (neither IMD nor $IMDUEL) on the hook (fee 3000, 1:1), add liquidity, swap 1,000e18 A exact-input zeroForOne. pile(A.toId()) == 1e18 and the hook holds 1e18 more A claims. Call hook.convert(A): it returns at line 434 and pile(A) stays 1e18. Expected: the slice is converted or not taken. Actual: it grows by 0.1% of every swap and no function can move it.

### 5. Low: Without 20-byte hookData the bid owner is tx.origin, so relayed or ERC-4337 trades pay the move's bounty to the bundler or relayer

`src/IMDuelHook.sol:536`

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

Merged from permissions f0126fc5, economics 2b7ee786 and flow 92a2f4d0. _trade falls back to tx.origin ('the signing wallet') when hookData is not exactly 20 bytes. For ERC-4337 smart accounts, relayed Safe or meta transactions, and intent fillers (UniswapX, CoW, aggregator solvers) tx.origin is the bundler, relayer or filler, not the trader whose tokens paid for the bid. _resolve records it as the move's owner, and _payBounty sends the bounty (up to 2x the trade's output from the team chest) there. Most routers do not forward hookData, so these traders can never be paid. Fix: require the 20-byte owner for a bid to carry a bounty (treat a missing owner as owner-less, paying nobody), or use the swap's `sender` argument when it is not a known router, and document that integrators must pass hookData.

**Reproduction**

In a main duel setup, vm.prank(SA, BUN) with SA the paying account and BUN the bundler EOA as tx.origin, then swapRouter.swap(pool, SwapParams(true, -1e18, MIN_SQRT_PRICE + 1), settings, "") buying team 1's token. hook.game(id).bids[1].owner == BUN (line 536). When that move is played and captures a rook, _payBounty pays 150 bps of the team chest (capped at 2x the output) to BUN. Expected: SA, who paid, is the owner. Actual: BUN is.

---

Judge's submission `0a78adcfe9e18dbd192be3a94d712fe56a345418a341d692186b9fa63fec4a64`, 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.
