# Audit report

> Audit src/DerbyAuction.sol in this Foundry repository. It is the daily Theme Day auction of Swarm Derby, specified in specs/WP3-auction-contract.md, and it reads src/SwarmDerby.sol (dayClosed and the arcade top 3) when it pays the daily bonus. Focus on the IMD token accounting (the contract balance must always equal the current lead + credited refunds + unpaid bonuses + carry), bid and refund ordering, the anti-snipe extensions capped at 19:00 UTC by MAX_EXTENSION, settle, payBonus, veto, reclaim and the two-step ownership. src/SwarmDerby.sol and src/DerbyOdds.sol are already live: read them only as context for DerbyAuction. test/SwarmDerby.t.sol sets chain id 31337 because Foundry 1.8.5 and later intercept ArbSys on chain 4663 and bypass the etched MockArbSys, so run forge test as it is.

| | |
|---|---|
| Repository | https://github.com/pepegobig/swarm-derby-contracts.git |
| Commit | `f797a19c9697a0ae4c57d9df3bd064ede32d3343` |
| Job | `928b670b-477f-4132-875c-7c0b872ddfcd` |
| Judged | 2026-10-08 05:45 UTC |
| Findings | 1 medium · 3 low · 3 info |

Four agents audited the code as it is at `f797a19`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: reclaim grace runs from the theme day, not from settlement, so a late settle lets the winner settle and reclaim before payBonus was ever callable

`src/DerbyAuction.sol:250`

```
        if (block.timestamp <= (day + 1) * 1 days + RECLAIM_AFTER) revert TooEarly();
```

settle(day) has no deadline: it only requires block.timestamp >= _end(day) (line 187), and the runbook (WP6), the build job and the WP4 page all rely on someone calling it; WP6 promises that if the operator goes quiet "the auction can be settled by anyone, and the bonus still pays from the board". payBonus(day) requires a.settled (via _checkRefundable, line 212). reclaim(day) measures RECLAIM_AFTER only from the end of the theme day, (day + 1) * 1 days, never from the moment the bonus came into existence. So when an auction stays unsettled for RECLAIM_AFTER after its theme day, the winner (or anyone) can call settle(day) and then reclaim(day) back to back, in one transaction from a contract bidder or as two consecutive calls: _checkRefundable passes because settle just set settled with bonus > 0, the time check passes because the grace already elapsed, paid is set, bonus is zeroed and the full bid (less fee) goes back to the winner. payBonus(day) then reverts WrongStatus forever, and the theme day's arcade top 3, whose board is already final in SwarmDerby, never had a single block in which they could be paid. The owner cannot intervene either: veto needs block.timestamp < day * 1 days. This contradicts WP3 "Done when 7" (reclaim only after a grace period, only if unpaid) and the WP6 quiet-operator guarantee. Preconditions: an unprivileged actor plus nobody calling settle for about eight days after the theme day; no privileged action, no capital beyond the bid. Impact: the whole bonus of that day is taken from identifiable victims (the day's top 3). Fix (keeps late settlement and board payment exactly as WP6 describes): record settledAt[day] = block.timestamp in settle when there is a winner, and in reclaim require block.timestamp > max((day + 1) * 1 days, settledAt[day]) + RECLAIM_AFTER, so the board always has a full grace period of payBonus availability after the bonus exists. Verified: the attached proof fails on this code and passes on a copy of the contract with that change. Merged from the audit_flow and audit_permissions reports (same mechanism and fix).

**Reproduction**

