{"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":"9396db7f-19f5-40b7-aa19-46b45ab87101","kind":"audit","nodes":[{"acceptedSubmissionHash":"d32cdada5c16886260b5c613391ae43fea0fd3fa5c6b7f5efb1976d324c20507","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":"1d7725ea883b4f38e7ecac022600c85d8d27d9d15ed2e2176464c62c4843e808","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":"9456d45ce8e46e7980fa92d0207253c373cf50540a2b4160bb4887e90b1a7e90","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":"02ef0a3040ceef7e69bc82c1695c1cfcff91a9d4cc9d1cdd743eeabb3aeaaa8d","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":"231665a7b6a6acd70384520e99bb740ce15d6a330782435550b88e0d02bc04c8","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 (src/SwarmDerby.sol, src/DerbyOdds.sol): IMD turn purchases and the 40/45/10/5 split into per-day pots, commit-reveal swing randomness using Robinhood Chain (Arbitrum Nitro) block hashes via ArbSys, EIP-712 session-key consent, the 20-swing arcade cap, the on-chain top-10 boards, slam vault payouts, and settleNextDay's in-order daily payout math and rollover.","parentJobId":null,"planHash":"400e4fbff6b13c3daaf17e44605bae870d4c67faa3b6592eb6cd4b2a955600a7","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"9396db7f-19f5-40b7-aa19-46b45ab87101","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":"52271","feedbackHash":"c32234e696926cd7623e41fe0e1515670f56769474d4c08f84c73bc49e915bcb","nodeKey":"audit_economics","submissionHash":"d32cdada5c16886260b5c613391ae43fea0fd3fa5c6b7f5efb1976d324c20507","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52252","feedbackHash":"380d4c2614820910bf0c5e8051dfa3fa4ef0b9072cba0cca6b620c893d9f8e91","nodeKey":"audit_flow","submissionHash":"1d7725ea883b4f38e7ecac022600c85d8d27d9d15ed2e2176464c62c4843e808","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52270","feedbackHash":"2e309dbdfc0bd749315bade6fe186fca0637e966d052fb2748695c0ec3d7c492","nodeKey":"audit_judge","submissionHash":"9456d45ce8e46e7980fa92d0207253c373cf50540a2b4160bb4887e90b1a7e90","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52263","feedbackHash":"e118070367328d23f5db12b500b15eb3ee5a2d5ca675645be9d3fe3c6934e833","nodeKey":"audit_math","submissionHash":"02ef0a3040ceef7e69bc82c1695c1cfcff91a9d4cc9d1cdd743eeabb3aeaaa8d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52261","feedbackHash":"3eeecdc824fd4f9988f2ca4ed7591d722f5ce8b6af05aa8bdc644283cc3515f6","nodeKey":"audit_permissions","submissionHash":"231665a7b6a6acd70384520e99bb740ce15d6a330782435550b88e0d02bc04c8","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"9fb9e89b7a44f13256ed2924570769793a45c72afa84cbf342958b071d80accf","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b16b4979d227241","findings":[{"citation":"resolved","description":"_buy has no count > 0 / cost > 0 check. With count = 0 the cost is 0, so _pull moves nothing (transferFrom of 0 succeeds on standard tokens even with no balance or allowance), _send(DEAD, 0) is a no-op, yet _markDay(league, currentDay()) still runs and dayPot/pot are credited with 0. Any address can therefore insert the current UTC day into either league's in-order settlement queue for free and emit a TurnsBought event with count 0. Each such day must later be passed through settleNextDay (paying no tip because the board is empty) before any later day can be paid. It is a zero-input boundary that bypasses the 'purchase' semantics; impact is limited to one extra settle call per league per day and misleading events. Fix: in _buy, revert when count == 0 (e.g. `if (count == 0) revert BadPrice();` or a new ZeroCount error), which also covers buyPacks(league, 0).","line":178,"path":"src/SwarmDerby.sol","reproduction":"Fresh deploy. From address 0xA0 holding no IMD and having granted no allowance: buyTurns(0, 0) and buyPacks(1, 0). Expected: revert (nothing to buy). Actual: both succeed, openDays(0).length == 1 and openDays(1).length == 1, the contract's IMD balance is still 0, TurnsBought(0xA0, league, 0, 0, 0) is emitted. After warping one day, settleNextDay(0) must be called once to clear the empty day (verified in test/scratch/Probe.t.sol::test_zeroCountBuyEntersQueue).","severity":"low","snippet":"        _buy(league, count, count * singlePrice);","title":"buyTurns/buyPacks accept count == 0: a free, token-less call enqueues a settlement day"},{"citation":"resolved","description":"buyTurns(league, count) and buyPacks(league, packs) compute cost from the current singlePrice/packPrice storage at execution time and pull it with the buyer's standing (typically unlimited) allowance. There is no maxCost parameter, so a buyer who quoted 0.15 IMD per turn can be charged whatever setPrices has set by the time the transaction executes; setPrices has a floor but no ceiling. This is an owner-trust assumption (documented: the owner may change prices), not a permission bypass, and the owner only receives the 5% ops share of the overpayment while 40% burns and 55% goes to pot/vault. It is listed because it is the one place where a documented owner power imposes an unbounded, unconsented cost on a specific buyer. Fix (minimal, preserves the owner's pricing power): add a `maxCost` argument to buyTurns/buyPacks and revert when `cost > maxCost`; the frontend passes the quoted price.","line":177,"path":"src/SwarmDerby.sol","reproduction":"Owner calls setPrices(10 ether, 50 ether) (both above the floor). Player, who approved type(uint256).max expecting 0.15 IMD, calls buyTurns(0, 1). Expected: either the quoted 0.15 IMD or a revert. Actual: 10 IMD are pulled; 4 IMD burned, 4.5 to the day pot, 1 to the vault, 0.5 to opsBalance (test/scratch/Probe.t.sol::test_priceChangeBetweenQuoteAndBuy).","severity":"low","snippet":"    function buyTurns(uint8 league, uint256 count) external validLeague(league) {","title":"Purchases have no max-cost bound: a price change between quote and execution is paid in full"},{"citation":"resolved","description":"finalize pays a slam with _send, which reverts on a failed transfer, while settleNextDay deliberately uses _trySend so 'one unpayable winner can't stop the queue'. If the IMD transfer to s.player fails (blocklisted recipient, paused token, or any transfer that returns false), finalize reverts in its entirety: the swing stays Committed, the homer is not recorded on the board, and once the 240-block window passes the only exit is expire(), which marks it FOUL. The player loses a legitimately rolled slam (board credit and payout), and the vault is left unchanged rather than owed. Impact is confined to the affected player and depends on the token refusing a transfer, which the settlement path already treats as plausible. Fix: mirror settlement: `if (!_trySend(s.player, payout)) { vault[s.league] += payout; payout = 0; }` before emitting GrandSlam, so the board credit always lands and the swing always finalizes.","line":326,"path":"src/SwarmDerby.sol","reproduction":"Player buys 100 arcade turns (vault 1.5 IMD), commits a q=100/v=100 swing whose target-block hash rolls SLAM, then the token blocks transfers to the player. finalize(id, salt) at target+1: expected the homer recorded and the slam either paid or rolled back into the vault; actual revert TransferFailed, swing still Committed. At target+241 anyone calls expire(id): status Final as FOUL, board empty, vault still 1.5 IMD. (test/scratch/Probe.t.sol::test_blockedSlamRecipientLosesSwing).","severity":"low","snippet":"            _send(s.player, payout);","title":"Slam payout uses the reverting _send, so a recipient the token refuses loses the whole swing"},{"citation":"resolved","description":"imd is immutable and only checked against address(0). _pull and _trySend use a raw address(imd).call and treat success with empty return data as a successful transfer (the void-return ERC20 accommodation). Against an address with no code, every call returns (true, \"\"), so purchases credit turns, dayPot, pot, vault and opsBalance without any token moving, 'burns' succeed, slam payouts and settlements 'succeed' while paying nothing. The deployment goes through a launch request where the token address is typed by hand and the two IMD tokens live on different chains (HANDOFF.md warns about exactly this), so a wrong address, e.g. the Ethereum-mainnet IMD on Robinhood Chain, would produce a live game running on air until the explorer check in HANDOFF step 4 catches it. Fix: `if (address(imd_).code.length == 0) revert ZeroAddress();` in the constructor.","line":166,"path":"src/SwarmDerby.sol","reproduction":"new SwarmDerby(owner, IERC20(0xDEADBEEF), 0.15e18, 0.5e18) where 0xDEADBEEF has no code. Then from 0xCAFE (no tokens anywhere): buyTurns(0, 100). Expected: revert (no token). Actual: turns(0, 0xCAFE) == 100, pot(0) == 6.75e18, vault(0) == 1.5e18, opsBalance == 0.75e18 with zero assets behind them (test/scratch/Probe.t.sol::test_codelessTokenGivesFreeTurns).","severity":"low","snippet":"        if (owner_ == address(0) || address(imd_) == address(0)) revert ZeroAddress();","title":"Constructor does not verify the IMD address has code: a codeless token yields free turns and phantom pots"},{"citation":"resolved","description":"Ownership moves in one step with no acceptance and no zero-address check. A mistyped address permanently loses setPrices and withdrawOps. HANDOFF.md step 9 presents 'renounce it with transferOwnership' as an option; after transferOwnership(address(0)) every later purchase still routes 5% into opsBalance, which no one can ever withdraw (withdrawOps is onlyOwner and there is no sweep), so the share accrues as permanently locked IMD. Trust assumption, not a bypass. Fix: two-step transfer (pending owner + accept), and either document that renouncing locks the ops share or redirect the ops share to the pot when owner == address(0).","line":503,"path":"src/SwarmDerby.sol","reproduction":"Owner calls transferOwnership(address(0)). Player buys 100 turns: opsBalance == 0.75e18. Any later withdrawOps(to, 0.75e18) reverts NotOwner from every address, and no other function can move opsBalance. Expected per HANDOFF: 'withdrawOps releases the 5% ops share'. Actual after renounce: locked forever.","severity":"info","snippet":"    function transferOwnership(address to) external onlyOwner { owner = to; }","title":"Single-step transferOwnership; renouncing (as HANDOFF suggests) strands all future ops revenue"},{"citation":"resolved","description":"Design observation on the slam-vault payout math, not a precision defect: at quality 100 the slam probability is (10000 - 9920) / 10000 = 0.8%, and a slam pays vault/2. Expected slam value per swing is therefore 0.004 x vault. A pack turn costs 0.1 IMD, so once a league's vault exceeds 25 IMD (37.5 IMD at the single-turn price) every perfect-quality, velo >= 60 swing has positive expected value from the slam payout alone, before any share of the day pot. In the agent league there is no cap, so a script can wait for a large vault and farm it; each slam halves the vault so the condition self-corrects, and the vault is money players put in, so this is a property of the chosen jackpot design rather than a loss to the protocol. Recorded so the requester can decide whether a vault cap or a smaller SLAM_VAULT_SHARE_BPS is wanted.","line":38,"path":"src/DerbyOdds.sol","reproduction":"State: vault[AGENT] = 30 IMD (reached after 300 IMD of agent purchases without a slam, or more with slams). Agent buys packs at 0.1 IMD/turn and swings q=100, velo=100. Per swing: P(slam) = 80/10000, payout = 15 IMD, EV = 0.12 IMD > 0.1 IMD cost. Verified numbers: DerbyOdds.thresholds(100)[3] == 9920 (test_oddsMonotoneInQuality) and payout = vault * 5000 / 10000 (test_slamPaysHalfOfItsLeagueVault).","severity":"info","snippet":"        c[3] = (9980 * iq + 9920 * q) / 100;","title":"Slam vault EV: above ~25 IMD in the vault, an uncapped q=100 agent swing is +EV from the slam alone"}],"hash":"02ef0a3040ceef7e69bc82c1695c1cfcff91a9d4cc9d1cdd743eeabb3aeaaa8d","nodeId":"a68274ea-0994-4f21-95b0-b60929c389ce","outcome":"completed","summary":"The review is complete. The findings file is written and all six snippets match their stated lines. The working tree is clean apart from the findings file and my scratch tests, which git ignores.\n\n**Outcome.** The math in SwarmDerby and DerbyOdds holds up. I found no critical, high or medium defects. Six findings are recorded in `.imd-findings.json`: four low, two informational.\n\n**What I verified in depth, with concrete values**\n\n- **The 40/45/10/5 split** reconciles exactly for every cost, including prices that are not multiples of 10,000 wei. Dust goes to ops, and contract balance equals pot plus vault plus ops in every scenario tested.\n- **Settlement math.** The 90% distributable, 0.5% tip and 60/25/15 shares round down with the remainder landing in rollover. The view in `nextSettlement` uses the identical formula as the write. Each day settles once, in order, and pot equals the sum of unsettled day pots plus rollover after every settlement.\n- **Reveal windows.** The finalize, expire and dayClosed comparisons all agree at the target+240 boundary, so a swing can never score after its day settles. The live chain produces about ten blocks a second, which confirms the 24-second wall-clock comment.\n- **Boards.** A fuzz with 40 players and 300 records per run confirmed sorted order, no duplicates, correct top-10 membership and tie handling in both leagues.\n- **Odds table.** Thresholds are monotone in quality, feet ranges fit uint16, and the browser twin encodes identically.\n- **Session keys.** Domain, type hash, nonce, malleability and chain binding are correct. No binding or chaining path bypasses the key's consent.\n\n**The findings**\n\n1. **Low.** Zero-count purchases succeed with no tokens moving and push the current day into the settlement queue for free.\n2. **Low.** Purchases carry no max-cost bound, so a price raised between quote and execution is paid in full. This is an owner trust assumption, not a bypass.\n3. **Low.** The slam payout uses the reverting send path. A recipient the token refuses loses the swing entirely as a foul, unlike settlement which rolls refused prizes over.\n4. **Low.** The constructor does not check the token address has code. A codeless address yields free turns and phantom pots because empty return data counts as success.\n5. **Info.** Single-step ownership transfer. Renouncing, which the handoff suggests, permanently locks all future ops revenue.\n6. **Info.** Design observation on the vault: above roughly 25 IMD, an uncapped perfect-quality agent swing is positive expected value from the slam alone.\n\n**Limits.** No proofs were attached because no finding reached high severity. Static analyzers were not run, as the task does not provide them. The economic observation on the vault is a property of the chosen jackpot design, recorded for the requester's decision rather than as a defect.","treeHash":null,"usage":{"cachedInputTokens":1002115,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":44231,"runtime":"claude","turns":23,"wallClockMs":691237}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c0fc4ea4f50e3380","findings":[{"citation":"resolved","description":"buyTurns(league, 0) and buyPacks(league, 0) reach _buy with count = 0 and cost = 0. Nothing is pulled (transferFrom of 0 succeeds on a standard token, and _pull needs no allowance for it), nothing is burned, yet _markDay(league, day) runs, so the current UTC day is pushed onto the league's settlement queue and a TurnsBought event with count 0 is emitted. Any address with no IMD and no allowance can therefore add one queue entry per league per UTC day. Because settleNextDay must pay days strictly in order and an empty day (no board) pays no tip, every empty entry is a tip-less settleNextDay transaction that someone has to send before the next real day can be paid. On a quiet league (the agent league on a slow day) this turns one settlement into several, and the leaderboard's 'Pay the winners' button shows a ready day with amount 0 and tip 0. Nothing is stolen; it is a liveness/UX nuisance and an event-spam vector. Fix: revert when count == 0 (or cost == 0) at the top of _buy, e.g. `if (count == 0) revert BadPrice();` or a dedicated ZeroAmount error, so only paid activity can enqueue a day.","line":196,"path":"src/SwarmDerby.sol","reproduction":"State: fresh deployment, griefer address 0x6B1E holds no IMD and granted no allowance. Day DAY0 at 01:00 UTC: griefer calls buyTurns(1, 0). Expected: revert (nothing to buy). Actual: succeeds, emits TurnsBought(0x6B1E, 1, 0, 0, 0), openDays(1) == [DAY0]. Repeat on DAY0+1 with buyPacks(1, 0) and on DAY0+2 with buyTurns(1, 0): openDays(1) == [DAY0, DAY0+1, DAY0+2]. On DAY0+3 a real player buys 10 agent turns; on DAY0+4 nextSettlement(1) returns (exists=true, ready=true, day=DAY0, amount=0, tip=0) and settleNextDay(1) has to be called three times, each paying nothing, before the day holding 0.675 IMD is reachable. Verified with test/scratch/Probe.t.sol::test_zeroCountPurchaseEnqueuesDay (forge test --match-path test/scratch/Probe.t.sol).","severity":"low","snippet":"        _markDay(league, day);","title":"Zero-count purchases are accepted and enqueue empty settlement days for free"},{"citation":"resolved","description":"The constructor only rejects address(0) for imd_. _pull and _trySend use a raw low-level call and treat `ok && data.length == 0` as success, which is exactly what a call to an address without code returns. If the deployment passes a wrong or not-yet-deployed token address (DEPLOY.md hands the constructor arguments to a third-party launch flow that 'may adapt the code before deploying', and the e2e flow deploys its own MockIMD), the contract is live but unbacked: anyone can buy unlimited turns at no cost, pot/vault/opsBalance grow with nothing behind them, and later (if a real token were ever at that address) the first slam or settlement would try to send tokens the contract never received. The token address is immutable, so the only remedy is redeploying. Fix: require `address(imd_).code.length > 0` in the constructor (and optionally probe a view such as balanceOf(address(this)) so the deployment fails loudly instead of running free).","line":166,"path":"src/SwarmDerby.sol","reproduction":"State: deploy SwarmDerby(owner, IERC20(0xC0DE1E55), 0.15e18, 0.5e18) where 0xC0DE1E55 has no code. Expected: constructor reverts. Actual: deployment succeeds; griefer 0x6B1E (no tokens anywhere) calls buyPacks(0, 100) and gets turns(0, 0x6B1E) == 500 while pot(0) == 22.5e18 with zero tokens held. Verified with test/scratch/Probe.t.sol::test_codelessTokenGrantsFreeTurns.","severity":"low","snippet":"        if (owner_ == address(0) || address(imd_) == address(0)) revert ZeroAddress();","title":"Constructor accepts an IMD address with no code; every purchase then succeeds for free with phantom pots"},{"citation":"resolved","description":"The cost of a purchase is `count * singlePrice` (or `packs * packPrice`) read at execution time, and the buyer passes no `maxCost`/expected-price argument. setPrices has a floor (MIN_TURN_PRICE) but no ceiling and no delay, so a pending purchase that is sequenced after a setPrices call pays whatever the new price is, up to the buyer's full allowance (the game page grants the contract an allowance large enough for repeated buys). This is an owner-triggered path and the owner is a documented trust assumption, but the missing bound also bites honest operations: any routine price update will overcharge users whose transactions were already in flight, with no way for them to opt out. Fix: add a `maxCost` parameter to buyTurns and buyPacks (`if (cost > maxCost) revert BadPrice();`) so the buyer's signed intent bounds what is pulled.","line":178,"path":"src/SwarmDerby.sol","reproduction":"State: player approved the contract for type(uint256).max and holds 100 IMD, singlePrice = 0.15 IMD. Owner calls setPrices(15e18, 50e18) (100x). Player's already-prepared buyTurns(0, 1) executes next. Expected: the player's call fails or is bounded near 0.15 IMD. Actual: 15 IMD is pulled for one turn (balance drops from 100e18 by 15e18). Verified with test/scratch/Probe.t.sol::test_purchaseHasNoCostBound.","severity":"low","snippet":"        _buy(league, count, count * singlePrice);","title":"buyTurns/buyPacks have no maximum-cost bound; a price change that lands first is charged in full"},{"citation":"resolved","description":"Session(player, session, nonce) carries no expiry. A throwaway browser key that signs consent for a player, but whose setSession transaction is never sent (page closed, tx dropped), leaves a signature that stays valid indefinitely for that player, because sessionNonce[session] only advances on a successful bind. The blast radius is small by construction (the signature only lets that specific player bind that key, the key can leaveSession, and the key spends only the player's turns), so this is informational. It does, however, contradict the EIP-712 replay-safety checklist item and means a leaked old signature from a key the player later reuses can be submitted by the player at any time. Fix: add a `uint256 deadline` field to the typed struct and check `block.timestamp <= deadline` in setSession.","line":214,"path":"src/SwarmDerby.sol","reproduction":"State: key 0x5E55 signs sessionDigest(player, key) at DAY0. Nothing is submitted. 365 days later the player calls setSession(key, sig). Expected: a year-old consent is rejected. Actual: it binds; playerOf(key) == player. Verified with test/scratch/Probe.t.sol::test_sessionConsentNeverExpires.","severity":"info","snippet":"        bytes32 structHash = keccak256(abi.encode(SESSION_TYPEHASH, player, session, sessionNonce[session]));","title":"Session-key consent signatures have no deadline and remain valid until the key is bound once"},{"citation":"resolved","description":"The design note states that neither the player nor the sequencer can steer a roll alone. The player side of that claim depends on the player having no influence over the contents of L2 block target = commit + 5. On an Arbitrum Nitro / Orbit chain, L2 blocks are produced per sequencer message: when nobody else is transacting, the only way for blocks commit+1..commit+5 to exist is for someone, typically the player who wants to reveal, to send transactions, and the player then authors the target block's entire transaction set. The player can prepare many candidate filler transactions (different calldata/gas), locally compute the resulting header hash for the expected sequencer timestamp and L1 block fields, and submit the candidate whose hash rolls a slam at swingSeed(salt, hash); if the prediction misses (timestamp off by a second, another tx lands) the swing simply resolves at the normal odds, so attempts are free and repeatable. The window also means REVEAL_DELAY, FINALIZE_WINDOW and dayClosed are measured in blocks that are only produced on demand: on an idle chain a day with swings cannot settle until 240 further blocks exist, and the '~24s' guarantees do not hold. This is not reproducible in Foundry (it depends on the live sequencer's header construction and timing), so it is recorded as an open design lead, not a confirmed exploit. Fixes within the current design: require that the target block hash is also mixed with something the player cannot author (e.g. a later block hash chosen after a minimum number of distinct-sender transactions, or a longer REVEAL_DELAY measured in time via block.timestamp as well as blocks), document the assumption explicitly, and derive dayClosed from both a block and a time bound so settlement liveness does not depend on block production.","line":294,"path":"src/SwarmDerby.sol","reproduction":"State: Robinhood Chain idle (no other senders). Player P commits swing S at L2 block B with salt s; target = B+5. P sends four filler transactions producing blocks B+1..B+4, then for block B+5 prepares candidate transactions t_1..t_k, predicts each resulting header hash h_i for the expected sequencer timestamp, and submits the t_i for which DerbyOdds.roll(swingSeed(s, h_i), S, 100, 100) returns SLAM. Expected (per docs): P has no influence on the roll. Actual: if the prediction holds, finalize(S, s) pays half the vault; if it misses, P is exactly as well off as an honest player. Not reproduced on-chain in this review.","severity":"info","snippet":"        uint64 target = uint64(ARB_SYS.arbBlockNumber() + REVEAL_DELAY);","title":"Roll entropy is the hash of a block the player may be the sole author of on a quiet Nitro chain"},{"citation":"resolved","description":"Ownership moves in one step with no acceptance by the new owner and no zero-address check. The documented plan is to move owner to a multisig or renounce it. A typo in the multisig address, or a transfer to a contract that cannot call back, leaves withdrawOps and setPrices permanently unreachable, and every future 5% ops share (opsBalance) is stuck in the contract forever because no other path can move it. Pots and vaults are unaffected. Fix: two-step transfer (pendingOwner + acceptOwnership) with a separate explicit renounceOwnership for the intentional zero case.","line":503,"path":"src/SwarmDerby.sol","reproduction":"State: owner O, opsBalance = 0.75 IMD after 100 arcade turns. O calls transferOwnership(0x000...0001) by mistake. Expected: recoverable or rejected. Actual: owner == 0x1; withdrawOps from O reverts NotOwner; nobody controls 0x1; every later purchase adds to an opsBalance that can never be withdrawn.","severity":"info","snippet":"    function transferOwnership(address to) external onlyOwner { owner = to; }","title":"Single-step transferOwnership: a mistyped address permanently strands the ops balance"}],"hash":"1d7725ea883b4f38e7ecac022600c85d8d27d9d15ed2e2176464c62c4843e808","nodeId":"50f110b5-6a1d-4505-a775-00a43cf99152","outcome":"completed","summary":"Both files are on disk and are hidden from status only by the harness's own exclude list, which is expected. The review is complete.\n\n## Review result\n\nThe findings file at `.imd-findings.json` holds six entries: three low, three informational, none critical or high. The code is in good shape. Every mechanism named in the brief was traced and holds up:\n\n- **Purchase split and per-day pots.** The 40/45/10/5 split conserves value exactly, and the invariant that a league's pot equals its unsettled day pots plus rollover survives settlement, partial-share days, and refused transfers.\n- **Commit-reveal randomness.** Hiding a bad roll is never better than revealing it, commits are bound to the player, and a day cannot be settled while any of its swings can still score. Late reveals and expiry both resolve to a foul.\n- **Session keys.** Consent requires the key's own EIP-712 signature with chain and contract in the domain, nonces prevent replay, and key chaining is blocked.\n- **Arcade cap and boards.** The cap is charged to the player, not the key. The top-10 board stays sorted and handles eviction and re-entry correctly.\n- **Slam vault and settlement.** Vault accounting, the 60/25/15 split, the settler tip, in-order days, and rollover all match the documentation and the view function.\n\n**What I did find**, each verified with a scratch Foundry test except where noted:\n\n1. **Zero-count purchases** cost nothing yet enqueue a settlement day. Anyone with no tokens can add tip-less days that must be settled before real ones.\n2. **The constructor accepts a code-less token address.** Purchases then succeed for free with phantom pots. Relevant because deployment is handed to a third-party launch flow.\n3. **Purchases have no maximum-cost bound.** A price change sequenced before a pending buy is charged in full.\n4. **Consent signatures never expire.** Informational, blast radius is one player and one key.\n5. **Block-hash entropy on a quiet Nitro chain.** When nobody else transacts, the player authors the target block. Recorded as a design lead, not reproduced on chain.\n6. **Single-step ownership transfer** can permanently strand the ops balance.\n\nNo proofs were attached because no finding reached high severity. The scratch test under `test/scratch/Probe.t.sol` reproduces findings 1 through 4 and passes against the current code. No repository files were changed.","treeHash":null,"usage":{"cachedInputTokens":2790758,"inputTokens":516,"model":"claude-fable-5-1","outputTokens":62010,"runtime":"claude","turns":35,"wallClockMs":986566}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dedc96e38388cb45","findings":[{"citation":"resolved","description":"Trust gap (access x economics). setPrices is correctly owner-only and _buy's split is correct, but buyTurns/buyPacks read singlePrice/packPrice at execution time and take no caller-supplied bound (maxCost or expectedPrice). The game page quotes 0.15 / 0.5 IMD and the test setup and site flow grant the derby an unlimited IMD allowance (test/SwarmDerby.t.sol:58 approves type(uint256).max). Any purchase that is sequenced after a setPrices call, whether the owner timed it or a user simply had a stale page open, is charged the new price with no revert, up to the buyer's whole balance. The owner receives 5% of the overcharge directly as ops, 40% is burned and the rest moves into pots/vault. The docs' claim that the owner 'cannot touch pots or vaults' holds, but the owner can make buyers overpay without limit (the floor is enforced, there is no ceiling). Fix that preserves the design: add a `maxCost` (or `expectedSinglePrice`/`expectedPackPrice`) parameter to buyTurns/buyPacks and revert when `cost > maxCost`; optionally cap how far a single setPrices call can raise prices or delay price increases by one day.","line":178,"path":"src/SwarmDerby.sol","reproduction":"State: owner is a normal EOA, player holds 100 IMD and has approved the derby for type(uint256).max (as the test harness and page do). Sequence in one block: (1) owner calls setPrices(1.5 ether, 5 ether); (2) player's already-signed buyTurns(0, 10) executes. Expected (what the page quoted): 1.5 IMD charged. Actual: 15 IMD charged, 10x the quoted price, no revert. Verified with test/scratch/Leads.t.sol::test_leadA_priceChangeChargesPendingBuyer: `before - imd.balanceOf(player) == 15 ether` passes on the current code. With a maxCost parameter the call would revert with BadPrice instead.","severity":"low","snippet":"        _buy(league, count, count * singlePrice);","title":"Purchases have no maximum cost: a price change that lands before a pending buy charges the new price against the buyer's standing allowance"},{"citation":"resolved","description":"Asymmetry between two payout paths. settleNextDay deliberately pays winners with _trySend so that 'one unpayable winner can't stop the queue' and rolls a refused prize over. finalize pays a slam with _send, which reverts on a refused transfer and takes the whole finalize with it, including the _recordDinger board credit that a non-slam homer would have received. This is not hypothetical for this deployment: the IMD token at 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 on Robinhood Chain (chain 4663) exposes setBlocked(address,bool)/blocked(address), and on a fork a transfer to or from a blocked address reverts with 'BridgedFP: blocked'. A blocked player's slam swing therefore cannot be finalized by anyone (the salt holder gets TransferFailed), and once the 240-block window passes the only exit is expire(), which records a foul. The player paid for the turn, rolled a 550+ ft slam, and gets nothing on the board; a non-blocked rival who would have placed below them gains the podium slot. Fix: use _trySend for the slam payout and, if it fails, leave the payout in vault[s.league] (or add it to rollover[s.league]) while still recording the dinger and emitting GrandSlam with amount 0, mirroring the settlement path.","line":326,"path":"src/SwarmDerby.sol","reproduction":"State: player buys 100 arcade turns (vault[0] = 1.5 IMD), commits swing 0 with quality 100 / velo 100 and a salt whose roll against the target block hash is a SLAM; afterwards the token owner calls setBlocked(player, true). At target+1 anyone calls finalize(0, salt). Expected (by analogy with settlement): swing resolves, slam/homer feet recorded on board(0, day), payout kept by the contract if undeliverable. Actual: finalize reverts with TransferFailed; at target+241 expire(0) succeeds with tier FOUL and board(0, day) is empty. Verified with test/scratch/Leads.t.sol::test_leadD_blockedPlayerCannotFinalizeSlam (uses a mock token with the same blocked[from]/blocked[to] revert as the live IMD).","severity":"low","snippet":"            _send(s.player, payout);","title":"Slam payout uses the reverting _send while settlement uses _trySend: a player the IMD token has blocked can never finalize a slam, loses the turn and the board credit"},{"citation":"resolved","description":"The EIP-712 struct the key signs carries player, session and the key's nonce but no expiry, and the nonce only advances when a binding succeeds. Any consent signature that leaks (browser storage, a phishing page that asks a wallet to sign a 'quick swings' message with the victim's own address in the `session` field and the attacker in `player`) can be submitted by the named player at any later time. Once bound, playerOf(victim) == attacker: every buyTurns the victim sends is paid by the victim and credited to the attacker (line 189), every swing the victim makes spends the attacker's turns and scores for the attacker, and every slam the victim hits is paid to the attacker (line 326); the victim's own turns are frozen until they notice and call leaveSession. The checklist this review is judged against lists deadlines alongside domain separator and nonce as required EIP-712 replay protections. Fix: add `uint256 deadline` to the Session type and to setSession, revert when block.timestamp > deadline; keep the per-key nonce.","line":214,"path":"src/SwarmDerby.sol","reproduction":"State: key K (private key 0x5E55) signs sessionDigest(player, K) at time T0 (day 20370, nonce 0). Ten years later (vm.warp(T0 + 3650 days)) player calls setSession(K, sig). Expected with an expiry: revert BadSession. Actual: binding succeeds, playerOf(K) == player. Verified with test/scratch/Leads.t.sol::test_leadE_consentHasNoDeadline. Phishing variant: victim V signs Session(player=A, session=V, nonce=sessionNonce[V]) on any site; A calls setSession(V, sig) whenever convenient; afterwards V's buyTurns(0, 10) debits V 1.5 IMD and sets turns[0][A] += 10 (test_sessionBuysForThePlayer shows the credit direction).","severity":"low","snippet":"        bytes32 structHash = keccak256(abi.encode(SESSION_TYPEHASH, player, session, sessionNonce[session]));","title":"Session consent has no deadline: a signed Session(player, session, nonce) stays valid indefinitely until that key's nonce moves"},{"citation":"resolved","description":"Boundary case 'no code at receiver'. _pull and _trySend use low-level address(imd).call and treat (success, empty returndata) as success, which is what a call to an address without code returns. The constructor only rejects address(0). The handoff documents two different IMD addresses (Ethereum mainnet 0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7 for paying the swarm, Robinhood 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 for the game) and deployment goes through a third-party factory that 'may adapt the code'; a mix-up passes constructor checks. cast code on Robinhood Chain confirms the mainnet address has no code there. In that state anyone buys unlimited turns for free, pot/vault/opsBalance grow with no backing, slams and settlements 'pay' nothing, and the contract looks healthy to the page until the first real payout is missing. Fix: `if (address(imd_).code.length == 0) revert ZeroAddress();` (or a dedicated error) in the constructor.","line":166,"path":"src/SwarmDerby.sol","reproduction":"Deploy `new SwarmDerby(owner, IERC20(0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7), 0.15 ether, 0.5 ether)` on a chain where that address has no code (Robinhood Chain 4663 today). Expected: constructor reverts. Actual: deployment succeeds; an account with zero IMD and zero allowance calls buyPacks(1, 100) and gets turns[1][caller] == 500 while pot[1] == 22.5 ether and no token moved. Verified with test/scratch/Leads.t.sol::test_leadC_codelessTokenIsAccepted.","severity":"low","snippet":"        if (owner_ == address(0) || address(imd_) == address(0)) revert ZeroAddress();","title":"Constructor accepts a token address with no code: every purchase then succeeds without payment and every payout is a silent no-op"},{"citation":"resolved","description":"HANDOFF.md step 9 and DEPLOY.md 'Known limits' tell the operator to 'move owner to a multisig, or renounce it'. After transferOwnership(address(0)) (or any mistyped address, since there is no acceptance step) withdrawOps is unreachable forever, yet _buy keeps crediting opsBalance with 5% of every purchase. Those tokens sit in the contract and are not part of pot or vault, so no payout path can ever release them; the 'money held' invariant the tests check (balance == pot + vault + ops) stays true while the ops term becomes dead weight. The design intent is that ops is spendable. Fix: either reject address(0) and use a two-step (pending owner + accept) transfer, or make the ops share flow to a fixed `opsRecipient` so renouncing admin rights does not freeze revenue; and update the docs to say which.","line":503,"path":"src/SwarmDerby.sol","reproduction":"Owner calls transferOwnership(address(0)). A player then buys 10 arcade turns (1.5 IMD). Expected per the docs: ops share still withdrawable by whoever operates the game. Actual: opsBalance == 0.075 ether and every withdrawOps call reverts NotOwner; the balance grows with each later purchase and can never leave the contract. Verified with test/scratch/Leads.t.sol::test_leadB_renounceStrandsOps.","severity":"low","snippet":"    function transferOwnership(address to) external onlyOwner { owner = to; }","title":"transferOwnership is single-step and accepts address(0); the documented 'renounce it' option permanently strands the 5% ops share"},{"citation":"resolved","description":"The live IMD token on Robinhood Chain is an owner-controlled OFT (owner 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7) with setBlocked(address,bool) and an enableTransfers/transfersEnabled switch; transfers to or from a blocked address revert ('BridgedFP: blocked', confirmed on a fork). The derby's 'Known limits' section lists the sequencer, self-reported quality, the per-wallet cap and the derby owner, but not the token owner, who is a stronger party than the derby owner: blocking the derby contract address stops every _pull and _send (no purchases, no slams, no settlement, no ops withdrawal), and blocking 0xdead stops all purchases because _buy burns with the reverting _send. This is a trust assumption to document, not a code bug; no derby-side change can remove it. Suggested doc line: 'IMD is an owner-controlled token with a blocklist; if its owner blocks this contract, the burn address or a player, the corresponding transfers fail.'","line":86,"path":"src/SwarmDerby.sol","reproduction":"State: IMD owner calls setBlocked(<SwarmDerby address>, true). Expected per the docs: nobody but the derby owner and the sequencer can affect play. Actual: buyTurns reverts TransferFailed (transferFrom to a blocked recipient), finalize of a slam reverts TransferFailed, settleNextDay reverts TransferFailed on the tip transfer, withdrawOps reverts. The blocklist behaviour was confirmed with `cast send IMD setBlocked(addr,true)` from the token owner on an anvil fork of chain 4663 followed by a reverting transfer in both directions.","severity":"info","snippet":"    IERC20 public immutable imd;","title":"External trust not stated in the docs: the IMD token owner can freeze the whole game or any player via the token's blocklist"},{"citation":"resolved","description":"_buy does not reject count == 0. With cost 0 the token calls are transferFrom(from, derby, 0) and transfer(0xdead, 0); the live IMD accepts a zero-value transferFrom with no allowance (confirmed via eth_call) and _trySend short-circuits on amount 0, so the call succeeds. Side effects: the current day is pushed onto _days[league] even if no one plays, and a TurnsBought(player, league, 0, 0, 0) event is emitted. An empty day settles with no tip, so nobody is paid to clear it and the next real day's payout needs one extra settleNextDay call. Bounded to one queue entry per league per day and harmless to funds. Fix: `if (count == 0) revert BadPrice();` (or a dedicated error) at the top of _buy.","line":196,"path":"src/SwarmDerby.sol","reproduction":"A stranger with no IMD and no allowance calls buyTurns(0, 0) on day D. Expected: revert. Actual: success, openDays(0) == [D], TurnsBought emitted with count 0. Verified with test/scratch/Leads.t.sol::test_leadF_zeroBuyMarksDay.","severity":"info","snippet":"        _markDay(league, day);","title":"A zero-count purchase costs nothing but appends the day to the settlement queue and emits a TurnsBought event"}],"hash":"231665a7b6a6acd70384520e99bb740ce15d6a330782435550b88e0d02bc04c8","nodeId":"55223c87-2711-496d-9f4b-85011c7823f3","outcome":"completed","summary":"The review is complete. The findings file is valid, every snippet matches its source line, the project's fuzz tests hold at 3000 runs each, and the full suite plus my scratch tests pass (54 tests).\n\n**Outcome: no critical, high or medium defects in the two contracts.** Seven findings are recorded in `.imd-findings.json`, five low and two informational, each with a reproduction that runs in `test/scratch/Leads.t.sol`.\n\n**Findings, most severe first**\n\n- **Purchases have no maximum cost.** A `setPrices` call that lands before a pending buy charges the new price against the buyer's unlimited allowance. Repro: prices raised to 1.5 IMD, then a queued `buyTurns(0, 10)` takes 15 IMD instead of 1.5. Fix is a `maxCost` parameter.\n- **Slam payout reverts where settlement tolerates failure.** The live IMD token has an owner blocklist (confirmed on a fork, revert text \"BridgedFP: blocked\"). A blocked player's slam can never be finalized, so the turn and the board credit are lost, while `settleNextDay` already handles the same case with `_trySend`.\n- **Session consent has no deadline.** A signed consent stays valid until the key's nonce moves; a ten-year-old signature binds. A phished signature redirects the victim's purchases, swings and slam payouts to the attacker until the victim calls `leaveSession`.\n- **Constructor accepts a codeless token address.** The low-level call pattern turns that into free turns and no-op payouts. The handoff lists two different IMD addresses, and the mainnet one has no code on Robinhood Chain.\n- **Single-step ownership transfer to zero strands ops revenue.** The docs recommend renouncing, after which the 5% ops share accrues forever with no way out.\n- **Info: the IMD token owner is an undocumented trust party.** Blocking the derby address or the burn address stops every purchase, slam and settlement.\n- **Info: zero-count purchases are accepted.** They cost nothing but append a day to the settlement queue.\n\n**What I verified and found sound**\n\n- Settlement ordering and rollover math: pot equals the sum of unsettled day pots plus rollover on every path, including refused transfers; days cannot be paid out of order or twice, and a day's board cannot change after `dayClosed` becomes true because the finalize window and the close condition use the same block arithmetic.\n- Commit-reveal: no outcome is worse than a foul, so selective reveal has no edge; the 240-block window sits inside ArbSys's 256-block hash range, and Robinhood Chain produces a block roughly every 100 ms, so the documented 24-second window is accurate.\n- Session keys: consent is bound to player, key, nonce, chain and contract; keys cannot chain, cannot be claimed twice, and revoke and leave paths mirror each other.\n- Arcade cap, top-10 board insertion and tie handling, slam vault isolation per league, and the odds table's monotonicity.\n\n**Coverage notes.** All twelve Pashov passes were applied as review passes by one reviewer, with the Access Control, Trust Gap and Asymmetry guides as the primary lenses. No Slither or Mythril ran. The chain facts came from the public Robinhood RPC and a local anvil fork; the sequencer's ability to grind block hashes in collusion with a player remains a documented trust assumption I could not test. No contract or configuration files were changed.","treeHash":null,"usage":{"cachedInputTokens":1984334,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":64835,"runtime":"claude","turns":47,"wallClockMs":940197}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f9a12c57a094ca8d","findings":[{"citation":"resolved","description":"Merged from audit_flow, audit_economics, audit_permissions and audit_math (four duplicates). _buy never checks count > 0 or cost > 0. buyTurns(league, 0) and buyPacks(league, 0) compute cost = 0, so _pull issues transferFrom(caller, derby, 0), which succeeds on the live IMD token with no balance and no allowance (confirmed with an eth_call of transferFrom(...,0) from an unfunded address against 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 on chain 4663), _send(DEAD, 0) short-circuits, yet _markDay(league, currentDay()) still pushes the day onto the league's in-order settlement queue and TurnsBought(player, league, 0, 0, 0) is emitted. Any address can therefore add one empty entry per league per UTC day for free. Because settleNextDay pays days strictly in order and an empty day has no board (so no tip), each padded day is a tip-less settleNextDay transaction someone must send before the next real day can be paid, and the page's 'Pay the winners' button shows a ready day with amount 0. No funds are at risk; it is a liveness/UX nuisance and an event-spam vector for indexers that count TurnsBought as purchases. Fix: revert at the top of _buy when count == 0 (e.g. `if (count == 0) revert BadPrice();` or a dedicated ZeroCount error); that covers both buyTurns and buyPacks.","line":188,"path":"src/SwarmDerby.sol","reproduction":"Fresh deployment; address 0x6B1E holds no IMD and has granted no allowance. Day DAY0: 0x6B1E calls buyTurns(1, 0). Expected: revert (nothing to buy). Actual: succeeds; openDays(1) == [DAY0]; contract IMD balance still 0. Repeat on DAY0+1 with buyPacks(1, 0) and on DAY0+2 with buyTurns(1, 0): openDays(1) has 3 entries. On DAY0+3 a real player buys 10 agent turns (0.675 IMD to that day's pot). On DAY0+4 nextSettlement(1) returns (exists=true, ready=true, day=DAY0, amount=0, tip=0) and settleNextDay(1) must be called three times, each paying nothing, before nextSettlement(1) reports day DAY0+3 with amount 0.675e18. Reproduced by test/scratch/Judge.t.sol::test_zeroCountPurchaseEnqueuesDay (passes on current code, i.e. the behaviour is present).","severity":"low","snippet":"    function _buy(uint8 league, uint256 count, uint256 cost) internal {","title":"Zero-count purchases cost nothing, enqueue a settlement day and emit TurnsBought(count=0)"},{"citation":"resolved","description":"Merged from audit_flow, audit_permissions and audit_math (three duplicates). The constructor rejects only address(0) for imd_. _pull (line 523-524) and _trySend (line 533-534) use a raw address(imd).call and treat `ok && data.length == 0` as success, which is exactly what a call to an address without code returns. With a wrong or not-yet-deployed token address the contract is live but unbacked: anyone buys unlimited turns at no cost, pot/vault/opsBalance grow with nothing behind them, 'burns', slam payouts and settlements all 'succeed' while moving nothing. imd is immutable, so the only remedy is redeploying. The risk is concrete for this deployment: HANDOFF.md lists two different IMD addresses on two chains, the Ethereum-mainnet one (0xd34a99bc...) has no code on Robinhood Chain, and DEPLOY.md hands the constructor arguments to a third-party launch flow that 'may adapt the code before deploying'. Fix: `if (address(imd_).code.length == 0) revert ZeroAddress();` (or a dedicated error) in the constructor.","line":166,"path":"src/SwarmDerby.sol","reproduction":"Deploy `new SwarmDerby(owner, IERC20(0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7), 0.15e18, 0.5e18)` on a chain where that address has no code (true on Robinhood Chain today and in the Foundry test). Expected: constructor reverts. Actual: deployment succeeds; address 0x6B1E holding no tokens anywhere calls buyPacks(0, 100) and gets turns(0, 0x6B1E) == 500 while pot(0) == 22.5e18, vault(0) == 5e18 and opsBalance == 2.5e18 with zero tokens held. Reproduced by test/scratch/Judge.t.sol::test_codelessTokenGrantsFreeTurns.","severity":"low","snippet":"        if (owner_ == address(0) || address(imd_) == address(0)) revert ZeroAddress();","title":"Constructor accepts an IMD address with no code; every purchase then succeeds for free with unbacked pots"},{"citation":"resolved","description":"Merged from audit_flow, audit_permissions and audit_math (three duplicates). The cost of a purchase is count * singlePrice (or packs * packPrice) read from storage at execution time, and the buyer passes no maxCost / expected-price argument. setPrices is correctly owner-only and has a floor (MIN_TURN_PRICE) but no ceiling and no delay, so any purchase sequenced after a setPrices call pays the new price, bounded only by the buyer's allowance, which the game page and the test harness set to type(uint256).max. This is a documented owner power (prices are a trust assumption and the owner receives only the 5% ops share of any overcharge; 40% burns, 55% goes to pots/vault), not a permission bypass. It is listed because it also bites honest operation: a routine price update overcharges every user whose transaction was already in flight, with no way for them to opt out, and the docs' quoted prices become unenforceable at the contract level. Fix that preserves the design: add a `maxCost` parameter to buyTurns and buyPacks and `if (cost > maxCost) revert BadPrice();` so the buyer's signed intent bounds what is pulled; the page passes the quoted price.","line":178,"path":"src/SwarmDerby.sol","reproduction":"State: player holds 100 IMD and approved the derby for type(uint256).max; singlePrice == 0.15e18. Owner calls setPrices(15e18, 50e18) (100x, above the floor so it is accepted). The player's already-prepared buyTurns(0, 1) executes next. Expected: revert or a charge near the quoted 0.15 IMD. Actual: 15e18 IMD is pulled for one turn (player balance drops from 100e18 to 85e18). Reproduced by test/scratch/Judge.t.sol::test_purchaseHasNoCostBound.","severity":"low","snippet":"        _buy(league, count, count * singlePrice);","title":"buyTurns/buyPacks take no maximum cost: a price change sequenced before a pending buy is charged in full against the standing allowance"},{"citation":"resolved","description":"Merged from audit_permissions and audit_math (two duplicates). settleNextDay (line 466) deliberately pays winners with _trySend so that 'one unpayable winner can't stop the queue' and rolls a refused prize over. finalize pays a slam with _send, which reverts on a refused transfer and takes the whole finalize with it, including the _recordDinger board credit on line 321 that a non-slam homer would have received. The swing stays Committed; once the 240-block window passes the only exit is expire(), which marks it FOUL with no score. The precondition is real for this deployment: the live IMD token on Robinhood Chain (0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127) is owner-controlled and exposes a per-address blocked(address) flag and a transfersEnabled() switch (verified by cast call and selector scan of its bytecode; owner 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7). The player paid for the turn and rolled a 550+ ft slam, yet gets neither payout nor podium credit, and a rival below them moves up. Fix: mirror settlement: `if (!_trySend(s.player, payout)) { vault[s.league] += payout; payout = 0; }` (or add it to rollover) before emitting GrandSlam, so the dinger is always recorded and the swing always finalizes.","line":326,"path":"src/SwarmDerby.sol","reproduction":"State: player buys 100 arcade turns (vault[0] = 1.5 IMD) and commits swing 0 with quality 100 / velo 100 and a salt whose roll against the target block hash is a SLAM; afterwards the token owner blocks the player. At target+1 anyone calls finalize(0, salt). Expected (by analogy with settlement): swing resolves, the homer lands on board(0, day), the undeliverable payout stays in the contract. Actual: finalize reverts with TransferFailed; at target+241 expire(0) succeeds, status Final as FOUL, board(0, day) is empty, vault(0) still 1.5 IMD. Reproduced by test/scratch/Judge.t.sol::test_blockedPlayerCannotFinalizeSlam with a mock token that reverts on transfers to or from a blocked address, the same behaviour the live token's blocked(address) flag implies.","severity":"low","snippet":"            _send(s.player, payout);","title":"Slam payout uses the reverting _send while settlement uses _trySend: a player the token blocks cannot finalize a slam and loses the swing and its board credit"},{"citation":"resolved","description":"Merged from audit_flow, audit_economics and audit_permissions (three duplicates). The EIP-712 struct the key signs carries player, session and the key's nonce but no expiry, and sessionNonce[session] only advances on a successful bind. A consent whose setSession transaction was never sent (page closed, tx dropped) stays valid indefinitely for that player, and the key holder has no way to invalidate it other than binding elsewhere. Replay to other players, contracts and chains is correctly blocked by the nonce, verifyingContract and chainId. The blast radius is small by construction: the key is a throwaway the page generates, a bind only lets that key spend the player's turns and buy turns for the player, and the key can leaveSession at any time. The phishing variant (a wallet tricked into signing with itself as `session` and an attacker as `player`, after which the wallet's buys and swings are credited to the attacker until it calls leaveSession) is a general signature-phishing risk that a deadline only shortens. The eth-security checklist lists deadlines alongside domain separator and nonce as required EIP-712 replay protections. Fix: add `uint256 deadline` to the Session type and setSession and revert when block.timestamp > deadline; optionally expose a function for a key to bump its own nonce.","line":214,"path":"src/SwarmDerby.sol","reproduction":"Key 0x5E55 signs sessionDigest(player, key) at T0 (day 20370, nonce 0). Nothing is submitted. vm.warp(T0 + 3650 days); player calls setSession(key, sig). Expected with an expiry: revert BadSession. Actual: binds; playerOf(key) == player. Reproduced by test/scratch/Judge.t.sol::test_sessionConsentNeverExpires.","severity":"info","snippet":"        bytes32 structHash = keccak256(abi.encode(SESSION_TYPEHASH, player, session, sessionNonce[session]));","title":"Session-key consent has no deadline: a signed Session(player, session, nonce) stays bindable until that key's nonce moves"},{"citation":"resolved","description":"Merged from audit_flow, audit_economics, audit_permissions and audit_math (four duplicates). Ownership moves in one call with no acceptance by the new owner and no zero-address check (the constructor rejects owner_ == 0, the setter does not), and no event is emitted. HANDOFF.md step 9 and DEPLOY.md 'Known limits' present 'renounce it with transferOwnership' as an option. After transferOwnership(address(0)) or any mistyped address, withdrawOps and setPrices are unreachable forever, yet _buy keeps crediting opsBalance with 5% of every purchase; those tokens are outside pot and vault so no payout path can ever release them. Pots and vaults are unaffected. This is a trust/operational note, not an exploit. Fix: two-step transfer (pendingOwner + acceptOwnership) with an explicit renounceOwnership for the documented renounce case, emit OwnershipTransferred, and either document that renouncing locks the ops share or route the ops share to a fixed opsRecipient so giving up admin rights does not freeze revenue.","line":503,"path":"src/SwarmDerby.sol","reproduction":"Owner O; player buys 100 arcade turns so opsBalance == 0.75e18. O calls transferOwnership(address(0)). Expected per HANDOFF ('withdrawOps releases the 5% ops share'): still recoverable by whoever operates the game, or the call rejected. Actual: owner() == address(0); withdrawOps(to, 0.75e18) reverts NotOwner for every caller; a further 100-turn purchase raises opsBalance to 1.5e18, which can never leave the contract. Reproduced by test/scratch/Judge.t.sol::test_transferOwnershipStrandsOps.","severity":"info","snippet":"    function transferOwnership(address to) external onlyOwner { owner = to; }","title":"transferOwnership is single-step with no zero-address check; renouncing (as HANDOFF suggests) or a typo permanently strands the 5% ops share"},{"citation":"resolved","description":"Merged from audit_economics and audit_math (two duplicates). Design observation, not a code bug. At quality 100 the slam probability is (10000 - 9920) / 10000 = 0.8% (DerbyOdds.thresholds(100)[3] == 9920) and any script can claim quality 100 and velo 100. A slam pays vault / 2, so the expected vault take per swing is 0.004 * V regardless of how many other players exist. A pack turn costs 0.1 IMD (0.5 / 5), so once V > 25 IMD a perfect swing is +EV on the vault alone (37.5 IMD at the single-turn price; 2.5 IMD at the MIN_TURN_PRICE floor). The AGENT league has no swing cap, so a bot keeps buying packs and swinging until the vault is back under ~25 IMD; in ARCADE the 20-swing cap only adds the cost of extra wallets. The dominant agent's effective turn cost is lower still because it recovers ~54% of the 45% pot share as first place. Consequence: the vault, described as a growing jackpot, equilibrates at a few tens of IMD per league and the value of the 10% vault share flows to whoever runs the cheapest perfect-quality script. Each slam halves the vault so the condition self-corrects, and the vault is money players put in, so nothing is lost by the protocol. Any fix changes economics and is a scope decision: cap a single slam payout (min(vault/2, K)), lower SLAM_VAULT_SHARE_BPS (10% gives a 125 IMD threshold), or pay a fraction that shrinks with vault size.","line":323,"path":"src/SwarmDerby.sol","reproduction":"State: vault[AGENT] = 30 IMD (reached after 300 IMD of agent purchases without a slam). A bot buys 1 pack (0.5 IMD, 5 turns) and swings 5x with quality 100, velo 100 in league 1. Per swing: P(slam) = 80/10000, payout = 15 IMD, EV = 0.12 IMD > 0.1 IMD turn cost, so the bot is +EV and repeats until vault < 25 IMD. Arithmetic checked in Foundry: thresholds(100)[3] == 9920 and (25e18 * 5000 / 10000) * 80 / 10000 == 0.1e18 (test/scratch/Judge.t.sol::test_slamEvArithmetic); payout = vault * 5000 / 10000 is shown by the existing test_slamPaysHalfOfItsLeagueVault.","severity":"info","snippet":"            uint256 payout = (vault[s.league] * SLAM_VAULT_SHARE_BPS) / 10_000;","title":"Slam-vault economics: above ~25 IMD in a league vault, a quality-100 swing is positive expected value from the slam alone, so uncapped agents pin the vault there"},{"citation":"resolved","description":"From audit_economics (single report), kept as an info-level design note because the behaviour is documented ('Not revealed within 240 blocks (~24s) counts as a foul'). finalize refuses the roll once more than 240 L2 blocks have passed since targetBlock and records FOUL regardless of what the hash says. Robinhood Chain mainnet produces blocks continuously at ~100 ms (measured in this review: blocks 82091760 to 82092760 span 103 s), so the whole budget from commit to the reveal cutoff is ~24.5 s and shrinks under any faster block production. A player without a session key must get a second wallet-signed transaction mined in that window; an RPC hiccup, wallet prompt or sequencer backlog converts a turn that rolled a HOMER or SLAM into a foul and, for a slam, forfeits half the vault. The player cannot game the cutoff (an unrevealed roll is never better than a revealed one, there are no negative tiers), so it protects nothing economically; it exists so dayClosed() can treat the board as final, and dayClosed is keyed off the same constant. ArbSys.arbBlockHash serves hashes for 256 blocks, so 16 blocks of usable reveal time are forfeited. Minimal change: raise FINALIZE_WINDOW to 255 (dayClosed follows) and document the budget in seconds for the live block rate; a materially longer window needs a design change (a keeper storing the target hash, or a time bound).","line":312,"path":"src/SwarmDerby.sol","reproduction":"Commit at arbBlockNumber 1000 (target 1005) with quality 100, velo 100 and a hash rigged to roll HOMER. Advance to block 1246 (241 blocks after target). ArbSys.arbBlockHash(1005) still returns a non-zero hash (within 256) but finalize(id, salt) returns (FOUL, 0) and credits nothing; at block 1245 the same call would have scored. Reproduced by test/scratch/Judge.t.sol::test_lateFinalizeFoulWhileHashAvailable and the existing test_lateRevealIsFoul. On-chain timing: 240 blocks * ~0.103 s = ~24.7 s between target and cutoff.","severity":"info","snippet":"        if (current - s.targetBlock <= FINALIZE_WINDOW) {","title":"FINALIZE_WINDOW is ~24 s on Robinhood Chain and is measured in blocks: a decided swing becomes a foul although its block hash is still served for 256 blocks"},{"citation":"resolved","description":"From audit_permissions, with the trust-assumption inventory from audit_economics folded in. The live IMD token on Robinhood Chain (0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127, symbol IMD, owner 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7, not a proxy: EIP-1967 slot is empty) exposes blocked(address), transfersEnabled() and enableTransfers() (verified by cast call and a selector scan of its bytecode in this review; the blocklist setter's name is not in the public signature database so it was not exercised). The derby's 'Known limits' section lists the sequencer, self-reported quality, the per-wallet cap and the derby owner, but not the token owner, who is a stronger party than the derby owner: blocking the derby contract stops every _pull and _send (no purchases, no slams, no settlement tips, no ops withdrawal), blocking 0xdead stops all purchases because _buy burns with the reverting _send, and disabling transfers stops everything. Other trust facts verified for launch: the derby owner's setPrices has a floor but no ceiling, so it can halt new purchases but can never reach pot[] or vault[] (the only owner-gated transfer is withdrawOps, bounded by opsBalance); ArbSys at address(100) answers arbBlockNumber() on chain 4663 and its 256-block arbBlockHash range covers FINALIZE_WINDOW. No derby-side code change removes the token dependency; this is a documentation item. Suggested line for 'Known limits': 'IMD is an owner-controlled token with a blocklist and a transfer switch; if its owner blocks this contract, the burn address or a player, the corresponding transfers fail.'","line":86,"path":"src/SwarmDerby.sol","reproduction":"With a token that mirrors the live IMD's blocked flag: player buys 10 arcade turns; token owner blocks the derby address. Expected per the docs: only the derby owner and the sequencer can affect play. Actual: buyTurns(0, 1) reverts TransferFailed (transferFrom to a blocked recipient) and withdrawOps reverts TransferFailed. Unblock the derby and block 0xdead instead: buyTurns(0, 1) reverts TransferFailed on the burn. Reproduced by test/scratch/Judge.t.sol::test_tokenBlocklistFreezesGame. Live facts: `cast call IMD 'blocked(address)(bool)' 0x...01` returns false, `cast call IMD 'transfersEnabled()(bool)'` returns true, `cast call IMD 'owner()(address)'` returns 0x047F606f..., all against https://rpc.mainnet.chain.robinhood.com on 2026-10-07.","severity":"info","snippet":"    IERC20 public immutable imd;","title":"External trust not stated in the docs: the IMD token owner can freeze the whole game or any player via the token's blocklist or transfer switch"}],"hash":"9456d45ce8e46e7980fa92d0207253c373cf50540a2b4160bb4887e90b1a7e90","nodeId":"d9f7c991-35c2-42aa-b6f7-f662477120fc","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 9 merged findings, every one reproduced in Foundry, and no contract or config file was touched. The scratch test and findings file are untracked, as the task allows.\n\n**What the specialists reported and what survived**\n\nThe four specialists filed 25 findings. They collapse to 9 distinct defects, since the same four or five issues were reported by two to four teams each. All kept findings reproduce against the code via `test/scratch/Judge.t.sol`, which passes on the current code, meaning each described behaviour is present. The existing 48-test suite also passes.\n\n| Severity | Finding | Duplicates merged |\n|---|---|---|\n| low | Zero-count purchases enqueue a settlement day for free | 4 |\n| low | Constructor accepts a codeless token address, purchases then free | 3 |\n| low | No maximum-cost bound on purchases after a price change | 3 |\n| low | Slam payout uses reverting `_send`, blocked player loses the swing and board credit | 2 |\n| info | Session consent signature has no deadline | 3 |\n| info | Single-step `transferOwnership`, renouncing strands the ops share | 4 |\n| info | Slam vault is +EV for perfect swings above ~25 IMD | 2 |\n| info | 240-block reveal window is ~24 s on the live chain | 1 |\n| info | Token-owner blocklist and transfer switch are an undocumented trust dependency | 1 |\n\n**Dropped.** The audit_flow lead that a player could author the target block on an idle Nitro chain was not kept. It cannot be reproduced in Foundry, the specialist marked it unreproduced, and my block-cadence measurement shows the chain producing blocks every ~100 ms with other senders, so the idle precondition does not hold today.\n\n**Live-chain facts I verified myself** on Robinhood Chain over its public RPC: chain id 4663, the IMD token has code, symbol IMD, an owner, a `blocked(address)` flag and a `transfersEnabled()` switch, is not a proxy, accepts a zero-value `transferFrom` with no allowance, and ArbSys answers at address 100. I could not name the blocklist setter, so the trust finding states the views I confirmed rather than asserting the setter's signature the specialist used.\n\n**Severity reasoning.** Nothing reached medium. The four lows need either an unprivileged griefer with no funds at stake, a deployment mistake, or an external privileged party. None of them lets anyone reach pots or vaults, and the accounting invariants traced through purchase, finalize and settlement hold. No critical or high findings, so no proof files were attached.","treeHash":null,"usage":{"cachedInputTokens":1240531,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":33561,"runtime":"claude","turns":29,"wallClockMs":549396}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b414b10f97bca557","findings":[{"citation":"resolved","description":"The slam prize is a fixed 50% of the league vault and the slam probability is a fixed 80 bps at quality 100 (DerbyOdds.thresholds(100)[3] = 9920), which any script can claim (velo 100 clears POWER_LINE). The expected vault take per swing is therefore 0.008 * 0.5 * V = 0.004 V, independent of how many other players exist. A turn costs 0.1 IMD in packs (0.5 / 5), so once V > 25 IMD a quality-100 swing is positive expected value on the vault alone; at the MIN_TURN_PRICE floor (0.01 IMD) the threshold is 2.5 IMD. The AGENT league has no swing cap, so a bot keeps buying packs and swinging until the vault is back under ~25 IMD; in ARCADE the 20-swing cap only adds the cost of extra wallets (one 0.1 IMD turn each), it does not change the per-swing EV. For the dominant agent the break-even is lower still, because 45% of each purchase goes to the day pot and the dominant agent recovers ~54% of that (0.9 * 0.995 * 0.6) as first place, so its real cost per turn is ~0.076 IMD and the vault threshold ~19 IMD. Consequence: the vault, intended as a growing jackpot ('a 550+ ft swing pays half of its league's vault instantly'), is pinned to a few tens of IMD and the economic value of the 10% vault share flows to whoever runs the cheapest quality-100 script, not to arcade players. Expected: the vault accumulates with volume. Actual: it equilibrates at ~0.1 / (0.008 * 0.5) = 25 IMD per league. Fix options (any changes economics, so this is a scope decision): pay a fixed fraction that shrinks with vault size or cap a single slam payout (e.g. min(vault/2, K IMD)); lower the slam share (SLAM_VAULT_SHARE_BPS) so 0.004 V becomes e.g. 0.0008 V (threshold 125 IMD at 10%); or tie the arcade vault to a per-wallet cumulative-turns cap. None of this is a code bug; it is the equilibrium the constants imply.","line":323,"path":"src/SwarmDerby.sol","reproduction":"State: vault[AGENT] = 30 IMD (after 300 IMD of agent purchases). A bot buys 1 pack (0.5 IMD, 5 turns) and swings 5x with quality=100, velo=100 in league 1. Per swing: P(slam) = (10000-9920)/10000 = 0.008, payout = 15 IMD, EV = 0.12 IMD > 0.1 IMD turn cost, so the bot is +EV and repeats until vault < 25 IMD. Check in Foundry: DerbyOdds.thresholds(100)[3] == 9920 and (25e18 * 80 / 10000) * 5000 / 10000 == 0.1e18 (test/scratch/Probe.t.sol::test_slamOddsAndEv). The same arithmetic at singlePrice 0.15 gives a 37.5 IMD ceiling, and at the 0.01 price floor a 2.5 IMD ceiling.","severity":"low","snippet":"            uint256 payout = (vault[s.league] * SLAM_VAULT_SHARE_BPS) / 10_000;","title":"Slam vault has a rational-drain ceiling of ~25 IMD: above it, every perfect-quality swing is +EV and uncapped agents (or multi-wallet arcade scripts) farm it down"},{"citation":"resolved","description":"finalize() refuses the roll once more than 240 L2 blocks have passed since targetBlock and records a FOUL regardless of what the hash says. On Robinhood Chain mainnet (chain 4663) blocks are produced continuously at ~100 ms (measured: blocks 82084862 -> 82085862 span 101 s), so the whole reveal window from commit is ~24.5 s, and the window is denominated in blocks, so it shrinks further under any faster block production. A player without a session key (the page offers quick swings as an opt-in) must get a second wallet-signed transaction mined within that budget; an RPC hiccup, a wallet prompt, or sequencer backlog converts a turn that already rolled a HOMER/SLAM into a foul and, for a slam, forfeits half the vault. The player cannot game this: an unrevealed roll is never better than a revealed one (no negative tiers), so the strict cutoff protects nothing economically; it exists only so dayClosed() can treat the board as final, and ArbSys.arbBlockHash is available for 256 blocks, so the contract already forfeits 16 blocks of usable reveal time, and dayClosed is keyed off the same constant so extending it is a one-constant change. Expected (player's view): a swing whose target block exists and whose hash the chain still serves resolves to its rolled tier. Actual: tier = FOUL, feet = 0, no Dinger, no slam payout. Minimal fix: raise FINALIZE_WINDOW to 255 (the chain limit; dayClosed follows it automatically) and document the budget in seconds for the live block rate; a larger change (storing the target hash via a keeper, or letting any later reveal count when the hash is still available) would be needed for a materially longer window.","line":312,"path":"src/SwarmDerby.sol","reproduction":"Commit at arbBlockNumber 1000 (target 1005) with quality=100, velo=100 in league 1. Advance to block 1246 (241 blocks after target). ArbSys.arbBlockHash(1005) still returns a non-zero hash (within the 256-block window) but finalize(id, salt) returns (FOUL, 0) and credits nothing; at block 1245 the same call would have returned the rolled tier. Shown by test/scratch/Probe.t.sol::test_lateFinalizeFoulWhileHashAvailable and by the existing test_lateRevealIsFoul. On-chain timing: 240 blocks * ~0.101 s = ~24 s between target and cutoff.","severity":"low","snippet":"        if (current - s.targetBlock <= FINALIZE_WINDOW) {\n            try ARB_SYS.arbBlockHash(s.targetBlock) returns (bytes32 h) { bh = h; } catch {}\n        }","title":"FINALIZE_WINDOW (240 L2 blocks ≈ 24 s measured on Robinhood Chain) turns a paid, already-decided swing into a foul even though the deciding block hash is still retrievable for 256 blocks"},{"citation":"resolved","description":"buyTurns(league, 0) and buyPacks(league, 0) compute cost = 0, call transferFrom(msg.sender, this, 0) (succeeds on the live IMD token and most ERC-20s), push currentDay() onto _days[league] via _markDay, write dayPot[league][day] += 0 and emit TurnsBought(player, league, 0, 0, 0). Anyone, without holding or approving any IMD, can thereby add one entry per UTC day to each league's in-order settlement queue, which then needs its own settleNextDay() call (tip 0, nothing paid) before later days can settle, and can spam TurnsBought events that indexers/the site may count as purchases. Impact is cosmetic plus one extra cheap L2 transaction per padded day for whoever settles, since _markDay dedups within a day; it does not touch pots. Fix: revert when count == 0 (e.g. `if (count == 0) revert BadPrice();` or a new ZeroCount error) at the top of _buy.","line":177,"path":"src/SwarmDerby.sol","reproduction":"From an address with 0 IMD and no allowance call buyTurns(0, 0). Expected: revert (nothing bought). Actual: succeeds; openDays(0) returns [currentDay()], nextSettlement(0) reports exists=true for that day, TurnsBought emitted with count 0 and cost 0. Shown by test/scratch/Probe.t.sol::test_zeroPurchaseMarksDay.","severity":"info","snippet":"    function buyTurns(uint8 league, uint256 count) external validLeague(league) {\n        _buy(league, count, count * singlePrice);\n    }","title":"Zero-count purchases succeed with no token movement, enqueue the day for settlement and emit TurnsBought(count=0)"},{"citation":"resolved","description":"Ownership moves in one call with no zero-address check and no acceptance by the new owner. HANDOFF.md deliberately lists 'renounce it with transferOwnership', so address(0) is an intended input, but the same path also accepts any typo. Since the only owner-gated value flow is withdrawOps (the 5% ops split, opsBalance), a wrong address makes every future ops accrual unrecoverable while purchases keep adding to it. The constructor rejects owner_ == 0 but the setter does not, so the invariant 'owner != 0 unless deliberately renounced' is not enforced. This is a trust/operational note, not an exploit. Fix: two-step transfer (pendingOwner + acceptOwnership) and an explicit renounceOwnership() for the documented renounce case; also emit an OwnershipTransferred event, which is currently missing.","line":503,"path":"src/SwarmDerby.sol","reproduction":"Owner calls transferOwnership(0x000...0) (or any address whose key it does not hold). Then withdrawOps(to, x) reverts NotOwner for everyone forever, while opsBalance keeps growing by 5% of each purchase. Shown by test/scratch/Probe.t.sol::test_ownerCanBurnOwnership.","severity":"info","snippet":"    function transferOwnership(address to) external onlyOwner { owner = to; }","title":"transferOwnership is single-step and unchecked: a mistyped address or address(0) permanently strands the ops share"},{"citation":"resolved","description":"Session(player, session, nonce) carries no expiry, and sessionNonce[session] only advances on a successful bind. A consent the key signed for player P (nonce n) can be submitted by P at any later time as long as the key has not been bound by anyone since; there is no way for the key holder to invalidate it other than binding somewhere else. Within this design the key is a throwaway generated by the page and the only thing a bind grants is spending P's turns and buying turns for P with the key's own IMD, so the exposure is small (a stale browser key being re-bound by its own owner). Replay to other players, other contracts and other chains is correctly blocked by the nonce, verifyingContract and chainId in the digest. Fix: add a `uint256 deadline` to the typed struct and `require(block.timestamp <= deadline)` in setSession, and/or expose a function for a key to bump its own nonce.","line":214,"path":"src/SwarmDerby.sol","reproduction":"Key K signs consent for (player=P, session=K, nonce=0). P keeps the signature; a year later P calls setSession(K, sig) and it succeeds because sessionNonce[K] is still 0. Expected (typical EIP-712 flow): the consent lapses; actual: binds. Same shape as test_sessionConsentIsSingleUse, without the intervening bind.","severity":"info","snippet":"        bytes32 structHash = keccak256(abi.encode(SESSION_TYPEHASH, player, session, sessionNonce[session]));","title":"Session consent signatures have no deadline: a key's signed consent for a player stays bindable indefinitely until that key's nonce moves"},{"citation":"resolved","description":"Documented privileged powers and dependencies, recorded so the operator sees them separately from defects. (1) Owner: setPrices has a floor (0.01 IMD/turn) but no ceiling, so the owner can halt new purchases by setting an unaffordable price; existing turns, pots, vault and settlement keep working, and the owner can never reach pot[] or vault[] (verified: the only owner-gated transfer is withdrawOps, bounded by opsBalance). Lowering the price to the floor lowers the slam-vault drain threshold to ~2.5 IMD (see the vault finding). (2) Randomness: seed = keccak(salt, arbBlockHash(commit block + 5)). The sequencer builds the target block and, if told the salt, could grind it; the player alone cannot: Robinhood Chain mainnet (chain 4663, probed over its public RPC) produces blocks every ~100 ms carrying 5-27 transactions each, so a player cannot predict or dominate the content of block B+5, and an unrevealed roll is never better than a revealed one, so withholding never pays. DEPLOY.md's 'neither the player nor the sequencer alone' is accurate for this chain. (3) Token: the live IMD at 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 has code, symbol IMD, 18 decimals, exposes no paused()/implementation()/fee selectors and its bytecode contains no pause/blacklist selectors, so the fee-on-transfer, pausable and blacklist failure modes in the guides do not apply; _pull/_trySend handle both bool-returning and void-returning tokens correctly. (4) ArbSys at address(100) answers arbBlockNumber() on that chain (82,085,687 at probe time), and its arbBlockHash 256-block availability covers FINALIZE_WINDOW = 240. Accounting invariant pot[l] == sum(unsettled dayPot[l][d]) + rollover[l] and contract balance == pot[0]+pot[1]+vault[0]+vault[1]+opsBalance were traced through _buy, finalize and settleNextDay (including the _trySend failure branch) and hold; the existing fuzz test covers them. No action required beyond the launch policy already in HANDOFF.md (multisig or renounce).","line":492,"path":"src/SwarmDerby.sol","reproduction":"setPrices(1e30, 5e30) by the owner: buyTurns(0, 1) then reverts for any realistic balance (transferFrom of 1e30) while swing/finalize/settleNextDay continue to work. The chain and token facts above were obtained with `cast chain-id`, `cast code`, `cast call ... symbol()/decimals()/paused()`, `cast block ... -f timestamp` and eth_getBlockTransactionCountByNumber against https://rpc.mainnet.chain.robinhood.com on 2026-10-07.","severity":"info","snippet":"    function setPrices(uint256 single, uint256 pack) external onlyOwner {\n        _checkPrices(single, pack);","title":"Trust assumptions verified for launch: owner price power is bounded below but not above, the sequencer is the only party that could steer a roll, and the live IMD token / ArbSys behave as the contract"}],"hash":"d32cdada5c16886260b5c613391ae43fea0fd3fa5c6b7f5efb1976d324c20507","nodeId":"ada997db-cb76-4508-93a7-6daf830b9882","outcome":"completed","summary":"Review complete. `.imd-findings.json` holds 6 findings, all snippets verified against the tree; the only other files I created are under `test/scratch/` (not kept). No source or config files were changed.\n\n## What I reviewed\n\nFull read of `src/SwarmDerby.sol` and `src/DerbyOdds.sol`, the 48-test suite (all pass), e2e mocks and deploy/handoff docs, checked against the Economic Security, Invariant and Flow Gap guides plus the entry-point inventory. I also probed Robinhood Chain mainnet over its public RPC to replace assumptions with facts: ~100 ms blocks carrying 5–27 txs each, ArbSys live at `address(100)`, and the IMD token at `0x5F7B…7127` is a plain ERC-20 (no pause/blacklist/fee selectors).\n\n## Result: no critical/high/medium defects\n\nThe core accounting is sound. I traced the invariants `pot[l] == Σ unsettled dayPot + rollover[l]` and `balance == pots + vaults + ops` through `_buy`, `finalize` (slam path) and `settleNextDay` including the failed-transfer rollover branch — they hold. Settlement ordering is airtight: `dayClosed` requires `block > dayLastTarget + 240` while `finalize` only reads a hash when `current − target ≤ 240`, so no swing can score on a day after it settles. The top-10 board (`_bump`) is a correct sorted insert with no duplicates; session binding is bijective and consent replay is blocked by nonce/chainId/contract. Block-hash grinding by a player is infeasible on this chain given the measured block density — that lead was demoted after the probe.\n\n## What is in the file\n\n| Sev | Finding |\n|---|---|\n| low | **Slam vault equilibrates at ~25 IMD.** Slam EV per quality-100 swing is `0.004·V`; a pack turn costs 0.1, so above 25 IMD the uncapped agent league (or multi-wallet arcade) farms it down. ~19 IMD for the dominant agent, 2.5 IMD at the price floor. |\n| low | **240-block reveal window ≈ 24 s** on the live chain; a swing that already rolled a slam becomes a foul if `finalize` lands at target+241, though `arbBlockHash` still serves it until +256. Hiding never pays, so the cutoff protects nothing economically. |\n| info | `buyTurns(league, 0)` succeeds with no IMD, enqueues the day and emits `TurnsBought(count=0)`. |\n| info | `transferOwnership` is single-step with no zero check (renounce is intended per HANDOFF, but typos strand `opsBalance`). |\n| info | Session consent has no deadline. |\n| info | Trust assumptions recorded: owner price ceiling absent, sequencer-only randomness steering, verified token/ArbSys facts. |\n\nEach low finding names a concrete state and its reproduction was executed in `test/scratch/Probe.t.sol` (4/4 pass). No proof files were attached since nothing reached high severity.","treeHash":null,"usage":{"cachedInputTokens":1374737,"inputTokens":458,"model":"claude-fable-5-1","outputTokens":50921,"runtime":"claude","turns":22,"wallClockMs":869909}}],"verification":[]}