# Audit report

> Audit SwarmDerby v2 in this Foundry repository: src/SwarmDerby.sol with src/HouseDraw.sol and src/DerbyOdds.sol. v2 decides each roll with a house draw. The player commits keccak256(abi.encode(salt, player)) with swing; the house signs drawMessage(swingId) with a 2048-bit RSA key (RSASSA-PKCS1-v1_5, SHA-256, e = 65537, checked through the modexp precompile) and anyone submits it with draw within DRAW_WINDOW (5 minutes); the player reveals the salt with finalize within REVEAL_WINDOW (5 more minutes). Focus on: the RSA verification and the key shape checks in HouseDraw; draw, finalize and expire timing (an undrawn swing gives back the turn and the arcade daily slot, a drawn but unrevealed swing is a foul); single-use commits; dayClosed (last commit + 10 minutes) and settleNextDay with refunded swings; the house key change (proposeHouseKey, activateHouseKey after KEY_DELAY, revokeHouseKey); EIP-712 session-key consent; the IMD accounting (40/45/10/5 split, day pots, slam vaults, rollover); and whether the house, the owner or a player can steer or predict a roll, or gain from holding back a draw or a reveal. src/DerbyAuction.sol reads SwarmDerby only through dayClosed and board; read it as context. house/house.mjs is the off-chain signer; review its key handling if time allows. forge test has 114 tests and needs no ffi.

| | |
|---|---|
| Repository | https://github.com/pepegobig/swarm-derby-contracts.git |
| Commit | `8b60d1b147b36dcd7455649f74c68beaf648da05` |
| Job | `f8614c57-0570-4af5-aa6f-85eae302d86b` |
| Judged | 2026-10-08 22:03 UTC |
| Findings | 1 medium · 6 low · 2 info |

Four agents audited the code as it is at `8b60d1b`, 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: house.mjs trusts its RPC for the swing it signs and hands the signature to that RPC before broadcast, so a dishonest RPC (default: a public third-party endpoint) lets a colluding player pre-select sla

`house/house.mjs:108`

```
        [drawTag, CHAIN_ID, DERBY, id, s.player, s.commit]);
```

Merged from four specialist reports (permissions, math, flow, economics). drawOne() reads the swing with derby.swings(id) (line 99) over the configured RPC and copies s.player and s.commit straight into the message it signs (lines 106-109); the ids themselves come from SwingCommitted logs served by the same RPC (line 86) and DRAW_TAG is also fetched from it (line 151). The comment on line 105 ('Built here from the swing, not read from the RPC') and house/README.md ('never read from the RPC') are therefore wrong: every field that distinguishes one message from another is RPC data. The signature is then sent to that same RPC three times before anything is mined: draw.staticCall (line 110), estimateGas (111) and the raw transaction (112). RPC_URL defaults to the public https://rpc.mainnet.chain.robinhood.com and RPC_URL_2 is unset by default. Two consequences for an RPC operator (or anyone who can answer the house's RPC calls) who also plays, or who works with a player P: (1) Pre-selection. The RPC reports a fabricated Committed swing for id = nextSwingId with player P and commit keccak256(salt_i, P). The house signs it and reveals the signature in the staticCall. Because RSASSA-PKCS1-v1_5 is deterministic, that is the exact signature the real swing will get, so P evaluates DerbyOdds.roll(keccak256(salt_i, keccak256(sig)), id, 100, 100) offline, repeats with new salts until the roll is a SLAM (one in ~125 at quality 100), then commits that salt. draw() accepts the pre-made signature and finalize pays 10% of the league's slam vault, plus a top board place. (2) Free re-roll. For P's real swing the RPC reads the signature from the staticCall, P computes the roll, and for a bad roll the RPC answers 'execution reverted' to every simulate/send; with no RPC_URL_2 the service retries the same RPC each tick, the draw window closes and expire refunds the turn. The README's 'use RPCs you trust' sentence names half of this but the shipped default contradicts it and the leak-before-mining half is not covered. DEPLOY.md's trust list (house, key holder, sequencer) does not list the RPC provider. Fix (off-chain, minimal): run the house against a node the operator controls and make that the documented requirement; before signing, verify the swing exists on a second independent source (nextSwingId() > id and swings(id) matching on both), and never send a signature to a third-party endpoint before broadcast (simulate on the local node or skip simulation and rely on the on-chain checks plus the alert); hard-code DRAW_TAG; correct the line-105 comment and the README. Optional on-chain hardening: store a value the committer cannot know when it builds the commit (e.g. blockhash(block.number - 1)) in the Swing and include it in drawMessage, so a signature obtained for a fabricated tuple can never match the real swing's message.

