# Audit report

> Audit SwarmDerby (src/SwarmDerby.sol, src/DerbyOdds.sol): IMD turn purchases and the 40/45/10/5 split into per-day pots, commit-reveal swing randomness using Robinhood Chain (Arbitrum Nitro) block hashes via ArbSys, EIP-712 session-key consent, the 20-swing arcade cap, the on-chain top-10 boards, slam vault payouts, and settleNextDay's in-order daily payout math and rollover.

| | |
|---|---|
| Repository | https://github.com/pepegobig/swarm-derby-contracts |
| Commit | `9682e152bdc82bbfe15e520505b51da3d03c5607` |
| Job | `9396db7f-19f5-40b7-aa19-46b45ab87101` |
| Judged | 2026-10-07 01:41 UTC |
| Findings | 4 low · 5 info |

Four agents audited the code as it is at `9682e15`, 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: Zero-count purchases cost nothing, enqueue a settlement day and emit TurnsBought(count=0)

`src/SwarmDerby.sol:188`

```
    function _buy(uint8 league, uint256 count, uint256 cost) internal {
```

Merged from audit_flow, audit_economics, audit_permissions and audit_math (four duplicates). _buy never checks count > 0 or cost > 0. buyTurns(league, 0) and buyPacks(league, 0) compute cost = 0, so _pull issues transferFrom(caller, derby, 0), which succeeds on the live IMD token with no balance and no allowance (confirmed with an eth_call of transferFrom(...,0) from an unfunded address against 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 on chain 4663), _send(DEAD, 0) short-circuits, yet _markDay(league, currentDay()) still pushes the day onto the league's in-order settlement queue and TurnsBought(player, league, 0, 0, 0) is emitted. Any address can therefore add one empty entry per league per UTC day for free. Because settleNextDay pays days strictly in order and an empty day has no board (so no tip), each padded day is a tip-less settleNextDay transaction someone must send before the next real day can be paid, and the page's 'Pay the winners' button shows a ready day with amount 0. No funds are at risk; it is a liveness/UX nuisance and an event-spam vector for indexers that count TurnsBought as purchases. Fix: revert at the top of _buy when count == 0 (e.g. `if (count == 0) revert BadPrice();` or a dedicated ZeroCount error); that covers both buyTurns and buyPacks.

**Reproduction**

