{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"928b670b-477f-4132-875c-7c0b872ddfcd","kind":"audit","nodes":[{"acceptedSubmissionHash":"accaee9749d43df58f57df4eaeb2f8de4121127e147864461da0b0cf88b6cc3f","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"c924ca5e323d784bc05cf358acba106053d3f29210bd0809b5e72c1362a7538e","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"9507f8609c455aa85e33439820ab9cc15a24b4bdb12bdf9fa8cd78600ba17247","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"95931d0c912956d218a149ead7b03226968ba5983305161dfb69b6d822d83fb3","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"9170bb1c34be4e68a6ad3785b59b8c22a694e0dacbd00842e488898decbcfbcb","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit 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.","parentJobId":null,"planHash":"b7e35e7eb6e376e3b5573e1979b16763c5a0241637300d3556b01c74e2deddeb","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"928b670b-477f-4132-875c-7c0b872ddfcd","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51566","feedbackHash":"14076103f882aae3380809e930ae8d4261b89e415526435bf6d0bc32154da470","nodeKey":"audit_economics","submissionHash":"accaee9749d43df58f57df4eaeb2f8de4121127e147864461da0b0cf88b6cc3f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51429","feedbackHash":"7491f171a1b2001439df41cd45caeb38590477ec1c31fa244067ffddb2f38e87","nodeKey":"audit_flow","submissionHash":"c924ca5e323d784bc05cf358acba106053d3f29210bd0809b5e72c1362a7538e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51352","feedbackHash":"9becfadce294c4fbfedc7ecffe311cba718d86cb57645769d621285cdd10d66e","nodeKey":"audit_judge","submissionHash":"9507f8609c455aa85e33439820ab9cc15a24b4bdb12bdf9fa8cd78600ba17247","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51101","feedbackHash":"3d7caf781922ac8590bfb047a34d50cad587de84914e02a9b5ec16aab76f72a6","nodeKey":"audit_math","submissionHash":"95931d0c912956d218a149ead7b03226968ba5983305161dfb69b6d822d83fb3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52286","feedbackHash":"eb4f3ac606b4244ce2a070bd9b65bd279ccc64f4c002f80553eae413b2f06441","nodeKey":"audit_permissions","submissionHash":"9170bb1c34be4e68a6ad3785b59b8c22a694e0dacbd00842e488898decbcfbcb","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f31389252d86bd0b053174220e7269dc8b3d97c6ed8ec45dce243abb64507e41","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fcb71e606c933181","findings":[{"citation":"resolved","description":"reclaim(day) only checks the wall clock against the theme day plus RECLAIM_AFTER. It does not check how long the bonus has actually been claimable. payBonus(day) needs a.settled (via _checkRefundable), and settle is permissionless but has no deadline. If settle(day) is not called by anyone before (day + 1) * 86400 + 7 days, the auction winner can call settle(day) and reclaim(day) back to back (two calls in one block, or from a contract; nonReentrant does not stop sequential calls) and get the whole net bid back. The arcade top 3 of that theme day, whose board is final and whose bonus the spec promises 'still pays from the board' when the operator goes quiet (WP6), get nothing and payBonus reverts with WrongStatus forever. The same asymmetry exists in the normal path: once the grace period passes, a winner who simply never calls payBonus can reclaim while the players must have acted within 7 days. The exploit needs an unprivileged actor (the winner) plus nobody calling settle or payBonus for ~8 days, so it is a liveness assumption rather than a direct theft, hence low. Fix (keeps the design): record the settlement time (for example settledAt[day] = block.timestamp in settle) and require both block.timestamp > (day + 1) * 1 days + RECLAIM_AFTER and block.timestamp > settledAt[day] + RECLAIM_AFTER in reclaim, so the board always has a full seven days of payBonus availability after the bonus exists.","line":250,"path":"src/DerbyAuction.sol","reproduction":"Day D = 20400. Alice bids 2 IMD during the window. Nobody calls settle. Warp to (D + 1) * 86400 + 7 days + 1 with derby.dayClosed(0, D) true and a 3-player arcade board. Alice calls settle(D) then reclaim(D). Expected (spec WP3 payBonus / WP6 'the bonus still pays from the board'): the board can still be paid, reclaim only after an unpaid grace window. Actual: Alice's balance is back to 2 IMD, a.paid is true, payBonus(D) reverts WrongStatus. Verified in a scratch Foundry test (test_lateSettleThenImmediateReclaimSkipsPayBonusWindow) against the current source: settle+reclaim succeed and payBonus reverts.","severity":"low","snippet":"        if (block.timestamp <= (day + 1) * 1 days + RECLAIM_AFTER) revert TooEarly();","title":"reclaim is anchored to the theme day, not to settlement, so a late settle lets the winner take the bid back before payBonus was ever callable"},{"citation":"resolved","description":"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 time with no timing restriction. 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, and 1 IMD of the locked bid is paid to studio instead of becoming bonus. This is the access x asymmetry seam from the Trust Gap guide (an admin setter that alters the destination of in-flight value) and DEPLOY.md documents it as intended ('Fees and studio changes apply when an auction settles, including auctions already bid on'), so it is reported as a trust assumption with bounded impact: at most 1 IMD per theme day, only by the owner, and 0 at launch. Minimal fix 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 (feeAt[day] = buildFee when sale.leader == address(0)) and use that in settle; or only let setBuildFee apply to days whose start is in the future.","line":191,"path":"src/DerbyAuction.sol","reproduction":"buildFee = 0 at deployment. Alice bids 2 IMD on day D. Warp to end(D) = (D - 1) * 86400 + 64800 (bidding closed). Owner calls setBuildFee(1e18) then settle(D). Expected from the bidder's view: bonus 2 IMD, studio 0. Actual: studio receives 1 IMD and a.bonus = 1 IMD. Verified in a scratch Foundry test (test_feeRaisedAfterCloseIsTakenAtSettleAndKeptOnVeto).","severity":"low","snippet":"            fee = buildFee < a.amount ? buildFee : a.amount;","title":"Build fee and studio are read at settle, so an owner change after bidding closed is charged to a bid that can no longer be withdrawn"},{"citation":"resolved","description":"settle sends fee = min(buildFee, amount) to studio immediately (line 195) and stores bonus = amount - fee + carry. veto then returns bonus - carryIn[day] = amount - fee, so the fee stays with the studio even though the owner rejected the answers and no theme was built or published for that bid. With buildFee = 0 at launch this is moot, and the spec (WP3 veto rule, DEPLOY.md 'less any fee already paid') states it, so this is a documented trust assumption: the owner plus studio (both owner-controlled at launch) net up to 1 IMD from any veto. If the requester wants vetoes to be cost-free for the bidder, hold the fee in the contract until the veto window closes (send it in payBonus/reclaim instead of settle, or let veto pull it back from a fee escrow) so the accounting identity still holds.","line":205,"path":"src/DerbyAuction.sol","reproduction":"buildFee = 1e18 (owner set). Alice bids 2 IMD on day D; settle(D) at end(D): studio +1 IMD, bonus 1 IMD. Owner calls veto(D) before D * 86400. Expected if a veto is meant to be neutral for the bidder: Alice gets 2 IMD back. Actual: Alice gets 1 IMD, studio keeps 1 IMD. Verified in a scratch Foundry test (test_feeRaisedAfterCloseIsTakenAtSettleAndKeptOnVeto).","severity":"info","snippet":"        uint256 amount = _releaseBonus(day, a);\n        _refund(a.leader, amount);","title":"veto refunds the bid net of the build fee, so a vetoed bidder pays the studio for a theme that never ran"},{"citation":"resolved","description":"openDay() is computed from the fixed CLOSE_OFFSET schedule only. When day D's auction has been extended past 18:00 UTC on D-1 (up to MAX_EXTENSION, 19:00), openDay() returns D+1 from 18:00 even though bid(D) is still accepted until the stored end. WP4 reads openDay() and auction(openDay()) to render the drawer, so for up to an hour the page would show D+1 as the open auction and hide the live final minutes of D, which is exactly when sniping and counter-bids matter. No funds are at risk and the contract logic is consistent (settle and bid both use _end). Fix: either return D while _end(D) > block.timestamp (check the previous day's stored end before advancing), or document in WP4 that the UI must also poll auction(openDay() - 1).end.","line":134,"path":"src/DerbyAuction.sol","reproduction":"Day D = 20400, close = (D - 1) * 86400 + 64800. At close - 1 Alice bids 2 IMD: auction(D).end becomes close + 299. At close + 100: openDay() returns D + 1 (expected: D, the auction still accepting bids), and bid(D, 3 IMD) from Bob succeeds, making Bob the leader of D. Verified in a scratch Foundry test (test_openDayIgnoresExtension).","severity":"info","snippet":"        return (block.timestamp - CLOSE_OFFSET) / 1 days + 2;","title":"openDay() ignores anti-snipe extensions, so during an extension it reports the next day while the extended day still takes bids"}],"hash":"9170bb1c34be4e68a6ad3785b59b8c22a694e0dacbd00842e488898decbcfbcb","nodeId":"12a07132-8670-4db7-afcc-d7b08fc6cc70","outcome":"completed","summary":"The review is complete. The findings file is in place and the scratch tests are removed, so no further requests are needed.\n\n**Outcome:** no critical or high defects in `src/DerbyAuction.sol`. Four bounded findings are recorded in `.imd-findings.json`, two low and two informational.\n\n**What I checked**\n\n- Entry-point inventory: every state-changing function, its guard, and every writer of `carry`, `bonus`, `paid`, `refunds`, `studio`, `buildFee`, `owner`. No storage slot has a weaker writer than its guarded peer. Two-step ownership matches the live SwarmDerby.\n- Bid and refund ordering: full pull before the previous leader's refund, credit on failed refund, self-raise, reentrancy through token hooks. All sound.\n- Anti-snipe: extensions are monotone and capped at 19:00 UTC, bid and settle use the same stored end, so settle can never land while bidding is open.\n- Timing windows: bid, settle, veto, payBonus and reclaim are pairwise disjoint except reclaim versus payBonus, which the spec intends.\n- Accounting: a handler-based invariant fuzz with a mock derby and a token that reverts or returns false kept balance equal to lead + refunds + unpaid bonuses + carry across roughly 16,000 interleaved calls.\n- SwarmDerby boundary: `dayClosed` guarantees the board is final, scores go to the player not the session key, and a broken derby address degrades to reclaim rather than stuck funds.\n\n**Findings**\n\n1. **Low, reclaim timing.** The reclaim deadline is anchored to the theme day only. If nobody settles for eight days, the winner can settle and reclaim back to back and the arcade board can never be paid. Fix: also require seven days since settlement.\n2. **Low, retroactive fee.** `settle` reads the live fee and studio, so the owner can raise the fee after bidding closes and charge a locked bid. Documented in DEPLOY.md and capped at 1 IMD. Reported as a trust assumption.\n3. **Info, veto keeps the fee.** A vetoed bidder loses the fee to the studio. Moot at launch with fee zero, and documented.\n4. **Info, openDay() view.** It ignores extensions, so the UI would show the next day while the extended auction still takes bids.\n\n**Limits.** Slither was not available and was not installed. The fuzz used a mock derby for breadth; the repo's own tests cover the real derby integration and all 91 pass on Foundry 1.8.5.","treeHash":null,"usage":{"cachedInputTokens":1670429,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":40182,"runtime":"claude","turns":27,"wallClockMs":630318}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8f08088e7a7f557f","findings":[{"citation":"resolved","description":"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).","line":250,"path":"src/DerbyAuction.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DerbyAuction, ISwarmDerby} from \"src/DerbyAuction.sol\";\nimport {SwarmDerby, IERC20} from \"src/SwarmDerby.sol\";\nimport {DerbyOdds} from \"src/DerbyOdds.sol\";\n\n/// Finding: `reclaim` measures its grace period from the theme day, not from settlement.\n/// `settle` has no deadline, so an auction nobody settled for 8 days can be settled and\n/// reclaimed by the winner in one transaction, before anyone can call `payBonus`.\n/// The arcade top 3 of that theme day get nothing.\ncontract ScratchArbSys {\n    uint256 public arbBlockNumber;\n    mapping(uint256 => bytes32) public hashes;\n\n    function setBlock(uint256 n) external {\n        arbBlockNumber = n;\n    }\n\n    function setHash(uint256 n, bytes32 h) external {\n        hashes[n] = h;\n    }\n\n    function arbBlockHash(uint256 n) external view returns (bytes32) {\n        require(n < arbBlockNumber && n + 256 >= arbBlockNumber, \"range\");\n        return hashes[n] != bytes32(0) ? hashes[n] : keccak256(abi.encode(\"blk\", n));\n    }\n}\n\ncontract ScratchToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address to, uint256 amount) external returns (bool) {\n        allowance[msg.sender][to] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// The winner settles and reclaims in a single transaction.\ncontract ScratchWinner {\n    function settleAndReclaim(DerbyAuction sale, uint256 day) external {\n        sale.settle(day);\n        sale.reclaim(day);\n    }\n}\n\ncontract LateSettleReclaimTest is Test {\n    ScratchArbSys internal arb = ScratchArbSys(address(100));\n    ScratchToken internal imd;\n    SwarmDerby internal derby;\n    DerbyAuction internal sale;\n    ScratchWinner internal winner;\n    address internal studio = makeAddr(\"studio\");\n    uint256 internal constant DAY = 20_400;\n\n    function setUp() public {\n        vm.chainId(31337);\n        vm.warp((DAY - 2) * 1 days + 18 hours);\n        vm.etch(address(100), address(new ScratchArbSys()).code);\n        arb.setBlock(1_000);\n        imd = new ScratchToken();\n        derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether);\n        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 0);\n        winner = new ScratchWinner();\n    }\n\n    function _homer(address who) internal {\n        imd.mint(who, 0.15 ether);\n        vm.startPrank(who);\n        imd.approve(address(derby), 0.15 ether);\n        derby.buyTurns(0, 1);\n        vm.stopPrank();\n        uint256 id = derby.nextSwingId();\n        bytes32 salt = keccak256(abi.encode(\"scratch\", id, who));\n        bytes32 commitment = derby.commitFor(salt, who);\n        uint256 target = arb.arbBlockNumber() + derby.REVEAL_DELAY();\n        vm.prank(who);\n        derby.swing(0, 100, 100, commitment);\n        bytes32 hash;\n        for (uint256 i;; ++i) {\n            hash = keccak256(abi.encode(\"future block\", id, i));\n            (uint8 tier,) = DerbyOdds.roll(derby.swingSeed(salt, hash), id, 100, 100);\n            if (tier == DerbyOdds.HOMER) break;\n        }\n        arb.setHash(target, hash);\n        arb.setBlock(target + 1);\n        derby.finalize(id, salt);\n    }\n\n    function test_lateSettleCannotBeReclaimedBeforePayersGetTheirGracePeriod() public {\n        // The winner contract bids 10 IMD for theme day DAY.\n        imd.mint(address(winner), 10 ether);\n        vm.prank(address(winner));\n        imd.approve(address(sale), 10 ether);\n        vm.prank(address(winner));\n        sale.bid(DAY, 10 ether, DerbyAuction.Answers(\"Comet critter\", 3, 5, 5, \"Starlight Derby\", \"\"));\n\n        // Nobody calls settle. The theme day runs; three arcade players homer and top the board.\n        vm.warp(DAY * 1 days);\n        address p1 = address(0x10001);\n        address p2 = address(0x10002);\n        address p3 = address(0x10003);\n        _homer(p1);\n        _homer(p2);\n        _homer(p3);\n        (address[] memory board,) = derby.board(0, DAY);\n        assertEq(board.length, 3);\n\n        // Eight days later the day is closed and the grace period (measured from the theme\n        // day) has already elapsed although no bonus ever existed to pay.\n        vm.warp((DAY + 1) * 1 days + 7 days + 1);\n        arb.setBlock(arb.arbBlockNumber() + 1_000);\n        assertTrue(derby.dayClosed(0, DAY));\n\n        // Winner settles and reclaims atomically. Nobody can interleave payBonus.\n        try winner.settleAndReclaim(sale, DAY) {} catch {}\n\n        // Expected: the bid is still held for the board (either settle was refused this\n        // late, or reclaim is not yet allowed). Actual on current code: the winner has its\n        // 10 IMD back, the day is marked paid, and the top 3 can never be paid.\n        assertEq(imd.balanceOf(address(winner)), 0, \"winner reclaimed in the same tx as a late settle\");\n        assertEq(sale.refunds(address(winner)), 0, \"winner was credited a refund\");\n        (,,,, bool vetoed, bool paid,) = sale.auction(DAY);\n        assertFalse(paid && !vetoed, \"day marked paid without paying the board\");\n    }\n}","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.","severity":"medium","snippet":"        if (block.timestamp <= (day + 1) * 1 days + RECLAIM_AFTER) revert TooEarly();","title":"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"},{"citation":"resolved","description":"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).","line":192,"path":"src/DerbyAuction.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DerbyAuction, ISwarmDerby} from \"src/DerbyAuction.sol\";\nimport {SwarmDerby, IERC20} from \"src/SwarmDerby.sol\";\nimport {DerbyOdds} from \"src/DerbyOdds.sol\";\n\n/// Finding: `settle` folds the whole current `carry` into any auction with a winner, even\n/// one settled long after its theme day, whose arcade board is already final. A bidder\n/// who sits alone on that past board waits for carry to accumulate, then settles the stale\n/// day and pays itself the tip plus 60% of the carry.\ncontract ScratchArbSys {\n    uint256 public arbBlockNumber;\n    mapping(uint256 => bytes32) public hashes;\n\n    function setBlock(uint256 n) external {\n        arbBlockNumber = n;\n    }\n\n    function setHash(uint256 n, bytes32 h) external {\n        hashes[n] = h;\n    }\n\n    function arbBlockHash(uint256 n) external view returns (bytes32) {\n        require(n < arbBlockNumber && n + 256 >= arbBlockNumber, \"range\");\n        return hashes[n] != bytes32(0) ? hashes[n] : keccak256(abi.encode(\"blk\", n));\n    }\n}\n\ncontract ScratchToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address to, uint256 amount) external returns (bool) {\n        allowance[msg.sender][to] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract LateSettleCarryTest is Test {\n    ScratchArbSys internal arb = ScratchArbSys(address(100));\n    ScratchToken internal imd;\n    SwarmDerby internal derby;\n    DerbyAuction internal sale;\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n    address internal studio = makeAddr(\"studio\");\n    uint256 internal constant DAY = 20_400;\n\n    function setUp() public {\n        vm.chainId(31337);\n        vm.warp(_start(DAY));\n        vm.etch(address(100), address(new ScratchArbSys()).code);\n        arb.setBlock(1_000);\n        imd = new ScratchToken();\n        derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether);\n        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 0);\n    }\n\n    function _start(uint256 day) internal pure returns (uint256) {\n        return (day - 2) * 1 days + 18 hours;\n    }\n\n    function _end(uint256 day) internal pure returns (uint256) {\n        return (day - 1) * 1 days + 18 hours;\n    }\n\n    function _bid(uint256 day, address who, uint256 amount) internal {\n        imd.mint(who, amount);\n        vm.prank(who);\n        imd.approve(address(sale), amount);\n        vm.prank(who);\n        sale.bid(day, amount, DerbyAuction.Answers(\"Comet critter\", 3, 5, 5, \"Starlight Derby\", \"\"));\n    }\n\n    function _homer(address who) internal {\n        imd.mint(who, 0.15 ether);\n        vm.startPrank(who);\n        imd.approve(address(derby), 0.15 ether);\n        derby.buyTurns(0, 1);\n        vm.stopPrank();\n        uint256 id = derby.nextSwingId();\n        bytes32 salt = keccak256(abi.encode(\"scratch\", id, who));\n        bytes32 commitment = derby.commitFor(salt, who);\n        uint256 target = arb.arbBlockNumber() + derby.REVEAL_DELAY();\n        vm.prank(who);\n        derby.swing(0, 100, 100, commitment);\n        bytes32 hash;\n        for (uint256 i;; ++i) {\n            hash = keccak256(abi.encode(\"future block\", id, i));\n            (uint8 tier,) = DerbyOdds.roll(derby.swingSeed(salt, hash), id, 100, 100);\n            if (tier == DerbyOdds.HOMER) break;\n        }\n        arb.setHash(target, hash);\n        arb.setBlock(target + 1);\n        derby.finalize(id, salt);\n    }\n\n    function _close(uint256 day) internal {\n        vm.warp((day + 1) * 1 days);\n        arb.setBlock(arb.arbBlockNumber() + 1_000);\n        assertTrue(derby.dayClosed(0, day));\n    }\n\n    function test_staleAuctionSettledAfterItsThemeDayDoesNotCaptureCarry() public {\n        // Alice wins DAY with the minimum bid and is the only arcade player that day.\n        _bid(DAY, alice, 2 ether);\n        vm.warp(DAY * 1 days);\n        _homer(alice);\n        _close(DAY);\n        // Nobody settles DAY.\n\n        // A later day: Bob bids 10 IMD, it settles normally, and its arcade board is empty,\n        // so the whole 10 IMD becomes carry for the next theme day.\n        uint256 later = DAY + 3;\n        vm.warp(_start(later));\n        _bid(later, bob, 10 ether);\n        vm.warp(_end(later));\n        sale.settle(later);\n        _close(later);\n        sale.payBonus(later);\n        assertEq(sale.carry(), 10 ether);\n\n        // Alice now settles the stale DAY: its board is final and only she is on it.\n        uint256 before = imd.balanceOf(alice);\n        vm.prank(alice);\n        try sale.settle(DAY) {} catch {}\n\n        // Expected: a day settled after its theme day does not absorb carry meant for a\n        // future theme day. Actual: carryIn[DAY] == 10 IMD and Alice can pay herself\n        // 0.5% tip + 60% of 12 IMD from a 2 IMD bid.\n        assertEq(sale.carryIn(DAY), 0, \"stale settle absorbed the carry\");\n        (,,, bool settled,,,) = sale.auction(DAY);\n        if (settled) {\n            vm.prank(alice);\n            sale.payBonus(DAY);\n            assertLe(imd.balanceOf(alice) - before, 2 ether, \"alice extracted more than her own bid\");\n        }\n    }\n}","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).","severity":"low","snippet":"            carryIn[day] = carry;\n            a.bonus = a.amount - fee + carry;\n            carry = 0;","title":"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"},{"citation":"resolved","description":"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.","line":195,"path":"src/DerbyAuction.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DerbyAuction, ISwarmDerby} from \"src/DerbyAuction.sol\";\nimport {IERC20} from \"src/SwarmDerby.sol\";\n\n/// ERC20 whose owner can block an address (the Robinhood IMD token can block addresses).\ncontract BlockableToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    mapping(address => bool) public blocked;\n\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }\n    function approve(address to, uint256 amount) external returns (bool) { allowance[msg.sender][to] = amount; return true; }\n    function setBlocked(address a, bool b) external { blocked[a] = b; }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        if (blocked[to] || blocked[msg.sender]) revert(\"blocked\");\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (blocked[from] || blocked[to]) revert(\"blocked\");\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract StubDerby {\n    function currentDay() external view returns (uint256) { return block.timestamp / 1 days; }\n    function dayClosed(uint8, uint256) external pure returns (bool) { return false; }\n    function board(uint8, uint256) external pure returns (address[] memory p, uint256[] memory s) {\n        p = new address[](0);\n        s = new uint256[](0);\n    }\n}\n\n/// Fails on the current code: when buildFee > 0 and the token refuses the studio transfer,\n/// settle() reverts, so the winning bid can be neither settled, vetoed, reclaimed nor refunded\n/// until the owner changes the studio. Passes once settle no longer depends on the studio\n/// transfer succeeding (e.g. a failed fee send is credited like a failed refund).\ncontract StudioFeeLockTest is Test {\n    BlockableToken imd;\n    StubDerby derby;\n    DerbyAuction sale;\n    address alice = makeAddr(\"alice\");\n    address studio = makeAddr(\"studio\");\n    uint256 constant DAY = 20_400;\n\n    function setUp() public {\n        vm.warp((DAY - 2) * 1 days + 18 hours);\n        imd = new BlockableToken();\n        derby = new StubDerby();\n        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 1 ether);\n    }\n\n    function test_settleSurvivesBlockedStudio() public {\n        imd.mint(alice, 2 ether);\n        vm.prank(alice);\n        imd.approve(address(sale), 2 ether);\n        vm.prank(alice);\n        sale.bid(DAY, 2 ether, DerbyAuction.Answers(\"Comet critter\", 0, 0, 0, \"Starlight\", \"\"));\n\n        // The token's owner blocks the studio after the bid landed.\n        imd.setBlocked(studio, true);\n        vm.warp((DAY - 1) * 1 days + 18 hours);\n\n        // Expected: the auction still settles; the winner's money is not hostage to the studio.\n        sale.settle(DAY);\n        (,,, bool settled,,, uint256 bonus) = sale.auction(DAY);\n        assertTrue(settled, \"auction must settle even when the studio cannot be paid\");\n        assertEq(bonus, 1 ether, \"bonus is bid minus fee\");\n        // Whatever the fix does with the fee, the contract must still hold the bonus.\n        assertGe(imd.balanceOf(address(sale)), 1 ether);\n    }\n}","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.","severity":"low","snippet":"            _send(studio, fee);","title":"settle hard-reverts when the token refuses the studio fee, locking the winning bid until the owner changes studio"},{"citation":"resolved","description":"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.","line":134,"path":"src/DerbyAuction.sol","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.","severity":"low","snippet":"        return (block.timestamp - CLOSE_OFFSET) / 1 days + 2;","title":"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"},{"citation":"resolved","description":"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.","line":223,"path":"src/DerbyAuction.sol","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).","severity":"info","snippet":"        if (n > 0) {\n            tip = _bps(bonus, TIP_BPS);\n            paid = tip;","title":"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"},{"citation":"resolved","description":"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.","line":203,"path":"src/DerbyAuction.sol","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.","severity":"info","snippet":"        if (block.timestamp >= day * 1 days) revert BidClosed();","title":"If nobody settles before 00:00 UTC on the theme day, the owner's veto is lost permanently while the bonus still pays"},{"citation":"resolved","description":"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.","line":191,"path":"src/DerbyAuction.sol","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.","severity":"info","snippet":"            fee = buildFee < a.amount ? buildFee : a.amount;","title":"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"}],"hash":"9507f8609c455aa85e33439820ab9cc15a24b4bdb12bdf9fa8cd78600ba17247","nodeId":"a5ec0299-983c-4d78-8d05-99826b7bb5f7","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 7 findings, each reproduced against the code, with the 16 specialist items merged into them.\n\n**What I verified**\n\n- The repository suite passes as it is (91 tests, chain id 31337).\n- All three specialist proofs fail on the current code for exactly the reason their finding states, and pass against a scratch copy of the contract carrying the minimal fix each finding proposes.\n- Every other kept claim was reproduced with my own scratch tests, which pass on the current code and document the actual behaviour. Scratch files live under `test/scratch/` only. No other file in the tree was touched.\n\n**Findings kept, ranked**\n\n1. **Medium, with proof.** `reclaim` measures its grace from the theme day, not from settlement. If nobody settles for eight days, the winner settles and reclaims back to back, the day is marked paid, and the arcade top 3 can never be paid. This breaks the WP6 quiet-operator guarantee. Fix: record a settlement timestamp and anchor the grace to the later of the two.\n2. **Low, with proof.** `settle` folds the whole carry into any winner auction regardless of timing, so a stale day settled after its board is final captures carry meant for future players. The out-of-order carry note from the economics specialist is merged here as the same root cause.\n3. **Low, with proof.** `settle` hard-reverts when the studio fee transfer fails, which strands the winning bid until the owner changes studio. Every other outbound send credits on failure.\n4. **Low.** `openDay()` ignores anti-snipe extensions, so the WP4 drawer points page users at the next day while the extended auction is still live.\n5. **Info.** After the grace, payBonus and reclaim race with no priority, and an empty-board payBonus pays nobody, so only the winner has an incentive to act.\n6. **Info.** If nobody settles before midnight, the owner's veto is lost although the bonus still pays.\n7. **Info.** Owner trust assumptions, merged from four specialist items: the fee is read at settle so it applies to locked bids, and vetoed or reclaimed winners are refunded net of the fee.\n\n**Dropped.** The minNextBid arithmetic panic at a lead above roughly 0.95 times the uint256 maximum reproduces but needs a bid far beyond any token supply, so it is a boundary note, not a defect.\n\n**Coverage.** I applied the entry-point inventory, the accounting identity, math, timing, reentrancy, token-behaviour and ownership passes from the references. Tools run were forge build and forge test only, with the default 256 fuzz runs. Slither and longer fuzz runs were not available in this task.","treeHash":null,"usage":{"cachedInputTokens":2419955,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":37640,"runtime":"claude","turns":35,"wallClockMs":572275}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b1d937213bbfd9cf","findings":[{"citation":"resolved","description":"minNextBid computes ceil(lead * 5%) safely with _bps and mulmod, but the final `lead + increment` is checked arithmetic with no headroom guard. If a day's lead is above type(uint256).max / 1.05, minNextBid reverts with Panic(0x11) instead of returning a value, and every later bid(day, ...) call reverts with the same panic before reaching BidTooLow, because bid calls minNextBid at line 165. The auction itself is unharmed: the extreme lead still settles (a.amount - fee + carry cannot overflow because the contract's token balance, which already holds carry, bounds amount + carry <= max for any token whose balances fit in uint256) and pays out as the repository's max-bid test shows. The only effect is a panic instead of a clean revert on the view and on the bid entry point. Reaching this state needs a bid of roughly 0.95 * 2^256 IMD, far beyond any real supply, so this is a boundary note, not a live risk. Fixing it is optional: either saturate (`if (increment > type(uint256).max - lead) return type(uint256).max;`) or bound bids with an explicit MAX_BID so the increment math always has headroom.","line":155,"path":"src/DerbyAuction.sol","reproduction":"Token: any ERC20 mock that can mint 2^256-1. Warp to start(DAY). Alice bids lead = type(uint256).max / 105 * 100 + 1e18 (above max/1.05). Then call sale.minNextBid(DAY): expected a uint256 or a BidTooLow-style custom error, actual Panic(0x11). Bob with max allowance calls sale.bid(DAY, type(uint256).max, answers): expected BidTooLow() or acceptance, actual Panic(0x11) at line 155 via minNextBid. Verified in test/scratch/Probe.t.sol::test_minNextBidPanicsAtExtremeLead, which passes with vm.expectRevert(stdError.arithmeticError) on both calls.","severity":"info","snippet":"        uint256 next = lead + increment;","title":"minNextBid and bid revert with an arithmetic panic once the lead exceeds max/1.05"},{"citation":"resolved","description":"settle sends fee = min(buildFee, amount) to studio immediately and stores bonus = amount - fee + carry. _releaseBonus, used by veto and reclaim, returns bonus - carryIn[day] = amount - fee, so the fee is never returned to a vetoed or reclaimed winner. This matches WP3 (\"Refund bonus[day] - carryIn[day]\") and DEPLOY.md (\"less any fee already paid\"), so it is not a defect, but it is an owner power worth listing: with buildFee > 0 the owner can settle, collect up to MAX_BUILD_FEE (1 IMD) at the owner-chosen studio address, then veto the same auction, and the bidder gets no theme and loses the fee. Preconditions: owner sets buildFee > 0 (launch value is 0, so no exposure at launch). Accounting stays exact in every case: balance = lead + refunds + unpaid bonus + carry held before and after. No change is needed unless the requester wants vetoed winners made whole, which would require the fee to be held until the veto window closes (a design change to settle).","line":302,"path":"src/DerbyAuction.sol","reproduction":"buildFee = 1e18 (setBuildFee(1e18) before settle). Alice bids 2e18 on day D. Warp to end(D); settle(D): studio receives 1e18, bonus = 1e18, carryIn[D] = 0. Owner calls veto(D) before D*86400: Alice receives 1e18 (expected per spec: 1e18; a reader expecting a full refund would expect 2e18). Alice's net cost for a vetoed, unbuilt theme is 1e18 IMD. The repository's test_vetoOwnerOnlyBeforeThemeDayReturnsOwnNetBidNotCarry shows the same arithmetic with carry present (bid 3e18, fee 1e18, refund 2e18).","severity":"info","snippet":"        amount = a.bonus - carried;","title":"Trust assumption: veto and reclaim refund the bid net of the build fee already sent to studio"},{"citation":"resolved","description":"settle pays the studio with _send, which reverts on a failed or false-returning transfer, while every other outbound payment in the contract (refunds, winners' shares, reclaims) uses _trySend and credits or carries on failure. With buildFee > 0 and a studio address the token refuses (blacklisted, or a contract that rejects IMD), settle reverts for every auction until the owner calls setStudio. During that time the winning bid is held as an unsettled lead with no refund path, and because veto requires a settled auction and block.timestamp < day * 86400, the owner's veto window can lapse. The owner controls both studio and buildFee, so this is a documented operator risk rather than a bypass; the launch fee of 0 skips the transfer entirely (_trySend returns true for amount 0 without calling the token). If the requester wants settlement to be unblockable regardless of studio, the minimal change is to credit a failed fee to refunds[studio] (or to carry) instead of reverting, which keeps the accounting identity intact.","line":195,"path":"src/DerbyAuction.sol","reproduction":"setBuildFee(1e18). Alice bids 2e18 on day D; warp to end(D). Make the token refuse transfers to studio (repository mock: imd.setFailure(studio, 1)). settle(D): expected by a keeper to mark the day settled, actual revert TransferFailed() and auction(D).settled == false with Alice's 2e18 still held as lead. Warp to D * 86400: veto(D) now reverts WrongStatus() (not settled), and after setStudio(carol) and settle(D) the veto reverts BidClosed() because the theme day has begun. The repository's test_failedStudioPaymentLeavesAuctionUnsettled covers the first half of this sequence.","severity":"info","snippet":"            _send(studio, fee);","title":"Trust assumption: a failing studio transfer blocks settle while buildFee > 0, which can run out the veto window"}],"hash":"95931d0c912956d218a149ead7b03226968ba5983305161dfb69b6d822d83fb3","nodeId":"192e3b7b-033e-4713-867a-82f1a5bd9c0c","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root, and no source, test or configuration file was changed. The only new files are the scratch probes under `test/scratch/`, which the task says are mine and not kept.\n\n**Outcome: no defect in the DerbyAuction math or token accounting.** The suite of 91 tests passes on the stock tree with Foundry 1.8.3, and every lead I traced through the Math Precision, Boundary and Numerical Gap checklists either closed against a guard or turned out to be spec-conforming behaviour. The findings file holds three info-level entries, each with a concrete state and a probe that reproduces it. None is severe enough to carry a proof file.\n\n**What I verified in depth**\n\n- **Conservation identity.** I walked every balance-changing path. Bid pulls the exact amount, verified by a before/after balance diff, then refunds or credits the previous leader. Settle moves the lead into the bonus minus the fee and absorbs carry. Veto and reclaim return exactly `bonus - carryIn` and restore carry. Pay splits tip then 60/25/15 with floor rounding, and every failed send or dust goes to carry. The identity holds before and after each step.\n- **Rounding.** The `_bps` helper is an exact floor without an overflowing intermediate. The increment rounds up correctly via `mulmod`. The tip is at least 5e15 wei because the bonus is at least 1e18, so no fee ever truncates to zero. The sum of the three share floors never exceeds the remainder.\n- **Boundaries.** Day 0 and 1 revert cleanly. Bid is open for `start <= now < end` and settle for `now >= end`, with no overlap. Extensions stop exactly at 19:00 UTC, leaving the five-hour veto window. Veto, pay and reclaim windows are mutually exclusive. The `bonus != 0` guard can never block a real winner because the minimum bid exceeds the maximum fee.\n- **Ordering and reentrancy.** Pull happens before refund, so a self-raise must be fully funded first. The guard covers every state-changing entry, and the repository's callback-token tests confirm it.\n- **Ownership.** Two-step transfer matches the live SwarmDerby pattern, with zero-address cancelling the handover.\n\n**The three entries in the file**\n\n1. **Extreme-lead panic.** Once a lead is above max/1.05, `minNextBid` and `bid` revert with an arithmetic panic rather than a clean error. Needs a bid of about 0.95 times 2^256 IMD, so it is a boundary note only.\n2. **Trust assumption on veto.** Veto and reclaim return the bid less the fee already paid to studio. This is exactly what WP3 and DEPLOY.md specify, and the launch fee is zero, but it is an owner power worth having on record.\n3. **Trust assumption on studio.** With a nonzero fee, a studio address the token refuses makes settle revert until the owner changes it, and the veto window can lapse in the meantime. My probe shows the full sequence through to the lapsed veto.\n\n**One dead lead worth noting.** I initially suspected `amount - fee + carry` could overflow at settle after a maximal bid. It cannot, for any token whose balances fit in uint256: the contract already holds carry, so amount plus carry is bounded by its own balance. I removed that probe and recorded the reasoning as a comment in the scratch file.","treeHash":null,"usage":{"cachedInputTokens":1304071,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":36890,"runtime":"claude","turns":27,"wallClockMs":558009}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d0653dc91b6e2259","findings":[{"citation":"resolved","description":"With buildFee > 0, settle() pays the fee to `studio` with `_send`, which reverts on any failed transfer. The Robinhood IMD token's owner can block addresses (DEPLOY.md, Known limits). If `studio` is blocked (or the token is paused) after bids were placed, settle(day) reverts with TransferFailed for everyone. Because veto, payBonus and reclaim all require `a.settled`, and bid is closed after `end`, the leader's full bid sits in the contract with no path out: not refundable, not reclaimable after the 7-day grace, and not vetoable. Only the owner can unblock it by calling setStudio to an unblocked address. Every other transfer the contract makes to a third party (refunds, prizes) is deliberately non-blocking via `_trySend` plus a credit, so this is the one place a single external address can halt a day's settlement. At launch buildFee is 0, which is why this is low rather than medium; it becomes live the moment the owner raises the fee. Fix: treat a failed fee send like a failed refund, e.g. `if (!_trySend(studio, fee)) { refunds[studio] += fee; emit RefundCredited(studio, fee); }`, so settlement never depends on the studio being payable. This keeps the balance invariant (the fee becomes a credited refund).","line":195,"path":"src/DerbyAuction.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DerbyAuction, ISwarmDerby} from \"src/DerbyAuction.sol\";\nimport {IERC20} from \"src/SwarmDerby.sol\";\n\n/// ERC20 whose owner can block an address (the Robinhood IMD token can block addresses).\ncontract BlockableToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    mapping(address => bool) public blocked;\n\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }\n    function approve(address to, uint256 amount) external returns (bool) { allowance[msg.sender][to] = amount; return true; }\n    function setBlocked(address a, bool b) external { blocked[a] = b; }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        if (blocked[to] || blocked[msg.sender]) revert(\"blocked\");\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        if (blocked[from] || blocked[to]) revert(\"blocked\");\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract StubDerby {\n    function currentDay() external view returns (uint256) { return block.timestamp / 1 days; }\n    function dayClosed(uint8, uint256) external pure returns (bool) { return false; }\n    function board(uint8, uint256) external pure returns (address[] memory p, uint256[] memory s) {\n        p = new address[](0);\n        s = new uint256[](0);\n    }\n}\n\n/// Fails on the current code: when buildFee > 0 and the token refuses the studio transfer,\n/// settle() reverts, so the winning bid can be neither settled, vetoed, reclaimed nor refunded\n/// until the owner changes the studio. Passes once settle no longer depends on the studio\n/// transfer succeeding (e.g. a failed fee send is credited like a failed refund).\ncontract StudioFeeLockTest is Test {\n    BlockableToken imd;\n    StubDerby derby;\n    DerbyAuction sale;\n    address alice = makeAddr(\"alice\");\n    address studio = makeAddr(\"studio\");\n    uint256 constant DAY = 20_400;\n\n    function setUp() public {\n        vm.warp((DAY - 2) * 1 days + 18 hours);\n        imd = new BlockableToken();\n        derby = new StubDerby();\n        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 1 ether);\n    }\n\n    function test_settleSurvivesBlockedStudio() public {\n        imd.mint(alice, 2 ether);\n        vm.prank(alice);\n        imd.approve(address(sale), 2 ether);\n        vm.prank(alice);\n        sale.bid(DAY, 2 ether, DerbyAuction.Answers(\"Comet critter\", 0, 0, 0, \"Starlight\", \"\"));\n\n        // The token's owner blocks the studio after the bid landed.\n        imd.setBlocked(studio, true);\n        vm.warp((DAY - 1) * 1 days + 18 hours);\n\n        // Expected: the auction still settles; the winner's money is not hostage to the studio.\n        sale.settle(DAY);\n        (,,, bool settled,,, uint256 bonus) = sale.auction(DAY);\n        assertTrue(settled, \"auction must settle even when the studio cannot be paid\");\n        assertEq(bonus, 1 ether, \"bonus is bid minus fee\");\n        // Whatever the fix does with the fee, the contract must still hold the bonus.\n        assertGe(imd.balanceOf(address(sale)), 1 ether);\n    }\n}","reproduction":"Deploy DerbyAuction with buildFee_ = 1e18 (or call setBuildFee(1e18) after the bid). Alice bids 2e18 on day D during [start(D), end(D)). The token blocks `studio`. Warp to end(D) or later and call settle(D): expected the auction to settle with bonus 1e18; actual revert TransferFailed(). Then veto(D) and reclaim(D) revert WrongStatus() (not settled), bid(D, ...) reverts BidClosed(), and withdrawRefund by Alice reverts NoRefund(). The contract holds 2e18 that nobody but the owner (via setStudio) can release. The attached test fails with TransferFailed() on the current code.","severity":"low","snippet":"            _send(studio, fee);","title":"settle() hard-reverts when the token refuses the studio fee, locking the winning bid until the owner intervenes"},{"citation":"resolved","description":"openDay() is a pure function of the clock: at 18:00 UTC on D-1 it switches from D to D+1 regardless of whether D's auction was extended by a last-minute bid (its `end` can run to 19:00). WP4 instructs the page to read openDay() and render only that auction. During an extension the page therefore shows D+1 (empty, 2 IMD minimum) while bid(D, ...) still succeeds and the lead on D can still be sniped. The anti-snipe exists so that other bidders can respond in the extra five minutes; 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. The contract exposes no view that says 'D is still open'. Fix: make openDay() return the earliest day that still accepts bids, e.g. `uint256 d = ...+2; if (d > 2 && block.timestamp < _end(d - 1)) return d - 1;`, or add an `openDays()` view returning both days when they overlap and have WP4 use it.","line":134,"path":"src/DerbyAuction.sol","reproduction":"Day D = 20400. At end(D) - 1 = (D-1)*86400 + 64799 Alice bids 2e18: auction(D).end becomes end(D) + 299. Warp to end(D) + 30. openDay() returns D + 1 (expected: D, which is still open for another 269 seconds). Bob calls bid(D, 3e18, answers) and succeeds, becoming leader of D; a user watching openDay() never saw D as open. Verified in a scratch test on this code.","severity":"low","snippet":"        return (block.timestamp - CLOSE_OFFSET) / 1 days + 2;","title":"openDay() ignores anti-snipe extensions, so an extended auction is invisible to anyone following the open-day view"},{"citation":"resolved","description":"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. No unprivileged party gains from making that call, and after (day+1)*86400 + 7 days reclaim(day) becomes callable by anyone, returning the net bid to the winner. The winner therefore has a direct incentive to wait 7 days and reclaim, obtaining the theme day for free, while the spec (WP3 payBonus row) says an empty board's bonus becomes carry for future arcade players. More generally, once the grace period passes, payBonus and reclaim are both live for the same day and whichever transaction the sequencer orders first decides whether the arcade top 3 or the bidder receives the money (there is no priority rule). The operator runbook assumes the operator calls payBonus the next morning, so the practical exposure is operator inactivity, hence low. Fix options: pay the tip from the bonus even when the board is empty (so a keeper is paid to carry it), or gate reclaim on payBonus being impossible, e.g. require `!derby.dayClosed(0, day)` so reclaim only serves days the derby never closes, or give payBonus priority by letting reclaim only run after a second, longer grace.","line":223,"path":"src/DerbyAuction.sol","reproduction":"Alice wins day D with 10e18 (settled at end(D), fee 0, bonus 10e18). Nobody plays arcade on D, so derby.board(0, D) is empty; derby.dayClosed(0, D) becomes true at (D+1)*86400 + ~25 s. payBonus(D) at that point 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 back and carry stays 0. Race variant: with Bob first on the board, at the same timestamp reclaim(D) by Alice followed by payBonus(D) by Bob leaves Bob with 0 and Alice with 10e18, while the reverse order pays Bob 5.97e18; verified in scratch tests on this code.","severity":"low","snippet":"        if (n > 0) {\n            tip = _bps(bonus, TIP_BPS);\n            paid = tip;","title":"Empty-board bonus has no incentivised payer, so the 'empty board moves the bonus to carry' rule is defeated by reclaim after 7 days"},{"citation":"resolved","description":"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 18:00/19:00 and 00:00, settle(D) can still be called later (by anyone, for ever), after which payBonus pays the full bonus to the board, but the owner can no longer reject the answers. WP6's quiet-operator story ('nothing breaks') holds for money but not for the veto power the spec promises ('the owner can still veto the day after the longest extension'). The owner can avoid this by settling themselves before midnight, so this is informational. If the veto is meant as a content safety valve, consider allowing veto on an unsettled-but-ended auction (settle-and-veto in one call) or until the first payBonus.","line":203,"path":"src/DerbyAuction.sol","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, bonus 2e18. veto(D) now reverts BidClosed() at every later timestamp. Verified in a scratch test on this code.","severity":"info","snippet":"        if (block.timestamp >= day * 1 days) revert BidClosed();","title":"If nobody settles before 00:00 UTC on the theme day the veto window is lost permanently"},{"citation":"resolved","description":"settle folds the entire global `carry` into the first settled auction that has a winner, regardless of day order. Because settle is permissionless and two or more ended auctions can be unsettled at once, a bidder who has placed the 2 IMD minimum on a quiet day can settle their own day first and capture a carry that accumulated from a busier day, then (as the documented known limit already allows) play the arcade with several wallets on that day to collect 60/25/15 of bid + carry. Numbers: carry 100 IMD from an empty-board day; attacker bids 2 IMD on D+1 while an honest 100 IMD auction on D is unsettled; attacker calls settle(D+1) before settle(D) at 18:00 on D; bonus(D+1) = 102 IMD, bonus(D) = 100 IMD; arcade turns cost 0.15 IMD each with 20 swings per wallet, so three wallets able to take the top 3 cost about 9 IMD. This does not break accounting (carry always reaches some arcade board) and the spec explicitly says 'the next settled auction picks it up', so it is reported as an economic design note, not a defect. If carry is meant to go to the earliest unpaid day, settle could require that no earlier day with a leader remains unsettled, or carry could be split rather than taken whole.","line":192,"path":"src/DerbyAuction.sol","reproduction":"Carry 2e18 exists after payBonus on an empty-board day. Alice bids 100e18 on day d1 = D+3, Bob bids 2e18 on day d2 = D+4. At end(d2) (which is after end(d1)) call settle(d2) then settle(d1): auction(d2).bonus = 4e18 (Bob's day took the carry) and auction(d1).bonus = 100e18. Verified in a scratch test on this code.","severity":"info","snippet":"            carryIn[day] = carry;\n            a.bonus = a.amount - fee + carry;\n            carry = 0;","title":"Carry is taken by whichever winner-auction settles first, not by the chronologically next theme day"},{"citation":"resolved","description":"Documented, intentional owner powers worth stating as trust assumptions for the launch review. (1) `buildFee` is read at settle time, so the owner can raise it (up to MAX_BUILD_FEE = 1e18) after bids are in, even by front-running a pending public settle(); up to 1 IMD of the leader's bid then goes to `studio` instead of the bonus. (2) veto and reclaim return bonus - carryIn = bid - fee, so a vetoed (never built) design still pays the fee to the studio. (3) setStudio redirects future fees instantly. (4) There is no owner sweep, so tokens sent directly to the contract are permanently stuck, and carry with no future winner auctions stays in the contract indefinitely. All are bounded (1 IMD per auction) and spelled out in DEPLOY.md; launch uses fee 0. No action required beyond keeping the fee at 0 or announcing fee changes before an auction opens.","line":191,"path":"src/DerbyAuction.sol","reproduction":"Alice bids 2e18 on day D with buildFee 0. Before settle the owner calls setBuildFee(1e18). settle(D) sends 1e18 to studio and sets bonus 1e18 (test_settleOneIMDFeeAndSettingsApplyOnlyAtSettlement in the repo shows this). veto(D) then refunds Alice 1e18, not 2e18; the studio keeps 1e18 although no build happened.","severity":"info","snippet":"            fee = buildFee < a.amount ? buildFee : a.amount;","title":"Owner trust assumptions: build fee applies retroactively to bids already placed and is kept on veto"}],"hash":"accaee9749d43df58f57df4eaeb2f8de4121127e147864461da0b0cf88b6cc3f","nodeId":"8b79230e-d64c-4336-a2b3-e4099e36bd96","outcome":"completed","summary":"The audit is complete. The findings file is at `.imd-findings.json` in the repository root; both it and `test/scratch/` are excluded by the harness's git exclude list, so no tracked file was touched.\n\n**Outcome: no critical, high, or medium defects in src/DerbyAuction.sol.** The core token-accounting invariant held under every path I could construct: the repo's own fuzz plus my handler-based stateful invariant test (128,000 calls across adjacent days, extension overlaps, blocked and false-returning transfers, veto, payBonus, reclaim, refund withdrawals, and fee changes mid-auction) never produced a balance that differed from lead + unpaid bonuses + carry + credited refunds. Bid/refund ordering (pull first, then non-blocking refund), the 19:00 UTC extension cap, the carry bookkeeping through veto and reclaim, reentrancy coverage, and the two-step ownership all check out against the spec.\n\n**Reported findings, most severe first:**\n\n- **Low, settle() line 195.** With a non-zero build fee, a studio address the token refuses to pay makes settle revert for everyone. Since veto, payBonus, and reclaim all require a settled auction, the winner's bid is locked until the owner changes the studio. Launch fee is zero, so this is latent. This finding carries a proof test that fails on the current code with the stated error.\n- **Low, openDay() line 134.** The view ignores anti-snipe extensions. During an extension it points at the next day while the extended day still accepts bids, so page users following the view cannot see or answer a snipe. That defeats the extension's purpose for anyone not polling the stored end directly.\n- **Low, payBonus() line 223.** An empty arcade board pays no tip, so nobody is incentivised to call payBonus, and after seven days the winner can reclaim the whole bid. The spec's rule that an empty board's bonus becomes carry has no economic enforcer, and after the grace period payBonus and reclaim race with sequencer order deciding the recipient.\n- **Info, veto() line 203.** If nobody settles before midnight on the theme day, the veto is forfeited forever, since settle is permissionless and unscheduled.\n- **Info, settle() line 192.** Carry is captured by whichever winner-auction settles first, not the chronologically next one. A minimum bid on a quiet day can take a large carry, amplifying the documented arcade-bot limit. Spec-conformant, reported as a design note with numbers.\n- **Info, settle() line 191.** Owner trust assumptions: the fee is read at settle time so it applies retroactively to placed bids, is kept on veto, and there is no sweep for stuck tokens. All documented and bounded at 1 IMD.\n\n**Coverage and limits.** All twelve Pashov passes were applied as review passes by me alone, not as separate agents. Slither was not available and was not installed. Fuzzing ran at Foundry's defaults plus my 256-run invariant campaign. SwarmDerby and DerbyOdds were read only as context for the board, closure, and token-handling assumptions the auction relies on. A clean result on the accounting invariant is evidence, not proof, that no defect exists.","treeHash":null,"usage":{"cachedInputTokens":1973001,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":47838,"runtime":"claude","turns":33,"wallClockMs":819595}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3987a51ff810f3b9","findings":[{"citation":"resolved","description":"settle(day) has no deadline (it only requires block.timestamp >= _end(day)), and the theme-build job, the runbook and the WP4 page all rely on someone calling it: WP6 says the operator settles at 18:00 and that if the operator goes quiet 'the auction can be settled by anyone, and the bonus still pays from the board', while WP4 offers no settle button (only PAY BONUS, which requires auction(day).settled). reclaim(day) measures RECLAIM_AFTER from (day + 1) * 1 days, the end of the theme day, not from the moment the bonus came into existence at settlement. So if an auction stays unsettled for RECLAIM_AFTER after its theme day, the winner can call settle(day) followed by reclaim(day) in one transaction (a contract bidder, or two back-to-back calls on the sequencer): _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. The arcade top 3 of that theme day, whose board is already final, never had a window in which payBonus could be called, which contradicts the WP3 'Done when 7' rule that reclaim is only available after a grace period and the WP6 promise that the bonus still pays from the board. The owner cannot intervene either: veto requires block.timestamp < day * 1 days. Fix: record the settlement time (e.g. settledAt[day] = block.timestamp in settle when there is a winner) and make reclaim require block.timestamp > max((day + 1) * 1 days, settledAt[day]) + RECLAIM_AFTER, or alternatively refuse settle after the theme day has started. The first option preserves 'anyone can settle late and the board still gets paid' exactly as WP6 describes.","line":250,"path":"src/DerbyAuction.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DerbyAuction, ISwarmDerby} from \"src/DerbyAuction.sol\";\nimport {SwarmDerby, IERC20} from \"src/SwarmDerby.sol\";\nimport {DerbyOdds} from \"src/DerbyOdds.sol\";\n\n/// Finding: `reclaim` measures its grace period from the theme day, not from settlement.\n/// `settle` has no deadline, so an auction nobody settled for 8 days can be settled and\n/// reclaimed by the winner in one transaction, before anyone can call `payBonus`.\n/// The arcade top 3 of that theme day get nothing.\ncontract ScratchArbSys {\n    uint256 public arbBlockNumber;\n    mapping(uint256 => bytes32) public hashes;\n\n    function setBlock(uint256 n) external {\n        arbBlockNumber = n;\n    }\n\n    function setHash(uint256 n, bytes32 h) external {\n        hashes[n] = h;\n    }\n\n    function arbBlockHash(uint256 n) external view returns (bytes32) {\n        require(n < arbBlockNumber && n + 256 >= arbBlockNumber, \"range\");\n        return hashes[n] != bytes32(0) ? hashes[n] : keccak256(abi.encode(\"blk\", n));\n    }\n}\n\ncontract ScratchToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address to, uint256 amount) external returns (bool) {\n        allowance[msg.sender][to] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// The winner settles and reclaims in a single transaction.\ncontract ScratchWinner {\n    function settleAndReclaim(DerbyAuction sale, uint256 day) external {\n        sale.settle(day);\n        sale.reclaim(day);\n    }\n}\n\ncontract LateSettleReclaimTest is Test {\n    ScratchArbSys internal arb = ScratchArbSys(address(100));\n    ScratchToken internal imd;\n    SwarmDerby internal derby;\n    DerbyAuction internal sale;\n    ScratchWinner internal winner;\n    address internal studio = makeAddr(\"studio\");\n    uint256 internal constant DAY = 20_400;\n\n    function setUp() public {\n        vm.chainId(31337);\n        vm.warp((DAY - 2) * 1 days + 18 hours);\n        vm.etch(address(100), address(new ScratchArbSys()).code);\n        arb.setBlock(1_000);\n        imd = new ScratchToken();\n        derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether);\n        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 0);\n        winner = new ScratchWinner();\n    }\n\n    function _homer(address who) internal {\n        imd.mint(who, 0.15 ether);\n        vm.startPrank(who);\n        imd.approve(address(derby), 0.15 ether);\n        derby.buyTurns(0, 1);\n        vm.stopPrank();\n        uint256 id = derby.nextSwingId();\n        bytes32 salt = keccak256(abi.encode(\"scratch\", id, who));\n        bytes32 commitment = derby.commitFor(salt, who);\n        uint256 target = arb.arbBlockNumber() + derby.REVEAL_DELAY();\n        vm.prank(who);\n        derby.swing(0, 100, 100, commitment);\n        bytes32 hash;\n        for (uint256 i;; ++i) {\n            hash = keccak256(abi.encode(\"future block\", id, i));\n            (uint8 tier,) = DerbyOdds.roll(derby.swingSeed(salt, hash), id, 100, 100);\n            if (tier == DerbyOdds.HOMER) break;\n        }\n        arb.setHash(target, hash);\n        arb.setBlock(target + 1);\n        derby.finalize(id, salt);\n    }\n\n    function test_lateSettleCannotBeReclaimedBeforePayersGetTheirGracePeriod() public {\n        // The winner contract bids 10 IMD for theme day DAY.\n        imd.mint(address(winner), 10 ether);\n        vm.prank(address(winner));\n        imd.approve(address(sale), 10 ether);\n        vm.prank(address(winner));\n        sale.bid(DAY, 10 ether, DerbyAuction.Answers(\"Comet critter\", 3, 5, 5, \"Starlight Derby\", \"\"));\n\n        // Nobody calls settle. The theme day runs; three arcade players homer and top the board.\n        vm.warp(DAY * 1 days);\n        address p1 = address(0x10001);\n        address p2 = address(0x10002);\n        address p3 = address(0x10003);\n        _homer(p1);\n        _homer(p2);\n        _homer(p3);\n        (address[] memory board,) = derby.board(0, DAY);\n        assertEq(board.length, 3);\n\n        // Eight days later the day is closed and the grace period (measured from the theme\n        // day) has already elapsed although no bonus ever existed to pay.\n        vm.warp((DAY + 1) * 1 days + 7 days + 1);\n        arb.setBlock(arb.arbBlockNumber() + 1_000);\n        assertTrue(derby.dayClosed(0, DAY));\n\n        // Winner settles and reclaims atomically. Nobody can interleave payBonus.\n        try winner.settleAndReclaim(sale, DAY) {} catch {}\n\n        // Expected: the bid is still held for the board (either settle was refused this\n        // late, or reclaim is not yet allowed). Actual on current code: the winner has its\n        // 10 IMD back, the day is marked paid, and the top 3 can never be paid.\n        assertEq(imd.balanceOf(address(winner)), 0, \"winner reclaimed in the same tx as a late settle\");\n        assertEq(sale.refunds(address(winner)), 0, \"winner was credited a refund\");\n        (,,,, bool vetoed, bool paid,) = sale.auction(DAY);\n        assertFalse(paid && !vetoed, \"day marked paid without paying the board\");\n    }\n}","reproduction":"State: DAY = 20400. At start(DAY) a bidder contract bids 10 IMD and nobody calls settle(DAY). On DAY three arcade players homer, so derby.board(0, DAY) has 3 entries. Warp to (DAY + 1) * 86400 + 7 days + 1 with derby.dayClosed(0, DAY) true. The bidder contract calls sale.settle(DAY) then sale.reclaim(DAY) in the same transaction. Expected: reclaim reverts TooEarly (or settle is refused) so payBonus(DAY) can still pay the three players 60/25/15 of 9.95 IMD plus a 0.05 IMD tip. Actual: both calls succeed, the bidder's balance goes from 0 back to 10 IMD, auction(DAY) reads paid = true, bonus = 0, and payBonus(DAY) reverts WrongStatus forever; the three players receive nothing. test/scratch/LateSettleReclaim.t.sol fails with 'winner reclaimed in the same tx as a late settle: 10000000000000000000 != 0'.","severity":"medium","snippet":"        if (block.timestamp <= (day + 1) * 1 days + RECLAIM_AFTER) revert TooEarly();","title":"reclaim grace period runs from the theme day, so a late settle lets the winner settle and reclaim atomically before anyone can pay the board"},{"citation":"resolved","description":"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 yet exist. settle(day) does not check that it is being called 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 publicly known. A bidder who placed the minimum 2 IMD bid 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), then settles the stale day and calls payBonus on it. 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 window lets anyone who is on a stale board front-run the regular 18:00 settle of the real next day between the midnight payBonus and 18:00, when carry sits in the contract. Fix: only fold carry into auctions settled before their theme day (block.timestamp < day * 1 days), or refuse settle after that point; either keeps the bidder's own bid payable or reclaimable while carry stays with the live rotation.","line":192,"path":"src/DerbyAuction.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {DerbyAuction, ISwarmDerby} from \"src/DerbyAuction.sol\";\nimport {SwarmDerby, IERC20} from \"src/SwarmDerby.sol\";\nimport {DerbyOdds} from \"src/DerbyOdds.sol\";\n\n/// Finding: `settle` folds the whole current `carry` into any auction with a winner, even\n/// one settled long after its theme day, whose arcade board is already final. A bidder\n/// who sits alone on that past board waits for carry to accumulate, then settles the stale\n/// day and pays itself the tip plus 60% of the carry.\ncontract ScratchArbSys {\n    uint256 public arbBlockNumber;\n    mapping(uint256 => bytes32) public hashes;\n\n    function setBlock(uint256 n) external {\n        arbBlockNumber = n;\n    }\n\n    function setHash(uint256 n, bytes32 h) external {\n        hashes[n] = h;\n    }\n\n    function arbBlockHash(uint256 n) external view returns (bytes32) {\n        require(n < arbBlockNumber && n + 256 >= arbBlockNumber, \"range\");\n        return hashes[n] != bytes32(0) ? hashes[n] : keccak256(abi.encode(\"blk\", n));\n    }\n}\n\ncontract ScratchToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address to, uint256 amount) external returns (bool) {\n        allowance[msg.sender][to] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract LateSettleCarryTest is Test {\n    ScratchArbSys internal arb = ScratchArbSys(address(100));\n    ScratchToken internal imd;\n    SwarmDerby internal derby;\n    DerbyAuction internal sale;\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n    address internal studio = makeAddr(\"studio\");\n    uint256 internal constant DAY = 20_400;\n\n    function setUp() public {\n        vm.chainId(31337);\n        vm.warp(_start(DAY));\n        vm.etch(address(100), address(new ScratchArbSys()).code);\n        arb.setBlock(1_000);\n        imd = new ScratchToken();\n        derby = new SwarmDerby(address(this), IERC20(address(imd)), 0.15 ether, 0.5 ether);\n        sale = new DerbyAuction(address(this), IERC20(address(imd)), ISwarmDerby(address(derby)), studio, 0);\n    }\n\n    function _start(uint256 day) internal pure returns (uint256) {\n        return (day - 2) * 1 days + 18 hours;\n    }\n\n    function _end(uint256 day) internal pure returns (uint256) {\n        return (day - 1) * 1 days + 18 hours;\n    }\n\n    function _bid(uint256 day, address who, uint256 amount) internal {\n        imd.mint(who, amount);\n        vm.prank(who);\n        imd.approve(address(sale), amount);\n        vm.prank(who);\n        sale.bid(day, amount, DerbyAuction.Answers(\"Comet critter\", 3, 5, 5, \"Starlight Derby\", \"\"));\n    }\n\n    function _homer(address who) internal {\n        imd.mint(who, 0.15 ether);\n        vm.startPrank(who);\n        imd.approve(address(derby), 0.15 ether);\n        derby.buyTurns(0, 1);\n        vm.stopPrank();\n        uint256 id = derby.nextSwingId();\n        bytes32 salt = keccak256(abi.encode(\"scratch\", id, who));\n        bytes32 commitment = derby.commitFor(salt, who);\n        uint256 target = arb.arbBlockNumber() + derby.REVEAL_DELAY();\n        vm.prank(who);\n        derby.swing(0, 100, 100, commitment);\n        bytes32 hash;\n        for (uint256 i;; ++i) {\n            hash = keccak256(abi.encode(\"future block\", id, i));\n            (uint8 tier,) = DerbyOdds.roll(derby.swingSeed(salt, hash), id, 100, 100);\n            if (tier == DerbyOdds.HOMER) break;\n        }\n        arb.setHash(target, hash);\n        arb.setBlock(target + 1);\n        derby.finalize(id, salt);\n    }\n\n    function _close(uint256 day) internal {\n        vm.warp((day + 1) * 1 days);\n        arb.setBlock(arb.arbBlockNumber() + 1_000);\n        assertTrue(derby.dayClosed(0, day));\n    }\n\n    function test_staleAuctionSettledAfterItsThemeDayDoesNotCaptureCarry() public {\n        // Alice wins DAY with the minimum bid and is the only arcade player that day.\n        _bid(DAY, alice, 2 ether);\n        vm.warp(DAY * 1 days);\n        _homer(alice);\n        _close(DAY);\n        // Nobody settles DAY.\n\n        // A later day: Bob bids 10 IMD, it settles normally, and its arcade board is empty,\n        // so the whole 10 IMD becomes carry for the next theme day.\n        uint256 later = DAY + 3;\n        vm.warp(_start(later));\n        _bid(later, bob, 10 ether);\n        vm.warp(_end(later));\n        sale.settle(later);\n        _close(later);\n        sale.payBonus(later);\n        assertEq(sale.carry(), 10 ether);\n\n        // Alice now settles the stale DAY: its board is final and only she is on it.\n        uint256 before = imd.balanceOf(alice);\n        vm.prank(alice);\n        try sale.settle(DAY) {} catch {}\n\n        // Expected: a day settled after its theme day does not absorb carry meant for a\n        // future theme day. Actual: carryIn[DAY] == 10 IMD and Alice can pay herself\n        // 0.5% tip + 60% of 12 IMD from a 2 IMD bid.\n        assertEq(sale.carryIn(DAY), 0, \"stale settle absorbed the carry\");\n        (,,, bool settled,,,) = sale.auction(DAY);\n        if (settled) {\n            vm.prank(alice);\n            sale.payBonus(DAY);\n            assertLe(imd.balanceOf(alice) - before, 2 ether, \"alice extracted more than her own bid\");\n        }\n    }\n}","reproduction":"State: DAY = 20400. 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 back. Actual: carryIn(DAY) == 10e18, auction(DAY).bonus == 12e18, and payBonus(DAY) sends Alice 0.06 IMD tip + 7.164 IMD share = 7.224 IMD against her 2 IMD bid; 4.776 IMD returns to carry. test/scratch/LateSettleCarry.t.sol fails with 'stale settle absorbed the carry: 10000000000000000000 != 0'.","severity":"low","snippet":"            carryIn[day] = carry;\n            a.bonus = a.amount - fee + carry;\n            carry = 0;","title":"settle folds the whole carry into a stale auction settled after its theme day, letting a bidder on that day's final board capture carry meant for a future theme day"},{"citation":"resolved","description":"openDay() is the only entry the WP4 drawer uses to pick which auction to display (it reads openDay(), then auction(day) and minNextBid(day)). The formula switches to day + 1 at exactly (day - 1) * 1 days + CLOSE_OFFSET, the regular close, regardless of whether _auctions[day].end was pushed past that moment by an anti-snipe bid. The anti-snipe mechanism (ANTI_SNIPE, MAX_EXTENSION) exists so that a bid in the last five minutes can still be answered, but from 18:00:00 UTC every page shows the next theme day's empty auction and a countdown to the following day, so only callers who talk to the contract directly (bots) can see and answer bids placed in the extension window. A sniper who bids at 17:59:59 therefore faces no page-driven competition for the remaining extension, which is the situation the extension was added to prevent, and a page user who bids at 18:00:30 lands on day + 1 instead of the auction they were watching. Fix: have openDay() return day - 1 while _auctions[day - 1].end > block.timestamp (the previous theme day's auction is still live), or expose a helper such as liveDay() and have the drawer use it; the UI alternative is to also poll auction(openDay() - 1) and show it while its end is in the future.","line":134,"path":"src/DerbyAuction.sol","reproduction":"State: DAY = 20400, regular end = 20399 * 86400 + 64800 = 1762538400. Alice bids 2 IMD at end - 1, so auction(DAY).end becomes 1762538699 (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 4 minutes 29 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. Reproduced in test/scratch/OpenDayExtension.t.sol (logs: openDay at 18:00:30 = 20401, openDay leader = 0x0, then bid(DAY) succeeds).","severity":"low","snippet":"        return (block.timestamp - CLOSE_OFFSET) / 1 days + 2;","title":"openDay() ignores anti-snipe extensions, so during the extension window the page-driven bidders are pointed at the next day while the sniped auction is still live"}],"hash":"c924ca5e323d784bc05cf358acba106053d3f29210bd0809b5e72c1362a7538e","nodeId":"c5373fc1-459f-4dfd-8541-eff3bb8c08eb","outcome":"completed","summary":"The review is complete. The findings file `.imd-findings.json` holds three substantiated defects, two of them with Foundry proofs under `test/scratch/` that fail on the current code. No tracked file was changed, and the existing 91 tests still pass.\n\n**What I checked.** I traced every state-changing entry point of `src/DerbyAuction.sol` against the spec, the SwarmDerby contract it reads, and the guides supplied: token accounting through every path that moves IMD, the pull-then-refund ordering in `bid`, the extension cap, `settle`, `payBonus`, `veto`, `reclaim`, `withdrawRefund`, and the two-step ownership. The conservation invariant holds on every path I could construct. The refund ordering is correct and is what makes the exact-receipt check in `_pull` sound for self-raises. The extension cap is monotonic and stops at 19:00 UTC as specified. Ownership transfer is correct. I found no reentrancy, rounding, overflow, or access-control defect.\n\n**Findings, most severe first.**\n\n- **Medium, line 250.** The reclaim grace period runs from the theme day, not from settlement, and `settle` has no deadline. An auction nobody settled for eight days can be settled and reclaimed by the winner in one transaction, so the arcade top three never get a window in which `payBonus` is callable. This matters because the WP4 page offers no settle button and the runbook explicitly says a quiet operator is fine since \"anyone can settle and the bonus still pays\". Proof: `test/scratch/LateSettleReclaim.t.sol`.\n- **Low, line 192.** `settle` folds the whole current carry into any auction with a winner, including one settled after its theme day whose board is already final. A bidder alone on a stale board waits for carry to grow, settles that day, and pays themselves the tip plus 60 percent. With 10 IMD of carry they net about 5.2 IMD on a 2 IMD bid. Proof: `test/scratch/LateSettleCarry.t.sol`.\n- **Low, line 134.** `openDay()` ignores extensions. From 18:00:00 the page shows the next day's empty auction while a sniped auction is still live until 19:00 at the latest, so only direct contract callers can answer a late bid. Verified with `test/scratch/OpenDayExtension.t.sol`.\n\n**Suggested fixes** are stated in each finding. The simplest set is to record a settlement timestamp and measure reclaim's grace from the later of theme-day end and settlement, to only fold carry into auctions settled before their theme day, and to have `openDay()` return the previous day while that auction's stored end is still in the future.\n\n**Trust assumptions not reported as defects.** The owner can raise the build fee up to 1 IMD before a settlement and redirect the studio address, and a veto returns the bid less that fee. Both are documented in DEPLOY.md and have no unprivileged amplifier.\n\n**Limits.** Slither and long fuzz runs were not run since they are not provided. The token mock in the proofs is a plain ERC20, which matches the documented Robinhood IMD behaviour of exact transfers.","treeHash":null,"usage":{"cachedInputTokens":2640470,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":58234,"runtime":"claude","turns":38,"wallClockMs":972502}}],"verification":[]}