**Reproduction**

On-chain enabler reproduced in test/scratch/Judge.t.sol::test_signatureIsPredictableBeforeTheSwingExists with the repo's test house key (test/HouseKey.sol): with vault(1) = 15.75 IMD and id = nextSwingId(), sign abi.encode(DRAW_TAG, chainid, derby, id, house, commitFor(salt_i, house)) for salt_i = keccak256('grind', i) and evaluate DerbyOdds.roll; salt 75 rolls SLAM. Then swing(1, 100, 100, commitFor(salt_75, house)) returns id, draw(id, sig) with the pre-made signature succeeds, finalize(id, salt_75) returns tier 5 (SLAM) and pays 1,575,000,000,000,000,000 wei (10% of the vault) for one 0.15 IMD turn. Expected: a signature can only be obtained after the swing exists and nobody learns it before the draw is mined. Actual: the service signs whatever tuple its RPC reports (code trace: house.mjs lines 86, 99, 105-112) and the contract accepts a signature made before the swing existed. Service-side trace for the re-roll: drawOne catch at lines 119-121 only logs and moves to the next RPC; with one RPC configured the same id is retried every tick until line 132 drops it and line 135 logs; expire() then refunds the turn.

### 2. Low: Trust model overclaim: the house key holder (or the owner after a 2-day key change, or a player who shares its salt with the house) can pick slams before committing and re-roll losing swings for free

`src/SwarmDerby.sol:32`

```
///           know the salt, and an unrevealed draw counts as a foul, so nobody can steer a roll
```

Merged from the economics (medium), flow (info) and math (info) reports. DEPLOY.md 'Known limits' already states that the key holder must not play and that the house can hold back draws, so this is recorded as a documented trust assumption, not a permission bypass; it is reported because the task asks whether the house or the owner can steer a roll or gain from holding back a draw, and because the contract header (lines 30-33: 'nobody can steer a roll and hiding a bad one never pays') and HANDOFF.md ('It can never touch pots or vaults') overstate what the code enforces. Whoever holds the RSA private key knows the unique signature, hence drawHash, for any (swingId, player, commit) tuple, and swingId is the predictable nextSwingId. Route A: grind salts offline and commit only one that slams. Route B: commit swings with known salts, compute each roll, draw only the winners, let the losers pass DRAW_WINDOW and call expire(), which returns the turn (lines 388-393). The owner reaches the same position via proposeHouseKey/activateHouseKey (2 days, the key is indistinguishable from an honest one: _checkKey is shape-only), and any player reaches it by sending its salt to the house operator. Other actors cannot: a player without the key cannot predict drawHash and a withheld reveal is a FOUL, never better than any roll; the sequencer alone can only delay. Fix: no contract change removes route B without penalising honest players for house downtime; keep the operational rules and correct the header comment and HANDOFF wording so operators read the real trust model. Route A can be closed cheaply by mixing a value the committer cannot know in advance (e.g. blockhash(block.number - 1) stored at commit) into drawMessage. Monitoring the undrawn-swing ratio per player is the operational signature of route B.

**Reproduction**

test/scratch/Judge.t.sol with the test house key. Route A: test_signatureIsPredictableBeforeTheSwingExists (salt 75 of a keccak256('grind', i) sequence slams; swing, draw with the pre-made signature and finalize pay 10% of vault(1)). Route B: test_keyHolderWithholdsLosingDrawsForFree: the house buys 50 agent turns, commits 50 swings with known salts, signs each drawMessage, draws and finalizes only the 36 that roll HOMER or better, warps past DRAW_WINDOW and calls expire on the other 14: turns(1, house) goes from 0 back to 14 and board(1, day)[0] is the house. Expected by the header comment: a bad outcome always costs the turn and nobody can pick a slam. Actual: the key holder never pays for a bad outcome and selects slams at will.