State: DAY = 20400, fee 0. During the window a bidder contract bids 10 IMD on DAY and nobody calls settle(DAY). On DAY three arcade players each hit a real homer, so derby.board(0, DAY) has 3 entries. Warp to (DAY + 1) * 86400 + 7 days + 1 and advance ArbSys so derby.dayClosed(0, DAY) is true. The bidder contract calls sale.settle(DAY) then sale.reclaim(DAY) in the same transaction. Expected: reclaim reverts TooEarly (the bonus has existed for 0 seconds) so payBonus(DAY) can still pay the three players 60/25/15 of 9.95 IMD plus the 0.05 IMD tip. Actual: both calls succeed, the bidder's balance goes from 0 back to 10 IMD, auction(DAY) reads paid = true and bonus = 0, payBonus(DAY) reverts WrongStatus, the three players receive nothing. test/scratch/Proof_cfb21cdabe12.t.sol fails with "winner reclaimed in the same tx as a late settle: 10000000000000000000 != 0". The same sequence from an EOA (settle then reclaim as two consecutive calls, test_lateSettleThenImmediateReclaim in test/scratch/Repro.t.sol) gives the same result with a 2 IMD bid.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {DerbyAuction, ISwarmDerby} from "src/DerbyAuction.sol";
import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
import {DerbyOdds} from "src/DerbyOdds.sol";

/// Finding: `reclaim` measures its grace period from the theme day, not from settlement.
/// `settle` has no deadline, so an auction nobody settled for 8 days can be settled and
/// reclaimed by the winner in one transaction, before anyone can call `payBonus`.
/// The arcade top 3 of that theme day get nothing.
contract ScratchArbSys {
    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 ScratchToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
    }

    function approve(address to, uint256 amount) external returns (bool) {
        allowance[msg.sender][to] = amount;
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

/// The winner settles and reclaims in a single transaction.
contract ScratchWinner {
    function settleAndReclaim(DerbyAuction sale, uint256 day) external {
        sale.settle(day);
        sale.reclaim(day);
    }
}

contract LateSettleReclaimTest is Test {
    ScratchArbSys internal arb = ScratchArbSys(address(100));
    ScratchToken internal imd;
    SwarmDerby internal derby;
    DerbyAuction internal sale;
    ScratchWinner internal winner;
    address internal studio = makeAddr("studio");
    uint256 internal constant DAY = 20_400;

    function setUp() public {
        vm.chainId(31337);
        vm.warp((DAY - 2) * 1 days + 18 hours);
        vm.etch(address(100), address(new ScratchArbSys()).code);
        arb.setBlock(1_000);
        imd = new ScratchToken();
        derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether);
        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 0);
        winner = new ScratchWinner();
    }

    function _homer(address who) internal {
        imd.mint(who, 0.15 ether);
        vm.startPrank(who);
        imd.approve(address(derby), 0.15 ether);
        derby.buyTurns(0, 1);
        vm.stopPrank();
        uint256 id = derby.nextSwingId();
        bytes32 salt = keccak256(abi.encode("scratch", id, who));
        bytes32 commitment = derby.commitFor(salt, who);
        uint256 target = arb.arbBlockNumber() + derby.REVEAL_DELAY();
        vm.prank(who);
        derby.swing(0, 100, 100, commitment);
        bytes32 hash;
        for (uint256 i;; ++i) {
            hash = keccak256(abi.encode("future block", id, i));
            (uint8 tier,) = DerbyOdds.roll(derby.swingSeed(salt, hash), id, 100, 100);
            if (tier == DerbyOdds.HOMER) break;
        }
        arb.setHash(target, hash);
        arb.setBlock(target + 1);
        derby.finalize(id, salt);
    }

    function test_lateSettleCannotBeReclaimedBeforePayersGetTheirGracePeriod() public {
        // The winner contract bids 10 IMD for theme day DAY.
        imd.mint(address(winner), 10 ether);
        vm.prank(address(winner));
        imd.approve(address(sale), 10 ether);
        vm.prank(address(winner));
        sale.bid(DAY, 10 ether, DerbyAuction.Answers("Comet critter", 3, 5, 5, "Starlight Derby", ""));

        // Nobody calls settle. The theme day runs; three arcade players homer and top the board.
        vm.warp(DAY * 1 days);
        address p1 = address(0x10001);
        address p2 = address(0x10002);
        address p3 = address(0x10003);
        _homer(p1);
        _homer(p2);
        _homer(p3);
        (address[] memory board,) = derby.board(0, DAY);
        assertEq(board.length, 3);

        // Eight days later the day is closed and the grace period (measured from the theme
        // day) has already elapsed although no bonus ever existed to pay.
        vm.warp((DAY + 1) * 1 days + 7 days + 1);
        arb.setBlock(arb.arbBlockNumber() + 1_000);
        assertTrue(derby.dayClosed(0, DAY));

        // Winner settles and reclaims atomically. Nobody can interleave payBonus.
        try winner.settleAndReclaim(sale, DAY) {} catch {}

        // Expected: the bid is still held for the board (either settle was refused this
        // late, or reclaim is not yet allowed). Actual on current code: the winner has its
        // 10 IMD back, the day is marked paid, and the top 3 can never be paid.
        assertEq(imd.balanceOf(address(winner)), 0, "winner reclaimed in the same tx as a late settle");
        assertEq(sale.refunds(address(winner)), 0, "winner was credited a refund");
        (,,,, bool vetoed, bool paid,) = sale.auction(DAY);
        assertFalse(paid && !vetoed, "day marked paid without paying the board");
    }
}
```

### 2. Low: settle folds the whole carry into any winner-auction regardless of when it settles, so a stale day settled after its theme day captures carry meant for a future board

`src/DerbyAuction.sol:192`

```
            carryIn[day] = carry;
            a.bonus = a.amount - fee + carry;
            carry = 0;
