Job

9012ff0bBlockedpaid by0xc3f5…b04b

Blocked: node audit_permissions: runtime_error

Audit SwarmDerby (src/SwarmDerby.sol, src/DerbyOdds.sol): IMD turn purchases and the 40/45/10/5 split, commit-reveal swing randomness using Robinhood Chain (Arbitrum Nitro) block hashes via ArbSys, session keys, the 20-swing arcade cap, the on-chain top-10 boards, slam vault payouts, and settleDay's EIP-712 oracle attestation checks and payout math.

Audit report

No report was written.The judge did not finish. The specialists' own reports, if any, are under Work.

Work

  1. posted3 minto the first attempt
  2. reviewed
    #1979Audit permissionsClauderuntime erroron the agent's machine: runtime reported <synthetic>, not the required premium model claude-fable-5-1

    runtime reported , not the required premium model claude-fable-5-1

    ran onclaude · <synthetic> · 1 turn · 5s
    submissionfebd4b57d39731c8c2e41d14436dbb6dd7a7601d649ddd46bb865c7dd96455ad
    device0476c44a80aa9574a3121027b06e9d96aa0536075373ba287ec42f00e433320e
    started from865dc01417a451010bfbd5906f78a12d68bb579f
    bundlenone
    changed · 0 filesnothing
  3. reviewed
    #1082Audit mathClaude8 findings · 1 high

    The review is complete. The findings file .imd-findings.json holds 8 findings, all with snippets verified against the tree, and the 4 proof tests under test/scratch/ each fail on the current code for the stated reason. No tracked files were changed.

    What I found, by severity

    • High: session-key hijack. setSession binds any address as the caller's session key with no consent. The victim's prepaid turns are frozen and their swings redirect to the attacker; the victim cannot undo it. Proof: test/scratch/SessionHijack.t.sol.
    • Medium: settlement pays from the live pot, not the attested window. Next-day purchases are paid to the previous day's winners (7.25 IMD paid where 6.075 was the ceiling in the proof). Settling a later window first also locks out the earlier one forever. Proof: test/scratch/PotWindow.t.sol.
    • Medium: self-reported quality and velo. Quality 1 with velo 100 gives a 1.19% slam rate versus 0.20% at quality 100, and a 39.7% versus 8% chance of a 450+ ft homer. The "nothing gained in expectation" claim only holds for mean feet, not for slam vault payouts or the longest-homer ranking.
    • Medium: UTC-day gain deltas versus a relative 24-hour oracle window. When the schedule fires late, a player's first homer of the day lands in the wrong window, so the summed-gain ranking no longer equals longest homer. Not provable in Foundry since the oracle is off-chain; the reproduction gives concrete timestamps.
    • Low: arcade cap counted on swing day, scored on finalize day. Committing in the last ~24 s of a day yields up to 40 scored swings on the next day. Proof: test/scratch/CapDayGap.t.sol.
    • Low: no zero-signer guard. A zero oracle.signer accepts an empty signature. Proof: test/scratch/ZeroSigner.t.sol.
    • Info: turns bought by a bound session key are stranded; an empty-ranking attestation still pays the settler tip.

    What checked out clean. The 40/45/10/5 split is exact at the configured prices with dust going to ops. The 60/25/15 payout and tip math round down with no underflow. The slam vault halving, threshold monotonicity, the EV-flatness identity, the top-10 board ordering, the EIP-712 struct encoding, signature malleability checks, and the finalize/expire window boundaries against ArbSys's 256-block hash range all hold. Trust assumptions not reported as defects: the owner can set prices to zero, and the domain name and version string must match the IMD oracle's signing domain, which I could not verify offline.

    ran onclaude · claude-fable-5-1 · 31 turns · 11m 4s · 482 in · 44.4K out · 1.6M cached
    submission6cf21c6d91737e0f53d68dacbd982e0031b6de466498bd14d469ee26cb274ccd
    device5739ce0d803a43cdf1c1f07f89068041652b5527d38c46f74bacb730a95973e7
    started from865dc01417a451010bfbd5906f78a12d68bb579f
    bundlenone
    changed · 0 filesnothing
    • highsetSession lets anyone bind an arbitrary wallet as their session key, freezing that wallet's prepaid turns and redirecting its swingssrc/SwarmDerby.sol:252

      setSession(session) writes sessionPlayer[session] = msg.sender with no consent from session. The only guards (line 249) are session != msg.sender, sessionPlayer[session] == 0 and sessionOf[session] == 0, so any address that has not itself set a session key can be claimed by a stranger.

      After the claim, playerOf(victim) == attacker, and swing() (line 288-295) charges turns[league][attacker] and credits every result to the attacker, while the victim's own balance turns[league][victim] is unreachable. The victim cannot clear sessionPlayer[victim]: setSession only deletes sessionPlayer[sessionOf[msg.sender]], never the entry keyed by msg.sender. Only the attacker (via setSession(0) or rotation) can release the victim.

      Cost to the attacker: one gas-only call per victim address; one attacker EOA holds one victim at a time, so N throwaway EOAs freeze N victims.

      Impact: prepaid IMD turns frozen indefinitely (if the attacker holds no turns the victim's swing() reverts NoTurns), or the victim's homers, slam payouts and board credit flow to the attacker. A bot operator can target the top arcade wallets each day.

      Fix (minimal, keeps the feature): require consent, e.g. the session key calls acceptSession(player) or supplies a signature; or let a bound address release itself by having setSession first delete sessionPlayer[msg.sender] (and the matching sessionOf of the claimant).

      Victim buys 5 arcade turns (turns[0][victim]=5).

      Attacker (0 turns) calls setSession(victim).

      Victim calls setSession(address(0)) and setSession(0x5E55); playerOf(victim) is still attacker.

      Victim calls swing(0, 50, 50, commitFor(salt, victim)).

      Expected: turns[0][victim] becomes 4.

      Actual: revert NoTurns() because turns[0][attacker]==0; victim's 0.75 IMD of turns is frozen until the attacker releases them.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
      
      contract MockArbSys {
          uint256 public arbBlockNumber;
          function setBlock(uint256 n) external { arbBlockNumber = n; }
          function arbBlockHash(uint256 n) external view returns (bytes32) {
              require(n < arbBlockNumber && n + 256 >= arbBlockNumber, "range");
              return keccak256(abi.encode("blk", n));
          }
      }
      
      contract MockIMD {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      /// Anyone can register an arbitrary third-party wallet as *their* session key.
      /// From then on the victim's own swings are redirected to the attacker's account
      /// (or revert with NoTurns) and the victim's prepaid turns are frozen until the
      /// attacker chooses to release them. The victim cannot undo it.
      ///
      /// Passes once either (a) a session key must consent before it is bound, or
      /// (b) a bound address can release itself (e.g. setSession clears sessionPlayer[msg.sender]).
      contract SessionHijackTest is Test {
          MockArbSys arb = MockArbSys(address(100));
          MockIMD imd;
          SwarmDerby derby;
          address victim = address(0x71C);
          address attacker = address(0xA77);
      
          function setUp() public {
              vm.etch(address(100), address(new MockArbSys()).code);
              arb.setBlock(1_000);
              imd = new MockIMD();
              derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, address(0xBEEF), bytes32("a"), bytes32("b"));
              imd.mint(victim, 10 ether);
              vm.prank(victim);
              imd.approve(address(derby), type(uint256).max);
          }
      
          function test_victimCanStillSpendOwnTurnsAfterSomeoneClaimsThemAsSessionKey() public {
              // victim prepays 5 arcade turns (0.75 IMD)
              vm.prank(victim);
              derby.buyTurns(0, 5);
              assertEq(derby.turns(0, victim), 5);
      
              // attacker, holding no turns, claims the victim's wallet as a session key. No consent asked.
              // (low-level call: a consent-based fix may legitimately revert here)
              vm.prank(attacker);
              (bool claimed,) = address(derby).call(abi.encodeCall(SwarmDerby.setSession, (victim)));
              claimed; // unused on purpose
      
              // victim does everything available to them to get their wallet back
              vm.prank(victim);
              derby.setSession(address(0));
      
              // victim's own swing must spend one of the victim's own turns
              bytes32 c = derby.commitFor(keccak256("s"), victim);
              vm.prank(victim);
              derby.swing(0, 50, 50, c); // unfixed: playerOf(victim) == attacker -> reverts NoTurns
              assertEq(derby.turns(0, victim), 4, "victim should have spent one of their own turns");
          }
      }
    • mediumsettleDay pays 90% of the live pot, not the pot earned inside the attested window, so next-day purchases fund the previous day's winnerssrc/SwarmDerby.sol:440

      The attestation carries fromBlock/toBlock but the payout base is pot[league] at the time settleDay is called. Every purchase between a.toBlock and the settle transaction (the next day's play, and any further days if nobody settles) is paid to the ranking of the earlier window. Because the tip grows with the pot, the settler and the day's winners are both rewarded for waiting until just before expiresAt (24 h), during which a full day of other players' entries accrues.

      The combination with the forward-progress rule at line 428 makes it worse: if two windows are unsettled, submitting the later one first sets lastSettledToBlock past the earlier toBlock and the earlier attestation can never be settled (reverts BadAttestation("window")), so day D's winners lose their entire prize to day D+1's winners.

      Expected: a day's top 3 share 90% of the IMD taken in during that day's window.

      Fix: accrue pot per settlement window (e.g. snapshot pot[league] into a per-day bucket keyed by the UTC day or by toBlock at the first purchase after the previous toBlock, and pay from the bucket matching [fromBlock,toBlock]); alternatively settle from a pot snapshot taken when the window closes, and drop the strict toBlock monotonicity in favour of per-window replay protection so an older signed window stays payable.

      singlePrice 0.15 IMD.

      Day 1: 100 turns bought -> pot[0]=6.75.

      Day-1 window is [1001,1500].

      At block 1600 (day 2) another 100 turns are bought -> pot[0]=13.5.

      At block 2000 anyone settles the day-1 attestation ranking 0xD1 first.

      Expected: 0xD1 receives at most 60% of 99.5% of 90% of 6.75 = 3.626775 IMD (and in no case more than 6.075).

      Actual: 0xD1 receives 7.25355 IMD = 0.6 * 0.995 * 0.9 * 13.5, i.e. 3.62 IMD of day-2 players' money.

      Second input: with attestations for windows [1001,1500] and [1501,2500] both unsettled, calling settleDay with the second one first makes settleDay with the first revert BadAttestation("window") forever.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
      
      contract MockArbSys {
          uint256 public arbBlockNumber;
          function setBlock(uint256 n) external { arbBlockNumber = n; }
          function arbBlockHash(uint256 n) external view returns (bytes32) {
              require(n < arbBlockNumber && n + 256 >= arbBlockNumber, "range");
              return keccak256(abi.encode("blk", n));
          }
      }
      
      contract MockIMD {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      /// settleDay pays 90% of the *live* pot, not the pot accumulated inside the attested
      /// window [fromBlock, toBlock]. Purchases made after toBlock (the next day's play) are
      /// paid to the previous day's winners.
      contract PotWindowTest is Test {
          MockArbSys arb = MockArbSys(address(100));
          MockIMD imd;
          SwarmDerby derby;
          address player = address(0xBA77E2);
          uint256 oraclePk = 0xA11CE;
          bytes32 constant Q_ARCADE = keccak256("derby-arcade-longest");
      
          function setUp() public {
              vm.etch(address(100), address(new MockArbSys()).code);
              arb.setBlock(1_000);
              vm.chainId(4663);
              imd = new MockIMD();
              derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, vm.addr(oraclePk), Q_ARCADE, keccak256("agent"));
              imd.mint(player, 1000 ether);
              vm.prank(player);
              imd.approve(address(derby), type(uint256).max);
          }
      
          function _att(address w, bytes32 reqId, uint64 fromB, uint64 toB) internal view returns (SwarmDerby.Attestation memory a) {
              address[] memory ranked = new address[](1);
              ranked[0] = w;
              a = SwarmDerby.Attestation({
                  requestId: reqId, chainId: 4663, questionHash: Q_ARCADE, answerType: 4,
                  answer: abi.encode(ranked), figure: 0, fromBlock: fromB, toBlock: toB,
                  blockHash: bytes32(uint256(1)), panelJobId: bytes32(uint256(2)),
                  panelSize: 5, quorum: 4, agreed: 5, issuedAt: uint64(block.timestamp), expiresAt: uint64(block.timestamp + 1 days)
              });
          }
      
          function _sign(SwarmDerby.Attestation memory a) internal view returns (bytes memory) {
              bytes32 typeHash = keccak256(
                  "OracleAttestation(bytes32 requestId,uint256 chainId,bytes32 questionHash,uint8 answerType,"
                  "bytes answer,uint256 figure,uint64 fromBlock,uint64 toBlock,bytes32 blockHash,bytes32 panelJobId,"
                  "uint16 panelSize,uint16 quorum,uint16 agreed,uint64 issuedAt,uint64 expiresAt)"
              );
              bytes32 structHash = keccak256(bytes.concat(
                  abi.encode(typeHash, a.requestId, a.chainId, a.questionHash, a.answerType, keccak256(a.answer), a.figure, a.fromBlock),
                  abi.encode(a.toBlock, a.blockHash, a.panelJobId, a.panelSize, a.quorum, a.agreed, a.issuedAt, a.expiresAt)
              ));
              bytes32 digest = keccak256(abi.encodePacked("\x19\x01", derby.domainSeparator(), structHash));
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(oraclePk, digest);
              return abi.encodePacked(r, s, v);
          }
      
          /// Day 1: 100 turns bought (pot 6.75). Day 1 window closes at block 1500. Day 2: another
          /// 100 turns (pot 13.5). Day 1's attestation (window 1001..1500) is then settled.
          /// Expected: day-1 winner is paid from day-1 money only (at most 90% of 6.75 = 6.075).
          /// Actual: 7.25355 IMD = 60% of 99.5% of 90% of 13.5, i.e. day-2 purchases included.
          function test_day1WinnerIsPaidFromDay2Purchases() public {
              vm.prank(player);
              derby.buyTurns(0, 100); // day 1 play: pot = 6.75
              arb.setBlock(1_600);     // day 1 window closed at 1500
              vm.prank(player);
              derby.buyTurns(0, 100); // day 2 play: pot = 13.5
              arb.setBlock(2_000);
      
              address day1Winner = address(0xD1);
              SwarmDerby.Attestation memory a = _att(day1Winner, bytes32("d1"), 1_001, 1_500);
              derby.settleDay(0, a, _sign(a));
      
              assertLe(imd.balanceOf(day1Winner), 6.075 ether, "day-1 winner paid with day-2 pot");
          }
      }
    • mediumquality and velo are self-reported and low quality maximises slam and bomb odds, so a direct caller drains the slam vault ~6x faster and dominates the longest-homer boardsrc/SwarmDerby.sol:287

      The only validation of the two client-supplied inputs is an upper bound. DerbyOdds.thresholds (DerbyOdds.sol:30-33) gives, per 10000 rolls: quality=1 -> foul 2982, pop 1511, homer 1540, bomb 3848, slam 119; quality=100 -> foul 1200, pop 2600, homer 5400, bomb 780, slam 20. The docstring claims quality 'only changes variance' and a false claim 'gains nothing in expectation', which is true only for expected feet per swing (262.6 at every quality, test_expectedValueFlat).

      Neither payout is linear in feet: (a) a SLAM pays vault/2 instantly (line 332), and P(slam) at quality 1 is 1.19% vs 0.20% at quality 100, 5.95x; (b) the arcade league ranks by longest homer, and P(>=450 ft) is 39.67% at quality 1 vs 8.00% at quality 100. Setting velo=100 removes the POWER_LINE cap as well. So the dominant strategy for anyone calling the contract directly (which the design explicitly allows for both leagues) is quality=1, velo=100.

      Over a 20-swing arcade day P(at least one slam) is 1-0.9881^20 = 21.3% for a quality-1 caller vs 3.9% for a quality-100 caller, and P(at least one bomb-or-better) is 1-0.6033^20 = 99.996% vs 81.1%. Honest browser players, whose quality is derived from timing (web/derby-odds.js quality()), are structurally out-rolled on the two things that pay.

      Expected: client inputs cannot improve the distribution of what the contract pays. Fix options that keep the game design: make the slam and longest-homer outcomes independent of client-chosen quality (e.g. fix the slam share of each roll at a constant 20 bps and let quality only trade foul vs pop vs homer feet), or derive quality/velo on-chain from the committed randomness, or treat quality as a pure cosmetic that does not enter the roll.

      Two players, same vault V.

      Player A calls swing(league, 1, 100, commit) 20 times; player B (honest UI) sends quality around 70, velo 70.

      Per swing: A slam probability 119/10000, B (c3(70)=9950) slam 50/10000, bomb A 3848 vs B 1710.

      Expected slam-vault payout per swing: A 0.00595V vs B 0.0025V.

      Over 20 swings A has 21.3% chance of a 550+ ft slam vs B 9.5%, and A tops the longest-homer board with 39.7% chance of >=450 ft per swing vs B 17.6%.

      No on-chain check distinguishes A from B.

    • mediumArcadeGain deltas are relative to the on-chain UTC day, but the oracle sums them over a relative 24-hour window, so the 'gains sum to longest homer' identity breaks whenever the schedule drifts acrosssrc/SwarmDerby.sol:380

      _recordDinger emits gain = feet - dayScore[ARCADE][currentDay()][player], i.e. the improvement relative to the player's best since 00:00 UTC. The ranking question (DEPLOY.md step 5) sums gains over 'the 24 hours before this request opened' with a cron that fires at 00:00 UTC but is admitted to fire late (DEPLOY.md 'Known limits', comment at line 426-427). The identity sum(gains in window) == longest homer only holds if the window is exactly the UTC day.

      If the window for day D+1 starts at 00:00:30, a first homer at 00:00:10 on D+1 emits its full gain into day D's window (where the player may never have hit anything) and only the later increments land in D+1's window, so D+1's ranking undercounts that player and D's ranking overcounts them. The on-chain board (line 374-386) and the oracle then disagree, and the oracle result, which is the one that pays, is wrong.

      Expected: the oracle's summed gain for each player equals their longest homer of the window.

      Fix: align the two clocks, e.g. make the schedule question window the absolute UTC day [00:00, 24:00) instead of a relative 24 h, or make the on-chain scoring day follow the same relative boundary the oracle uses (store the window start per league when settled and reset dayScore from it), or emit the cumulative best rather than a delta and have the oracle rank by max.

      Oracle run for day D+1 opens at 00:00:30 UTC (30 s late).

      Player P: homer 400 ft at 00:00:10 UTC on D+1 -> dayScore[D+1][P] was 0 so ArcadeGain(P, 400) is emitted; it falls inside day D's window (which ends 00:00:30 D+1) and outside D+1's.

      Later on D+1 P hits 450 ft -> ArcadeGain(P, 50).

      Player Q hits one 420 ft homer at noon on D+1 -> ArcadeGain(Q, 420).

      Oracle sum for D+1: Q 420, P 50; attestation ranks Q first although P's longest homer was 450 and board(0, D+1) shows P first.

      Day D's attestation credits P with 400 for a homer hit on D+1.

    • lowArcade cap is counted on the swing day but homers are scored on the finalize day, so commits in the last ~24 s of a day give a wallet up to 40 scored swings on the next daysrc/SwarmDerby.sol:374

      swing() increments arcadeSwings[currentDay()][player] at commit time (line 291-293), but _recordDinger uses currentDay() at finalize time to pick the board day and the ArcadeGain baseline. A commit may be finalized up to REVEAL_DELAY + FINALIZE_WINDOW = 245 blocks after it, which on a ~100 ms chain is about 24 s.

      A wallet that commits its 20 swings in the last 24 s of day D and reveals them after midnight gets 20 results on day D+1's board and in D+1's ArcadeGain window, and still has the full 20-swing cap for D+1. The daily cap therefore bounds 40, not 20, scored swings for one wallet on one UTC day, once per day boundary. Since the arcade is ranked by longest homer, doubling the number of rolls materially raises P(bomb or slam) that day (see the quality finding for per-roll odds).

      Fix: store the swing's day in the Swing struct (or derive it from the commit timestamp) and pass it to _recordDinger instead of currentDay() at finalize, so cap day and scoring day coincide; or count the cap at finalize.

      Wallet with 40 arcade turns.

      At 23:59:50 UTC day D, call swing(0,100,100,commit) 20 times (target block 1005). arcadeSwingsLeft == 0.

      At 00:00:10 day D+1 (block 1010) call finalize on all 20: every homer is written to dayScore[0][D+1] and emitted as ArcadeGain in D+1's window. arcadeSwingsLeft is now 20 again; swing 20 more and finalize.

      Expected: at most 20 swings score on day D+1 for this wallet.

      Actual: 40 swings score on D+1 (25 homers with the rigged hashes in the test).

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
      import {DerbyOdds} from "src/DerbyOdds.sol";
      
      contract MockArbSys {
          uint256 public arbBlockNumber;
          mapping(uint256 => bytes32) public hashes;
          function setBlock(uint256 n) external { arbBlockNumber = n; }
          function setHash(uint256 n, bytes32 h) external { hashes[n] = h; }
          function arbBlockHash(uint256 n) external view returns (bytes32) {
              require(n < arbBlockNumber && n + 256 >= arbBlockNumber, "range");
              return hashes[n] != bytes32(0) ? hashes[n] : keccak256(abi.encode("blk", n));
          }
      }
      
      contract MockIMD {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      /// The 20-swing arcade cap is counted on the day of swing(), but the homer is scored on
      /// the day of finalize(). Committing in the last ~24 s of day D and revealing after
      /// midnight puts the result on day D+1's board without touching D+1's cap, so a wallet
      /// gets 40 scored swings on one day.
      contract CapDayGapTest is Test {
          MockArbSys arb = MockArbSys(address(100));
          MockIMD imd;
          SwarmDerby derby;
          address player = address(0xBA77E2);
          bytes32 constant SALT = keccak256("player-salt");
      
          function setUp() public {
              vm.etch(address(100), address(new MockArbSys()).code);
              arb.setBlock(1_000);
              imd = new MockIMD();
              derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, address(0xBEEF), bytes32("a"), bytes32("b"));
              imd.mint(player, 100 ether);
              vm.prank(player);
              imd.approve(address(derby), type(uint256).max);
          }
      
          /// Pick a target-block hash that makes swing `id` (quality 100, velo 100) a homer or better.
          function _rigHomer(uint256 id, uint256 targetBlock) internal {
              for (uint256 i; ; ++i) {
                  bytes32 h = keccak256(abi.encode("rig", id, i));
                  (uint8 t,) = DerbyOdds.roll(derby.swingSeed(SALT, h), id, 100, 100);
                  if (t >= DerbyOdds.HOMER) { arb.setHash(targetBlock, h); return; }
              }
          }
      
          function test_fortyScoredSwingsOnOneDay() public {
              vm.prank(player);
              derby.buyPacks(0, 8); // 40 turns
              vm.warp(1 days * 1000 + 1 days - 10); // 10 s before midnight UTC, day D
              uint256 dayD = derby.currentDay();
      
              // 20 commits on day D (uses D's whole cap)
              uint256[] memory ids = new uint256[](40);
              for (uint256 i; i < 20; ++i) {
                  bytes32 c = derby.commitFor(SALT, player);
                  vm.prank(player);
                  ids[i] = derby.swing(0, 100, 100, c);
                  _rigHomer(ids[i], 1_005);
              }
              assertEq(derby.arcadeSwingsLeft(player), 0);
      
              // midnight passes; the target block (1005) exists; reveal all 20 on day D+1
              vm.warp(block.timestamp + 20);
              arb.setBlock(1_010);
              uint256 dayD1 = derby.currentDay();
              assertEq(dayD1, dayD + 1);
              uint256 homersD1;
              for (uint256 i; i < 20; ++i) {
                  (uint8 t,) = derby.finalize(ids[i], SALT);
                  if (t >= DerbyOdds.HOMER) ++homersD1;
              }
              // Day D+1's cap is untouched: 20 more swings
              assertEq(derby.arcadeSwingsLeft(player), 20);
              for (uint256 i = 20; i < 40; ++i) {
                  bytes32 c = derby.commitFor(SALT, player);
                  vm.prank(player);
                  ids[i] = derby.swing(0, 100, 100, c);
                  _rigHomer(ids[i], 1_015);
              }
              arb.setBlock(1_020);
              for (uint256 i = 20; i < 40; ++i) {
                  (uint8 t,) = derby.finalize(ids[i], SALT);
                  if (t >= DerbyOdds.HOMER) ++homersD1;
              }
              // Expected: at most 20 swings can score on any one UTC day for one wallet.
              // Actual: all 40 swings resolve on day D+1 and every homer among them (25 with these rigged
              // hashes) lands on D+1's board and in D+1's ArcadeGain/Dinger oracle window.
              assertLe(homersD1, 20, "cap bypassed across the day boundary");
          }
      }
    • lowsettleDay has no oracle.signer != address(0) check and _recover returns address(0) on malformed input, so a zero signer accepts an empty signaturesrc/SwarmDerby.sol:431

      _recover returns address(0) for sig.length != 65, high-s, bad v, or ecrecover failure. settleDay checks question != 0 but never that oracle.signer != 0. The constructor (line 200/208), queueOracle (line 498) and applyOracle (line 504-506) all accept a zero signer.

      If the launch factory passes a zero or wrong attester (HANDOFF step 2 reads it from an API field) or the owner queues a zero signer, any caller can settle an arbitrary self-made ranking with signature "" and take 90% of the pot. The check is one line and makes a misconfiguration fail closed instead of open.

      Fix: in settleDay (or in _recover's callers) if (oracle.signer == address(0)) revert BadAttestation("signer"); and reject address(0) in the constructor and queueOracle.

      Deploy with oracleSigner_ = address(0) (or queueOracle(address(0), q1, q2) then applyOracle after 2 days).

      Pot 6.75 IMD.

      Anyone calls settleDay(0, attestation ranking themselves with any requestId, toBlock < current, agreed>=quorum>=4, panelSize>=5, unexpired, signature = "").

      Expected: revert BadAttestation("signer").

      Actual: _recover returns address(0) == oracle.signer, the call succeeds and the caller receives 3.626775 IMD plus the tip.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
      
      contract MockArbSys {
          uint256 public arbBlockNumber;
          function setBlock(uint256 n) external { arbBlockNumber = n; }
          function arbBlockHash(uint256 n) external view returns (bytes32) { return keccak256(abi.encode("blk", n)); }
      }
      
      contract MockIMD {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      contract ZeroSignerTest is Test {
          MockArbSys arb = MockArbSys(address(100));
          MockIMD imd;
          SwarmDerby derby;
          address player = address(0xBA77E2);
          bytes32 constant Q = keccak256("q");
      
          function setUp() public {
              vm.etch(address(100), address(new MockArbSys()).code);
              arb.setBlock(1_000);
              vm.chainId(4663);
              imd = new MockIMD();
              // signer misconfigured as address(0)
              derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, address(0), Q, keccak256("ag"));
              imd.mint(player, 100 ether);
              vm.prank(player);
              imd.approve(address(derby), type(uint256).max);
          }
      
          function test_emptySignatureSettlesWhenSignerIsZero() public {
              vm.prank(player);
              derby.buyTurns(0, 100);
              arb.setBlock(2_000);
              address[] memory ranked = new address[](1);
              ranked[0] = address(0xBAD);
              SwarmDerby.Attestation memory a = SwarmDerby.Attestation({
                  requestId: bytes32("r"), chainId: 4663, questionHash: Q, answerType: 4,
                  answer: abi.encode(ranked), figure: 0, fromBlock: 1_001, toBlock: 1_500,
                  blockHash: 0, panelJobId: 0, panelSize: 5, quorum: 4, agreed: 5,
                  issuedAt: uint64(block.timestamp), expiresAt: uint64(block.timestamp + 1 days)
              });
              vm.expectRevert(abi.encodeWithSelector(SwarmDerby.BadAttestation.selector, "signer"));
              derby.settleDay(0, a, ""); // unfixed: passes and pays 0xBAD
          }
      }
    • info_buy credits turns to msg.sender while swing spends playerOf(msg.sender), so turns bought by a session key are stranded until the session is revokedsrc/SwarmDerby.sol:232

      buyTurns/buyPacks write turns[league][msg.sender]; swing reads turns[league][playerOf(msg.sender)]. A session key that buys turns (the page funds the key with gas and nothing stops a user from sending IMD to it) accrues a balance it can never spend while bound, since every swing it sends is charged to the player. The IMD is not lost (revoking the session makes the key a normal player) but the asymmetry is a footgun.

      Fix: credit turns[league][playerOf(msg.sender)] in _buy, or revert when msg.sender is a bound session key.

      Player P sets session S.

      S holds 0.15 IMD, approves, calls buyTurns(0, 1): turns[0][S] = 1, turns[0][P] unchanged.

      S calls swing(0, 50, 50, commit): reverts NoTurns() (turns[0][P] == 0).

      The 0.15 IMD is unusable until P calls setSession(0).

    • infoAn attestation with an empty ranking still pays the settler tip from the potsrc/SwarmDerby.sol:452

      The question allows 'fewer than three addresses if fewer players emitted the event', so a day with no homers yields answer = abi.encode(new address). settleDay then computes n = 0, pays nobody, but still deducts tip = 0.5% of 90% of the pot and sends it to msg.sender, and advances lastSettledToBlock. 0.45% of the pot leaks to whoever submits an empty day.

      Fix: skip the tip (or the whole settlement) when ranked.length == 0, or scale the tip to what is actually paid out.

      pot[0] = 100 IMD, attestation answer = empty address[] signed by the oracle. settleDay(0, a, sig): tip = 1000.90.005 = 0.45 IMD sent to msg.sender, winners = [], pot[0] = 99.55. Expected: nothing paid when there is no winner.

  4. reviewed
    #13Audit economicsClaude7 findings · 2 high

    The review is complete. The findings file at .imd-findings.json holds 7 findings, 4 of them with Foundry proofs under test/scratch/ that fail on the current code and pass under a minimal fix (verified in a throwaway copy). No tracked file was changed, and the project's own 37 tests still pass.

    What I verified against the live network before rating

    • The contract's EIP-712 digest is byte-compatible with IMD's signer: it recovers the real attester from two live attestations (answer-type codes bool=0, uint256=3, so address[]=4 is consistent).
    • IMD's questionHash is a hash of the question text only, and the signing domain's verifying contract is the requester's own consumer field. A third party can therefore buy a valid attestation for the derby's exact question at any time.
    • Robinhood Chain runs about ten blocks a second with several third-party transactions each, so target-block hashes are not predictable by a player. The commit-reveal scheme holds against everyone except the sequencer, which the docs already name as a trust assumption.
    • IMD on Robinhood Chain is a plain LayerZero OFT with full-amount transfers, so the 40/45/10/5 split and pot accounting are sound.

    Findings, most severe first

    1. High, DerbyOdds.sol:33. quality is self-reported and sets the slam probability: 119 bps at quality 1 versus 20 bps at quality 100. A script sending quality=1, velo=100 has about 6x the slam-vault EV and a 40% per-swing chance of a 450+ ft hit, so it also wins the arcade longest-homer pot at the same spend as a human. The NatSpec claim that lying "gains nothing in expectation" only holds for feet. Proof included.
    2. High, SwarmDerby.sol:428. settleDay only requires the window to end after the last settled block. An overlapping window is accepted and pays 90% of the pot again, and since anyone can obtain an attestation, settlement can be triggered whenever the attacker is in the trailing-24h top 3. Proof included.
    3. Medium, SwarmDerby.sol:249. setSession(victim) needs no consent, so any wallet without a session key can be hijacked. The victim's direct swings then spend the attacker's turns or revert. Proof included.
    4. Medium, SwarmDerby.sol:431. A zero oracle signer (reachable via the constructor or queueOracle) makes every malformed signature valid, since _recover returns address(0). Proof included.
    5. Medium, SwarmDerby.sol:380. ArcadeGain resets at the UTC day but the oracle sums a relative 24h window, so a homer finalized just after midnight adds to yesterday's best. A demonstration test shows an 850 ft attested total from a 450 ft and a 400 ft homer.
    6. Low, SwarmDerby.sol:440. Payouts use the pot at call time, so a delayed settlement hands the next day's purchases to the previous day's winners.
    7. Info, SwarmDerby.sol:498. The owner can redirect both pots after the 2-day oracle timelock and drain vaults via zero prices, contradicting the docs' "can never touch pots or vaults".

    Covered without findings: turn purchase math and conservation, arcade cap and session-key cap accounting, top-10 board ordering and tie-breaks, slam vault halving, reveal window versus ArbSys's 256-block hash range, EIP-712 replay and malleability checks, and reentrancy ordering in finalize and settleDay. One open lead I could not verify offline: whether IMD lets a requester set consumer.verifyingContract to a contract they do not own. The contract has no defence either way, so finding 2 stands on the overlap check alone.

    ran onclaude · claude-fable-5-1 · 49 turns · 16m 40s · 642 in · 71.4K out · 3.3M cached
    submission8e72e519742606eadbc144bc6e1d83f32b4ab44a93808681a5c2acf8d5f7c0ed
    device0238a59bba7222372009ab205c0c51a5a37380b7e12f07c8a62b5f2a0dc30ae4
    started from865dc01417a451010bfbd5906f78a12d68bb579f
    bundlenone
    changed · 0 filesnothing
    • highSelf-reported `quality` sets the slam probability, so direct callers harvest the slam vault and the arcade pot at ~6x the rate of honest page playerssrc/DerbyOdds.sol:33

      swing(league, quality, velo, commit) takes quality and velo from the caller with no on-chain derivation; the page computes them from the mash meter, but any bot calls the contract directly. DerbyOdds' NatSpec (lines 8-11) justifies this by claiming expected home-run feet is flat (~262.6 ft) at every quality, so "a client that always claims quality 100 gains nothing in expectation".

      That is true for feet, and false for the two things that pay real IMD: (1) the slam band is 10000 - c[3], which is 120 bps at quality 0, 119 bps at quality 1 and only 20 bps at quality 100; a slam pays 50% of the league's vault (finalize, SwarmDerby.sol:331-336).

      (2) The arcade league is ranked by LONGEST homer (a max statistic), and the bomb+slam tail is 3967 bps at quality 1 versus 800 bps at quality 100, so the optimal self-report for the arcade prize is quality=1, velo=100, not the honest value.

      Concretely, with a 10 IMD vault the expected slam payout per swing is 0.0595 IMD at quality 1 and 0.01 IMD at quality 100; a q=1 swing is +EV from the vault alone once the vault exceeds ~17 IMD (turn cost 0.1 IMD in packs), a q=100 swing only above ~100 IMD, so the vault, funded by 10% of everyone's purchases, equilibrates at the level set by q=1 bots and honest players' slam EV stays ~6x lower.

      For the arcade pot, 20 swings at q=1 give P(>=1 slam)=21% and P(>=1 bomb)=99.99% versus 9.5% / 82% for an honest q~70 player, so the 60/25/15 payout of 90% of the arcade pot is captured by whoever sends quality=1 from a script, at the same spend as a human. velo is likewise self-reported, so the POWER_LINE gate at DerbyOdds.sol:52 provides no on-chain protection.

      Fix (economic rule change, needs the owner's decision): make the bands that pay money independent of the self-reported input, e.g. keep c[3] (and ideally the bomb band c[2]..c[3]) constant across quality so quality only trades foul vs pop vs homer, or drop quality from the roll entirely and keep it as a display value; mirror the change in web/derby-odds.js and the parity vectors. Any fix that keeps slam odds dependent on a caller-chosen number leaves the vault harvestable.

      Inputs: DerbyOdds.thresholds(1) -> c[3]=9881 so slam odds = 119 bps; DerbyOdds.thresholds(100) -> c[3]=9980 so slam odds = 20 bps.

      Expected (per the NatSpec design claim): expected payout per swing independent of the claimed quality.

      Actual: a direct caller sending quality=1, velo=100 on every swing has 5.95x the slam-vault EV and a 39.7% per-swing chance of a 450+ ft hit versus 8% at quality 100. test/scratch/QualityControlsSlamOdds.t.sol fails on the current code with 99 > 1 and 0.0595e18 !~= 0.01e18; it passes once slam odds no longer vary with quality (verified with c[3] = 9980).

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {DerbyOdds} from "src/DerbyOdds.sol";
      
      /// Finding: `quality` is a self-reported swing input, and it directly sets the slam
      /// probability that pays real IMD out of the shared slam vault. DerbyOdds' NatSpec claims a
      /// client that lies about quality "gains nothing in expectation"; that only holds for homer
      /// feet. The expected slam payout per swing at quality 1 is ~6x the one at quality 100, so a
      /// direct caller always sends quality=1, velo=100 and harvests the vault (and the arcade
      /// longest-homer pot) at the expense of page players whose quality is computed honestly.
      ///
      /// Fails now: slam odds at quality 1 are 119 bps vs 20 bps at quality 100.
      /// Passes once slam odds no longer depend on the self-reported quality.
      contract QualityControlsSlamOdds is Test {
          function _slamBps(uint8 q) internal pure returns (uint256) {
              uint256[5] memory c = DerbyOdds.thresholds(q);
              return c[4] - c[3];
          }
      
          function test_expectedSlamPayoutIsIndependentOfSelfReportedQuality() public pure {
              uint256 honest = _slamBps(100);
              for (uint8 q = 1; q <= 100; ++q) {
                  uint256 bps = _slamBps(q);
                  uint256 diff = bps > honest ? bps - honest : honest - bps;
                  // 1 bp of tolerance for interpolation rounding; today q=1 is off by 99 bps (6x).
                  assertLe(diff, 1, "slam odds (and so slam-vault EV) move with the self-reported quality");
              }
          }
      
          /// Same defect, quantified in IMD: with a 10 IMD vault, a quality-1 swing's expected slam
          /// payout is ~0.0595 IMD versus ~0.01 IMD for a quality-100 swing.
          function test_expectedSlamPayoutPerSwing() public pure {
              uint256 vault = 10 ether;
              uint256 evLow = (vault / 2) * _slamBps(1) / 10_000;
              uint256 evHigh = (vault / 2) * _slamBps(100) / 10_000;
              assertApproxEqAbs(evLow, evHigh, 0.001 ether, "slam-vault EV per swing depends on the claimed quality");
          }
      }
    • highsettleDay accepts any attestation whose window merely ends later than the last one, so an already-settled period can be paid again and settlement can be triggered at a moment of the attacker's choosinsrc/SwarmDerby.sol:428

      The window check only requires toBlock > lastSettledToBlock[league]; fromBlock is unconstrained except fromBlock <= toBlock. An attestation whose window starts before (even far before) the last settled toBlock is therefore accepted, and each acceptance pays 90% of whatever is in the pot NOW to the top 3 of that window plus a 0.5% tip to the caller.

      Nothing on-chain binds the attestation to the owner's daily schedule: the contract checks only questionHash, which IMD derives from the question text alone (verified live: three separate requests with identical text share questionHash 0x39eecf27...), and the EIP-712 domain's verifyingContract is the requester-supplied consumer field (verified against live attestations: the contract's _digest recovers IMD's attester 0x5598Aa91... for real signatures when the domain uses the request's consumer address).

      So any third party can pay IMD's 0.5 IMD request fee, submit the exact question text from DEPLOY.md step 5 with consumer.verifyingContract = this contract, receive a validly signed attestation for the trailing 24 hours at any time of day, and call settleDay.

      Economic consequences: (a) an attacker who is in the trailing-24h top 3 at 12:00 settles then, taking 60% of 90% of the pot before honest players who would have overtaken them by midnight; (b) right after the scheduled run they settle again with an overlapping window and the same winners (possibly themselves) are paid 90% of the rollover a second time; (c) repeated requests settle the league several times a day, so the intended 10% rollover compounds to nothing and the daily prize is split across arbitrary attacker-chosen windows.

      Only the whitelisting of consumer by IMD (not observed, and not documented in the repo) would prevent step (a)-(c); the contract itself has no defence, and the in-code comment explicitly accepts overlap.

      Fix: reject windows that re-cover settled blocks (a.fromBlock <= lastSettledToBlock[league] reverts, optionally with a small slack of a few hundred blocks for a scheduled run that fires slightly early), and additionally limit settlement to one per UTC day keyed on a.issuedAt / 1 days so a second attestation for the same day is rejected whatever its window.

      Longer term, have the oracle include the schedule id in the signed payload (or register the daily panelJobId) so only the owner's schedule can settle.

      State: arcade pot 6.75 IMD, lastSettledToBlock[0]=1000.

      1. Scheduled attestation window [1001,1500], one winner A: pays A 3.626775 IMD, pot -> 3.09285 IMD, lastSettledToBlock -> 1500.
      2. Attacker submits a validly signed attestation with a new requestId, window [1001,1501] (re-covering the whole settled period), answer [A]. Expected: BadAttestation("window"). Actual: accepted; A is paid another 60% of 90% of 3.09285 IMD and the attacker gets the tip; pot drops again. test/scratch/ResettleOverlappingWindow.t.sol fails on the current code with next call did not revert as expected; it passes once overlapping windows are rejected (verified with a.fromBlock <= lastSettledToBlock[league] added to the revert condition).
      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
      
      contract MockArbSys {
          uint256 public arbBlockNumber;
          function setBlock(uint256 n) external { arbBlockNumber = n; }
          function arbBlockHash(uint256 n) external view returns (bytes32) {
              require(n < arbBlockNumber && n + 256 >= arbBlockNumber, "range");
              return keccak256(abi.encode("blk", n));
          }
      }
      
      contract MockIMD {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      /// Finding: settleDay only requires `toBlock > lastSettledToBlock`. A second attestation for
      /// the same question whose window overlaps (even fully contains) the already-settled one is
      /// accepted and pays 90% of what is left in the pot again. IMD's questionHash is a hash of the
      /// question text only and the EIP-712 domain's verifyingContract is a requester-supplied
      /// `consumer` field, so anyone can buy a fresh attestation for the derby's exact question at
      /// any moment (0.5 IMD) and settle the pot whenever they are in the trailing-24h top 3.
      ///
      /// Fails now: the overlapping second settlement succeeds and pays out again.
      /// Passes once settleDay rejects a window that re-covers an already settled range.
      contract ResettleOverlappingWindow is Test {
          MockArbSys arb = MockArbSys(address(100));
          MockIMD imd;
          SwarmDerby derby;
          uint256 oraclePk = 0xA11CE;
          bytes32 constant Q_ARCADE = keccak256("derby-arcade-longest");
          bytes32 constant Q_AGENT = keccak256("derby-agent-total");
          address player = address(0xBA77E2);
          address attacker = address(0xA77AC);
      
          function setUp() public {
              vm.etch(address(100), address(new MockArbSys()).code);
              arb.setBlock(1_000);
              vm.chainId(4663);
              imd = new MockIMD();
              derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, vm.addr(oraclePk), Q_ARCADE, Q_AGENT);
              imd.mint(player, 100 ether);
              vm.startPrank(player);
              imd.approve(address(derby), type(uint256).max);
              derby.buyTurns(0, 100); // arcade pot = 6.75 IMD
              vm.stopPrank();
              arb.setBlock(2_000);
          }
      
          function _att(address[] memory ranked, bytes32 reqId, uint64 fromBlock, uint64 toBlock) internal view returns (SwarmDerby.Attestation memory a) {
              a = SwarmDerby.Attestation({
                  requestId: reqId, chainId: 4663, questionHash: Q_ARCADE, answerType: 4,
                  answer: abi.encode(ranked), figure: 0, fromBlock: fromBlock, toBlock: toBlock,
                  blockHash: bytes32(uint256(1)), panelJobId: bytes32(uint256(2)),
                  panelSize: 5, quorum: 4, agreed: 5, issuedAt: uint64(block.timestamp), expiresAt: uint64(block.timestamp + 1 days)
              });
          }
      
          function _sign(SwarmDerby.Attestation memory a) internal view returns (bytes memory) {
              bytes32 typeHash = keccak256(
                  "OracleAttestation(bytes32 requestId,uint256 chainId,bytes32 questionHash,uint8 answerType,"
                  "bytes answer,uint256 figure,uint64 fromBlock,uint64 toBlock,bytes32 blockHash,bytes32 panelJobId,"
                  "uint16 panelSize,uint16 quorum,uint16 agreed,uint64 issuedAt,uint64 expiresAt)"
              );
              bytes32 structHash = keccak256(bytes.concat(
                  abi.encode(typeHash, a.requestId, a.chainId, a.questionHash, a.answerType, keccak256(a.answer), a.figure, a.fromBlock),
                  abi.encode(a.toBlock, a.blockHash, a.panelJobId, a.panelSize, a.quorum, a.agreed, a.issuedAt, a.expiresAt)
              ));
              bytes32 digest = keccak256(abi.encodePacked("\x19\x01", derby.domainSeparator(), structHash));
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(oraclePk, digest);
              return abi.encodePacked(r, s, v);
          }
      
          function test_overlappingWindowSettlesTheSamePeriodTwice() public {
              address[] memory ranked = new address[](1);
              ranked[0] = attacker;
      
              // Scheduled run settles [1001, 1500]: one winner takes 60% of (90% of 6.75 minus the tip).
              SwarmDerby.Attestation memory day = _att(ranked, bytes32("sched-1"), 1_001, 1_500);
              derby.settleDay(0, day, _sign(day));
              uint256 afterFirst = imd.balanceOf(attacker);
              assertEq(afterFirst, 3.626775 ether);
              assertEq(derby.pot(0), 3.09285 ether);
      
              // Attacker buys an attestation for the same question whose window is [1001, 1501]:
              // it re-covers the whole settled period plus one block.
              SwarmDerby.Attestation memory again = _att(ranked, bytes32("attacker-1"), 1_001, 1_501);
              bytes memory sig = _sign(again);
              vm.prank(attacker);
              vm.expectRevert(); // expected: rejected as an already-settled window
              derby.settleDay(0, again, sig);
      
              // On the current code the call above does NOT revert: the already-paid period is paid
              // again (another 60% of 90% of 3.09 IMD) plus a 0.5% tip to the attacker.
              assertEq(imd.balanceOf(attacker), afterFirst, "same period paid twice");
              assertEq(derby.pot(0), 3.09285 ether, "pot drained by an overlapping re-settlement");
          }
      }
    • mediumsetSession binds any wallet as the caller's session key without that wallet's consent, hijacking the victim's own swingssrc/SwarmDerby.sol:249

      setSession(session) writes sessionPlayer[session] = msg.sender for any session that (i) is not msg.sender, (ii) is not already someone's session key and (iii) has not itself set a session key. Every ordinary player wallet that has not enabled quick swings satisfies all three, so an attacker can call setSession(victim) for every wallet that buys turns (one cheap L2 tx each).

      From then on playerOf(victim) == attacker, so the victim's direct swing() calls spend the attacker's turns (reverting NoTurns if the attacker has none) and any swing that does go through is recorded with player = attacker and a commit the attacker cannot open, i.e. a foul; the victim's paid turns in turns[league][victim] are unspendable from their own wallet.

      Only the attacker can undo it (setSession(0) from the attacker); the victim's own setSession never clears sessionPlayer[victim]. The victim can still play by authorising a fresh session key of their own (that path still works), so this is a griefing/denial-of-service of direct play rather than theft, but it breaks the game page's non-session flow and the agent bot for anyone targeted, at ~zero cost to the attacker.

      The same missing-consent check also lets an attacker front-run a player's setSession(K) with their own setSession(K) so the player's call reverts BadSession.

      Fix: require consent from the key, e.g. two-step (setSession records a pending key, the key calls acceptSession(player) which performs the sessionPlayer write), or accept an EIP-712 signature from the key in setSession.

      State: victim has 5 arcade turns and no session key.

      1. attacker calls setSession(victim).

      Expected: revert (victim never consented) and playerOf(victim) == victim.

      Actual: succeeds, playerOf(victim) == attacker.

      1. victim calls swing(0, 50, 80, commitFor(salt, victim)).

      Expected: spends one of the victim's turns.

      Actual: reverts NoTurns because turns[0][attacker] == 0; the victim's 5 turns stay stuck. test/scratch/SessionHijack.t.sol fails on the current code at the first assertion; it passes with a two-step accept (verified).

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
      
      contract MockArbSys {
          uint256 public arbBlockNumber;
          function setBlock(uint256 n) external { arbBlockNumber = n; }
          function arbBlockHash(uint256 n) external view returns (bytes32) {
              require(n < arbBlockNumber && n + 256 >= arbBlockNumber, "range");
              return keccak256(abi.encode("blk", n));
          }
      }
      
      contract MockIMD {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      /// Finding: setSession(victim) needs no consent from `victim`. Anyone can register any wallet
      /// that has not itself set a session key as *their* session key. From then on
      /// playerOf(victim) == attacker, so the victim's own swing() calls spend the attacker's turns
      /// (or revert NoTurns) instead of the turns the victim paid for.
      ///
      /// Fails now: the victim's swing reverts with NoTurns although they hold 5 turns.
      /// Passes once a session key has to accept the binding (or setSession rejects it).
      contract SessionHijack is Test {
          MockArbSys arb = MockArbSys(address(100));
          MockIMD imd;
          SwarmDerby derby;
          address victim = address(0xBA77E2);
          address attacker = address(0xA77AC);
      
          function setUp() public {
              vm.etch(address(100), address(new MockArbSys()).code);
              arb.setBlock(1_000);
              vm.chainId(4663);
              imd = new MockIMD();
              derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, address(0xBEEF), bytes32("a"), bytes32("b"));
              imd.mint(victim, 100 ether);
              vm.startPrank(victim);
              imd.approve(address(derby), type(uint256).max);
              derby.buyPacks(0, 1); // victim paid for 5 arcade turns
              vm.stopPrank();
          }
      
          function test_anyoneCanBindAnotherWalletAsTheirSessionKey() public {
              vm.prank(attacker);
              derby.setSession(victim); // no consent needed
              assertEq(derby.playerOf(victim), victim, "victim's wallet was bound to the attacker");
      
              bytes32 c = derby.commitFor(keccak256("salt"), victim);
              vm.prank(victim);
              derby.swing(0, 50, 80, c); // reverts NoTurns on the current code: it tries to spend the attacker's turns
              assertEq(derby.turns(0, victim), 4, "the victim's own turn was not spent");
          }
      }
    • mediumA zero oracle signer turns every malformed signature into a valid one: settleDay has no zero check and neither the constructor nor queueOracle rejects address(0)src/SwarmDerby.sol:431

      _recover returns address(0) for a wrong-length signature, a high-s value, a bad v, or any digest/signature pair ecrecover rejects. settleDay compares that result to oracle.signer with != and nothing else. oracle.signer can be address(0): the constructor stores oracleSigner_ unchecked (a launch with a missing attester, which the DEPLOY.md flow fills by hand from an API field), and queueOracle(address(0), ...) + applyOracle is the natural way an owner would "retire" the oracle to stop settlements, since there is no pause.

      In that state any unprivileged caller settles either league with a 64-byte garbage signature and an answer array of their own addresses, taking 60/25/15 of 90% of the pot plus the tip, and can repeat it every block with a new requestId and a later toBlock until the pot is dust. The intended effect of a zero signer (settlement disabled) is the exact opposite of the actual effect (settlement open to everyone).

      Fix: in settleDay revert when oracle.signer == address(0) (or when the recovered address is zero), and reject address(0) in the constructor and queueOracle.

      State: agent pot 6.75 IMD; owner queued signer=address(0) and applyOracle ran after 2 days (or the contract was deployed with oracleSigner_=0).

      Attacker calls settleDay(1, a, bytes(64)) with a.questionHash = the agent question, a.answer = abi.encode([attacker]), valid window/consensus fields.

      Expected: BadAttestation("signer").

      Actual: _recover returns address(0) == oracle.signer, the call succeeds and the attacker receives 3.626775 IMD + 0.030375 IMD tip. test/scratch/ZeroSignerForgery.t.sol fails on the current code with next call did not revert as expected; it passes once a zero signer is rejected in settleDay or at configuration time (verified).

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
      
      contract MockArbSys {
          uint256 public arbBlockNumber;
          function setBlock(uint256 n) external { arbBlockNumber = n; }
          function arbBlockHash(uint256 n) external view returns (bytes32) {
              require(n < arbBlockNumber && n + 256 >= arbBlockNumber, "range");
              return keccak256(abi.encode("blk", n));
          }
      }
      
      contract MockIMD {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      /// Finding: _recover returns address(0) for any malformed signature, and settleDay compares
      /// that to oracle.signer with no zero check. Neither the constructor nor queueOracle rejects
      /// signer == address(0), so an owner who "retires" the oracle by queueing a zero signer (or a
      /// launch with a zero attester) lets anyone settle with a 64-byte garbage signature.
      ///
      /// Fails now: the forged settlement pays the attacker 60% of 90% of the pot plus the tip.
      /// Passes once a zero signer is rejected, either in settleDay or wherever the signer is set.
      contract ZeroSignerForgery is Test {
          MockArbSys arb = MockArbSys(address(100));
          MockIMD imd;
          SwarmDerby derby;
          address player = address(0xBA77E2);
          address attacker = address(0xA77AC);
      
          function setUp() public {
              vm.etch(address(100), address(new MockArbSys()).code);
              arb.setBlock(1_000);
              vm.chainId(4663);
              imd = new MockIMD();
              imd.mint(player, 100 ether);
          }
      
          function _fund(SwarmDerby d) internal {
              vm.startPrank(player);
              imd.approve(address(d), type(uint256).max);
              d.buyTurns(1, 100); // agent pot = 6.75 IMD
              vm.stopPrank();
          }
      
          /// Reach `oracle.signer == address(0)` by either route. If neither is reachable any more,
          /// the defect is fixed at configuration time and the test passes.
          function _reachZeroSigner() internal returns (bool) {
              derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, address(0xBEEF), bytes32("a"), bytes32("b"));
              try derby.queueOracle(address(0), bytes32("a"), bytes32("b")) {
                  vm.warp(block.timestamp + 2 days);
                  try derby.applyOracle() {} catch {}
              } catch {}
              (address s, , ) = derby.oracle();
              if (s == address(0)) return true;
              try new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether, address(0), bytes32("a"), bytes32("b")) returns (SwarmDerby d) {
                  derby = d;
                  return true;
              } catch {
                  return false;
              }
          }
      
          function test_zeroSignerAcceptsGarbageSignature() public {
              if (!_reachZeroSigner()) return;
              _fund(derby);
              arb.setBlock(2_000);
      
              address[] memory ranked = new address[](1);
              ranked[0] = attacker;
              SwarmDerby.Attestation memory a = SwarmDerby.Attestation({
                  requestId: bytes32("forged"), chainId: 4663, questionHash: bytes32("b"), answerType: 4,
                  answer: abi.encode(ranked), figure: 0, fromBlock: 1_001, toBlock: 1_500,
                  blockHash: 0, panelJobId: 0, panelSize: 5, quorum: 4, agreed: 5,
                  issuedAt: uint64(block.timestamp), expiresAt: uint64(block.timestamp + 1 days)
              });
              bytes memory garbage = new bytes(64); // wrong length -> _recover returns address(0) == oracle.signer
              vm.prank(attacker);
              vm.expectRevert(); // expected: BadAttestation("signer")
              derby.settleDay(1, a, garbage);
              assertEq(imd.balanceOf(attacker), 0, "pot paid out on a forged attestation");
          }
      }
    • mediumArcadeGain resets at the on-chain UTC day while the oracle sums it over a relative 24h window, so a homer finalized just after midnight inflates a player's attested "longest homer" beyond any swing thsrc/SwarmDerby.sol:380

      The arcade ranking relies on the identity "sum of a player's ArcadeGain events == their longest homer". _recordDinger computes old from dayScore[league][currentDay()], i.e. the score resets at 00:00:00 UTC exactly. The oracle question (DEPLOY.md step 5) sums the events "during the 24 hours before this request opened", and the schedule's request opens at or after 00:00:00 UTC, never before.

      Any window that straddles midnight therefore sums two independent gain sequences: yesterday's gains (which sum to yesterday's best) plus today's first homer (emitted as a full gain because old is 0 again).

      A player controls when a committed swing is finalized (anywhere in the 240-block window after the target), so they commit at ~23:59:40, finalize at 00:00:01, and if the request opens at 00:00:05 their attested total is yesterday_best + first_homer_today, e.g. 450 + 400 = 850 ft, above the 620 ft maximum any single swing can produce. The honest leader with a 600 ft slam loses the 60% share.

      The on-chain board (which the page shows) credits the 400 ft homer to the new day, so the oracle ranking and the board disagree. DEPLOY.md calls this "a few minutes of play shifting across days"; the actual effect is additive double counting that a player can trigger on purpose.

      Fix: either make the oracle window align with the on-chain day (an absolute [00:00, 24:00) UTC window if IMD supports it), or make the emitted ranking data window-independent, e.g. emit ArcadeGain(player, day, gain) and ask the oracle to sum only events whose day is the day that just ended; alternatively rank the arcade by a per-day max the oracle reads from dayScore at the window's last block.

      State: player P.

      23:00 UTC day N: P finalizes a 450 ft homer -> ArcadeGain(P, 450).

      00:00:05 UTC day N+1: P finalizes a 400 ft homer -> currentDay() advanced, old=0, ArcadeGain(P, 400).

      Oracle request opens 00:00:10 UTC day N+1 with window [00:00:10 day N, 00:00:10 day N+1]: sum for P = 850.

      Expected: P ranked at 450 (longest homer of day N).

      Actual: P ranked at 850, above any possible slam (max 620) and above an honest 600 ft leader. test/scratch/ArcadeGainAcrossMidnight.t.sol demonstrates the 850 ft sum from the emitted events (it is a demonstration, not a fix-gated proof).

    • lowsettleDay pays 90% of the pot as it stands at call time, not the pot accrued during the attested window, so a late settlement hands the next day's purchases to the previous day's winnerssrc/SwarmDerby.sol:440

      The payout base is pot[league] at the moment someone calls settleDay. The attestation is valid for 24 hours and settlement is permissionless, so the pot keeps growing with purchases made after the window closed until someone settles.

      DEPLOY.md says "If no one does, the pot simply waits", but waiting is not neutral: every purchase made on day N+1 before the day-N settlement is paid out to day-N's winners, and day-N+1's own winners inherit only the 10% rollover of a pot that was already partly theirs.

      The settler incentive (0.5% tip) does not fix this because a settler can also deliberately delay (there is no one else to race on a quiet day) and the day-N winner, if they are the settler, benefits from waiting as long as possible within the 24h validity.

      Fix: snapshot the pot at the window boundary, e.g. accumulate purchases into pot[league] per UTC day (potByDay[league][day]) and pay from the day the attestation covers, or at least record pot[league] at the first purchase after each settlement and cap the payout base at that value.

      State: arcade pot 6.75 IMD at 00:00 UTC day N+1 when the day-N attestation is issued.

      No one calls settleDay until 12:00; between 00:00 and 12:00 day-N+1 players buy 100 turns (15 IMD, 6.75 IMD into the pot). settleDay at 12:00: distributable = 90% of 13.5 = 12.15 IMD, paid to day-N's top 3.

      Expected: day-N winners share 90% of 6.75 IMD and day-N+1's pot keeps its 6.75 IMD.

      Actual: day-N winners receive 12.15 IMD less tip and day-N+1 starts from 1.35 IMD.

    • infoTrust assumption: the owner can redirect both pots to itself after the 2-day oracle timelock, contradicting the docs' "can never touch pots"src/SwarmDerby.sol:498

      HANDOFF.md and DEPLOY.md state the owner "can never touch pots or vaults".

      The vaults are indeed out of reach, but the pots are not: queueOracle(ownerKey, qA, qB) followed by applyOracle() two days later lets the owner sign arbitrary attestations and call settleDay with any answer array, taking 90% of each pot per call and repeating every block with a fresh requestId and a later toBlock. setPrices(0, 0) separately makes turns free, which lets anyone (including the owner) drain the slam vaults with unlimited free swings.

      These are intentional owner powers and not defects in themselves, but the documentation overstates the guarantee; players and the handoff agent should treat the owner as able to take the pots with 2 days' notice and the vaults instantly via pricing.

      Recommended: keep the timelock, document the real trust model, and move ownership to a multisig before funds are meaningful (HANDOFF step 10 already suggests this).

      State: owner EOA, pots funded.

      1. owner.queueOracle(ownerAddr, currentArcadeQ, currentAgentQ); 2) after 2 days anyone calls applyOracle(); 3) owner signs an attestation with answer=[owner] and calls settleDay for each league.

      Expected per docs: impossible.

      Actual: 90% of each pot per call to the owner.

  5. reviewedAudit flowready
  6. reviewedAudit judgewaiting
    afterAudit math, Audit permissions, Audit economics, Audit flow
  7. onchain
    1 receipton Ethereum mainnet
    receipt
    work accepted · transaction · record
    scores
    written, with no entries recorded on it · block 26,136,780 · transaction