### 3. Low: commitUsed is keyed by the bare commit, so anyone who sees a player's pending swing can burn their commit with one turn

`src/SwarmDerby.sol:321`

```
        if (commitUsed[commit]) revert CommitUsed();
```

Merged from the math and flow reports. A commit is keccak256(abi.encode(salt, player)) and finalize re-derives it with s.player, so a swing committed by B with A's commit can never be revealed by B (BadSalt; it fouls after the draw). swing() nevertheless accepts it and sets commitUsed[commit] globally, so A's own swing with that commit reverts with CommitUsed. Anyone who sees A's transaction before inclusion (on Robinhood Chain: the sequencer operator, a shared relay or RPC, not a public mempool) can grief A for one turn; A loses the transaction and must retry with a fresh salt, and a client that retries the same salt fails again. No roll information is gained. Fix: scope the reuse check to the player, e.g. commitUsed[keccak256(abi.encode(player, commit))] or a mapping(address => mapping(bytes32 => bool)), which keeps the stated purpose (a player cannot reuse a salt the house has seen) while a stranger's copy of the commit no longer blocks the salt's owner.

**Reproduction**

test/scratch/Judge.t.sol::test_commitFrontRunBurnsVictimCommit: Alice and Bob each hold 5 arcade turns; c = commitFor(keccak256('alice salt'), alice). Bob calls swing(0, 1, 0, c): accepted as swing n. Alice calls swing(0, 100, 100, c): expected success (the commit names her), actual revert CommitUsed(). After draw(n, sig), finalize(n, salt) reverts BadSalt, so Bob's swing can only foul.

### 4. Low: A ready house-key proposal never lapses and cannot be cancelled, so anyone picks the switch moment long after the announced delay; in-flight draws under the old key then fail and refund

`src/SwarmDerby.sol:572`

```
        if (pendingHouseKeyAt == 0 || block.timestamp < pendingHouseKeyAt) revert KeyNotReady();
```

Merged from the permissions (low) and flow (info) reports, with the economics note on prepaid turns. activateHouseKey is permissionless by design so a proposal cannot be stalled, but it only checks that the delay has passed: a proposal stays activatable forever, so the KEY_DELAY notice (lines 63-65: 'players can see a key change coming and stop playing') bounds the earliest switch, not the actual one. Any account can flip the key at a moment of its choosing (e.g. late on a day with a large pot): every swing whose draw has not been mined then reverts with BadDraw and is refunded after DRAW_WINDOW, and with the current house service (see the stale-key finding) every later swing refunds too. The owner cannot drop an abandoned or compromised proposal except by revokeHouseKey (a 2-day outage) or by re-proposing the current key, which works but is undocumented. Prepaid turns cannot be converted back to IMD, so 'stop playing' means stranding them. Fix: add an owner-only cancel (delete pendingHouseKey; pendingHouseKeyAt = 0) and an activation window (revert when block.timestamp > pendingHouseKeyAt + e.g. 1 day, after which the proposal must be re-proposed and re-announced); document that re-proposing the current key is the cancel path until then, and that the notice protects future purchases only.

**Reproduction**

test/scratch/Judge.t.sol::test_anyoneActivatesKeyLaterAndInFlightDrawsFail: owner proposes K2 at T0; nobody activates; at T0 + 30 days Alice commits swing id and the house signs drawMessage(id) with K1; address 0xBEEF calls activateHouseKey(): houseKey() == K2, draw(id, sigK1) reverts BadDraw, and after DRAW_WINDOW expire(id) returns Alice's turn. Expected per NatSpec: the change is visible KEY_DELAY ahead of taking effect. Actual: it takes effect at an unannounced moment 28 days after the delay. The test also shows proposeHouseKey(K2) after activation re-arms a new 2-day proposal, i.e. re-proposing is the only cancel.

### 5. Low: house.mjs checks houseKey() only at start-up and never alerts when draws stop landing, so a key activation or revoke, or a base fee above MAX_GWEI, silently turns every swing into a 5-minute refund

`house/house.mjs:149`

```
  if ((await derby.houseKey()).toLowerCase() !== modulus) throw new Error('HOUSE_KEY_FILE does not match the houseKey of DERBY');
```