```

carry exists to roll unfilled places, failed sends, dust and empty-board bonuses forward "to the next settled auction" (WP3 Done-when 6), i.e. to a future theme day whose players do not exist yet. settle(day) does not check that it runs before the theme day, so an auction left unsettled past its theme day can be settled at any later time and at that moment absorbs the entire current carry into a bonus whose recipients, derby.board(0, day), are already fixed and public. A bidder who placed the 2 IMD minimum on a quiet day and was the only arcade player that day (one 0.15 IMD turn with a homer puts them alone on the board) simply does not settle, waits until carry is large (for example after a later theme day with an empty arcade board moved its whole bonus to carry at 00:00), then settles the stale day and calls payBonus on it before the 18:00 settle of the live auction takes the carry. With 10 IMD of carry the bonus becomes 12 IMD: the bidder receives the 0.06 IMD tip plus 60% of 11.94 IMD = 7.164 IMD, a net gain of 5.224 IMD on a 2 IMD bid, and only the unfilled 40% returns to carry. The same code also means that when two ended auctions are unsettled at once, the carry goes to whichever settles first rather than to the earliest day (a 2 IMD bid on day D+4 settled before a 100 IMD bid on day D+3 takes the carry; this part matches the spec wording and is a design note). Preconditions: the operator's 18:00 settle is missed for that day and nobody else settles it (settle pays no tip, so only the operator has a reason to call it), then carry accumulates. Low because it needs that liveness lapse and the gain is bounded by the carry. Fix: only fold carry into auctions settled before their theme day begins, e.g. if (block.timestamp < day * 1 days) { carryIn[day] = carry; a.bonus = a.amount - fee + carry; carry = 0; } else { a.bonus = a.amount - fee; }. That keeps the stale bidder's own bid payable or reclaimable while carry stays with the live rotation. Verified: the attached proof fails on this code and passes on a copy of the contract with that change. Merged from the audit_flow report and the audit_economics carry-ordering note (same line, same root cause).

**Reproduction**

State: DAY = 20400, fee 0. Alice bids 2 IMD on DAY, is the only arcade player with a homer on DAY, and nobody settles DAY. Day DAY+3: Bob bids 10 IMD, it is settled at its end, its arcade board is empty and payBonus(DAY+3) moves 10 IMD to carry. Alice then calls settle(DAY) and payBonus(DAY). Expected: carryIn(DAY) == 0 and Alice can receive at most her own 2 IMD. Actual: carryIn(DAY) == 10e18, auction(DAY).bonus == 12e18, payBonus(DAY) sends Alice 0.06 IMD tip + 7.164 IMD share = 7.224 IMD against her 2 IMD bid, and 4.776 IMD returns to carry (test_staleSettleCapturesCarryNumbers in test/scratch/Repro.t.sol). test/scratch/Proof_764d3f7d45a5.t.sol fails with "stale settle absorbed the carry: 10000000000000000000 != 0". Ordering variant: with 2 IMD of carry, Alice bids 100 IMD on D+3 and Bob 2 IMD on D+4; at end(D+4) calling settle(D+4) then settle(D+3) gives bonus(D+4) = 4 IMD and bonus(D+3) = 100 IMD (test_carryTakenByFirstSettledNotEarliestDay).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {DerbyAuction, ISwarmDerby} from "src/DerbyAuction.sol";
import {SwarmDerby, IERC20} from "src/SwarmDerby.sol";
import {DerbyOdds} from "src/DerbyOdds.sol";

/// Finding: `settle` folds the whole current `carry` into any auction with a winner, even
/// one settled long after its theme day, whose arcade board is already final. A bidder
/// who sits alone on that past board waits for carry to accumulate, then settles the stale
/// day and pays itself the tip plus 60% of the carry.
contract ScratchArbSys {
    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 ScratchToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
    }

    function approve(address to, uint256 amount) external returns (bool) {
        allowance[msg.sender][to] = amount;
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

contract LateSettleCarryTest is Test {
    ScratchArbSys internal arb = ScratchArbSys(address(100));
    ScratchToken internal imd;
    SwarmDerby internal derby;
    DerbyAuction internal sale;
    address internal alice = makeAddr("alice");
    address internal bob = makeAddr("bob");
    address internal studio = makeAddr("studio");
    uint256 internal constant DAY = 20_400;

    function setUp() public {
        vm.chainId(31337);
        vm.warp(_start(DAY));
        vm.etch(address(100), address(new ScratchArbSys()).code);
        arb.setBlock(1_000);
        imd = new ScratchToken();
        derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether);
        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 0);
    }

    function _start(uint256 day) internal pure returns (uint256) {
        return (day - 2) * 1 days + 18 hours;
    }

    function _end(uint256 day) internal pure returns (uint256) {
        return (day - 1) * 1 days + 18 hours;
    }

    function _bid(uint256 day, address who, uint256 amount) internal {
        imd.mint(who, amount);
        vm.prank(who);
        imd.approve(address(sale), amount);
        vm.prank(who);
        sale.bid(day, amount, DerbyAuction.Answers("Comet critter", 3, 5, 5, "Starlight Derby", ""));
    }

    function _homer(address who) internal {
        imd.mint(who, 0.15 ether);
        vm.startPrank(who);
        imd.approve(address(derby), 0.15 ether);
        derby.buyTurns(0, 1);
        vm.stopPrank();
        uint256 id = derby.nextSwingId();
        bytes32 salt = keccak256(abi.encode("scratch", id, who));
        bytes32 commitment = derby.commitFor(salt, who);
        uint256 target = arb.arbBlockNumber() + derby.REVEAL_DELAY();
        vm.prank(who);
        derby.swing(0, 100, 100, commitment);
        bytes32 hash;
        for (uint256 i;; ++i) {
            hash = keccak256(abi.encode("future block", id, i));
            (uint8 tier,) = DerbyOdds.roll(derby.swingSeed(salt, hash), id, 100, 100);
            if (tier == DerbyOdds.HOMER) break;
        }
        arb.setHash(target, hash);
        arb.setBlock(target + 1);
        derby.finalize(id, salt);
    }

    function _close(uint256 day) internal {
        vm.warp((day + 1) * 1 days);
        arb.setBlock(arb.arbBlockNumber() + 1_000);
        assertTrue(derby.dayClosed(0, day));
    }

    function test_staleAuctionSettledAfterItsThemeDayDoesNotCaptureCarry() public {
        // Alice wins DAY with the minimum bid and is the only arcade player that day.
        _bid(DAY, alice, 2 ether);
        vm.warp(DAY * 1 days);
        _homer(alice);
        _close(DAY);
        // Nobody settles DAY.

        // A later day: Bob bids 10 IMD, it settles normally, and its arcade board is empty,
        // so the whole 10 IMD becomes carry for the next theme day.
        uint256 later = DAY + 3;
        vm.warp(_start(later));
        _bid(later, bob, 10 ether);
        vm.warp(_end(later));
        sale.settle(later);
        _close(later);
        sale.payBonus(later);
        assertEq(sale.carry(), 10 ether);

        // Alice now settles the stale DAY: its board is final and only she is on it.
        uint256 before = imd.balanceOf(alice);
        vm.prank(alice);
        try sale.settle(DAY) {} catch {}

        // Expected: a day settled after its theme day does not absorb carry meant for a
        // future theme day. Actual: carryIn[DAY] == 10 IMD and Alice can pay herself
        // 0.5% tip + 60% of 12 IMD from a 2 IMD bid.
        assertEq(sale.carryIn(DAY), 0, "stale settle absorbed the carry");
        (,,, bool settled,,,) = sale.auction(DAY);
        if (settled) {
            vm.prank(alice);
            sale.payBonus(DAY);
            assertLe(imd.balanceOf(alice) - before, 2 ether, "alice extracted more than her own bid");
        }
    }
}
```

