{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"f8614c57-0570-4af5-aa6f-85eae302d86b","kind":"audit","nodes":[{"acceptedSubmissionHash":"07fcb133d46ab88f125acad6d3f9be47d0d3af7bea9f95f2b2e7e72885f51151","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"e70d862a931c63145c2d4f208b4e0a04c75119b9c7cad729c329dd8968d3e9e0","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"6fa4b62114bb7b9d0317ca9ccec9b2c643e217627d91cbdc5645a55763a3d5c1","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"8173ff70993d4d8af022c67d05f990b49cb9f2902a52bd9650033e1b8ccf71fc","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"4a8a67c688892de255f94680d50a86569f33a22d82cb109a5ccdb01a4472f74a","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"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.","parentJobId":null,"planHash":"c59da5a3cba03c335c2e7c677c0540ce638e207ea8984d4ebf3e5575150ffdd4","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"f8614c57-0570-4af5-aa6f-85eae302d86b","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51231","feedbackHash":"0854e33ddf8bf4ea40959b2bfdee805c87b17dacb883a28959f54bea952616a3","nodeKey":"audit_economics","submissionHash":"07fcb133d46ab88f125acad6d3f9be47d0d3af7bea9f95f2b2e7e72885f51151","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52152","feedbackHash":"9e2e2eb95a92f62575838bd2fe59242ac736785d4472568d9b3b0bcb86d59be2","nodeKey":"audit_flow","submissionHash":"e70d862a931c63145c2d4f208b4e0a04c75119b9c7cad729c329dd8968d3e9e0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52169","feedbackHash":"62801168eaf2677728bb68ee1389c904f033f186929527aa488f0696cd1cd02b","nodeKey":"audit_judge","submissionHash":"6fa4b62114bb7b9d0317ca9ccec9b2c643e217627d91cbdc5645a55763a3d5c1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52157","feedbackHash":"05b3785c6c673e0318feadf5a5a615aa7cfef845a416bcf7cb857f3e1760fdee","nodeKey":"audit_math","submissionHash":"8173ff70993d4d8af022c67d05f990b49cb9f2902a52bd9650033e1b8ccf71fc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51043","feedbackHash":"d95a5e0bb6d6510065f7c10ea1fe548c74ca3f2f25903669a672d2c419684c46","nodeKey":"audit_permissions","submissionHash":"4a8a67c688892de255f94680d50a86569f33a22d82cb109a5ccdb01a4472f74a","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7bf51be3ba7183966f6def14203017da8997c7f2da33deac1314b3d539ed42c7","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1507f63d3f1b973a","findings":[{"citation":"resolved","description":"The contract's own comment (lines 30-33) says 'the house can't choose it ... and doesn't know the salt, and an unrevealed draw counts as a foul, so nobody can steer a roll and hiding a bad one never pays', and HANDOFF.md says the owner 'can never touch pots or vaults'. Neither holds once the house key holder is also the player of a swing, or once any player hands its salt to the house operator. The roll is keccak256(salt, keccak256(sig)); sig is deterministic for the (swingId, player, commit) message, so whoever holds the key knows the outcome of a committed swing the moment it exists, and because an undrawn swing is refunded by expire (lines 388-393) the house has a free option: draw the winners, let the losers expire, get every losing turn back. There is a second route that does not even need the refund: swingId is nextSwingId (predictable while the chain is quiet), so the key holder can grind candidate salts offline, signing each candidate drawMessage, and only commit a salt that it already knows will slam. Either route pays 10% of the league's slam vault per slam (SLAM_VAULT_SHARE_BPS) and guarantees the top of the agent board (every drawn swing is a homer or better), i.e. the day pot too. DEPLOY.md's 'Known limits' names the rule 'the holder of the house key must not play' and warns about a key the owner holds, so this is 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 the answer is yes on both counts, with nothing on chain making it cost anything: the owner reaches it through proposeHouseKey -> activateHouseKey (2 days, key indistinguishable from a good one), the operator reaches it directly, and any ordinary player reaches it by sending the house its salt (the house then only draws that player's winners). _checkKey (line 589) is shape-only; a modulus with a factor p where 65537 divides p-1 admits 65537 valid signatures per signable message, which the contract also cannot detect, but that route is weaker than the two above because it leaves 65536 of 65537 honest swings unsignable. Minimal mitigations that keep the design: (a) make withholding cost the accomplice its turn, i.e. treat an undrawn swing as a foul unless houseKey is empty (the owner has revoked, declaring the outage) so the refund stays for a real outage; (b) to close the pre-commit grind, mix into the signed message something the committer cannot know when it builds the commit, e.g. the previous block hash stored at commit, so the key holder cannot evaluate candidate salts before the swing lands; (c) at least correct the comment at lines 30-33 and the HANDOFF 'never touch pots or vaults' claim so operators and players read the real trust model.","line":390,"path":"src/SwarmDerby.sol","reproduction":"Checked with the test house key (test/HouseKey.sol modulus and exponent) against the unmodified SwarmDerby in a Foundry test. Setup: honest players buy 1000 agent turns (150 IMD), so vault(1) = 15 IMD and pot(1) = 67.5 IMD; the house wallet buys 50 agent turns (vault becomes 15.75 IMD). Route A (grind before commit): for id = nextSwingId, try salts keccak256(abi.encode('grind', i)) for i = 1, 2, ...; for each compute commitFor(salt, house), sign abi.encode(DRAW_TAG, chainid, derby, id, house, commit) with the house key and evaluate DerbyOdds.roll(swingSeed(salt, keccak256(sig)), id, 100, 100). Salt number 44 rolls SLAM. The house then calls swing(1, 100, 100, commitFor(salt44, house)) -> gets id, draw(id, sig), finalize(id, salt44): tier == SLAM and the house receives 1,575,000,000,000,000,000 wei (10% of the 15.75 IMD vault) for one 0.15 IMD turn. Repeating drains the vault geometrically. Route B (withhold): the house commits 50 swings with known salts, computes each signature, draws and finalizes only the 29 whose roll is HOMER or better (expected: 65% at quality 100), does not submit the 21 losing signatures, warps past DRAW_WINDOW and calls expire on them: turns(1, house) goes from 0 back to 21 (expected per the current code; a fair player would have 0), all 29 resolved swings are homers, and board(1, day)[0] is the house with 11,882 feet. Expected by the stated model: a bad outcome always costs the swing's turn and nobody can pick a slam. Actual: the key holder never pays for a bad outcome and can pick slams at will.","severity":"medium","snippet":"            s.status = Status.Refunded;\n            turns[s.league][s.player] += 1;","title":"Holding back a draw is free for the house: the key holder (or a player who shares its salt with the house) gets a costless re-roll, and can also pick a slam before committing"},{"citation":"resolved","description":"The header comment and house/README.md promise that 'a signature stays secret until its draw is mined' and that it goes 'only into the eth_call that simulates the draw and into the draw transaction'. Both of those go to whatever answers RPC_URL, which defaults to the public https://rpc.mainnet.chain.robinhood.com, a third party. The signature therefore leaves the house before the draw is mined, at three points (staticCall on line 110, estimateGas on line 111, the raw transaction on line 112). The message signed on lines 106-108 is built from s.player and s.commit returned by derby.swings(id) on line 99, and the pending ids come from SwingCommitted logs served by the same RPC (line 86). The README's own line 'Use RPCs you trust. An RPC that lies about a swing could get the house to sign a draw for a swing that does not exist yet' acknowledges half of this, but the shipped default contradicts it and the leak-before-mining half is not covered. Two concrete consequences for an RPC operator who also plays: (1) pre-signing: serve a fabricated SwingCommitted(id = X in the future, player = attacker) log and answer swings(X) with status Committed, player = attacker and commit C of the attacker's choice; the house signs and sends the signature in the staticCall; the RPC returns success, a fake tx hash and lets tx.wait time out. The attacker now holds the unique valid signature for (X, attacker, C) and can evaluate the roll for its salt before ever committing; it repeats this for many (X_i, C_i) pairs and commits only the pairs that slam when nextSwingId reaches X_i (the swing is then drawable by anyone with that signature). A second honest RPC does not help: the signature left in the first staticCall. (2) censorship: for its own real swing the RPC reads the signature from the staticCall, evaluates the roll, and for a bad roll returns 'execution reverted' to every simulate/send for five minutes; without RPC_URL_2 the service retries the same RPC each tick (line 119-121, then tick again), the draw window closes and expire refunds the turn, i.e. the free re-roll of the previous finding without holding the key. Fix: run the house against a node the operator controls (and say so in README and in the default), never send a signature to a third party before broadcast (simulate against the local node, or skip the simulation and rely on the on-chain checks plus the alert), read logs and swings() from the local node, and raise an alert when the same swing fails simulation repeatedly.","line":110,"path":"house/house.mjs","reproduction":"Start house.mjs with the defaults (RPC_URL unset, RPC_URL_2 unset) and point RPC_URL at a proxy that forwards everything except: eth_getLogs returns one extra SwingCommitted log with swingId = nextSwingId + 100, player = P (the proxy owner), committedAt = now; eth_call for swings(nextSwingId + 100) returns (P, 1, 100, 100, status 1, now, C, day, 0) with C = commitFor(salt, P) for a salt P chose; eth_call for draw(id, sig) and eth_estimateGas return success; eth_sendRawTransaction returns a random hash and drops the transaction. Expected by the README: the house never signs a swing that does not exist and the signature is unknown to anyone until the draw is mined. Actual: the proxy logs a 256-byte sig that satisfies HouseDraw.verify(houseKey, drawMessage(nextSwingId + 100), sig) once that swing exists with player P and commit C, so P computes DerbyOdds.roll(swingSeed(salt, keccak256(sig)), id, 100, 100) in advance and decides whether to commit.","severity":"low","snippet":"      await derby.draw.staticCall(id, sig); // simulate: a draw must never land and revert","title":"house.mjs reveals each signature to the RPC endpoint before broadcast and trusts the RPC for the swing it signs; with the default public RPC an RPC operator can pre-sign its own future swings or censo"},{"citation":"resolved","description":"SwarmDerby.activateHouseKey (src/SwarmDerby.sol lines 571-577) is callable by anyone once pendingHouseKeyAt has passed, and draw verifies against the key current at draw time, so the instant the key flips every signature the running service produces reverts with BadDraw. house.mjs reads houseKey() once (line 149), never watches HouseKeySet, and its failure path (lines 119-121) only logs 'draw #id failed on RPC n: execution reverted' once per tick per swing; alert() fires only for a mined revert or a low gas balance, so nobody is paged. Until the operator notices and restarts with the new PEM, every swing waits five minutes and refunds. Because activation is permissionless, the moment is chosen by whoever calls it: a player can hold the call until, for example, 23:57 UTC on a day with a large pot so that competitors' late arcade and agent swings all refund and never score (dayClosed then arrives at 00:10 and settleNextDay pays the board as it stands). Swings committed shortly before the flip are not lost for a service that follows the key: draw checks the key at draw time, so a signature with the new key is valid for them. Fix: subscribe to HouseKeySet (or re-read houseKey() whenever a simulation reverts with BadDraw), support a second key file for the pending key so the service switches keys the moment HouseKeySet is emitted, and alert when the same swing fails simulation more than a few times.","line":149,"path":"house/house.mjs","reproduction":"State: service running with key K1 matching houseKey(); owner has called proposeHouseKey(K2) two days earlier. Any account calls activateHouseKey() at 23:57 UTC. From then on every drawOne() hits BadDraw in derby.draw.staticCall, the catch on line 119 logs it, no alert is sent, and every swing committed after 23:52 UTC that day (and all later ones) is refunded by expire instead of drawn. Expected: the service notices HouseKeySet and either switches to the already-known pending key or alerts the operator immediately. Actual: silent outage, and the arcade and agent boards of that day close without the late swings.","severity":"low","snippet":"  if ((await derby.houseKey()).toLowerCase() !== modulus) throw new Error('HOUSE_KEY_FILE does not match the houseKey of DERBY');","title":"house.mjs checks houseKey() only at startup; after a permissionless activateHouseKey every draw fails silently until the operator restarts it, and anyone can pick that moment"},{"citation":"resolved","description":"The only mitigation offered against an owner-held key (DEPLOY.md 'Known limits': 'a key the owner holds would let the owner's accomplice steer rolls') is the two-day notice. Turns are prepaid and there is no function that converts turns back into IMD; expire only returns turns, not money. So a player holding turns bought before HouseKeyProposed can either play them against a key it does not trust or abandon them, and the 45% pot share and 10% vault share of those purchases are already in pots the accomplice would win. The same applies after revokeHouseKey: all turns are frozen for at least KEY_DELAY with no way out. This is the stated design (no turn refunds), so it is recorded as a gap in the stated mitigation rather than a defect: either document that the notice only protects future purchases, or add an owner-independent refund of unused turns that is enabled while a key proposal is pending or while houseKey is empty, paid from that purchase's non-burned 60%.","line":63,"path":"src/SwarmDerby.sol","reproduction":"Player buys 100 agent turns on day D (15 IMD: 6 burned, 6.75 to pot, 1.5 to vault, 0.75 ops). On day D the owner calls proposeHouseKey(K) for a key it holds. The player reads HouseKeyProposed, decides to stop playing, and looks for a way to recover value: swing/expire returns turns, not IMD; withdrawOps is owner-only and limited to opsBalance; there is no other transfer path. Expected per the KEY_DELAY rationale: the player can step aside without loss. Actual: 15 IMD spent, 100 turns stranded or played against a key whose holder can steer its own rolls.","severity":"info","snippet":"    /// @notice A new house key takes effect only this long after the owner proposes it, so\n    ///         players can see a key change coming and stop playing.","title":"KEY_DELAY lets players 'see a key change coming and stop playing', but prepaid turns cannot be refunded, so a player who distrusts the new key or sits through a revoke has no exit"}],"hash":"07fcb133d46ab88f125acad6d3f9be47d0d3af7bea9f95f2b2e7e72885f51151","nodeId":"d207af4a-1da6-42d6-8526-b2fca4625361","outcome":"completed","summary":"The review is complete. The working tree is clean apart from the deliverable, which the repo's ignore rules already cover.\n\n**Outcome.** The on-chain code is sound within the assigned area. I found no undocumented defect in the RSA verification, the draw/finalize/expire timing, single-use commits, dayClosed and settlement, the key change, the session consent or the 40/45/10/5 accounting. All 114 tests pass. The findings in `.imd-findings.json` are one medium, two low and one info, all with concrete inputs.\n\n**What I verified on chain**\n- RSA path: PKCS#1 v1.5 encoding is exactly 256 bytes (205-byte padding plus DigestInfo and hash), the modexp call is well formed, the signature must be strictly below the modulus, and a proper key yields one signature per message. The modulus shape check cannot see a deliberately weak key, but that route is weaker than the one in the medium finding.\n- Timing: draw at or before commit plus 5 minutes, refund strictly after, scoring reveal at or before 10 minutes, foul after, and dayClosed strictly after the last commit plus 10 minutes. These never overlap, so a late reveal can never score a settled day.\n- Accounting: contract balance always equals the two pots, two vaults and ops; failed prize sends roll over; rollover and tip math match the view function.\n- Session consent: domain, struct hash, per-key nonce and malleability checks are correct, and key chains are refused.\n\n**Findings written**\n- **Medium.** Withholding a draw is free for the house. With the test key I showed both routes against the unmodified contract: grinding 44 candidate salts before committing yields a guaranteed slam that paid 1.575 IMD from a 15.75 IMD vault for one 0.15 IMD turn, and committing 50 swings then drawing only the 29 winners returned all 21 losing turns through expire. The DEPLOY notes name the rule that the key holder must not play, so this is a trust assumption, but the in-code comment and HANDOFF overstate the guarantee. I proposed design-preserving mitigations and flagged that they need a scope decision.\n- **Low.** The house service sends each signature to the configured RPC in the simulation and gas estimate before broadcast, and takes the swing's player and commit from that RPC. With the shipped default of a public third-party RPC, the RPC operator can pre-sign its own future swings or censor its own bad draws.\n- **Low.** The house service reads the key only at startup. Because anyone can call activateHouseKey, a third party can choose the moment every draw starts failing silently until an operator restart.\n- **Info.** The two-day key notice offers no exit to players holding prepaid turns, since turns never convert back to IMD.\n\n**Not covered.** No proof tests were attached because no finding reached high severity. DerbyAuction was read only as context and the browser odds twin was not reviewed.","treeHash":null,"usage":{"cachedInputTokens":1608441,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":60620,"runtime":"claude","turns":35,"wallClockMs":995395}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2565f234b0a569e9","findings":[{"citation":"resolved","description":"The contract's guarantee that \"the player can't make the signature\" only holds while the house signs tuples that already exist on chain. drawMessage(swingId) is abi.encode(DRAW_TAG, chainid, contract, swingId, player, commit): every field is chosen by the player (swingId is the public nextSwingId, commit = keccak256(salt, player)). house.mjs learns the swing id from eth_getLogs (scan, line 86), reads player and commit from eth_call swings(id) (line 99), signs that tuple (line 109), simulates draw through the same RPC (line 110) and sends the transaction through it (line 112). Nothing checks the data against a second source, a verified block header, or nextSwingId(). An RPC that answers with a fabricated SwingCommitted log and a fabricated swings(id) for id = nextSwingId therefore receives a valid RSASSA signature for a swing the attacker has not committed yet, inside the eth_call and eth_sendRawTransaction payloads. Because the signature is deterministic (same tuple, same signature), the attacker evaluates DerbyOdds.roll(keccak256(salt, keccak256(sig)), id, 100, 100) offline, discards losing salts, and commits only a salt whose roll is a SLAM (one per ~125 tries at quality 100). The house then draws the real swing with the identical signature and the attacker reveals. This yields every slam vault payout (10% of the league vault per slam) and any daily board position the attacker wants, at the cost of turns only. The attack is reachable by whoever controls or can impersonate the RPC the house talks to: the default RPC_URL is the public third-party endpoint https://rpc.mainnet.chain.robinhood.com and RPC_URL_2 is tried on any failure, so a compromised provider, a poisoned DNS/TLS path, or an operator-chosen bad endpoint suffices. README.md names this risk in one sentence (\"Use RPCs you trust\") but ships no mitigation. Fix (off-chain, minimal): before signing, confirm independently that the swing exists: read nextSwingId() and swings(id) from a second, independently operated RPC (or a self-run node) and sign only when both agree that id < nextSwingId and (player, commit) match; never pass the signature to eth_call or eth_sendRawTransaction on an endpoint that failed the check. Defense in depth (on-chain, optional): store a value at commit time that the player cannot know in advance (for example blockhash(block.number - 1) on chains where it is meaningful) in the Swing and include it in drawMessage, so a signature obtained for a fabricated tuple can never match the real swing's message.","line":108,"path":"house/house.mjs","reproduction":"State: SwarmDerby v2 deployed with houseKey N; house.mjs running against RPC R controlled by the attacker; attacker holds turns in the agent league. Inputs: (1) R answers eth_getLogs with one SwingCommitted(swingId = nextSwingId, player = attacker, committedAt = now). (2) R answers eth_call swings(nextSwingId) with (player = attacker, league 1, quality 100, velo 100, status 1 Committed, committedAt now, commit = keccak256(abi.encode(salt_i, attacker)), day, 0). (3) house.mjs line 106-109 signs abi.encode(drawTag, 4663, DERBY, nextSwingId, attacker, commit) and sends draw(nextSwingId, sig) to R at line 110/112; R records sig and drops the transaction. (4) Attacker computes DerbyOdds.roll(keccak256(abi.encode(salt_i, keccak256(sig))), nextSwingId, 100, 100). If it is not SLAM, repeat from (1) with salt_{i+1}. The scratch test test/scratch/Probe.t.sol::test_signatureIsPredictableBeforeTheSwingExists does exactly this with the repo's test house key: it found a SLAM salt after 180 offline signatures, then called swing(1, 100, 100, commitFor(salt, attacker)) -> id == nextSwingId, draw(id, sig) succeeded with the pre-made signature, and finalize(id, salt) returned tier SLAM. Expected: a signature can only be obtained for a swing that exists on chain, so the player never knows the draw before committing. Actual: the house signs any tuple the RPC reports, and a signature obtained this way is accepted by draw for the swing created afterwards.","severity":"medium","snippet":"        [drawTag, CHAIN_ID, DERBY, id, s.player, s.commit]);","title":"House service signs draw messages built from unverified RPC data, so a lying RPC yields signatures for swings that do not exist yet and lets a player grind the salt before committing"},{"citation":"resolved","description":"The contract states (lines 63-65, 562-563, DEPLOY.md Known limits) that a new house key \"takes effect only this long after the owner proposes it, so players can see a key change coming and stop playing\". activateHouseKey only checks that the delay has elapsed; a proposal never expires and the switch can happen at any block after pendingHouseKeyAt, with no further notice. Once two days have passed, the active key is therefore the pending key \"at will\": the owner (or anyone) can keep the game running on K1 for weeks and flip to K2 inside a single block at a time players cannot anticipate. Players who watched HouseKeyProposed two days or two months earlier have no signal for the actual switch other than the HouseKeySet event emitted in the same transaction. The threat the delay is meant to cover (DEPLOY.md: \"a key the owner holds would let the owner's accomplice steer rolls\") needs only minutes after an unannounced switch: swings committed by the accomplice right after activation are drawn with the key the owner holds, and the accomplice can grind salts exactly as in the house-service finding. Swings in flight under K1 at the switch revert with BadDraw and are refunded, so honest players lose nothing but the warning they were promised. Fix: give a proposal an activation window, for example revert in activateHouseKey when block.timestamp > pendingHouseKeyAt + 1 days (the proposal then lapses and must be re-proposed, re-emitting HouseKeyProposed with a fresh activeAt), so the switch moment is always known within a bounded, recently announced interval. HANDOFF.md step 4 already asks the deployer to confirm pendingHouseKeyAt() == 0 after launch, which shows the lingering-proposal case is not intended.","line":572,"path":"src/SwarmDerby.sol","reproduction":"State: derby live with key K1; owner calls proposeHouseKey(K2) at T0 (HouseKeyProposed(keccak(K2), T0 + 2 days)); nobody calls activateHouseKey; play continues on K1. Input: at T0 + 30 days, in the same block, (1) address 0xBEEF (anyone) calls activateHouseKey(); (2) a K2 holder commits swings. Expected per NatSpec: the change is visible KEY_DELAY ahead of taking effect. Actual: houseKey becomes K2 immediately at an unannounced time; a swing committed one block earlier (id, Committed) now reverts draw(id, sigK1) with BadDraw and is later refunded, while swings committed from this block on are drawn under K2. Scratch test test/scratch/Probe.t.sol::test_activationMomentIsUnannounced reproduces: proposeHouseKey(k2); warp(T0 + 30 days); swing(...) -> id; sigK1 = sign(drawMessage(id)); prank(0xBEEF) activateHouseKey(); draw(id, sigK1) reverts BadDraw; houseKey() == k2.","severity":"low","snippet":"        if (pendingHouseKeyAt == 0 || block.timestamp < pendingHouseKeyAt) revert KeyNotReady();","title":"KEY_DELAY announces the proposal but not the switch: a ready pending key can be activated by anyone at an arbitrary later moment, so the advertised warning window does not exist at activation time"},{"citation":"resolved","description":"alert() is called in exactly two places: a mined draw that reverted (line 115) and a low gas balance (line 142). The conditions that actually stop the house from drawing are only logged: (a) after activateHouseKey or revokeHouseKey on chain, the running service keeps signing with the key loaded at start-up (the houseKey() comparison at line 149 runs once, in main); every draw.staticCall then reverts BadDraw, drawOne logs \"draw #id failed on RPC n: ...\" for each RPC, nothing marks the id as sent, and tick retries it every second until it leaves the window, where line 135 logs and moves on; (b) with the default MAX_GWEI = 1 (line 25), maxFeePerGas is capped at 1 gwei; when the chain's base fee is above that, estimateGas/send fail or the transaction is never included, with the same silent outcome. In both states every committed swing is refunded after 5 minutes (players lose time and the game is dead) with no operator notification, which contradicts the README's operating assumption that the house \"must stay online\" and that the operator is alerted on failure. Fix: in the line-135 branch call alert(...) (rate-limited) instead of log, and re-check houseKey() against the local modulus on every tick or on any BadDraw-shaped failure, exiting with an alert when they differ.","line":135,"path":"house/house.mjs","reproduction":"State: house.mjs started with HOUSE_KEY_FILE for K1, DERBY's houseKey() == K1. Input A: owner proposes K2, two days later anyone calls activateHouseKey() (or owner calls revokeHouseKey()). A player commits swing #n. Actual: drawOne reads status Committed, signs with K1, draw.staticCall reverts BadDraw, both RPCs log a short failure, the id is retried every tick, after 300 s line 135 logs \"left the draw window undrawn\"; no alert is raised; the player must call expire(#n) for a refund; this repeats for every swing until an operator notices the logs. Expected: alert() fires (and the service refuses to keep running with a key that does not match houseKey()). Input B: same service with MAX_GWEI unset while the chain base fee is 1.5 gwei. Actual: every send is rejected or never mined; same silent refund loop; expected: alert.","severity":"low","snippet":"      if (s?.status === COMMITTED) log(`#${id} left the draw window undrawn; the player can expire it for a refund`);","title":"House service never alerts when draws stop landing: a rotated or revoked key, or a base fee above MAX_GWEI, silently turns every swing into a refund"},{"citation":"resolved","description":"tick() iterates the pending map sorted by swingId and awaits drawOne for each; drawOne performs four awaited RPC calls (swings, staticCall, estimateGas, send) and then awaits tx.wait(1, 60_000) before returning (line 114). Throughput is therefore at most one draw per (block confirmation + four round trips), roughly 1-3 draws per second against a public RPC, so at most a few hundred draws fit inside the 300 s DRAW_WINDOW. Nothing bounds how many swings can be committed in that time: the agent league has no cap and swing() costs ~100k gas, so one wallet with pre-bought turns can commit many hundreds of swings in a few seconds. Because the queue is ordered by id, every honest swing committed after the burst is drawn only after the burst clears, and all swings that reach committedAt + 300 s undrawn are refunded through expire. The attacker's undrawn swings are refunded too (turns and arcade slots come back), so the attacker's cost per window is only the turns of the swings that were drawn (at most the service's capacity per window, about 0.1 IMD each) plus gas, while every other player's swings in that window are voided and the day's board freezes. This is an availability asymmetry between an uncapped on-chain entry point and a serial off-chain dependency rather than a fund-loss bug. Fix: draw concurrently (sign and send several draws per tick with explicit nonce management, waiting for receipts in the background), prioritise by committedAt deadline rather than id, and consider an on-chain drawMany(uint256[] ids, bytes[] sigs) so one transaction clears a burst.","line":138,"path":"house/house.mjs","reproduction":"State: house.mjs running normally; attacker holds 600 agent-league turns (60 IMD at pack price). Input: attacker sends 600 swing(1, 100, 100, commit_i) transactions within ~20 s at 12:00:00; an honest arcade player commits one swing at 12:00:30. Actual: pending is processed in id order at ~1-3 draws/s, so the service reaches the honest swing only after roughly 200-600 s; any swing not drawn by committedAt + 300 s is DrawClosed and must be refunded via expire(); the honest player's swing and most of the attacker's swings refund, the attacker loses only the turns of the ~300 swings that were drawn in time. Repeating the burst every 5 minutes keeps the arcade league unplayable for the whole day at a cost of about 30 IMD per window. Expected: a burst in one league cannot push another league's swings out of their draw window.","severity":"low","snippet":"    await drawOne(id, chainNow);","title":"Draws are processed strictly serially with a one-confirmation wait each, so a burst of uncapped agent-league swings starves later swings out of DRAW_WINDOW and forces refunds for other players"}],"hash":"4a8a67c688892de255f94680d50a86569f33a22d82cb109a5ccdb01a4472f74a","nodeId":"7b740a92-ccc7-4eba-b4fd-93096a994627","outcome":"completed","summary":"The review is complete. The findings file is in place at the repository root with four substantiated findings, all snippets verified against the tree, and no source or configuration files were modified (only `.imd-findings.json` and `test/scratch/`, both ignored by a harness-level ignore rule).\n\n**Result: no critical or high defects in the on-chain code.** The SwarmDerby v2 contracts hold up under the assigned Access Control, Trust Gap and Asymmetry passes. The permission surface has no gaps: every state-changing entry point is either correctly owner-gated, time-gated with complementary boundaries, or gated by something only the right party can produce (a house signature, a salt, a session key's EIP-712 consent). The draw and expire deadlines never overlap, the late-reveal foul condition is exactly the complement of `dayClosed`, the 40/45/10/5 split and the pot/rollover/vault accounting conserve on every path including failed transfers, and the RSA verification (PKCS#1 v1.5 encoding, full-EM comparison, `sig < N` check, top-bit key check guaranteeing EM < N) is correct for a properly generated key. Neither the house nor a player can predict or steer a roll alone, and holding back a reveal never pays because a foul equals the worst outcome.\n\n**What I found** (ranked):\n\n- **Medium, house service:** the signer builds the draw message from swing data it reads back from its RPC without any independent check. A lying or compromised RPC can obtain a valid signature for a swing that does not exist yet, which lets a player grind salts offline and commit only slams. I confirmed the on-chain half with a scratch test: a slam salt was found in 180 offline signatures and the pre-made signature was accepted by `draw` for the swing created afterwards. The README mentions the risk in one line; the default config points at a public third-party RPC. Fix: confirm swing existence through a second independent source before signing, optionally bind an unpredictable commit-time value into `drawMessage`.\n- **Low, contract:** the key-change delay announces the proposal but not the switch. A ready pending key never lapses and can be activated by anyone at an arbitrary later moment, so the \"players can see it coming\" guarantee does not hold at activation time. Fix: give proposals a bounded activation window.\n- **Low, house service:** no alert fires when draws stop landing after a key rotation, a revocation, or a base fee above the 1 gwei cap. Every swing silently becomes a refund.\n- **Low, house service:** draws are processed serially with a one-confirmation wait each, so a burst of uncapped agent-league swings pushes later swings out of the draw window and forces refunds for other players.\n\n**Trust assumptions documented, not reported as defects:** the owner selects the house key (a key the owner holds enables steering by an accomplice), the house can withhold draws for chosen players, a sequencer colluding with a player can force refunds, the client reports quality and velo, and the IMD token owner can block transfers. Each is already stated in DEPLOY.md.\n\n**Coverage:** all twelve specialty passes were applied to `SwarmDerby.sol`, `HouseDraw.sol`, `DerbyOdds.sol` and `house/house.mjs`; `DerbyAuction.sol` was read as context only. No fuzzing beyond the repo's own suite and no static analyzer ran. The full suite passes (114 tests) and the three scratch probes pass.","treeHash":null,"usage":{"cachedInputTokens":1803181,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":56673,"runtime":"claude","turns":27,"wallClockMs":914710}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e8816d4386532a66","findings":[{"citation":"resolved","description":"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.","line":108,"path":"house/house.mjs","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.","severity":"medium","snippet":"        [drawTag, CHAIN_ID, DERBY, id, s.player, s.commit]);","title":"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"},{"citation":"resolved","description":"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.","line":32,"path":"src/SwarmDerby.sol","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.","severity":"low","snippet":"///           know the salt, and an unrevealed draw counts as a foul, so nobody can steer a roll","title":"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 "},{"citation":"resolved","description":"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.","line":321,"path":"src/SwarmDerby.sol","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.","severity":"low","snippet":"        if (commitUsed[commit]) revert CommitUsed();","title":"commitUsed is keyed by the bare commit, so anyone who sees a player's pending swing can burn their commit with one turn"},{"citation":"resolved","description":"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.","line":572,"path":"src/SwarmDerby.sol","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.","severity":"low","snippet":"        if (pendingHouseKeyAt == 0 || block.timestamp < pendingHouseKeyAt) revert KeyNotReady();","title":"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"},{"citation":"resolved","description":"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.","line":149,"path":"house/house.mjs","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.","severity":"low","snippet":"  if ((await derby.houseKey()).toLowerCase() !== modulus) throw new Error('HOUSE_KEY_FILE does not match the houseKey of DERBY');","title":"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"},{"citation":"resolved","description":"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.","line":138,"path":"house/house.mjs","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.","severity":"low","snippet":"    await drawOne(id, chainNow);","title":"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"},{"citation":"resolved","description":"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.","line":320,"path":"src/SwarmDerby.sol","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.","severity":"low","snippet":"        if (commit == bytes32(0)) revert BadCommit();","title":"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"},{"citation":"resolved","description":"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.","line":242,"path":"src/SwarmDerby.sol","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.","severity":"info","snippet":"        bytes32 structHash = keccak256(abi.encode(SESSION_TYPEHASH, player, session, sessionNonce[session]));","title":"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"},{"citation":"resolved","description":"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.","line":649,"path":"src/SwarmDerby.sol","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.","severity":"info","snippet":"        return ok && (data.length == 0 || (data.length == 32 && abi.decode(data, (bool))));","title":"_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"}],"hash":"6fa4b62114bb7b9d0317ca9ccec9b2c643e217627d91cbdc5645a55763a3d5c1","nodeId":"1a1a9efe-2202-4dc3-9966-e69555eb5c96","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 9 findings, all reproduced against the pinned code, with the 21 specialist reports merged by mechanism. No repository files were changed; the only writes are the findings file and `test/scratch/Judge.t.sol`.\n\n**What I verified**\n\n- Baseline suite passes with all 114 tests, and the scratch reproductions pass alongside it.\n- Every finding's path, line and snippet was checked byte for byte against the tree.\n- Each contract-side claim was reproduced in a Foundry test using the repo's public test house key. Each service-side claim was reproduced by a code trace of `house/house.mjs` with the line numbers stated in the finding.\n\n**Findings kept, by severity**\n\n- **Medium.** The house service signs whatever player and commit its RPC reports and hands the signature to that RPC in three calls before broadcast. The default endpoint is a public third party. A colluding RPC and player can grind salts to pre-select slams, or censor a bad draw into a refund. The test confirmed the on-chain enabler: a signature made before the swing existed is accepted, and the 75th grind salt paid 10% of the vault. Four specialist reports merged here.\n- **Low, trust model overclaim.** The key holder, the owner after a key change, or a player who shares its salt with the house can pick slams and re-roll losing swings for free. DEPLOY.md documents the rule, so this stays a trust assumption, but the contract header and HANDOFF text overstate what the code enforces. Reproduced both routes with the test key.\n- **Low, commit burn.** The reuse map is keyed by the bare commit, so a stranger can consume another player's commit with one turn. Reproduced.\n- **Low, key activation.** A ready proposal never lapses and has no cancel, so anyone picks the switch moment and in-flight draws fail. Reproduced, with the owner's only cancel path noted.\n- **Low, stale key and silent failure in the service.** The modulus is compared once at start-up and alerts fire only on a mined revert or low gas. Four reports merged.\n- **Low, serial draws.** One blocking wait per draw lets a burst or a stuck transaction push other players' swings out of the draw window. Two reports merged.\n- **Low.** Swings are accepted while the house key is revoked, costing players a guaranteed five-minute wait. Reproduced.\n- **Info.** Session consent has no deadline. Reproduced.\n- **Info.** A non-boolean 32-byte transfer return reverts settlement instead of rolling over, unlike the auction contract's handling. Reproduced with a mock token and a passing control.\n\n**Severity changes from the specialists.** The key-holder steering report was lowered from medium to low because the behaviour is documented as a trust assumption. The RPC-trust reports were unified at medium because the shipped default contradicts the README and the impact is vault drain. No finding was dropped outright; every one reproduced.\n\n**Proof files.** None attached. No finding reached high or critical, and the scratch tests demonstrate current behaviour rather than failing on it, so they do not meet the proof contract.","treeHash":null,"usage":{"cachedInputTokens":1330956,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":37099,"runtime":"claude","turns":28,"wallClockMs":567422}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"37eed9f56188ea8b","findings":[{"citation":"resolved","description":"A commit is keccak256(abi.encode(salt, player)) and already binds the player (finalize re-derives it with s.player), yet swing() marks commitUsed by the raw commit for every caller. A third party who sees a victim's swing(league, q, v, C) before it is included can call swing(anyLeague, 1, 0, C) from their own account first: their swing is stored with commit C and player = attacker, commitUsed[C] becomes true, and the victim's transaction reverts with CommitUsed. The attacker can never reveal (commitFor(salt, attacker) != C), so they lose one turn (0.15 IMD at launch prices, never under 0.01) and the house pays one draw's gas; the victim loses the transaction and must generate a new salt, and a client that retries the same salt fails again. The griefing is cheap per block and gives the attacker no roll information. Reachability depends on seeing the victim's transaction before ordering, which on Robinhood Chain (Arbitrum Orbit, centralized sequencer, no public mempool) is limited to the sequencer operator or a shared relay, hence low. Fix: scope the reuse check to the player, e.g. mapping(address => mapping(bytes32 => bool)) or commitUsed[keccak256(abi.encode(player, commit))], which preserves the stated purpose (a player cannot reuse a salt the house has already seen) while a stranger's copy of the commit no longer blocks the owner of the salt.","line":321,"path":"src/SwarmDerby.sol","reproduction":"Victim V and attacker A each hold agent-league turns. salt = keccak256('victim salt'); C = derby.commitFor(salt, V). (1) A calls swing(1, 1, 0, C): succeeds, swing id n stored with player A, commit C. (2) V calls swing(1, 100, 100, C): expected success (V owns the salt); actual revert CommitUsed(). (3) The house draws id n; finalize(n, salt) reverts BadSalt because commitFor(salt, A) != C, so A's swing becomes a foul. Verified in a scratch Foundry test (test_commitFrontRunBurnsVictimCommit) against the current code.","severity":"low","snippet":"        if (commitUsed[commit]) revert CommitUsed();","title":"commitUsed is keyed by commit alone, so anyone can burn another player's pending commit with one turn"},{"citation":"resolved","description":"activateHouseKey is permissionless by design so a proposal cannot be stalled, but once pendingHouseKeyAt has passed the proposal can sit ready indefinitely and any account chooses the activation block. house/house.mjs compares HOUSE_KEY_FILE with houseKey() once in main() (line 149) and never re-reads it; every later signature is made with the old key. After a third-party activation (a) every Committed swing whose draw has not been mined fails draw() with BadDraw and is refunded only after the player calls expire() past DRAW_WINDOW, and (b) every new swing fails the same way, because draw.staticCall now reverts for each one, until an operator restarts the service with the new key file. Players lose no IMD, but the game is dead and every swing costs the player a wasted transaction plus an expire() call for as long as nobody notices. The owner cannot prevent a stranger from choosing a peak moment other than by activating promptly themselves. Fix (service side, no contract change): have house.mjs re-read houseKey() on each tick or subscribe to HouseKeySet, and hold the new private key before the proposal so it can switch automatically; optionally refuse to sign while houseKey() != modulus and alert. Alternatively (design change) restrict activateHouseKey to the owner, accepting that a proposal can then be left pending.","line":571,"path":"src/SwarmDerby.sol","reproduction":"Owner proposes key K2 at T; at T + 2 days + 3 hours nobody has activated. Player V commits swing n; the house signs drawMessage(n) with the current key K1 (sig valid now). Attacker calls activateHouseKey() first. Expected under the operator's plan: draw(n, sig) succeeds. Actual: houseKey is K2, draw(n, sig) reverts BadDraw; V must wait DRAW_WINDOW and call expire(n) for the refund; every further draw from the unrestarted house service reverts the same way. Verified in a scratch Foundry test (test_anyoneActivatesKeyAndInFlightDrawsFail) on the current code.","severity":"low","snippet":"    function activateHouseKey() external {\n        if (pendingHouseKeyAt == 0 || block.timestamp < pendingHouseKeyAt) revert KeyNotReady();","title":"Anyone can pick the moment a proposed house key goes live, and the house signer only reads the key at start-up, so a stranger can stall every draw until the operator restarts"},{"citation":"resolved","description":"The comment says the message is not read from the RPC, but s = await derby.swings(id) is an eth_call to the configured RPC, and s.player and s.commit are copied straight into the signed message; only drawTag, CHAIN_ID, DERBY and id are local. An RPC that answers swings(id) with status Committed, player = P and commit = C for an id that has not been minted yet (or that belongs to someone else) gets a valid RSA signature over drawMessage for (id, P, C). A player P colluding with that RPC knows the salt behind C, so they learn drawHash and the exact roll (DerbyOdds.roll(swingSeed(salt, keccak256(sig)), id, q, v)) before committing, and can ask for signatures over many candidate commits until one is a SLAM, then swing with that commit when nextSwingId == id (retrying with the next id if another swing takes it). That defeats the 'nobody can predict a roll' guarantee and drains 10% of the vault per engineered swing. The README names RPC trust as a requirement, so this is a documented trust assumption rather than a contract bug, but the code comment is wrong and the service has no cross-check even when RPC_URL_2 is configured. Fix: emit the commit in SwingCommitted and build the message from the log the service itself scanned (still RPC data, but a second, independently fetched source), cross-check swings(id) across both RPCs when two are configured, and correct the comment.","line":105,"path":"house/house.mjs","reproduction":"Run house.mjs against an RPC under the attacker's control. For id = nextSwingId + 1 have swings(id) return (player = P, commit = keccak256(abi.encode(salt_i, P)), status = 1). drawOne signs drawMessage(id, P, commit) and sends it to the RPC in draw.staticCall and the draw transaction; the RPC records sig and reports a failure. Repeat over salt_i until DerbyOdds.roll(keccak256(abi.encode(salt_i, keccak256(sig))), id, 100, 100) returns tier SLAM. Expected: the house never signs for a swing that is not on-chain. Actual: P commits salt_i as swing id and, once the real draw lands (same deterministic signature), finalize pays the slam.","severity":"low","snippet":"      // Built here from the swing, not read from the RPC, so an RPC can't pick what the key signs.\n      const message = ethers.AbiCoder.defaultAbiCoder().encode(\n        ['bytes32', 'uint256', 'address', 'uint256', 'address', 'bytes32'],\n        [drawTag, CHAIN_ID, DERBY, id, s.player, s.commit]);","title":"house.mjs signs the player and commit it reads from the RPC, contrary to its own comment, so a lying RPC can obtain draws for swings that do not exist yet"},{"citation":"resolved","description":"Documented for the report, not defects in the code. (1) Key quality: _checkKey only tests 256 bytes, top bit, odd. A modulus that is prime, has a small factor, or has gcd(65537, lambda(n)) != 1 (for example p = 1 mod 65537) breaks 'exactly one valid signature per message': the holder of the factorisation can pick among many signatures below n. This only matters together with knowledge of the salt, i.e. when the key holder also plays, which the README already forbids; a properly generated key gives the same holder the same power (they can precompute the roll for any commit before swinging). KEY_DELAY lets players see a key change but not judge the key. (2) Censorship: the house service alone decides which Committed swings get drawn. It cannot see or steer a roll, and an undrawn swing refunds the turn and arcade slot, but by withholding draws from chosen players late in a day it can decide who is able to score for that day's pot. (3) Signature visibility: house.mjs sends the signature to the RPC in eth_call and eth_estimateGas before the transaction; an RPC or sequencer colluding with the player learns the roll first and can suppress a bad draw into a refund, giving that player free re-rolls. RPC_URL_2 only helps after a 60 s wait. These are the actors players must trust; none is reachable by an unprivileged outsider.","line":8,"path":"src/HouseDraw.sol","reproduction":"State, not an exploit path: deploy with houseKey = a 2048-bit modulus of the shape _checkKey accepts but with p = 1 mod 65537; the contract accepts it and every property above holds for the key holder. No test can distinguish it from an honest key on-chain.","severity":"info","snippet":"///         The contract can't check how a key was generated; it only checks the key's shape.","title":"Trust assumptions the contract cannot enforce: key quality, house censorship and RPC/sequencer visibility"},{"citation":"resolved","description":"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 (sessionNonce[session] is still 0), including after the key's holder has started using the address as an ordinary player with turns: turns[league][key] then become unspendable by the key (playerOf(key) == P) and unspendable by P (they sit under the key's address) until the key calls leaveSession. The key holder consented and can always leave, so there is no loss to a third party and no path for an outsider; this is a hardening note. Fix: add a deadline field to the struct and check block.timestamp <= deadline in setSession, or let a key bump its own nonce.","line":242,"path":"src/SwarmDerby.sol","reproduction":"Key K signs sessionDigest(P, K) at nonce 0 and never submits it; later K buys turns as a player (turns[0][K] = 5). P calls setSession(K, sig): expected under the usual EIP-712 pattern that stale consent expires; actual: binding succeeds, playerOf(K) == P, and K's 5 turns cannot be spent by anyone until K calls leaveSession.","severity":"info","snippet":"        bytes32 structHash = keccak256(abi.encode(SESSION_TYPEHASH, player, session, sessionNonce[session]));","title":"Session-key consent has no deadline, so an unused EIP-712 consent stays valid for the signing key until its nonce moves"},{"citation":"resolved","description":"abi.decode(data, (bool)) reverts for any 32-byte word other than 0 or 1, so a transfer whose return data is malformed turns a 'failed send rolls over' path (settleNextDay winners, slam payout) into a revert of the whole call. DerbyAuction._accepted decodes the word in assembly and treats such data as a failed send, so the two contracts handle the same boundary differently. The IMD token at 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 is described as returning standard booleans, so this is not reachable today; it only matters if the token is ever upgraded to a non-standard return.","line":649,"path":"src/SwarmDerby.sol","reproduction":"Replace the token with one whose transfer() returns abi.encode(uint256(2)) for a blocked recipient and settle a day whose winner is blocked. Expected (per the NatSpec 'one unpayable winner can't stop the queue'): the prize rolls over. Actual: settleNextDay reverts in abi.decode and the queue is stuck until the token behaves.","severity":"info","snippet":"        return ok && (data.length == 0 || (data.length == 32 && abi.decode(data, (bool))));","title":"_trySend reverts instead of returning false when the token answers with a 32-byte word that is not 0 or 1"}],"hash":"8173ff70993d4d8af022c67d05f990b49cb9f2902a52bd9650033e1b8ccf71fc","nodeId":"632d31b4-aaa5-41b4-9d4f-177a2142c000","outcome":"completed","summary":"The review is complete. The findings file is written at `.imd-findings.json`, scratch tests are removed, and the tree is otherwise untouched with all 114 tests still passing.\n\n**Outcome: no critical, high or medium defects in the assigned area.** The RSA verification, the window arithmetic, the commit/draw/finalize/expire state machine and the IMD accounting all held up under concrete probing. I recorded three low findings and three informational notes.\n\n**What I verified, beyond reading:**\n- The PKCS#1 v1.5 padding constant is the right shape (205 bytes, 202 × ff, SHA-256 DigestInfo). The in-repo tests cannot catch a padding mistake because they sign with the contract's own encoder, so I signed with Node `crypto.sign` using the fixture key and confirmed the contract accepts it and rejects a wrong message.\n- The `_below` check, the modexp call layout, the key shape test and the `& 1 == 0` precedence are all correct.\n- Draw, expire, finalize and `dayClosed` flip at the same second, so a swing can never both score and be settled around.\n- A 400-run stateful fuzz over buys, draws, slams, refunds, late reveals, fouls and in-order settlement kept `balance == pot + vault + ops` exactly and the burn at 40%.\n- Nobody without the house private key can predict a roll, and neither the house nor the owner can steer one. Holding back a reveal is weakly dominated, and holding back a draw gives the house nothing but denial.\n\n**Findings written (low):**\n- `commitUsed` is keyed by the commit alone, so a stranger who sees a pending swing can submit the same commit first with one turn and make the victim's swing revert. Reachability on Robinhood Chain is limited to the sequencer, hence low. Fix is to scope the check per player.\n- `activateHouseKey` is permissionless and the house signer reads the key only at start-up, so once a proposal's delay has passed anyone can pick the moment, kill every in-flight draw, and stall the game until the operator restarts the service.\n- The house signer copies `player` and `commit` from an RPC read, contradicting its own comment. A lying RPC plus a colluding player can obtain draws for future swing ids and grind for slams. The README already names RPC trust, so low.\n\n**Informational:** unverifiable key quality and house censorship as trust assumptions, no deadline on session consent, and a `_trySend` boolean decode that reverts on malformed return data where DerbyAuction tolerates it.","treeHash":null,"usage":{"cachedInputTokens":2013547,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":51747,"runtime":"claude","turns":28,"wallClockMs":929900}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7c748c02cd2ee98f","findings":[{"citation":"resolved","description":"drawOne() builds the message from `s.player` and `s.commit` as returned by `derby.swings(id)` on the configured RPC, signs it, and then sends the signature to that same RPC three times (eth_call, eth_estimateGas, eth_sendRawTransaction) before anything is on-chain. The RPC is the only source of truth the service has for which swings exist. An RPC operator who wants to favour a player can answer the `swings(id)` call for a not-yet-existing id N with that player's address and a chosen commit, receive the real house signature for (N, player, commit) through the simulated call, and repeat for many candidate salts: each candidate yields the exact drawHash the contract will later store, so the player computes `DerbyOdds.roll(swingSeed(salt, keccak256(sig)), N, 100, 100)` off-chain and keeps only a salt that slams or lands a 620-ft bomb. The player then commits that salt and waits until the real swing id N is reached (on a quiet game, immediately). The honest house later draws N with the same deterministic signature and the pre-selected outcome lands. The DRAW_TAG is also fetched from the RPC (line 151) although the README says the message is built without RPC input. The README names 'use RPCs you trust', but the default is a public endpoint and the service has no cross-check.","line":110,"path":"house/house.mjs","reproduction":"State: house.mjs running against RPC R controlled by attacker P, nextSwingId on chain = N. R answers derby.queryFilter(SwingCommitted) with a fabricated log for id N and derby.swings(N) with (player=P, status=1, commit=keccak256(abi.encode(salt_i, P))). house.mjs signs abi.encode(DRAW_TAG, 4663, DERBY, N, P, commit_i) and calls draw.staticCall(N, sig_i) on R, which records sig_i and returns success. Expected: the house cannot be made to reveal the draw of a swing that has not been committed on-chain. Actual: P learns keccak256(sig_i) for every candidate salt, selects one whose roll is SLAM, commits it as swing N, and the real draw matches. Fix: (a) store the commit block's parent hash (blockhash(block.number-1)) in the Swing at commit time and include it in drawMessage, so a signature can only be produced after the commit exists on-chain and a forged state yields an unusable signature; (b) hard-code DRAW_TAG in the service; (c) require two independent RPCs to agree on the swing before signing, or run the house against its own node.","severity":"low","snippet":"      await derby.draw.staticCall(id, sig); // simulate: a draw must never land and revert","title":"House service hands every signature to its RPC before the draw is mined and signs whatever (player, commit) that RPC reports, so a dishonest RPC can let a colluding player grind salts off-chain"},{"citation":"resolved","description":"commit = keccak256(abi.encode(salt, player)) already binds the player, so a swing committed by B with A's commit can never be revealed by B (finalize checks commitFor(salt, s.player) and B does not know the salt; the swing fouls). Yet swing() accepts it and marks commitUsed[commit] = true globally, so A's own swing with that commit reverts with CommitUsed. Anyone who observes A's pending swing transaction (a sequencer, a shared RPC, or a relayer that sees the calldata before inclusion) can grief A for the price of one turn. A loses only a retry with a fresh salt, so the impact is a denial of a specific swing rather than funds, and on Robinhood Chain the window is the sequencer feed rather than a public mempool.","line":321,"path":"src/SwarmDerby.sol","reproduction":"Verified with a Foundry probe: Alice and Bob each buy 5 arcade turns. Bob calls swing(0, 100, 100, commitFor(keccak256('alice salt'), alice)) and gets swing 0 (accepted, Bob can never finalize it). Alice then calls swing(0, 100, 100, commitFor(keccak256('alice salt'), alice)). Expected: Alice's swing is accepted because the commit was made for her. Actual: revert CommitUsed(). Fix: key the map by the player as well, e.g. commitUsed[keccak256(abi.encode(player, commit))], or require commit != bytes32(0) only and check uniqueness per player; a commit that names another player is useless to the caller anyway.","severity":"low","snippet":"        if (commitUsed[commit]) revert CommitUsed();","title":"A third party can burn a player's commit before it lands: commitUsed is keyed on the bare commit, so any caller may consume another player's commit with a swing that can never be finalized"},{"citation":"resolved","description":"activateHouseKey() is permissionless once pendingHouseKeyAt has passed, so the switch to a new modulus can be triggered by anyone at any later second, independent of when the operator swaps HOUSE_KEY_FILE. The service never re-reads houseKey() and never watches HouseKeySet. From the activation block on, every draw.staticCall fails (BadDraw), drawOne logs 'draw failed' for both RPCs and retries every 20 s, and each swing is refunded after DRAW_WINDOW. The same happens after revokeHouseKey. Nothing alerts: the ALERT path only fires for a mined-and-reverted draw or low gas, so the outage is silent apart from log lines. Players lose time rather than IMD, but the game is down until a human notices.","line":149,"path":"house/house.mjs","reproduction":"State: house.mjs running with key A; owner calls proposeHouseKey(B) at T; at T + 2 days any address calls activateHouseKey(). Then a player commits swing N. Expected: the service notices the key change and either hot-loads B or raises an alert and stops. Actual: the service signs with A, draw.staticCall(N, sigA) reverts BadDraw on both RPCs, 'draw #N failed on RPC 1/2' is logged every 20 s, and at committedAt + 5 min the swing is refunded through expire; this repeats for every swing. Fix: poll houseKey() every few ticks (or subscribe to HouseKeySet/HouseKeyProposed), alert() on any change, and exit or reload the key file; and hard-code DRAW_TAG rather than reading it from the RPC.","severity":"low","snippet":"  if ((await derby.houseKey()).toLowerCase() !== modulus) throw new Error('HOUSE_KEY_FILE does not match the houseKey of DERBY');","title":"House service only checks houseKey() at start-up: after proposeHouseKey/activateHouseKey or revokeHouseKey it keeps signing with a stale key and every swing times out into a refund until an operator r"},{"citation":"resolved","description":"tick() awaits drawOne() for each pending swing in id order, and drawOne() awaits tx.wait(1, 60_000) before returning. If a draw is accepted by the RPC but not mined (base fee above MAX_GWEI = 1 gwei, RPC outage, nonce gap), the wait throws after 60 s, the catch falls through to RPC 2 which repeats the sign/simulate/send (another send, a possible nonce conflict, another wait), and only then does the loop move to the next swing id. With K stuck swings ahead of it, the K+1-th swing is first attempted roughly 60*K to 120*K seconds after it was seen; from K = 3 to 5 the five-minute draw window is already gone and the swing is refunded even though the service was alive. The next tick does not start until the current one finishes, so the backlog compounds. Outcome is refunds, not losses, but it is the only liveness failure the design cannot refund the player's time for.","line":114,"path":"house/house.mjs","reproduction":"State: MAX_GWEI=1 while the chain base fee sits at 1.5 gwei for a few minutes (or RPC 1 accepts but never relays). Players commit swings 10..20 within the same minute. Expected: each swing is drawn independently within its own 5-minute window once the fee allows, or at least the service does not let one stuck draw starve the others. Actual: drawOne(10) sends and waits 60 s, throws TimeoutError, tries RPC 2 (another ~60 s), then drawOne(11) ... ; swings 14 onwards are first attempted after committedAt + 5 min, tick() drops them with 'left the draw window undrawn', and they are refunded. Fix: do not block the loop on receipts (track sent transactions separately and poll them), process pending swings concurrently with a per-swing nonce, and surface a stalled draw through alert() when a swing is within 60 s of its window with no mined draw.","severity":"low","snippet":"      const rc = await tx.wait(1, 60_000);","title":"House service draws strictly sequentially and blocks up to 60 s per unmined draw, so a burst of swings behind one stuck transaction misses DRAW_WINDOW and refunds"},{"citation":"resolved","description":"Traced per actor. Player: cannot compute the RSA signature, cannot change the salt after swing() (commit bound), and an unrevealed or late reveal is a FOUL which is never better than any outcome, so holding back a reveal gains nothing; holding back is only a timing choice for a known slam (the 10% of vault is read at finalize, which the player can delay up to ~10 minutes while the vault grows by 10% of any purchases). Sequencer alone: can only delay, which forces a refund or a foul, never a chosen outcome. Owner alone: can only revoke or propose a key with 2 days notice. House alone: can compute drawHash for every committed swing but learns nothing without the salt; it can refuse to draw chosen players (documented). House plus salt (the key holder plays, or the site leaks the salt to the operator): computes the roll before drawing, submits draw only when the roll is good and lets the rest refund through expire(); the comment's claim that 'hiding a bad one never pays' is true for the player side only. With a properly generated key every message has exactly one signature below n (x -> x^e mod n is a permutation when gcd(e, lambda(n)) = 1), and _below plus the full EM comparison enforce that, so the key holder cannot choose among signatures; _checkKey cannot detect a non-squarefree or small-factor modulus that admits several signatures for some messages, but that only matters to the same colluding key holder. DEPLOY.md documents 'the holder of the house key must not play' and the per-player withholding. No contract change can remove the withholding option without penalising honest players for house downtime; the exposure is bounded by the key holder's honesty and by the salt staying client-side.","line":33,"path":"src/SwarmDerby.sol","reproduction":"State: entity H holds the house private key and plays through address P in the AGENT league (no cap) with 200 turns. H commits 200 swings with salts it knows, computes sig_i off-chain for each, and evaluates DerbyOdds.roll(keccak256(abi.encode(salt_i, keccak256(sig_i))), id_i, 100, 100). Expected: every committed swing is decided by a draw H cannot avoid. Actual: H calls draw() only for the ~1.6 swings (0.8%) that roll SLAM and the handful that roll 549-ft bombs, finalizes those (each slam pays 10% of vault[1] and tops the agent board), and after 5 minutes calls expire() on the other ~195 swings, which return the turns at no cost; repeat daily. Mitigation is operational: keep the key out of any player's hands, keep the salt in the client only, and monitor the refund rate per player (a player whose undrawn-swing ratio is far above the house's overall ratio is the signature of this).","severity":"info","snippet":"///           and hiding a bad one never pays. No draw within DRAW_WINDOW gives the turn back.","title":"Steering analysis: nobody without the house private key can predict or steer a roll, but the holder of the house key who also knows a salt gets a free re-roll by withholding the draw; the on-chain ref"},{"citation":"resolved","description":"After revokeHouseKey() nothing can satisfy HouseDraw.verify (modulus.length == 0 returns false), yet swing() still spends the turn, takes the arcade cap slot, extends dayLastCommit for the day by 10 minutes and emits SwingCommitted. The player's client waits DRAW_WINDOW, then must send expire() to get the turn back; the game page cannot tell this case from house downtime. Since the revocation state is known on-chain at commit time, this is avoidable.","line":320,"path":"src/SwarmDerby.sol","reproduction":"Verified with a Foundry probe: owner calls revokeHouseKey(); Alice (5 turns) calls swing(0, 100, 100, commitFor(salt, alice)). Expected: revert (e.g. BadKey or a new NoHouseKey error) so the client shows the pause and no turn moves. Actual: swing accepted, turns(0, alice) == 4, draw() with any bytes reverts BadDraw, and only after block.timestamp > committedAt + 5 minutes does expire() restore turns(0, alice) == 5. Fix: in swing(), after the quality check, `if (houseKey.length == 0) revert BadKey();` (or a dedicated error).","severity":"info","snippet":"        if (commit == bytes32(0)) revert BadCommit();","title":"swing() accepts commits while houseKey is empty (after revokeHouseKey), so every swing during a revocation is a guaranteed five-minute wait and a refund rather than a clear revert"},{"citation":"resolved","description":"activateHouseKey() is callable by anyone forever once the delay has passed. If the owner proposes key B and then decides not to use it (for example B's private key was lost or exposed before activation), the only ways to drop the proposal are revokeHouseKey(), which also deletes the active key and forces a two-day outage, or re-proposing the current key A, which replaces B but is not obvious from the interface. Until one of those happens, a stranger can activate B: the live house (holding A) can no longer draw, every swing refunds, and if B is the exposed key its holder now controls draws. The 2-day delay was designed to give players notice; here it also gives an abandoned key an indefinite activation window.","line":572,"path":"src/SwarmDerby.sol","reproduction":"State: owner calls proposeHouseKey(B) at T and never activates; house.mjs keeps running with key A. At T + 40 days any address calls activateHouseKey(). Expected: an abandoned proposal cannot change the live key without the owner's involvement, or at least the owner can cancel it without revoking A. Actual: houseKey becomes B, HouseKeySet(keccak256(B)) is emitted, house.mjs (see the stale-key finding) keeps signing with A and every swing is refunded until the owner reacts. Fix: add an explicit cancel (`delete pendingHouseKey; pendingHouseKeyAt = 0;` under onlyOwner) and optionally an activation deadline (for example pendingHouseKeyAt + 7 days) after which the proposal lapses; document that re-proposing the current key is the cancel path until then.","severity":"info","snippet":"        if (pendingHouseKeyAt == 0 || block.timestamp < pendingHouseKeyAt) revert KeyNotReady();","title":"A pending house key never expires and cannot be explicitly cancelled, so a proposal the owner abandons can be activated by anyone at any later time and knocks the running house offline"}],"hash":"e70d862a931c63145c2d4f208b4e0a04c75119b9c7cad729c329dd8968d3e9e0","nodeId":"7e3e24c0-278b-4dbd-99eb-19b632949c20","outcome":"completed","summary":"The review is complete. The findings file holds 7 entries (4 low, 3 info), all snippets verified against the tree, the scratch directory is removed, and no source or config file was changed.\n\n**Outcome: no critical or high defect in the on-chain code.** I traced every assigned area and the core holds:\n\n- **HouseDraw** is correct. The modexp input layout, the 205-byte EMSA padding plus 19-byte SHA-256 DigestInfo, the full EM comparison and the strict `sig < n` check together admit exactly one signature per message for a properly generated key. Zero, one, `sig + n` and tampered inputs all fail. The shape check cannot detect a malicious modulus, but that only helps a key holder who also knows a salt.\n- **Timing** is consistent at every boundary: draw allowed at `commit + 5m` exactly and expire refused there, foul and settlement both flip at `lastCommit + 10m + 1s`, so a scoring reveal can never land after its day is paid and a refunded swing never scores.\n- **Commits, sessions, accounting** check out: single-use commits are player-bound, EIP-712 consent is single-use per key with correct domain and no key chaining, the 40/45/10/5 split conserves balance, and pot equals unsettled day pots plus rollover through settlement and failed sends.\n- **Steering**: no player, sequencer or owner can predict or steer a roll, and withholding a reveal never pays. The only free re-roll belongs to whoever holds the house key and a salt, which the repo already documents as a trust assumption.\n\n**What I reported**, most important first:\n\n1. **House service trusts its RPC with signatures (low).** It signs whatever player and commit the RPC reports for a swing id and hands the signature to that RPC before mining. A dishonest RPC can obtain draws for future swing ids and let a colluding player grind salts off-chain. Suggested fixes include binding the draw message to the commit block's parent hash and hard-coding the tag.\n2. **Commit burning (low).** Anyone who sees a commit before it lands can consume it with their own swing. Verified with a probe. Keying the map by player as well fixes it.\n3. **Stale key after rotation or revocation (low).** The service checks the modulus only at start-up, so every swing silently refunds after a key change until a restart.\n4. **Sequential draws with 60-second receipt waits (low).** One stuck draw delays all swings behind it and can push them past the window.\n5. **Three info items**: the steering analysis as a written answer to the brief's question, swings accepted while the key is revoked, and a pending key that never lapses and has no explicit cancel.\n\n**Not run**: Slither or any tool beyond Foundry, per the task rules. DerbyAuction was read as context only and showed nothing new against SwarmDerby v2.","treeHash":null,"usage":{"cachedInputTokens":1178838,"inputTokens":226,"model":"claude-fable-5-1","outputTokens":56245,"runtime":"claude","turns":32,"wallClockMs":926061}}],"verification":[]}