Merged from the permissions, math, flow and economics reports. The modulus comparison runs once in main(); nothing re-reads houseKey() or watches HouseKeySet/HouseKeyProposed. After activateHouseKey (permissionless once the delay passes) or revokeHouseKey, every draw.staticCall reverts BadDraw, the catch on lines 119-121 logs a short message for each RPC, nothing marks the id as sent, tick retries it every second until it leaves the window, and line 135 only logs 'left the draw window undrawn'. alert() fires in exactly two places: a mined draw that reverted (line 115) and a low gas balance (line 142). The same silent loop happens when the chain's base fee exceeds the default MAX_GWEI = 1 (line 25): sends are rejected or never mined. Players lose time, not IMD, but the game is dead until a human reads the logs, contrary to the README's 'the house must stay online' assumption and the HANDOFF check that the service is alerted on failure. Fix: re-check houseKey() against the local modulus on every tick (or on any BadDraw-shaped simulation failure) and alert and exit, or load the pending key from a second key file and switch on HouseKeySet; call alert() (rate-limited) in the line-135 branch and when the same swing fails simulation repeatedly.

**Reproduction**

Code trace against house/house.mjs: modulus is computed once at line 60 and compared once at line 149; drawOne() (lines 94-123) signs with the start-up houseKey object every time; the only alert() calls are lines 115 and 142; line 135 is a log. Contract side reproduced in test/scratch/Judge.t.sol::test_anyoneActivatesKeyLaterAndInFlightDrawsFail (draw with the old key reverts BadDraw after activation, expire refunds) and test_swingAcceptedWhileKeyRevoked (after revokeHouseKey a swing is still accepted and only refundable after DRAW_WINDOW). Expected: the service notices the key change (or the unmined sends) and pages the operator. Actual: per-swing log lines only, every swing refunds.

### 6. Low: house.mjs draws strictly serially and blocks up to 60 s per unmined draw, so a burst of uncapped agent-league swings or one stuck transaction pushes other players' swings out of DRAW_WINDOW

`house/house.mjs:138`

```
    await drawOne(id, chainNow);
```

Merged from the permissions and flow reports. tick() iterates the pending map in id order and awaits drawOne for each; drawOne performs four awaited RPC calls (swings, staticCall, estimateGas, send) and then awaits tx.wait(1, 60_000) (line 114) before returning, and the next tick does not start until the current one finishes. Throughput is therefore one draw per block confirmation plus round trips, and a draw that is accepted but not mined (base fee above MAX_GWEI, RPC outage) costs 60 s, then another sign/simulate/send/wait on RPC 2. Nothing bounds how many swings can be committed: the agent league has no cap and swing() costs about 100k gas, so one wallet with prebought turns can commit hundreds of swings in seconds. Every honest swing behind the burst is first attempted after the burst clears, and any swing not drawn by committedAt + 300 s is DrawClosed and refunded. The attacker's undrawn swings refund too, so the cost per window is only the turns of the swings that were drawn (0.1 IMD each at pack price) plus gas, while every other player's swings in that window are voided and the house pays the draw gas for the drawn ones. This is an availability asymmetry between an uncapped entry point and a serial off-chain dependency, not a fund loss. Fix: do not block the loop on receipts (track sent transactions separately and poll them), sign and send several draws per tick with explicit nonce management, order by committedAt deadline rather than id, alert when a swing nears its deadline undrawn, and consider an on-chain drawMany(ids, sigs) so one transaction clears a burst.

**Reproduction**

Code trace: house.mjs line 131 sorts pending by id and line 138 awaits each drawOne; line 114 awaits tx.wait(1, 60_000); lines 154-157 schedule the next tick only after the current one resolves. Scenario: attacker commits 600 agent swings within ~20 s at 12:00:00 and an honest arcade player commits one at 12:00:30. At one draw per ~1-3 s the service reaches the honest swing after roughly 200-600 s, past committedAt + 300 s, so draw() reverts DrawClosed (SwarmDerby.sol line 342) and the swing can only be refunded through expire(). With MAX_GWEI = 1 and a base fee of 1.5 gwei, each stuck draw adds 60-120 s before the next id is tried, so from the fourth queued swing on the window is already gone. Expected: a burst in one league cannot void another league's swings. Actual: serial processing with blocking waits lets it.