Fresh deployment; address 0x6B1E holds no IMD and has granted no allowance. Day DAY0: 0x6B1E calls buyTurns(1, 0). Expected: revert (nothing to buy). Actual: succeeds; openDays(1) == [DAY0]; contract IMD balance still 0. Repeat on DAY0+1 with buyPacks(1, 0) and on DAY0+2 with buyTurns(1, 0): openDays(1) has 3 entries. On DAY0+3 a real player buys 10 agent turns (0.675 IMD to that day's pot). On DAY0+4 nextSettlement(1) returns (exists=true, ready=true, day=DAY0, amount=0, tip=0) and settleNextDay(1) must be called three times, each paying nothing, before nextSettlement(1) reports day DAY0+3 with amount 0.675e18. Reproduced by test/scratch/Judge.t.sol::test_zeroCountPurchaseEnqueuesDay (passes on current code, i.e. the behaviour is present).

### 2. Low: Constructor accepts an IMD address with no code; every purchase then succeeds for free with unbacked pots

`src/SwarmDerby.sol:166`

```
        if (owner_ == address(0) || address(imd_) == address(0)) revert ZeroAddress();
```

Merged from audit_flow, audit_permissions and audit_math (three duplicates). The constructor rejects only address(0) for imd_. _pull (line 523-524) and _trySend (line 533-534) use a raw address(imd).call and treat `ok && data.length == 0` as success, which is exactly what a call to an address without code returns. With a wrong or not-yet-deployed token address the contract is live but unbacked: anyone buys unlimited turns at no cost, pot/vault/opsBalance grow with nothing behind them, 'burns', slam payouts and settlements all 'succeed' while moving nothing. imd is immutable, so the only remedy is redeploying. The risk is concrete for this deployment: HANDOFF.md lists two different IMD addresses on two chains, the Ethereum-mainnet one (0xd34a99bc...) has no code on Robinhood Chain, and DEPLOY.md hands the constructor arguments to a third-party launch flow that 'may adapt the code before deploying'. Fix: `if (address(imd_).code.length == 0) revert ZeroAddress();` (or a dedicated error) in the constructor.

**Reproduction**

Deploy `new SwarmDerby(owner, IERC20(0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7), 0.15e18, 0.5e18)` on a chain where that address has no code (true on Robinhood Chain today and in the Foundry test). Expected: constructor reverts. Actual: deployment succeeds; address 0x6B1E holding no tokens anywhere calls buyPacks(0, 100) and gets turns(0, 0x6B1E) == 500 while pot(0) == 22.5e18, vault(0) == 5e18 and opsBalance == 2.5e18 with zero tokens held. Reproduced by test/scratch/Judge.t.sol::test_codelessTokenGrantsFreeTurns.

### 3. Low: buyTurns/buyPacks take no maximum cost: a price change sequenced before a pending buy is charged in full against the standing allowance

`src/SwarmDerby.sol:178`

```
        _buy(league, count, count * singlePrice);
```

Merged from audit_flow, audit_permissions and audit_math (three duplicates). The cost of a purchase is count * singlePrice (or packs * packPrice) read from storage at execution time, and the buyer passes no maxCost / expected-price argument. setPrices is correctly owner-only and has a floor (MIN_TURN_PRICE) but no ceiling and no delay, so any purchase sequenced after a setPrices call pays the new price, bounded only by the buyer's allowance, which the game page and the test harness set to type(uint256).max. This is a documented owner power (prices are a trust assumption and the owner receives only the 5% ops share of any overcharge; 40% burns, 55% goes to pots/vault), not a permission bypass. It is listed because it also bites honest operation: a routine price update overcharges every user whose transaction was already in flight, with no way for them to opt out, and the docs' quoted prices become unenforceable at the contract level. Fix that preserves the design: add a `maxCost` parameter to buyTurns and buyPacks and `if (cost > maxCost) revert BadPrice();` so the buyer's signed intent bounds what is pulled; the page passes the quoted price.

**Reproduction**

State: player holds 100 IMD and approved the derby for type(uint256).max; singlePrice == 0.15e18. Owner calls setPrices(15e18, 50e18) (100x, above the floor so it is accepted). The player's already-prepared buyTurns(0, 1) executes next. Expected: revert or a charge near the quoted 0.15 IMD. Actual: 15e18 IMD is pulled for one turn (player balance drops from 100e18 to 85e18). Reproduced by test/scratch/Judge.t.sol::test_purchaseHasNoCostBound.

### 4. Low: Slam payout uses the reverting _send while settlement uses _trySend: a player the token blocks cannot finalize a slam and loses the swing and its board credit

`src/SwarmDerby.sol:326`

```
            _send(s.player, payout);
```

Merged from audit_permissions and audit_math (two duplicates). settleNextDay (line 466) deliberately pays winners with _trySend so that 'one unpayable winner can't stop the queue' and rolls a refused prize over. finalize pays a slam with _send, which reverts on a refused transfer and takes the whole finalize with it, including the _recordDinger board credit on line 321 that a non-slam homer would have received. The swing stays Committed; once the 240-block window passes the only exit is expire(), which marks it FOUL with no score. The precondition is real for this deployment: the live IMD token on Robinhood Chain (0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127) is owner-controlled and exposes a per-address blocked(address) flag and a transfersEnabled() switch (verified by cast call and selector scan of its bytecode; owner 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7). The player paid for the turn and rolled a 550+ ft slam, yet gets neither payout nor podium credit, and a rival below them moves up. Fix: mirror settlement: `if (!_trySend(s.player, payout)) { vault[s.league] += payout; payout = 0; }` (or add it to rollover) before emitting GrandSlam, so the dinger is always recorded and the swing always finalizes.

**Reproduction**

State: player buys 100 arcade turns (vault[0] = 1.5 IMD) and commits swing 0 with quality 100 / velo 100 and a salt whose roll against the target block hash is a SLAM; afterwards the token owner blocks the player. At target+1 anyone calls finalize(0, salt). Expected (by analogy with settlement): swing resolves, the homer lands on board(0, day), the undeliverable payout stays in the contract. Actual: finalize reverts with TransferFailed; at target+241 expire(0) succeeds, status Final as FOUL, board(0, day) is empty, vault(0) still 1.5 IMD. Reproduced by test/scratch/Judge.t.sol::test_blockedPlayerCannotFinalizeSlam with a mock token that reverts on transfers to or from a blocked address, the same behaviour the live token's blocked(address) flag implies.

### 5. Info: Session-key consent has no deadline: a signed Session(player, session, nonce) stays bindable until that key's nonce moves

`src/SwarmDerby.sol:214`

```
        bytes32 structHash = keccak256(abi.encode(SESSION_TYPEHASH, player, session, sessionNonce[session]));
```

Merged from audit_flow, audit_economics and audit_permissions (three duplicates). The EIP-712 struct the key signs carries player, session and the key's nonce but no expiry, and sessionNonce[session] only advances on a successful bind. A consent whose setSession transaction was never sent (page closed, tx dropped) stays valid indefinitely for that player, and the key holder has no way to invalidate it other than binding elsewhere. Replay to other players, contracts and chains is correctly blocked by the nonce, verifyingContract and chainId. The blast radius is small by construction: the key is a throwaway the page generates, a bind only lets that key spend the player's turns and buy turns for the player, and the key can leaveSession at any time. The phishing variant (a wallet tricked into signing with itself as `session` and an attacker as `player`, after which the wallet's buys and swings are credited to the attacker until it calls leaveSession) is a general signature-phishing risk that a deadline only shortens. The eth-security checklist lists deadlines alongside domain separator and nonce as required EIP-712 replay protections. Fix: add `uint256 deadline` to the Session type and setSession and revert when block.timestamp > deadline; optionally expose a function for a key to bump its own nonce.

**Reproduction**

Key 0x5E55 signs sessionDigest(player, key) at T0 (day 20370, nonce 0). Nothing is submitted. vm.warp(T0 + 3650 days); player calls setSession(key, sig). Expected with an expiry: revert BadSession. Actual: binds; playerOf(key) == player. Reproduced by test/scratch/Judge.t.sol::test_sessionConsentNeverExpires.

### 6. Info: transferOwnership is single-step with no zero-address check; renouncing (as HANDOFF suggests) or a typo permanently strands the 5% ops share

`src/SwarmDerby.sol:503`

```
    function transferOwnership(address to) external onlyOwner { owner = to; }
```

Merged from audit_flow, audit_economics, audit_permissions and audit_math (four duplicates). Ownership moves in one call with no acceptance by the new owner and no zero-address check (the constructor rejects owner_ == 0, the setter does not), and no event is emitted. HANDOFF.md step 9 and DEPLOY.md 'Known limits' present 'renounce it with transferOwnership' as an option. After transferOwnership(address(0)) or any mistyped address, withdrawOps and setPrices are unreachable forever, yet _buy keeps crediting opsBalance with 5% of every purchase; those tokens are outside pot and vault so no payout path can ever release them. Pots and vaults are unaffected. This is a trust/operational note, not an exploit. Fix: two-step transfer (pendingOwner + acceptOwnership) with an explicit renounceOwnership for the documented renounce case, emit OwnershipTransferred, and either document that renouncing locks the ops share or route the ops share to a fixed opsRecipient so giving up admin rights does not freeze revenue.

**Reproduction**

Owner O; player buys 100 arcade turns so opsBalance == 0.75e18. O calls transferOwnership(address(0)). Expected per HANDOFF ('withdrawOps releases the 5% ops share'): still recoverable by whoever operates the game, or the call rejected. Actual: owner() == address(0); withdrawOps(to, 0.75e18) reverts NotOwner for every caller; a further 100-turn purchase raises opsBalance to 1.5e18, which can never leave the contract. Reproduced by test/scratch/Judge.t.sol::test_transferOwnershipStrandsOps.

### 7. Info: Slam-vault economics: above ~25 IMD in a league vault, a quality-100 swing is positive expected value from the slam alone, so uncapped agents pin the vault there

`src/SwarmDerby.sol:323`

```
            uint256 payout = (vault[s.league] * SLAM_VAULT_SHARE_BPS) / 10_000;
```

Merged from audit_economics and audit_math (two duplicates). Design observation, not a code bug. At quality 100 the slam probability is (10000 - 9920) / 10000 = 0.8% (DerbyOdds.thresholds(100)[3] == 9920) and any script can claim quality 100 and velo 100. A slam pays vault / 2, so the expected vault take per swing is 0.004 * V regardless of how many other players exist. A pack turn costs 0.1 IMD (0.5 / 5), so once V > 25 IMD a perfect swing is +EV on the vault alone (37.5 IMD at the single-turn price; 2.5 IMD at the MIN_TURN_PRICE floor). The AGENT league has no swing cap, so a bot keeps buying packs and swinging until the vault is back under ~25 IMD; in ARCADE the 20-swing cap only adds the cost of extra wallets. The dominant agent's effective turn cost is lower still because it recovers ~54% of the 45% pot share as first place. Consequence: the vault, described as a growing jackpot, equilibrates at a few tens of IMD per league and the value of the 10% vault share flows to whoever runs the cheapest perfect-quality script. Each slam halves the vault so the condition self-corrects, and the vault is money players put in, so nothing is lost by the protocol. Any fix changes economics and is a scope decision: cap a single slam payout (min(vault/2, K)), lower SLAM_VAULT_SHARE_BPS (10% gives a 125 IMD threshold), or pay a fraction that shrinks with vault size.

**Reproduction**

State: vault[AGENT] = 30 IMD (reached after 300 IMD of agent purchases without a slam). A bot buys 1 pack (0.5 IMD, 5 turns) and swings 5x with quality 100, velo 100 in league 1. Per swing: P(slam) = 80/10000, payout = 15 IMD, EV = 0.12 IMD > 0.1 IMD turn cost, so the bot is +EV and repeats until vault < 25 IMD. Arithmetic checked in Foundry: thresholds(100)[3] == 9920 and (25e18 * 5000 / 10000) * 80 / 10000 == 0.1e18 (test/scratch/Judge.t.sol::test_slamEvArithmetic); payout = vault * 5000 / 10000 is shown by the existing test_slamPaysHalfOfItsLeagueVault.

### 8. Info: FINALIZE_WINDOW is ~24 s on Robinhood Chain and is measured in blocks: a decided swing becomes a foul although its block hash is still served for 256 blocks

`src/SwarmDerby.sol:312`

```
        if (current - s.targetBlock <= FINALIZE_WINDOW) {
```

From audit_economics (single report), kept as an info-level design note because the behaviour is documented ('Not revealed within 240 blocks (~24s) counts as a foul'). finalize refuses the roll once more than 240 L2 blocks have passed since targetBlock and records FOUL regardless of what the hash says. Robinhood Chain mainnet produces blocks continuously at ~100 ms (measured in this review: blocks 82091760 to 82092760 span 103 s), so the whole budget from commit to the reveal cutoff is ~24.5 s and shrinks under any faster block production. A player without a session key must get a second wallet-signed transaction mined in that window; an RPC hiccup, wallet prompt or sequencer backlog converts a turn that rolled a HOMER or SLAM into a foul and, for a slam, forfeits half the vault. The player cannot game the cutoff (an unrevealed roll is never better than a revealed one, there are no negative tiers), so it protects nothing economically; it exists so dayClosed() can treat the board as final, and dayClosed is keyed off the same constant. ArbSys.arbBlockHash serves hashes for 256 blocks, so 16 blocks of usable reveal time are forfeited. Minimal change: raise FINALIZE_WINDOW to 255 (dayClosed follows) and document the budget in seconds for the live block rate; a materially longer window needs a design change (a keeper storing the target hash, or a time bound).

**Reproduction**

Commit at arbBlockNumber 1000 (target 1005) with quality 100, velo 100 and a hash rigged to roll HOMER. Advance to block 1246 (241 blocks after target). ArbSys.arbBlockHash(1005) still returns a non-zero hash (within 256) but finalize(id, salt) returns (FOUL, 0) and credits nothing; at block 1245 the same call would have scored. Reproduced by test/scratch/Judge.t.sol::test_lateFinalizeFoulWhileHashAvailable and the existing test_lateRevealIsFoul. On-chain timing: 240 blocks * ~0.103 s = ~24.7 s between target and cutoff.

### 9. Info: External trust not stated in the docs: the IMD token owner can freeze the whole game or any player via the token's blocklist or transfer switch

`src/SwarmDerby.sol:86`

```
    IERC20 public immutable imd;
```

From audit_permissions, with the trust-assumption inventory from audit_economics folded in. The live IMD token on Robinhood Chain (0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127, symbol IMD, owner 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7, not a proxy: EIP-1967 slot is empty) exposes blocked(address), transfersEnabled() and enableTransfers() (verified by cast call and a selector scan of its bytecode in this review; the blocklist setter's name is not in the public signature database so it was not exercised). The derby's 'Known limits' section lists the sequencer, self-reported quality, the per-wallet cap and the derby owner, but not the token owner, who is a stronger party than the derby owner: blocking the derby contract stops every _pull and _send (no purchases, no slams, no settlement tips, no ops withdrawal), blocking 0xdead stops all purchases because _buy burns with the reverting _send, and disabling transfers stops everything. Other trust facts verified for launch: the derby owner's setPrices has a floor but no ceiling, so it can halt new purchases but can never reach pot[] or vault[] (the only owner-gated transfer is withdrawOps, bounded by opsBalance); ArbSys at address(100) answers arbBlockNumber() on chain 4663 and its 256-block arbBlockHash range covers FINALIZE_WINDOW. No derby-side code change removes the token dependency; this is a documentation item. Suggested line for 'Known limits': 'IMD is an owner-controlled token with a blocklist and a transfer switch; if its owner blocks this contract, the burn address or a player, the corresponding transfers fail.'

**Reproduction**

With a token that mirrors the live IMD's blocked flag: player buys 10 arcade turns; token owner blocks the derby address. Expected per the docs: only the derby owner and the sequencer can affect play. Actual: buyTurns(0, 1) reverts TransferFailed (transferFrom to a blocked recipient) and withdrawOps reverts TransferFailed. Unblock the derby and block 0xdead instead: buyTurns(0, 1) reverts TransferFailed on the burn. Reproduced by test/scratch/Judge.t.sol::test_tokenBlocklistFreezesGame. Live facts: `cast call IMD 'blocked(address)(bool)' 0x...01` returns false, `cast call IMD 'transfersEnabled()(bool)'` returns true, `cast call IMD 'owner()(address)'` returns 0x047F606f..., all against https://rpc.mainnet.chain.robinhood.com on 2026-10-07.

---

Judge's submission `9456d45ce8e46e7980fa92d0207253c373cf50540a2b4160bb4887e90b1a7e90`, 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.