### 3. Low: settle hard-reverts when the token refuses the studio fee, locking the winning bid until the owner changes studio

`src/DerbyAuction.sol:195`

```
            _send(studio, fee);
```

With buildFee > 0, settle pays the fee to studio with _send, which reverts on a failed or false-returning transfer, while every other outbound payment in the contract (outbid refunds, veto and reclaim refunds, winners' shares) uses _trySend and credits or carries on failure. The Robinhood IMD token's owner can block addresses (DEPLOY.md, Known limits). If studio is blocked, or is a contract that rejects IMD, settle(day) reverts TransferFailed for every auction with a winner. Because veto, payBonus and reclaim all require a.settled and bid is closed after end, the leader's whole bid sits in the contract with no path out: not refundable, not reclaimable after the grace, not vetoable, and withdrawRefund has nothing credited. Only the owner can release it by calling setStudio to an unblocked address, and while that takes time the owner's veto window (block.timestamp < day * 1 days) can lapse. Preconditions: owner has set buildFee > 0 (the launch value is 0, so no exposure at launch) and a third party (the token owner) blocks studio, or the owner points studio at a non-accepting contract. Low: a temporary denial of settlement with an owner-only recovery, no theft. Fix, keeping the accounting identity (balance = lead + refunds + unpaid bonuses + carry): treat a failed fee send like a failed refund, if (!_trySend(studio, fee)) { refunds[studio] += fee; emit RefundCredited(studio, fee); }, so settlement never depends on the studio being payable. Verified: the attached proof fails on this code and passes on a copy with that change. Merged from the audit_economics and audit_math reports.

**Reproduction**

Deploy DerbyAuction with buildFee_ = 1e18 (or setBuildFee(1e18) after the bid). Alice bids 2e18 on day D during [start(D), end(D)). The token then refuses transfers to studio (blocked address; in the repository mock imd.setFailure(studio, 1)). Warp to end(D) and call settle(D): expected the auction to settle with bonus 1e18 and the fee credited or carried; actual revert TransferFailed(), auction(D).settled == false, bonus 0, Alice's 2e18 still held. veto(D) and reclaim(D) then revert WrongStatus() (not settled), bid(D, ...) reverts BidClosed(), Alice's withdrawRefund reverts NoRefund(). After setStudio(carol) and settle(D) the veto reverts BidClosed() if the theme day has begun meanwhile. test/scratch/Proof_8a77d56b2c00.t.sol fails with TransferFailed(); the repository's test_failedStudioPaymentLeavesAuctionUnsettled shows the first half of the sequence.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {DerbyAuction, ISwarmDerby} from "src/DerbyAuction.sol";
import {IERC20} from "src/SwarmDerby.sol";

/// ERC20 whose owner can block an address (the Robinhood IMD token can block addresses).
contract BlockableToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    mapping(address => bool) public blocked;

    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }
    function approve(address to, uint256 amount) external returns (bool) { allowance[msg.sender][to] = amount; return true; }
    function setBlocked(address a, bool b) external { blocked[a] = b; }

    function transfer(address to, uint256 amount) external returns (bool) {
        if (blocked[to] || blocked[msg.sender]) revert("blocked");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        if (blocked[from] || blocked[to]) revert("blocked");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

contract StubDerby {
    function currentDay() external view returns (uint256) { return block.timestamp / 1 days; }
    function dayClosed(uint8, uint256) external pure returns (bool) { return false; }
    function board(uint8, uint256) external pure returns (address[] memory p, uint256[] memory s) {
        p = new address[](0);
        s = new uint256[](0);
    }
}

/// Fails on the current code: when buildFee > 0 and the token refuses the studio transfer,
/// settle() reverts, so the winning bid can be neither settled, vetoed, reclaimed nor refunded
/// until the owner changes the studio. Passes once settle no longer depends on the studio
/// transfer succeeding (e.g. a failed fee send is credited like a failed refund).
contract StudioFeeLockTest is Test {
    BlockableToken imd;
    StubDerby derby;
    DerbyAuction sale;
    address alice = makeAddr("alice");
    address studio = makeAddr("studio");
    uint256 constant DAY = 20_400;

    function setUp() public {
        vm.warp((DAY - 2) * 1 days + 18 hours);
        imd = new BlockableToken();
        derby = new StubDerby();
        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 1 ether);
    }

    function test_settleSurvivesBlockedStudio() public {
        imd.mint(alice, 2 ether);
        vm.prank(alice);
        imd.approve(address(sale), 2 ether);
        vm.prank(alice);
        sale.bid(DAY, 2 ether, DerbyAuction.Answers("Comet critter", 0, 0, 0, "Starlight", ""));

        // The token's owner blocks the studio after the bid landed.
        imd.setBlocked(studio, true);
        vm.warp((DAY - 1) * 1 days + 18 hours);

        // Expected: the auction still settles; the winner's money is not hostage to the studio.
        sale.settle(DAY);
        (,,, bool settled,,, uint256 bonus) = sale.auction(DAY);
        assertTrue(settled, "auction must settle even when the studio cannot be paid");
        assertEq(bonus, 1 ether, "bonus is bid minus fee");
        // Whatever the fix does with the fee, the contract must still hold the bonus.
        assertGe(imd.balanceOf(address(sale)), 1 ether);
    }
}
```

### 4. Low: openDay() ignores anti-snipe extensions, so during an extension the page-driven bidders are pointed at the next day while the extended auction still takes bids

`src/DerbyAuction.sol:134`

```
        return (block.timestamp - CLOSE_OFFSET) / 1 days + 2;