### 7. Low: swing() accepts commits while houseKey is empty after revokeHouseKey, so every swing during a revocation is a guaranteed 5-minute wait plus an expire() instead of a clear revert

`src/SwarmDerby.sol:320`

```
        if (commit == bytes32(0)) revert BadCommit();
```

From the flow report. After revokeHouseKey() HouseDraw.verify returns false for every signature (modulus.length == 0), yet swing() still spends the turn, takes the arcade cap slot, extends dayLastCommit by 10 minutes and emits SwingCommitted. The player's client waits DRAW_WINDOW and must send expire() to get the turn back, and cannot distinguish this state from house downtime, although the revocation is known on-chain at commit time. Fix: in swing(), after the quality check, revert when houseKey.length == 0 (BadKey or a dedicated NoHouseKey error) so the page shows the pause and no turn moves.

**Reproduction**

test/scratch/Judge.t.sol::test_swingAcceptedWhileKeyRevoked: owner calls revokeHouseKey() (houseKey().length == 0); Alice with 5 arcade turns calls swing(0, 100, 100, commitFor(salt, alice)). Expected: revert. Actual: swing accepted, turns(0, alice) == 4, draw(id, any 256 bytes) reverts BadDraw, expire(id) reverts NotExpired until block.timestamp > committedAt + 5 minutes, then restores turns(0, alice) == 5.

### 8. Info: Session-key consent has no deadline: an unused EIP-712 consent stays valid until the key's nonce moves, and binding it later strands turns the key bought as a player

`src/SwarmDerby.sol:242`

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

From the math report. Session(address player,address session,uint256 nonce) carries no deadline. A key that signed consent for player P but was never bound can be bound by P at any later time while sessionNonce[session] is still 0, including after the key's holder started using the address as an ordinary player with turns: those turns then sit under the key's address where neither the key (playerOf(key) == P) nor P can spend them until the key calls leaveSession. The key holder consented and can always leave, so there is no loss to a third party; hardening only. Fix: add a deadline to the struct and check block.timestamp <= deadline in setSession, or let a key bump its own nonce.

**Reproduction**

test/scratch/Judge.t.sol::test_sessionConsentNeverExpires: key K signs sessionDigest(alice, K) at nonce 0; 400 days later K buys 5 arcade turns as a player; alice calls setSession(K, consent): expected under the usual EIP-712 pattern that stale consent expires, actual binding succeeds, playerOf(K) == alice, turns(0, K) == 5 and K's swing reverts NoTurns because it now spends alice's turns.

### 9. Info: _trySend reverts instead of returning false when the token answers with a 32-byte word other than 0 or 1, unlike DerbyAuction._accepted, so a malformed winner transfer would block settleNextDay

`src/SwarmDerby.sol:649`

```
        return ok && (data.length == 0 || (data.length == 32 && abi.decode(data, (bool))));
```

From the math report. abi.decode(data, (bool)) reverts for any 32-byte word other than 0 or 1, so a transfer whose return data is malformed turns the 'failed send rolls over' paths (settleNextDay winners, slam payout) into a revert of the whole call, contrary to the NatSpec 'one unpayable winner can't stop the queue'. DerbyAuction._accepted (src/DerbyAuction.sol lines 370-377) decodes the word in assembly and treats such data as a failed send, so the two contracts handle the same boundary differently. The Robinhood IMD token returns standard booleans, so this is not reachable today; it matters only if the token is upgraded to a non-standard return. Fix: load the word in assembly as DerbyAuction does and return value == 1.

**Reproduction**

test/scratch/Judge.t.sol::test_trySendRevertsOnNonBooleanWord with a mock token whose transfer() returns abi.encode(uint256(2)) for one recipient: Alice homers on day D, the token is set to return 2 for Alice, and settleNextDay(0) on day D+1 reverts right after the winner transfer (trace: transfer to the settler succeeds, transfer to Alice returns, then EvmError: Revert). The control test_controlSettleSucceedsWithStandardReturn pays Alice with a standard return. Expected: the prize rolls over and the queue advances.

---

Judge's submission `6fa4b62114bb7b9d0317ca9ccec9b2c643e217627d91cbdc5645a55763a3d5c1`, 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.
