# 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). Since the last swarm audit of 8a46d28 (job 6acefe89-1311-462a-8f78-5697050c327d, all findings fixed) the hook was simplified so it reads no prices at all: a 0.75% hook fee on every swap; 0.65% fills both teams' pots, half from each side of the trade into that token's pot; the 0.1% slice is taken in $IMDUEL wherever the pool holds it and burned (owed to 0x...dEaD, sent by anyone's withdraw), and in a duel without $IMDUEL it is taken in IMD into the main duel's IMD pot; every trade bids; a bid's strength is what it received as a share of its own team's pot; only a checkmate pays: the owner of the mating move gets the smallest of a quarter of the losing team's pot, twice what that move's bid paid, and what that game's trades paid into the losing pot, in the loser's token; payouts are recorded and claimed, never sent inside a trade; a move owner is the hookData address, else the signer (zero, the hook, the manager and 0x...dEaD count as none); an expired dice hash replays the bid for nobody; a failing engine ends the game as a draw; per-player counts in the main duel only, for display. Check solvency (held claims cover every pot, the burn owed and every owed payout, in every currency), the three prize caps and the per-game inflow bookkeeping (a trade's pot fee counts for the game in progress after its move), the cross-pool slice into the main duel's pot, the claim and burn paths, bid ownership and replacement inside a block, partial fills and exact-output swaps, and anything a dust game, a side duel, a self-trading LP or several wallets can farm. Known and accepted, stated in the code: a trade naming no owner bids for tx.origin (a relayer, for a smart wallet without hookData); the engine's result (position, check, mate) is trusted, the engine being immutable and audited; the builder of a window's last block can bid in it and try versions of it; a bid for a team's next move carries into the next game when the other side ends the game first.

| | |
|---|---|
| Repository | https://github.com/nitro-slav/imduel.git |
| Commit | `f3ea087d070f2606651127c987e58b0353aa2cba` |
| Job | `74895075-9f30-49e2-8c89-ec79383f8102` |
| Judged | 2026-11-01 16:25 UTC |
| Findings | 1 low · 1 info |

Four agents audited the code as it is at `f3ea087`, 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: After turnBlocks without a bid from the side to move, a dust buy by anyone (e.g. the opponent) owns that move at 0% strength with a one-block window nobody can answer

`src/IMDuelHook.sol:440`

```
                if (block.number > g.closeBlock) g.closeBlock = uint40(block.number);
```

Merged from audit_flow (3063c483...), corrected. In _play a buy of the side to move's token always takes the move when no bid is open (_beats returns true for !b.open, any size). If it lands after the window, line 440 makes the current block the window's last block. If it lands in the window's last block, it already is. Either way the move is played by the first trade in the next block. A rival can raise the bid only inside that same block, so in practice nobody can. Strength comes from Strength.fromShare(got, chest(team)) and is 0 below 0.1% of the team's pot. Anyone can therefore play a silent team's move at 0% (a near-random legal move) for about 1000 wei plus gas once the team has gone turnBlocks blocks (~36 s at launch) without a bid. The 600 s TURN_TIMEOUT is supposed to be what forces silent sides to play at 0%. The bid's owner is whoever the attacker names. The specialist said this only works in the closing block and that a later first buyer would get a fresh window. That is wrong: line 440 gives a first buy made after the window the same one-block window (shown by the second test below), so the closing block is not special. The accepted risks name only the builder of the last block. This needs neither block building nor any knowledge of the hash, because the harm comes from the strength, not the dice. No money is at risk: a mate by such a move pays at most 2x the dust (PAYOUT_CAP), and the team being degraded is the one the attacker plays against. Fix, keeping 'every buy bids': when the first bid for a move arrives at or after g.closeBlock, open a full turnBlocks window from that block (g.closeBlock = block.number + turnBlocks - 1, as _openTurn does) instead of a one-block one, so the team can answer. Alternatively, ignore bids under a minimum share (strength 0) for window purposes.

**Reproduction**

Main duel, turnBlocks = 3, both pots funded with 100e18 through fundChest, game 0 at ply 0, no bid from White's team. (a) vm.roll(game.closeBlock); swap buying White's token with exact input 1000 wei, hookData = abi.encodePacked(0xBAD). Then bids[white] = {owner 0xBAD, strength 0, open}. Next block, any trade (Black's team buys 1e18): game.ply == 1, moveOwner == 0xBAD, moveStrength == 0. (b) The same with vm.roll(closeBlock + 5): the dust buy sets closeBlock = block.number and the next block's trade plays the move with moveOwner 0xBAD, moveStrength 0. Expected: a team's move falls to 0% only through TURN_TIMEOUT (600 s), or the team gets a window to answer a bid. Ran as test/scratch/Repro.t.sol (ReproDust::test_closingBlockDust and ::test_afterWindowDust, both on test/Base.sol with turnBlocks() overridden to 3). Both PASS on this code, showing the behaviour.

### 2. Info: Repetition draw doesn't follow the documented 'third time within the last sixteen half-moves': the window is 17 half-moves, and the start position counts at any distance

`src/IMDuelHook.sol:732`

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

Merged from audit_math (c333f8ae...), plus a second case on the same rule. Chess.sol (lines 14-15) states the draw as a position's third time within the last sixteen half-moves, and further-apart repetitions are left to the quiet-move limit. (1) _apply runs _repeated after g.ply++ but before the new position is written into lane (g.ply-1)%16 (lines 634-637). That lane still holds the position from 16 half-moves earlier, so the eight keys compared are the positions at plies P-2 ... P-16, where P = g.ply after the move. The same happens in _history (line 584) for the engine. A position at plies P-16, P-8 and P therefore draws, even though the P-16 occurrence was made by a half-move outside the last sixteen. (2) Lines 729-730 set seen = 1 whenever the current position is the start position with White to move, whatever the ply. The ply-0 occurrence is not in the ring, so it counts no matter how far back it is. With no pawn move or capture, the start position at plies 0, 12 and 24 draws at ply 24, though only two of those occurrences are within 16 half-moves. The engine's history (_history) doesn't include this extra start count either, so the engine can play into a draw it isn't shown. No money moves on a draw (pots carry over), so the only impact is that the rule as written and as played differ. Fix: either document the actual rule (17 half-moves; the start counts at any distance), or skip lane (g.ply-1)%16 in _repeated and _history and apply the start bonus only while g.ply <= 16.

**Reproduction**

Harness hook.applyMove on the main duel (test/scratch/Repro.t.sol, ReproRepetition). (1) 1.e3 e6, then twice the 8-ply knight cycle Nh3 Nh6 Ng5 Ng4 Nf3 Nf6 Ng1 Ng8. The position after 1.e3 e6 recurs at plies 2, 10 and 18. Result: a draw by repetition (moveResult 3, movePly 17, gameNo+1) at ply 18, where the ply-2 occurrence is outside the last sixteen half-moves (2..17). Expected per Chess.sol: no draw. (2) From the start, twice the 12-ply cycle Nh3 Nc6 Ng5 Nb8 Nf3 Nh6 Ng1 Ng4 Nc3 Nf6 Nb1 Ng8. Each position recurs only every 12 half-moves, and the start is at plies 0, 12 and 24. Result: a draw at ply 24 (movePly 23, moveResult 3). Expected: no draw, since only plies 12 and 24 fall within the last sixteen. test_seventeenHalfMoveWindow and test_startCountedOutsideWindow both PASS on this code, showing the behaviour.

---

Judge's submission `41a3d8c211539adffe89eb1a6e22def5445f8d127a83b0584a0f0c1552111aad`, 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.