```

openDay() is a pure function of the clock: it switches from D to D + 1 at exactly (D - 1) * 1 days + CLOSE_OFFSET, the regular close, regardless of whether _auctions[D].end was pushed past that moment by an anti-snipe bid (up to MAX_EXTENSION, 19:00 UTC). WP4 instructs the drawer to read openDay(), then auction(day) and minNextBid(day), and to render only that auction. During an extension the page therefore shows D + 1 (empty, 2 IMD minimum, countdown to the following day) while bid(D, ...) still succeeds and the lead on D can still be sniped. The anti-snipe mechanism exists so that a bid in the last five minutes can be answered; a bidder who follows the contract's own view cannot see that window, so only parties polling auction(D).end directly (bots) benefit from it, and a page user who bids at 18:00:30 lands on D + 1 instead of the auction they were watching. No funds are at risk and bid and settle both use the stored end consistently; the formula matches the WP3 definition of openDay(), so the spec shares the gap. Fix: have openDay() return the earliest day that still accepts bids, e.g. uint256 d = (block.timestamp - CLOSE_OFFSET) / 1 days + 2; if (d > 2 && block.timestamp < _end(d - 1)) return d - 1; return d; (or expose a liveDay() helper and have WP4 use it). Merged from the audit_flow, audit_permissions and audit_economics reports.

**Reproduction**

State: DAY = 20400, regular end = 20399 * 86400 + 64800 = 1762538400. Alice bids 2 IMD at end - 1, so auction(DAY).end becomes end + 299 (18:04:59 UTC). Warp to end + 30 (18:00:30). Expected: the open-auction helper still names DAY, whose auction accepts bids for another 269 seconds. Actual: openDay() returns 20401 and auction(20401) reads leader = address(0), amount = 0, while bid(20400, 3 ether, answers) from Bob succeeds and makes Bob the leader of DAY. test_openDayIgnoresExtension in test/scratch/Repro.t.sol passes on this code with those assertions.

### 5. Info: After the grace period payBonus and reclaim compete with no priority, and an empty-board payBonus pays nobody, so the winner is the only party with an incentive to act

`src/DerbyAuction.sol:223`

```
        if (n > 0) {
            tip = _bps(bonus, TIP_BPS);
            paid = tip;
```

payBonus only pays the 0.5% tip when the arcade board for the theme day is non-empty. On a day with no arcade homers the call pays nothing to anyone and merely moves the bonus into carry for a later auction, so no unprivileged party gains from making that call, and once (day + 1) * 86400 + RECLAIM_AFTER has passed reclaim(day) returns the net bid to the winner. The winner therefore has a direct incentive to wait seven days and reclaim, obtaining the theme day for free, while WP3 (payBonus row, Done-when 6) says an empty board's bonus becomes carry for future arcade players. More generally, once the grace passes payBonus and reclaim are both live for the same day and whichever transaction lands first decides whether the arcade top 3 or the bidder receives the money. Both behaviours follow the spec's own rules, and the runbook has the operator call payBonus the next morning and the WP4 page offers a PAY BONUS button to anyone, so the practical exposure is operator and player inactivity for a full week. Reported as information. If the requester wants the spec's intent enforced: pay the tip from the bonus even when the board is empty (so a keeper is paid to carry it), or let reclaim run only after a second, longer grace, or gate reclaim on payBonus being impossible (for example !derby.dayClosed(0, day) never becoming true). Merged from the audit_economics report.

**Reproduction**

Empty board: Alice wins day D with 10e18 (settled at end(D), fee 0). Nobody plays arcade on D, so derby.board(0, D) is empty and dayClosed(0, D) is true after (D + 1) * 86400. payBonus(D) would pay tip 0 and move 10e18 to carry, so nobody calls it. At (D + 1) * 86400 + 7 days + 1 Alice calls reclaim(D): expected per spec that the 10e18 has become carry; actual Alice receives 10e18 and carry stays 0 (test_emptyBoardBonusReclaimedInsteadOfCarried). Race: with Bob alone on the board, at the same timestamp reclaim(D) followed by payBonus(D) leaves Bob with 0, Alice with 10e18 and payBonus reverting WrongStatus; the reverse order pays Bob 5.97e18 and makes reclaim revert WrongStatus (test_reclaimAndPayBonusRaceAfterGrace in test/scratch/Repro.t.sol).

### 6. Info: If nobody settles before 00:00 UTC on the theme day, the owner's veto is lost permanently while the bonus still pays

`src/DerbyAuction.sol:203`

```
        if (block.timestamp >= day * 1 days) revert BidClosed();
```

veto requires the auction to be settled and the theme day not to have begun. settle is permissionless and unscheduled; nothing forces it to happen before midnight. If the operator and owner are both quiet between the close (18:00, or 19:00 after the longest extension) and 00:00, settle(D) can still be called later by anyone, after which payBonus pays the full bonus to the board, but the owner can no longer reject the answers. WP6's quiet-operator story holds for the money but not for the veto power WP3 promises ("the owner can still veto the day after the longest extension"), which only holds if someone settles before midnight. The owner avoids this by settling before midnight themselves, so this is informational and shares its root cause (settle has no deadline) with the two carry and reclaim findings. If the veto is meant as a content safety valve regardless of who settles, allow veto on an ended-but-unsettled auction (settle-and-veto in one call), or allow it until the first payBonus. Merged from the audit_economics report.

**Reproduction**

Alice bids 2e18 on day D. Nobody calls settle(D) before D * 86400. At D * 86400 (theme day start) anyone calls settle(D): succeeds, settled = true, bonus 2e18. veto(D) now reverts BidClosed() at every later timestamp, while payBonus(D) becomes callable after the day closes. test_vetoLostWhenSettledAfterMidnight in test/scratch/Repro.t.sol passes on this code with those assertions.

### 7. Info: Owner trust assumptions: the build fee is read at settle so it applies to bids already locked, and a vetoed or reclaimed winner is refunded net of the fee already sent to studio

`src/DerbyAuction.sol:191`

```
            fee = buildFee < a.amount ? buildFee : a.amount;
```

Documented, intentional owner powers worth stating as trust assumptions for the launch review; none is a permission bypass. (1) settle reads the live buildFee and studio at settlement time, while setBuildFee (line 271) and setStudio (line 265) are callable by the owner at any moment. A bidder who bid under fee 0 is locked in from the moment the auction ends (bid reverts BidClosed, there is no withdrawal), yet the owner can still raise the fee to MAX_BUILD_FEE (1 IMD) in the gap between end and settle, including by front-running a pending public settle, and 1 IMD of the locked bid goes to studio instead of the bonus. WP3 ("applies to auctions settled later") and DEPLOY.md ("Fees and studio changes apply when an auction settles, including auctions already bid on") state this. (2) settle sends fee = min(buildFee, amount) to studio immediately (line 195) and _releaseBonus, used by veto and reclaim, returns bonus - carryIn[day] = amount - fee, so a vetoed (never built) design still pays the fee to the studio; WP3's veto rule and DEPLOY.md ("less any fee already paid") state this too. (3) setStudio redirects future fees instantly. (4) There is no owner sweep, so tokens sent directly to the contract are stuck, and carry with no future winner auction stays in the contract. All are bounded to 1 IMD per theme day, only by the owner, and 0 at launch (the launch body sets buildFee_ = 0). The accounting identity holds in every case. If the requester wants bids to lock the terms they were made under, snapshot the fee into the Auction struct on the first bid of a day and use that in settle, and hold the fee until the veto window closes (send it in payBonus or reclaim) so a veto can return it; both are design changes to WP3. Merged from the audit_math, audit_permissions and audit_economics reports.

**Reproduction**

buildFee = 0 at deployment. Alice bids 2 IMD on day D. Warp to end(D) = (D - 1) * 86400 + 64800: bid(D, 3e18) now reverts BidClosed. Owner calls setBuildFee(1e18) then settle(D). Expected from the bidder's view at bid time: bonus 2 IMD, studio 0. Actual: studio receives 1 IMD and auction(D).bonus = 1 IMD. Owner then calls veto(D) before D * 86400: Alice receives 1 IMD, the studio keeps 1 IMD although no build happened. test_feeRaisedAfterCloseIsTakenAtSettleAndKeptOnVeto in test/scratch/Repro.t.sol passes on this code with those assertions; the repository's test_settleOneIMDFeeAndSettingsApplyOnlyAtSettlement and test_vetoOwnerOnlyBeforeThemeDayReturnsOwnNetBidNotCarry show the same arithmetic.

---

Judge's submission `9507f8609c455aa85e33439820ab9cc15a24b4bdb12bdf9fa8cd78600ba17247`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
