Hamster Hour: a pari-mutuel betting game on a real hamster. Squeekchain (https://sepolia.squeekchain.com) runs a physical hamster wheel and settles a round every 10 minutes with the number of wheel passes. Hamster Hour lets people bet IMD on how far the hamster runs over the next 6 rounds (one hour). Results come only from the IMD oracle.

CONTRACTS (Ethereum mainnet, no owner, no upgradeability, no admin withdraw). TEAM = the wallet that requested this launch, immutable.

  1. HamsterHour
  • Windows are defined by Squeekchain round numbers: window k covers rounds 6k to 6k+5. Round duration 600 s from genesisTimestamp 1791847800; the contract derives the current round from block.timestamp. Betting on window k is open until round 6k starts.

  • Bets in IMD (0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7) via transferFrom, minimum 0.5 IMD.

  • Three pari-mutuel markets per window, each its own pool: a) OVER/UNDER: total passes above or below the line. Line = average total passes over the last 7 settled windows, fixed at window creation; bootstrap line 1000 until 7 windows exist.

    b) EXACT: bettor submits a uint256 guess of total passes. Closest wins 60% of the pool, second 30%, third 10%; ties split equally. The EXACT winner also receives the full jackpot balance (see below).

    c) SLEEP: yes/no, total passes == 0.

  • Settlement: after round 6k+5 ends, anyone may call settle(k) with a signed IMD oracle attestation for the question template "What is the total number of wheel passes recorded by Squeekchain for rounds {6k} through {6k+5} inclusive, as published at {sourceUrl}? Answer as a single uint256." The contract verifies it through OracleVerifier and stores the result. Lazy: windows with no bets are never settled.

  • Split of every window's total bets: 91% to winners pro rata, 5% to stakers, 3% to an oracle reserve that reimburses 0.5 IMD to whoever supplied the settlement attestation, 1% to TEAM.

  • Staking: once setStakingToken is set, anyone can stake and unstake that token here. The 5% share is distributed pro rata by stake-weight at each settlement (accumulator pattern, pull-based claimStaking). Before the token is set, the 5% accrues in a reserve that is released to stakers evenly over the 30 windows after the token is set.

  • Jackpot: the contract accepts any ERC-20 sent to it; the balance of the token set once via setJackpotToken is paid in full to the EXACT winner of each settled window. Empty jackpot pays nothing.

  • Edge cases: a market with bets on only one side is void and refunded; a window with no valid attestation within 48 hours after it ends is void and refunded; all claims pull-based via claim(k).

  • Configuration: setStakingToken and setJackpotToken are one-shot; sourceUrl and genesisTimestamp are updatable. All four callable only by TEAM, each with a 48-hour timelock and a public event. Nothing else is adjustable. No other TEAM powers.

  • Events for every bet, settlement, claim, void, stake, unstake, config change.

  1. OracleVerifier
  • Verifies IMD oracle attestations exactly as the IMD attester scheme requires: attester signature, question hash, validity window, uint256 answer. Rejects expired, mismatched or replayed attestations.

TESTS: pari-mutuel math for all three markets, EXACT ties, one-sided void, no-attestation void, split sums to 100%, oracle reimbursement, staking accumulator, pre-token reserve release over 30 windows, jackpot payout, attestation replay rejected, line over 7 windows, round-to-window math around genesis, timelocks.

WEBSITE: dark console style like sepolia.squeekchain.com, monospace. Top: the live Squeekchain stream embedded full width (YouTube id from https://media.squeekchain.com/live/current.json), countdown to the current window close, the line, live pool sizes and implied payouts per side, jackpot balance. Tabs: Bet (three markets, connect wallet, approve IMD, place bet, claim), Stake ("staking opens with the token" until set, then stake, unstake, claim), History (settled windows with links to the oracle attestation on explorer.imdnotdead.fun). Mobile first. No fake data.

README first section: no owner, no house, pari-mutuel only, results only from the IMD oracle, the contract never owes more than it holds, TEAM gets 1% of bets and nothing else.

Also approved

The requester chose this release: source code published to GitHub, website hosted on IPFS, contracts deployed on chain.

Published · Site

site
hamster-hour-bet-on-the-wheel.sites.imd.fun
ipfs
bafybeidp4zcka5l7nf3gnj4fcmooxhp3vc3wucml6st4bckcjwa3ain3f4
website
identity-md-launches/launch-1959-workflow-frontend-stage-context

Published · Contracts

app
HamsterHour 0xc5217f129da200a8e86229de4eabf3686eef64b1source verified
app
OracleVerifier 0xdcd7a7dee31d589f9df8113780050c18e7ea1f7dsource verified
github
identity-md-launches/launch-1951-workflow-contract-stage-context

Work

  1. Build contract projectAgent #180169 files changedsent back

    Implemented HamsterHour and OracleVerifier, ABI exports, vendored dependencies, and deployment/operations documentation.

    All checks pass with Solidity 0.8.26:

    • forge build
    • forge test: 56 passed
    • forge fmt --check

    Assumptions and operational responsibilities are documented in README.md. Ready for independent review.

    ran oncodex · gpt-6-astra · 9 turns · 35m 51s · 180.8K in · 66.4K out · 3.8M cached
    submissionc0d716cef596e3d9162ecd2a8fa5ddc15567eb9588db0f79339bea0c5bf0363d
    device4d6b0e34f0d3e31013bed1fffa521b47bc021d4b8a7a0cd635b18736ae58f9a3
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundle28718a9b21b065de987d341303d7f455c46e885ff5c8d1ca2dd7425b15d6b3cc · 164 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 69 files
    .gitignoreLICENSEREADME.mddocs/ABI.mddocs/DEPENDENCIES.mddocs/ORACLE.mddocs/VERIFICATION.mddocs/abi/HamsterHour.jsondocs/abi/OracleVerifier.jsondocs/dependencies.sha256docs/references/oracle-consumer-REFERENCE.mddocs/references/oracle-consumer.mddocs/references/question-check.jsondocs/references/squeekchain-attestation.jsonfoundry.tomllib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/src/Base.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.sollib/forge-std/src/StdConstants.sollib/forge-std/src/StdError.sollib/forge-std/src/StdInvariant.sollib/forge-std/src/StdJson.sollib/forge-std/src/StdMath.sollib/forge-std/src/StdStorage.sollib/forge-std/src/StdStyle.sollib/forge-std/src/StdToml.sollib/forge-std/src/StdUtils.sollib/forge-std/src/Test.sollib/forge-std/src/Vm.sollib/forge-std/src/console.sollib/forge-std/src/console2.sollib/forge-std/src/interfaces/IMulticall3.sollib/forge-std/src/safeconsole.sollib/openzeppelin-contracts/LICENSElib/openzeppelin-contracts/contracts/interfaces/IERC1363.sollib/openzeppelin-contracts/contracts/interfaces/IERC165.sollib/openzeppelin-contracts/contracts/interfaces/IERC20.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sollib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sollib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sollib/openzeppelin-contracts/contracts/utils/Context.sollib/openzeppelin-contracts/contracts/utils/Panic.sollib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sollib/openzeppelin-contracts/contracts/utils/Strings.sollib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sollib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sollib/openzeppelin-contracts/contracts/utils/math/Math.sollib/openzeppelin-contracts/contracts/utils/math/SafeCast.sollib/openzeppelin-contracts/contracts/utils/math/SignedMath.solremappings.txtscripts/export-abis.shsrc/HamsterHour.solsrc/OracleVerifier.solsrc/libraries/OracleAttestation.solsrc/libraries/OracleQuestion.soltest/AccountingInvariant.t.soltest/Configuration.t.soltest/Deployment.t.soltest/ExactFuzz.t.soltest/Markets.t.soltest/Oracle.t.soltest/StakingJackpot.t.soltest/helpers/GameTest.soltest/helpers/MockToken.sol
  2. ManifestAgent #16581 file changedsent back

    I wrote launch.json for the two accepted contracts, and it is the only file changed.

    Manifest contents

    • OracleVerifier first, with one static address argument: the IMD oracle attester key documented in the accepted README, docs/ORACLE.md and the deployment test. I confirmed it live against the public IMD oracle API, which reports the same attester. It is an external signer the brief's oracle scheme requires, not an owner.
    • HamsterHour second, with two arguments: $owner for the immutable TEAM, then $contract:OracleVerifier for the verifier address. This matches the constructor signature constructor(address team_, address verifier_) in the source.
    • Notes explain the roles, the TEAM powers, the absence of any msg.sender or tx.origin privilege, and that no token, pool, ETH funding or initialization call is involved. They are under the 4000 character limit.

    Checks run

    • A schema check on the manifest passed: exactly the keys kind, contracts and notes, unique identifiers under 32 characters, string arguments, backward-only contract references, and no zero address.
    • forge build succeeded and forge test with the verifier's fuzz seed passed all 56 tests across 7 suites.
    • git status shows launch.json as the only new or changed file.
    ran onclaude · claude-fable-5-1 · 7 turns · 1m 2s · 194 in · 3.3K out · 270.7K cached
    submission9a2484736e4d9307265a4a7e2ea9695774408d5a31e5b481d0607608e1f89a38
    device0ef0cddb996179300bb0f1cf7d490cada2736c6816f3761d4c0d33058b5df6aa
    started from0c95b0371e186ca7b44dafcad442d7108ee46076
    bundle485993749c8c9b90b4a093899a90b9decc8fcee52a8c2f49b9ef76445d43cf63 · 165 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    changed · 1 file
    launch.json
    may write
    launch.json
  3. Audit mathAgent #528found 1 info

    The review is complete and the findings file is written and validated.

    Outcome: the math in HamsterHour and OracleVerifier holds up under the Math Precision, Boundary and Numerical Gap checklists. I found no funds-affecting defect. The findings file carries one informational note and a full coverage record.

    What I checked, with concrete tracing

    • Fee split: 5/3/1 percent floors per market, remainder to winners. Binary payouts use 512-bit mulDiv with the last claimant taking the rounding remainder, so each market pays out exactly its reward.
    • EXACT ranking: the streaming top-3 distinct-distance insertion, tie counters, 60/30/10 tiers with missing-tier rollup, and per-tier remainders. A scratch fuzz with a six-value guess space and 12 tickets per window matched an independent reference and conserved both IMD and jackpot to the unit.
    • Jackpot: snapshot excludes IMD liability and staked principal when currencies overlap; tie shares floor with remainder to the last winner; zero-share acknowledgement works after an IMD-only claim.
    • Staking: accumulator at 1e27 cannot overflow with uint128 stake caps; the 30-tranche release empties an odd reserve exactly; no path adds to the pre-token reserve after activation, so no underflow.
    • Boundaries: window time arithmetic cannot under or overflow given the genesis validation; settle and void deadlines do not overlap; a genesis change leaves an Open window's snapshot intact and cannot reopen a started window.
    • Static-analysis leads: the aderyn shift-order and reentrancy lines and the slither uninitialized-local lines are false positives on inspection.

    Reported

    • Info: settle accepts an attestation whose issuedAt is up to 300 seconds before the window's last round ends. It is documented and tested as intended, so it is recorded as a trust assumption on the oracle rather than a revision request.

    Coverage: all 20 listed entry points have rows, 19 hold and settle carries the info note. launch.json is marked unreached because none exists yet. The ABI exports match the compiled ABIs. Scratch tests were removed and the tree is unchanged apart from the findings file.

    ran onclaude · claude-fable-5-1 · 26 turns · 9m 13s · 546 in · 37.7K out · 1.6M cached
    submission7787f4d6529812a25f6f365aff557f5d2725eb2dc0e4f55957142a4afda4ab19
    device45aa937328087de32ace0ccca4ca5ffecee6a239f16a12cf4e3fdd3ee3548623
    started from0c95b0371e186ca7b44dafcad442d7108ee46076
    bundlenone
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    • infosettle accepts an attestation issued up to 300 s before the window's last round endssrc/HamsterHour.sol:316

      Boundary note, not a funds defect. The brief says settlement happens after round 6k+5 ends. The contract enforces block.timestamp >= endsAt for the settle call itself, but the attestation's own issuedAt may be as early as endsAt - 300.

      The verifier's tolerance exists for an oracle clock that is ahead (issuedAt > now + 300 rejected); mirroring it in the other direction lets a signed answer that predates the end of the sixth round settle the window. Nothing in the contract can tell whether such an answer covered all six rounds; it relies entirely on the IMD oracle refusing to answer early.

      README documents the 300 s tolerance as intended and test_earlyAndPredatedAttestationsRejected asserts endsAt-300 is accepted, so this is reported as a trust assumption for the judge, not as a revision request. If the author prefers strictness, the check can be a.issuedAt < w.endsAt with no tolerance; the verifier's forward tolerance is unaffected.

      State: window 0 (ends at GENESIS+3600) has one EXACT bet.

      Warp to GENESIS+3600.

      Build an attestation for window 0 with issuedAt = GENESIS+3600-300, expiresAt = issuedAt+1 hours, signed by the oracle signer.

      Call settle(0, a, sig).

      Actual: settles (AttestationPredatesEnd only fires at issuedAt = endsAt-301).

      Expected per the literal brief: an answer dated before the window's last round ended should not settle it.

  4. Audit flowAgent #1561found 1 medium, 1 info

    The findings file is written and validated, the tree is clean (scratch tests and the findings file are untracked and ignored), and the existing suite still passes. Summary of the review:

    Result: one substantiated medium defect, one documented trust assumption, everything else in the control flow holds.

    Finding 1 (medium), src/HamsterHour.sol:316. The settle function accepts an attestation whose signing time is up to 300 seconds before the window's last round ends. The attester signs after the panel has read the source, so such an attestation was produced while round 6k+5 was still running. A bettor can buy the question in the window's last five minutes and, if the panel answers from the five published rounds, settle on a partial total so the wrong side wins. The proof test under test/scratch fails on the current code and passes when the lower bound becomes a plain comparison against the window end. I verified both directions locally and restored the file.

    Finding 2 (info), src/OracleVerifier.sol:10. The oracle signer is immutable with no rotation, as the brief requires. If the published attester key changes, every settlement reverts and every window refunds after 48 hours. No funds are at risk. It is recorded as a trust assumption, not a defect.

    What held under tracing and probes. Checks-effects-interactions ordering in every state-changing path, the fee split summing to the pool, exact-market ranking with ties and shifted-out tiers, jackpot tie sharing and liability accounting, result-equals-line refunds without fees, deferred settler reimbursement, genesis changes not reopening started windows, replay and domain binding in the verifier, and the real published attestation's question hash reproducing byte-for-byte. The exported ABI files match the compiled output. On mainnet the IMD address has code with symbol IMD and 18 decimals, and the pinned signer matches the oracle API's attester and has no code.

    Static analysis leads. The reentrancy, uninitialized-local, strict-equality and shift-order lines were all checked and are not defects. The shift places the selector correctly, confirmed by the jackpot balance read in a probe.

    Coverage. All 20 listed entry points have a row. Nineteen are marked holds, settle is marked finding 1, and three extra rows record the solvency invariant, the ABI export check and the README deployment table.

    Not reached. Long fuzz campaigns beyond the pinned 512 runs, and live oracle behaviour for a request issued before a window ends, which only the panel can demonstrate.

    ran onclaude · claude-fable-5-1 · 23 turns · 10m 12s · 706 in · 42.8K out · 2M cached
    submission51e89d12b30f3ca6fc069ccc0450468312faaa812f97ad463186ae58c5332a15
    deviceb1d937213bbfd9cfe8d05179832833438d3a92c2b3527481eb148c551ce32469
    started from0c95b0371e186ca7b44dafcad442d7108ee46076
    bundlenone
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    • mediumsettle accepts an oracle attestation signed up to 300 s before the window's last round endssrc/HamsterHour.sol:316

      The brief settles window k only with an attestation answering rounds 6k..6k+5 after round 6k+5 ends. settle() instead accepts any attestation whose issuedAt is at least endsAt - ISSUED_AT_TOLERANCE (300 s). issuedAt is the attester's signing time, which postdates the panel's read of the Squeekchain source, so an attestation with issuedAt < endsAt was necessarily produced while round 6k+5 was still running and its result unpublished.

      Anyone may buy an attestation (the keeper also chooses fromBlock/toBlock, which the contract does not constrain), so a bettor on UNDER or SLEEP can request the question during the last five minutes of the window and, if the panel answers from the five published rounds instead of refusing, submit it at endsAt; the window settles on the partial total and the over/under, sleep and exact pools pay the wrong side.

      The tolerance protects against the oracle clock running ahead of the chain (issuedAt > now), which OracleVerifier already handles; applying it to the lower bound as well is not needed for drift, since a keeper whose attestation is a few seconds early can simply wait. README documents the 300 s tolerance but not that it lets attestations predate the data they attest.

      Fix: require a.issuedAt >= w.endsAt (no tolerance on the lower bound), e.g. if (a.issuedAt < w.endsAt) revert AttestationPredatesEnd();. The proof test fails on the current code and passes with that change (verified locally).

      State: alice betOverUnder(5,true,1e18), bob betOverUnder(5,false,1e18); (startsAt,endsAt)=windowTimes(5).

      Build a valid v2 attestation for questionHash(5,100,200), answer 800, panelSize 5, quorum 4, agreed 5, issuedAt = endsAt - 300, expiresAt = endsAt + 1 days, signed by the pinned signer in HamsterHour's domain. warp(endsAt); settle(5, a, sig).

      Expected: revert AttestationPredatesEnd (attestation produced before round 6k+5 ended).

      Actual: window 5 is Settled with result 800, under wins; see test/scratch/IssuedBeforeEnd.t.sol (fails now with 'next call did not revert as expected').

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      import {OracleAttestation} from "src/libraries/OracleAttestation.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 a) external {
              _mint(to, a);
          }
      }
      
      /// Fails on the current code: settle(k) accepts an attestation whose issuedAt is 300 s before
      /// window k's endsAt, i.e. signed before round 6k+5 can have been published. Passes once settle
      /// requires a.issuedAt >= w.endsAt.
      contract IssuedBeforeEndTest is Test {
          uint256 constant KEY = 192837465;
          uint64 constant GENESIS = 1791847800;
          address constant TEAM = address(0xBEEF);
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address keeper = makeAddr("keeper");
          ProofToken imd;
          OracleVerifier verifier;
          HamsterHour game;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 10 days);
              verifier = new OracleVerifier(vm.addr(KEY));
              game = new HamsterHour(TEAM, address(verifier));
              ProofToken t = new ProofToken();
              vm.etch(game.IMD(), address(t).code);
              imd = ProofToken(game.IMD());
              address[2] memory who = [alice, bob];
              for (uint256 i; i < 2; ++i) {
                  imd.mint(who[i], 1e30);
                  vm.prank(who[i]);
                  imd.approve(address(game), type(uint256).max);
              }
          }
      
          function test_attestationIssuedBeforeWindowEndIsRejected() public {
              vm.prank(alice);
              game.betOverUnder(5, true, 1 ether);
              vm.prank(bob);
              game.betOverUnder(5, false, 1 ether);
              (, uint64 end) = game.windowTimes(5);
      
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256("request");
              a.chainId = 1;
              a.questionHash = game.questionHash(5, 100, 200);
              a.answerType = 3;
              a.answer = abi.encode(uint256(800));
              a.figure = 800;
              a.fromBlock = 100;
              a.toBlock = 200;
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 5;
              a.issuedAt = end - 300; // signed while round 6k+5 is still running
              a.expiresAt = end + 1 days;
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, verifier.attestationDigest(a, address(game)));
              bytes memory sig = abi.encodePacked(r, s, v);
      
              vm.warp(end);
              vm.prank(keeper);
              vm.expectRevert(HamsterHour.AttestationPredatesEnd.selector);
              game.settle(5, a, sig);
          }
      }
    • infoOracle signer is immutable with no rotation: an attester key change permanently disables settlement (trust assumption)src/OracleVerifier.sol:10

      The brief forbids any TEAM power beyond the four timelocked settings, so the verifier pins one EOA (0x5598aa9146215bc13eb26f2c692ad1461fd32982, confirmed against api.imdnotdead.fun/oracle/requests 'attester' on 2026-10-16; it has no code on mainnet).

      This is the intended design and not a permission defect, but it is a material operational dependency: if IMD rotates or loses that key, every settle() reverts BadSignature, every open window voids after 48 h and refunds, and the game can never settle again; a new deployment is the only remedy. No funds are lost.

      Reported as a trust assumption for the launch owner and the README's 'results only from the IMD oracle' claim; no code change is proposed because the brief mandates no additional admin power.

      State: any Open window k past endsAt; the attester now signs with key K2 != oracleSigner. settle(k, a, sig_K2) -> OracleVerifier.verify -> tryRecover returns K2 address != oracleSigner -> revert BadSignature.

      After endsAt + 48h, claim(k)/voidWindow(k) refund every bettor; no window created afterwards can ever reach Settled.

      Expected per brief: results only from the IMD oracle; actual: no results at all once the published attester changes.

  5. Audit permissionsAgent #475found 1 medium, 4 low, 1 info

    Review complete. The findings are in .imd-findings.json with 6 findings and 23 coverage rows, all snippets verified against the tree. The working tree is otherwise clean, and the scratch test lives only under test/scratch/.

    Outcome. The permission model is sound: every privileged write goes through onlyTeam plus the 48-hour timelock, no unguarded function writes a slot a guarded one owns, the constructor hands no role to the factory, and config changes never alter a created window. The existing 56 tests pass. The defects I could substantiate all sit at the trust-gap seams, where a correct guard meets an economic formula that the permitted actor can steer.

    Findings reported, most severe first:

    1. Medium, EXACT per-ticket split. Tier prizes and the jackpot are split per ticket, not per stake, while OVER/UNDER and SLEEP are pro rata. A scratch test shows 255 copy tickets at 0.5 IMD taking over 1,022 IMD from a 1,000 IMD ticket with the same guess.
    2. Low, staking-token choice. TEAM can set a token only it holds and collect the whole pre-staking reserve and every later 5% share, against the README's "1% and nothing else". Test confirms 400 IMD captured on 8,000 IMD bet.
    3. Low, unbounded genesis. A far-future genesis stops all new windows, or locks later bets until that schedule's timeout.
    4. Low, timelock without expiry. An armed source URL change can be executed months later in the same block as a victim's first bet, so the window snapshots TEAM's source.
    5. Low, early attestation. An attestation issued up to 300 seconds before the window ends is accepted.
    6. Info, reimbursement front-run. Already documented in the README, recorded for the judge.

    Coverage. All 20 listed entry points have a row. I covered the Access Control guide in full and the Trust Gap and Asymmetry guides across setters, bet/claim pairs, stake/unstake, and settle/void. I did not independently reproduce the Slither and Aderyn lines outside my area beyond confirming the assembly shift order and the uninitialized locals are benign.

    ran onclaude · claude-fable-5-1 · 25 turns · 10m 50s · 386 in · 43.7K out · 1.2M cached
    submission31e3ade79f9c4ccb054c9ae4d56d3b0fa99bfd595656371c620e906b18448eb7
    device3bed38612db34f328e6e2bf3e06a52b95ccef2145dee8aa1006f50c85517964a
    started from0c95b0371e186ca7b44dafcad442d7108ee46076
    bundlenone
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    • mediumEXACT tier prizes and jackpot split per ticket, so minimum-stake Sybil tickets drain a large tied stakesrc/HamsterHour.sol:476

      Asymmetry (economics x asymmetry seam). OVER/UNDER and SLEEP pay winners pro rata by stake (_binaryClaim, Math.mulDiv(rewards, weight, side)), but EXACT pays each tier equally per ticket (line 476) and the jackpot equally per closest ticket (line 452), ignoring exactAmount.

      Every ticket's guess is public (Bet event selection, exactGuess view) and betting stays open until the window starts, so anyone can copy a visible large ticket's guess from up to 255 fresh addresses at MIN_BET and take 255/256 of the tier prize and jackpot that the large stake funded. The large bettor's own stake is paid out to the copiers; the one-ticket-per-address rule gives no protection.

      The brief says winners are paid 'pro rata'; the README documents 'a larger EXACT stake does not buy a larger tied payout' but that leaves every EXACT stake above MIN_BET strictly dominated and parasitised by copy tickets, and also lets 256 x 0.5 IMD tickets fill the EXACT market (ExactFull) to exclude other entrants.

      Fix: weight tier and jackpot shares by exactAmount (pro rata within a tier, like _binaryClaim), or, if the per-ticket split is the agreed design, state in the brief/README that EXACT stakes above the minimum carry no additional payout and are exposed to copy tickets.

      Window 0. alice: betExact(0, 500, 1000e18).

      255 fresh addresses each: betExact(0, 500, 0.5e18) (total 127.5 IMD). settle(0) with result 500.

      All 256 tickets tie at distance 0: rankCount=1, ties[0]=256, rankRewards[0]=91% of 1127.5 = 1026.025 IMD. alice.claim(0) pays 1026.025e18/256 = 4.00791 IMD (she staked 1000).

      The 255 copiers together receive 1022.017 IMD for 127.5 staked.

      Expected under pro-rata: alice ~910 IMD.

      Verified by test/scratch/TrustGap.t.sol::test_sybilTicketsDrainLargeExactStake (logs: alice received 4007910156250000000, bobs 1022017089843750000000).

    • lowTEAM's one-shot staking token choice can route the entire 5% staker share and pre-staking reserve to TEAMsrc/HamsterHour.sol:631

      Trust gap (access x economics). The guard is correct (onlyTeam, 48h timelock) and the accumulator is correct, but the actor the guard permits chooses which token counts as 'stake' with no constraint beyond nonzero and has-code.

      If TEAM selects a token only TEAM holds (a fresh ERC20 minted to itself), only TEAM can ever stake, so the 5% of every bet accrued before configuration (preStakingReserve, released over 30 settlements from line 631) and the 5% of every later window go entirely to TEAM. The README's first section promises 'TEAM gets 1% of bets and nothing else as a privileged beneficiary', and the brief says 'No other TEAM powers', but this lever gives TEAM up to 6% of all non-void bets.

      Reported as a privileged-power trust assumption, not a permission bypass: the fix within the approved design is to document this consequence of the setter (and that a scheduled token should be inspected for open distribution) in the README/brief; a design change would be requiring the token to be chosen in the brief.

      Before any staking token exists, 10 windows settle with 200 IMD OU pools each -> preStakingReserve = 100 IMD.

      TEAM deploys Tok, mints 1e18 to itself only, scheduleStakingToken(Tok), warp 48h, setStakingToken(Tok), stake(1e18).

      Settle 30 more windows (each 200 IMD) that end after activation.

      TEAM.claimStaking() pays 400 IMD = 100 reserve + 30 x 10 share, i.e. 5% of all 8000 IMD bet, in addition to the 1% teamCredit.

      Verified by test/scratch/TrustGap.t.sol::test_teamCapturesStakerShareViaMonopolisedToken.

    • lowGenesis change is unbounded: TEAM can stop all new windows or schedule bets that lock for a centurysrc/HamsterHour.sol:679

      Trust gap (access x asymmetry). The brief grants TEAM a timelocked genesis correction so the contract clock tracks Squeekchain; the validation only rejects 0 and overflow. Two consequences exceed 'No other TEAM powers': (1) genesis = type(uint64).max - 3600 - 48h makes windowTimes revert for every k >= 1 (InvalidWindow) while k = 0 is below closedWindowFloor, so no window can be created until a further 48h-delayed change: an effective pause.

      (2) genesis 100 years ahead keeps betting 'open' on every uncreated window, and any bet placed after the change is held until that schedule's endsAt + 48h (a century), because the timeout refund is derived from the snapshotted schedule. Existing windows are unaffected (snapshot holds), so no funds already in the contract are lost. A genesis moved so that a window's Squeekchain rounds are already published also leaves betting open on a known result.

      Minimal fix that preserves the design: bound each change relative to the current value or to block.timestamp (e.g. within +-7 days of now), and document that bets after a pending genesis change must re-read windowTimes.

      (1) At GENESIS+1h TEAM scheduleGenesisTimestamp(type(uint64).max-3600-172800), warp 48h, setGenesisTimestamp(same): closedWindowFloor=50; betOverUnder(49,..) reverts BettingClosed, betOverUnder(50,..) and betOverUnder(1000000,..) revert InvalidWindow.

      (2) setGenesisTimestamp(GENESIS + 100*365 days) likewise; alice betOverUnder(60, true, 1e18) succeeds; windowTimes(60).startsAt = g + 60h; claim(60) reverts NotFinal until endsAt + 48h in year ~2126, then refunds.

      Verified by test/scratch/TrustGap.t.sol::test_genesisFarFutureFreezesAllNewBetting and test_genesisFutureLocksLaterBets.

    • lowPre-armed timelock has no expiry: TEAM can execute a source URL change at any later moment, including the block of a victim's first betsrc/HamsterHour.sol:700

      Trust gap (access x asymmetry, setter write moment). _execute only requires block.timestamp >= readyAt; a scheduled value never expires.

      TEAM can schedule a sourceUrl it controls, wait 48h, and hold it indefinitely, then execute it in the same block (front-run) as a user's first bet on a new window: _open snapshots w.source = sourceUrl (line 294) at that moment, so the window's oracle question now names TEAM's site and the panel will read whatever TEAM publishes there, deciding OVER/UNDER, SLEEP and EXACT (plus the jackpot) for every bettor in that window.

      The 48h notice the brief relies on is then months stale and the ConfigChanged event arrives in the same block as the bet. The README says participants must monitor proposals but does not say that a stale armed proposal can land at any time or that the source decides outcomes. Fix preserving the design: add an execution window (e.g. readyAt .. readyAt + 7 days, else the proposal lapses), and document that sourceUrl selects the result source.

      TEAM scheduleSourceUrl('https://team-controlled.example'); warp 60 days (no execution); TEAM setSourceUrl('https://team-controlled.example') succeeds; alice betExact(5000, 1000, 10e18) in the same block; window(5000).source == 'https://team-controlled.example'. Verified by test/scratch/TrustGap.t.sol::test_prearmedSourceUrlFrontRunsWindowCreation.

    • lowAttestation may be issued up to 300 s before the window ends, while round 6k+5 is still runningsrc/HamsterHour.sol:316

      Access x economics seam on the permissionless settle. The contract accepts an attestation whose issuedAt is as early as endsAt - 300, i.e. five minutes before the last round of the window has ended on the contract's own clock, and settle itself may run at endsAt exactly.

      A bettor-keeper who requests the oracle inside that period can obtain a panel answer computed from five published rounds plus whatever the source shows for the sixth; if the panel answers (rather than refusing), the signed total is final and cannot be re-settled. The tolerance is described as clock drift, but it is applied in the direction that lets an attestation predate the data.

      The same drift could be absorbed by requiring issuedAt >= endsAt and leaving the +300 s future tolerance in OracleVerifier.

      Window k with endsAt = E.

      Keeper obtains a valid attestation with issuedAt = E - 300 (oracle request made during round 6k+5), expiresAt = E + 86400.

      At block.timestamp = E, settle(k, a, sig) succeeds: line 316 passes because E-300+300 == E, OracleVerifier accepts issuedAt <= now + 300.

      Expected: an attestation issued before the window's end is refused; actual: it settles the window.

    • infoSettlement reimbursement goes to the transaction sender, so a copied attestation lets a front-runner take the 0.5 IMDsrc/HamsterHour.sol:319

      Documented in the README ('Reimbursement goes to the transaction caller ... can be copied'). Recorded as a trust/economic asymmetry for the judge: the keeper pays the oracle, anyone who sees the calldata can submit it first and become w.settler. Because the attestation's EIP-712 domain binds only the consumer contract, the contract cannot bind the submitter without a protocol change; private submission is the only mitigation.

      No change requested beyond keeping the README statement.

      Keeper K buys attestation a for window k and broadcasts settle(k,a,sig).

      Bot B copies (a,sig) with higher priority fee; B's settle succeeds, w.settler = B, K's reverts NotSettleable.

      B.claim(k) later receives ORACLE_REIMBURSEMENT (0.5 IMD); K receives nothing.

  6. Audit economicsAgent #715found 3 medium, 2 low, 1 info

    Review complete. The findings are in .imd-findings.json at the repository root, with 6 findings and a 22-row coverage record. No source files were changed. The scratch tests and findings file are ignored by git, so the tree reads as clean.

    Findings, by severity

    • Medium, flash-stake sandwich. The 5% staker share is credited to whoever is staked in the block settle() runs. There is no lock, delay or snapshot, so a same-block stake, settle, claim and unstake takes 99.99 of a 100 IMD share from a staker who held for ten days. The README calls this deliberate, but it hollows out the brief's staker guarantee and also captures the 30 pre-token reserve tranches. Proof test attached and failing on current code.
    • Medium, jackpot race. The jackpot goes to whichever window with any EXACT ticket is settled first. A lone 0.5 IMD ticket in a quiet window is a guaranteed winner, so its holder settles ahead of a contested window and takes the entire donation for a net 0.045 IMD. Filling all 256 slots of a future window gives the same guarantee in an active game for 11.52 IMD. Proof test attached and failing.
    • Medium, oracle reserve drain. A fully void window takes no fee yet still reserves the 0.5 IMD reimbursement. One-sided 0.5 IMD bets are refunded in full, so 20 such settlements moved a 10 IMD donated reserve to the attacker at zero IMD cost.
    • Low, selectable line. Void windows also enter the 7-window result history, so an actor choosing which zero-cost dummy windows to settle drove the line from 1000 to 0.
    • Low, pre-end attestation. settle() accepts an attestation issued 300 seconds before round 6k+5 ends. Harm depends on the oracle answering a partial sum, so impact is unverified; the acceptance is reproduced.
    • Info, trust assumption. A TEAM-controlled staking token lets TEAM take the 5% share plus the reserve, contrary to the README's "1% and nothing else" claim.

    What held. The IMD solvency invariant, the per-window 91/5/3/1 split, the EXACT tier remainders, claim and refund accounting, jackpot reservation against overlapping currencies, the timelocked setters and the verifier's replay and validity checks all traced correctly. The existing 56-test suite passes. Every listed entry point has a coverage row and none is marked unreached.

    For the judge. The first three findings reproduce in the scratch tests under test/scratch/. The two mediums with proofs assert properties that depend on a design decision by the author, and the descriptions state the tradeoff.

    ran onclaude · claude-fable-5-1 · 24 turns · 12m 50s · 354 in · 52.2K out · 1.1M cached
    submission1d1bdd7709c1ce3bedff1fe7c42cc6b0fe8e2927225624c7631faa49ee42a564
    device87804e27e9c9f85a56b7d27769006acebfcf590ed64f6eef9617da5195c9d826
    started from0c95b0371e186ca7b44dafcad442d7108ee46076
    bundlenone
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    • mediumStaker share is paid to whoever is staked in the settlement block, so a same-block stake/settle/unstake sandwich takes the whole 5% from long-term stakerssrc/HamsterHour.sol:614

      Economic Security / Invariant guides: deposit -> trigger distribution -> withdraw in one transaction. _distributeStaking credits the window's 5% share (plus any released pre-staking tranche and the stakingUndistributed backlog) to totalStaked at the instant settle() runs. stake() and unstake() have no lock, delay or snapshot, and settle() is permissionless and can be sandwiched (or called by the staker itself).

      An actor who stakes a large flash-loaned amount of the staking token in the same block as a settlement, calls claimStaking() and unstake() right after, receives share * X/(X+H) of the distribution and gives it back to nobody. With X = 1e24 against an honest H = 100e18 the attacker takes 99.99 IMD of a 100 IMD share; the honest staker who held for ten days is left 0.00999 IMD.

      The README states this is deliberate ('no holding-period economics were added'), but the brief's guarantee that the 5% is 'distributed pro rata by stake-weight' to stakers is hollow in practice: on mainnet any settlement with a non-trivial share will be sandwiched by a builder-side bundle, so honest stakers earn nothing, and the 30 pre-token reserve tranches (a possibly large accumulated reserve) are captured the same way.

      Victim: every honest staker; attacker profit per window = 5% of non-void pools minus flash-loan fee and gas; no IMD capital needed. Fix requires a design decision: snapshot eligible stake at the window's startsAt or endsAt (stake becomes eligible only for windows that start after the stake), or impose a minimum staking period/epoch so a stake present only during the settlement block has zero weight.

      The proof test asserts a same-block stake earns 0 while the long-term staker keeps >= 99 IMD.

      Setup: stakingToken configured (schedule + 48h + set). honest.stake(100e18) ten days before. alice.betOverUnder(5,true,1000e18), bob.betOverUnder(5,false,1000e18).

      Warp to windowTimes(5).endsAt.

      In one block: attacker.stake(1_000_000e18); keeper.settle(5, attestation(result 1500), sig); attacker.claimStaking(); attacker.unstake(1_000_000e18).

      Expected (brief: 5% share to stakers pro rata by stake weight, meaning those who actually stake): honest staker's pendingStaking ~= 100e18 and the transient staker gets ~0.

      Actual: attacker IMD balance rises by 99990000999900009999 (99.99 IMD), honest pendingStaking = 9999000099990000 (0.00999 IMD), attacker's staking tokens fully returned.

      Run: forge test --match-path test/scratch/FlashStakeProof.t.sol (fails on current code).

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      import {OracleAttestation} from "src/libraries/OracleAttestation.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 a) external {
              _mint(to, a);
          }
      }
      
      /// @notice Fails on the current code: a same-block stake/settle/claim/unstake sandwich takes
      /// essentially the entire 5% staker share of a window away from a staker who has held for days.
      contract FlashStakeProof is Test {
          uint256 constant KEY = 192837465;
          uint64 constant GENESIS = 1791847800;
          address constant TEAM = 0x6CBA4751742F6989fA224B2dC5d5d52fCef98a34;
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address honest = makeAddr("honestStaker");
          address attacker = makeAddr("attacker");
          address keeper = makeAddr("keeper");
          ProofToken imd;
          ProofToken stk;
          OracleVerifier verifier;
          HamsterHour game;
          uint256 nonce;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 10 days);
              verifier = new OracleVerifier(vm.addr(KEY));
              game = new HamsterHour(TEAM, address(verifier));
              ProofToken t = new ProofToken();
              vm.etch(game.IMD(), address(t).code);
              imd = ProofToken(game.IMD());
              stk = new ProofToken();
              address[5] memory who = [alice, bob, honest, attacker, keeper];
              for (uint256 i; i < 5; ++i) {
                  imd.mint(who[i], 1e30);
                  stk.mint(who[i], 1e30);
                  vm.startPrank(who[i]);
                  imd.approve(address(game), type(uint256).max);
                  stk.approve(address(game), type(uint256).max);
                  vm.stopPrank();
              }
              vm.prank(TEAM);
              game.scheduleStakingToken(address(stk));
              vm.warp(block.timestamp + 48 hours);
              vm.prank(TEAM);
              game.setStakingToken(address(stk));
          }
      
          function _settle(address who, uint256 k, uint256 result) internal {
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256(abi.encode(k, ++nonce));
              a.chainId = 1;
              a.questionHash = game.questionHash(k, 100, 200);
              a.answerType = 3;
              a.answer = abi.encode(result);
              a.fromBlock = 100;
              a.toBlock = 200;
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 5;
              a.issuedAt = uint64(block.timestamp);
              a.expiresAt = uint64(block.timestamp + 1 days);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, verifier.attestationDigest(a, address(game)));
              vm.prank(who);
              game.settle(k, a, abi.encodePacked(r, s, v));
          }
      
          function test_sameBlockStakeSandwichEarnsNothing() public {
              // Honest staker has held 100 tokens since well before the window was even created.
              vm.prank(honest);
              game.stake(100e18);
              // Window 5: 1000 IMD OVER vs 1000 IMD UNDER => 100 IMD staker share at settlement.
              vm.prank(alice);
              game.betOverUnder(5, true, 1000e18);
              vm.prank(bob);
              game.betOverUnder(5, false, 1000e18);
              (, uint64 end) = game.windowTimes(5);
              vm.warp(end);
              // One block: attacker stakes (flash-loaned) 1e24, keeper's settle lands, attacker claims and exits.
              vm.prank(attacker);
              game.stake(1_000_000e18);
              _settle(keeper, 5, 1500);
              vm.startPrank(attacker);
              game.claimStaking();
              game.unstake(1_000_000e18);
              vm.stopPrank();
              uint256 attackerGain = imd.balanceOf(attacker) - 1e30;
              // Expected: a stake that exists only during the settlement block earns no part of the share
              // and the long-term staker keeps (nearly) all 100 IMD. Actual: attacker takes ~99.99 IMD.
              assertEq(attackerGain, 0, "same-block staker captured the staker share");
              assertGe(game.pendingStaking(honest), 99e18, "long-term staker lost the share");
          }
      }
    • mediumJackpot goes to whichever window with any EXACT ticket is settled first, so a lone 0.5 IMD ticket in an empty window (or 256 self-tickets in a monopolised window) captures the whole prize ahead of consrc/HamsterHour.sol:521

      Flow Gap (execution x first principles) / Economic Security. _snapshotJackpot awards the token's entire free balance to the window being settled whenever rankCount != 0, i.e. whenever at least one EXACT ticket exists. EXACT has no one-sided/uncontested rule (README: 'EXACT is not a binary market, so the one-sided rule does not apply'), so a window whose only EXACT ticket belongs to one address is a guaranteed rank-0 win for that address.

      Settlement is lazy and order-free: any window that ended within the last 48 h can be settled by anyone at any moment. Therefore, whenever a jackpot donation lands (or is visible in the mempool), the holder of a lone ticket in any quiet window settles that window first and collects 100% of the jackpot; the contested window with real guessers, settled a moment later, receives nothing (w.jackpot == 0).

      Cost: 0.5 IMD ticket of which 91% is returned (net 0.045 IMD) plus one attestation and gas; if the oracle reserve is funded the settler is even reimbursed 0.5 IMD.

      In a game active enough that every window has honest EXACT entrants, the attacker instead pre-creates a future window and fills all 256 EXACT slots from 256 addresses (128 IMD, 91% returned, net 11.52 IMD): betExact reverts ExactFull for everyone else, so that window is a guaranteed win and the attacker holds a 48 h option on every jackpot donation after it ends.

      The brief says the jackpot rewards 'the EXACT winner of each settled window', i.e. a guessing contest; the implemented rule lets the prize be taken without any guess being contested, and front-runs sponsors' donations intended for live windows.

      Victims: honest EXACT bettors and the jackpot donor. Fix needs a design decision; the proof asserts the narrow property that a window whose EXACT market has a single ticket (uncontested, mirroring the binary one-sided void) receives no jackpot and the contested window does.

      A stronger fix binds the jackpot to a window determined before the donation is visible (e.g. jackpot for window k is the balance snapshotted at k's startsAt and awarded in window order), which also removes the settlement-order race.

      Setup: jackpotToken = prize configured. alice.betExact(6,1000,10e18), bob.betExact(6,1200,10e18), carol.betExact(6,800,10e18). attacker.betExact(5,0,0.5e18) (only EXACT ticket of window 5).

      Warp to windowTimes(6).endsAt. prize.mint(game, 1000e18) (sponsor donation). attacker.settle(5, attestation(result 123456), sig) in front of keeper.settle(6, attestation(result 1000), sig). attacker.claim(5).

      Expected (jackpot rewards the contested guess): window 6's closest guesser (alice) receives 1000 prize tokens.

      Actual: prize.balanceOf(attacker) == 1000e18, window(6).jackpot == 0, attacker's IMD cost 0.045 IMD.

      Run: forge test --match-path test/scratch/JackpotRaceProof.t.sol (fails on current code).

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      import {OracleAttestation} from "src/libraries/OracleAttestation.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 a) external {
              _mint(to, a);
          }
      }
      
      /// @notice Fails on the current code: the sole EXACT ticket of an otherwise empty window is a
      /// guaranteed winner, and because settlement order is free, its holder settles first and takes a
      /// jackpot donated while a contested window with three real guessers was waiting to be settled.
      contract JackpotRaceProof is Test {
          uint256 constant KEY = 192837465;
          uint64 constant GENESIS = 1791847800;
          address constant TEAM = 0x6CBA4751742F6989fA224B2dC5d5d52fCef98a34;
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address carol = makeAddr("carol");
          address attacker = makeAddr("attacker");
          address keeper = makeAddr("keeper");
          ProofToken imd;
          ProofToken prize;
          OracleVerifier verifier;
          HamsterHour game;
          uint256 nonce;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 10 days);
              verifier = new OracleVerifier(vm.addr(KEY));
              game = new HamsterHour(TEAM, address(verifier));
              ProofToken t = new ProofToken();
              vm.etch(game.IMD(), address(t).code);
              imd = ProofToken(game.IMD());
              prize = new ProofToken();
              address[5] memory who = [alice, bob, carol, attacker, keeper];
              for (uint256 i; i < 5; ++i) {
                  imd.mint(who[i], 1e30);
                  vm.prank(who[i]);
                  imd.approve(address(game), type(uint256).max);
              }
              vm.prank(TEAM);
              game.scheduleJackpotToken(address(prize));
              vm.warp(block.timestamp + 48 hours);
              vm.prank(TEAM);
              game.setJackpotToken(address(prize));
          }
      
          function _settle(address who, uint256 k, uint256 result) internal {
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256(abi.encode(k, ++nonce));
              a.chainId = 1;
              a.questionHash = game.questionHash(k, 100, 200);
              a.answerType = 3;
              a.answer = abi.encode(result);
              a.fromBlock = 100;
              a.toBlock = 200;
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 5;
              a.issuedAt = uint64(block.timestamp);
              a.expiresAt = uint64(block.timestamp + 1 days);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, verifier.attestationDigest(a, address(game)));
              vm.prank(who);
              game.settle(k, a, abi.encodePacked(r, s, v));
          }
      
          function test_uncontestedLoneTicketCannotTakeJackpotAheadOfContestedWindow() public {
              // Contested window 6: three real guessers.
              vm.prank(alice);
              game.betExact(6, 1000, 10e18);
              vm.prank(bob);
              game.betExact(6, 1200, 10e18);
              vm.prank(carol);
              game.betExact(6, 800, 10e18);
              // Attacker is the only EXACT entrant of window 5 (a quiet hour): minimum 0.5 IMD, any guess.
              vm.prank(attacker);
              game.betExact(5, 0, 0.5e18);
              (, uint64 end6) = game.windowTimes(6);
              vm.warp(end6);
              // Sponsor donates 1000 prize tokens while both windows are settleable.
              prize.mint(address(game), 1000e18);
              // Attacker settles its own window first (front-running the keeper's settle of window 6).
              _settle(attacker, 5, 123456);
              _settle(keeper, 6, 1000);
              vm.prank(attacker);
              game.claim(5);
              // Expected: an uncontested single ticket does not take the jackpot; the contested
              // window's closest guesser does. Actual: attacker holds all 1000 prize tokens for 0.045 IMD.
              assertEq(prize.balanceOf(attacker), 0, "lone uncontested ticket took the whole jackpot");
              HamsterHour.Window memory w6 = game.window(6);
              assertEq(w6.jackpot, 1000e18, "contested window received no jackpot");
          }
      }
    • mediumSettling a fully void window (one-sided 0.5 IMD bet, no fee taken) still reserves the 0.5 IMD oracle reimbursement, so the donated/accrued oracle reserve can be drained at zero IMD costsrc/HamsterHour.sol:346

      Invariant guide: the oracle reserve is funded by 3% of non-void pools and by fundOracleReserve donations, and is meant to reimburse settlement of windows that pay fees. settle() calls _reserveReimbursement unconditionally after the fee loop, even when every market in the window was voided and oracleShare == 0.

      A one-sided OVER/UNDER bet is void and refunded in full, so an actor can place a lone 0.5 IMD OVER bet on every future window (fully refundable, no fee), settle each window after it ends with an attestation, claim the refund plus 0.5 IMD reimbursement, and repeat. Each such settlement contributes 0 to the reserve and removes 0.5 IMD.

      In the reproduction a 10 IMD donation to fundOracleReserve is fully transferred to the attacker over 20 void windows while the attacker's own IMD is returned in full; the attacker's only costs are attestations and gas.

      The leak is bounded to 0.5 IMD per hourly window that nobody else settles first, and profitability depends on the external oracle fee being below 0.5 IMD, but the reserve is drained by settlements that serve no bettor, contrary to the brief's purpose for the 3%/reimbursement mechanism.

      Fix: only call _reserveReimbursement when at least one market took fees (e.g. if (oracleShare != 0)), or skip windows where all funded markets are void; also apply the same condition in _claim's deferred path.

      donor.fundOracleReserve(10e18).

      For k in 0..19: attacker.betOverUnder(k, true, 0.5e18) (sole bettor).

      Warp to windowTimes(19).endsAt.

      For k in 0..19: attacker.settle(k, attestation(result 5), sig) then attacker.claim(k).

      Expected: void windows take no fee and therefore fund/receive no reimbursement; oracleReserve stays 10e18 and the attacker only gets its 20 x 0.5 IMD refunds back.

      Actual: oracleReserve == 0 and imd.balanceOf(attacker) rose by exactly 10e18 above its starting balance (all bets refunded plus the whole donated reserve); settledWindows == 20.

      Reproduced in test/scratch/VoidReimbursement.t.sol test_fullyVoidWindowStillPaysReimbursementFromDonatedReserve.

    • lowFully void windows enter the 7-window line history, so one actor can select which results form the line at zero IMD costsrc/HamsterHour.sol:349

      Economic Security ('legitimate features turned against the protocol'). The line for new windows is the floored mean of the last seven settled results in settlement order. Settlement is optional and permissionless, and a lone one-sided 0.5 IMD bet creates a settleable window at no net cost (void, refunded in full).

      An actor can therefore seed windows during periods it expects to be favourable (e.g. hours when the hamster is known to sleep, result 0), settle only those, let the others time out, and drive currentLine() to 0 (or to any value its chosen results average to). Windows pre-created afterwards snapshot that line: with line 0 every nonzero result wins OVER, so UNDER bettors who trust the published line lose to the actor.

      Pari-mutuel pricing limits the damage (informed bettors see the line), and the actor pays an attestation per selected window, so this is low; but the line is advertised as 'average total passes over the last 7 settled windows' and is presented to users as the market's fair reference.

      Fix: only record results of windows in which at least one market took fees (non-void), and/or record in window order rather than settlement order.

      attacker.betOverUnder(k, true, 0.5e18) for k = 0..9 (sole bettor, all void).

      Warp to windowTimes(9).endsAt. attacker.settle(k, attestation(result 0), sig) for k = 0..6 only. currentLine() == 0 (was 1000). attacker.claim(k) for k = 0..6 refunds every bet in full. attacker.betOverUnder(20, true, 0.5e18): window(20).line == 0, so any result >= 1 wins OVER for that window.

      Reproduced in test/scratch/Leads.t.sol test_dummyWindowsSelectLineHistoryAtZeroCost.

    • lowsettle() accepts an attestation issued up to 300 s before round 6k+5 has endedsrc/HamsterHour.sol:316

      Flow Gap (execution x periphery x first principles). The brief says settlement happens 'after round 6k+5 ends' with the oracle's answer for rounds 6k..6k+5. The guard allows issuedAt >= endsAt - 300, i.e. an attestation the oracle signed while the final round was still running and its result could not yet have been published at the source.

      A keeper can request the oracle ~10 minutes before the window ends; if the panel answers from the five published rounds (a partial sum) and signs at endsAt - 300, settle() accepts it at endsAt and the OVER/UNDER, SLEEP and EXACT markets resolve on an understated total.

      Whether the IMD panel would return a partial sum rather than 'not answerable' is oracle behaviour outside this code, so impact is unverified here; the contract-side fact (acceptance of a pre-end issuedAt) is reproduced. Honest keepers always request after the sixth result is published, so requiring issuedAt >= w.endsAt costs nothing legitimate.

      Fix: if (a.issuedAt < w.endsAt) revert AttestationPredatesEnd(); (keep the verifier's +300 s tolerance only for the future-issuance check).

      alice.betOverUnder(5,true,10e18), bob.betOverUnder(5,false,10e18).

      (endsAt) = windowTimes(5).

      Build attestation for window 5 with issuedAt = endsAt - 300, expiresAt = issuedAt + 1 day, answer 7, signed by the oracle signer in the game's domain.

      Warp to endsAt. keeper.settle(5, a, sig).

      Expected: AttestationPredatesEnd (round 6k+5 was still running when the attestation was issued).

      Actual: settle succeeds, window(5).result == 7, window Settled.

      Reproduced in test/scratch/Leads.t.sol test_attestationIssuedBeforeWindowEndAccepted.

    • infoTrust assumption: TEAM's one-shot staking token choice determines who can earn the 5% staker share; a TEAM-controlled mintable token lets TEAM take it, contrary to the README's 'TEAM gets 1% and nothisrc/HamsterHour.sol:629

      Documented privileged power, not a bypass: the brief gives TEAM the one-shot setStakingToken. The contract validates only nonzero, not-self and has-code. If TEAM selects a token it can mint or whose supply it dominates, TEAM stakes an arbitrarily large amount and, via the same stake-weight-at-settlement rule as finding 1, receives essentially all of the 5% staker share and the entire pre-staking reserve (all 30 tranches) on top of its 1% fee.

      The README's first section promises TEAM 'gets 1% of bets and nothing else as a privileged beneficiary'; that promise holds only if the chosen staking token is independently distributed. Reported as a trust assumption for the launch review and website copy, not as a code defect; no unprivileged amplifier beyond finding 1 is required.

      Suggested handling: state the assumption in README/UI and have the review of the eventual scheduleStakingToken proposal check the token's supply control.

      TEAM deploys MintableToken it controls; TEAM.scheduleStakingToken(tok); +48h; TEAM.setStakingToken(tok); TEAM mints 1e30 and stake(1e30).

      Every subsequent settle(): _distributeStaking credits share * 1e30/(1e30 + others) to TEAM; TEAM.claimStaking() collects the 5% share and the released pre-staking reserve in addition to teamCredit.

      Expected per README: TEAM receives only 1%.

      Actual: TEAM receives ~6% of every non-void pool plus the reserve.

      Code path: setStakingToken -> _validateToken (zero/self only) -> stakingToken = token; stake(); _distributeStaking line 614.

  7. Write foundry testsAgent #3583 files changedsent back

    Done. The full suite passes with the verifier's fuzz seed, and one defect is reported with a runnable proof.

    Result

    CheckOutcome
    forge buildclean
    forge test with the verifier seed95 passed, 0 failed
    Files addedtest/EdgeCases.t.sol, test/Properties.t.sol, test/GameInvariant.t.sol
    Findings written.imd-findings.json, 6 entries, 1 with proof

    What the new tests cover

    • Edge cases, 28 unit tests. Claims by strangers, losers, and repeat claimants. Claims before settlement. Settlement accepted one second before the timeout and refused at it, and refused one second before the window ends. Voided windows cannot settle and settled windows cannot void or re-settle. Settlement in any order with the line following settlement order. Oracle reimbursement at exactly 0.5 IMD versus one wei short, and once per recorded settler only. Stake and unstake zero, excess, and cap inputs. Jackpot snapshot timing, tied three-way remainder, second place never paid, no retroactive jackpot. Far-future window creation, closed-window floor monotonicity, timelock consumption, source URL length and character bounds, and every event the brief requires.
    • Properties, 10 fuzz tests at 1000 runs. Round and window math against an independent clock, betting closing exactly at round start, the line as floor average of the last seven results, SLEEP and OVER/UNDER conservation with the push case, the 60/30/10 EXACT tiers summing exactly, the staking accumulator never overpaying and losing only bounded dust, late stakers earning nothing retroactively, and voided windows refunding every wei.
    • Second invariant suite, 128 runs of 64 calls. A handler with separate stake and prize tokens, up to three windows open at once, real time advancing, timeouts, settler and TEAM claims, donations, reserve funding, and TEAM source and genesis changes. Ghost variables record IMD and prize in and out per window. Invariants: each asset backs its own liability, no value is created, final states never reopen and the settlement counter matches, the 91/5/3/1 split and per-window payout bounds hold, and reserve release is bounded. After each run everyone drains and at most dust remains.

    Defect reported, not tested around

    A genesis correction that moves genesis earlier leaves already-created windows bettable even though their rounds have started or finished under the corrected clock. The floor only protects uncreated window ids. The proof in the findings file opens window 5, moves genesis 10 hours earlier through the timelock, and shows a bet on finished rounds is accepted where the brief says betting closes when round 6k starts. Severity medium. Five further entries are low or informational: per-ticket EXACT tie splits that make stake size irrelevant and are Sybil-able, unbounded far-future window creation freezing a stale line and source, just-in-time staking, the 300-second pre-end attestation tolerance, and the permanently locked oracle reserve surplus.

    Nothing was installed and no configuration file was touched. The three test files are untracked in the working tree for the harness to commit.

    ran onclaude · claude-fable-5-1 · 48 turns · 13m 3s · 866 in · 59.7K out · 3.4M cached
    submissionceef786a5f41f2b51cc202ecfbd85c19f204859e58230a5fd326c97ca7f30669
    device7591760a616c6429719f71d890030c12b4d6f905aa1e8dd2b1937fd710e32bb5
    started from0c95b0371e186ca7b44dafcad442d7108ee46076
    bundle99c54e4b9a3b2b6cfac6a72270ac89e4da7aeb872ce584d9848283f37e6b374e · 174 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    changed · 3 files
    test/EdgeCases.t.soltest/GameInvariant.t.soltest/Properties.t.sol
    may write
    testtest/**
    • mediumGenesis correction that moves genesis earlier leaves already-created windows open for betting on rounds that have started or finishedsrc/HamsterHour.sol:297

      setGenesisTimestamp protects only uncreated window ids (closedWindowFloor, line 667-670). A window that already exists keeps its stored startsAt, and _open (line 297) and isBettingOpen (line 238) check only that stored time.

      When TEAM corrects genesisTimestamp to an earlier value (the purpose of the setting is to realign the contract clock with Squeekchain's real round numbering), the rounds 6k..6k+5 that window k is about may already have started or finished under the corrected clock, with their pass counts public on Squeekchain, while the contract still accepts OVER/UNDER, EXACT and SLEEP bets on that window until the stale stored startsAt.

      Anyone can pre-create windows far ahead with a 0.5 IMD bet, so the exposure is not limited to the few windows open at the time of the change. Bettors who placed earlier bets in good faith lose to bettors who already know the result, and the EXACT winner also takes the jackpot. The brief says betting on window k is open until round 6k starts.

      Suggested fix: in _open and isBettingOpen also require block.timestamp < genesisTimestamp + k * WINDOW_DURATION for an existing Open window (or close existing windows whose corrected start has passed when genesis is executed).

      1. alice bets 1 IMD OVER on window 5 (startsAt = GENESIS + 5h).

      2. TEAM schedules and, after 48h, executes setGenesisTimestamp(GENESIS - 10h).

      3. warp to GENESIS + 1h: currentRound() is now >= 36, i.e. rounds 30..35 (window 5) are finished under the corrected clock.

      Expected: isBettingOpen(5) == false and betExact(5, 1234, 1 IMD) reverts BettingClosed.

      Actual: isBettingOpen(5) == true and the bet is accepted.

      Run: forge test --match-path test/scratch/GenesisEarlierProof.t.sol

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("IMD", "IMD") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @notice A genesis correction that moves genesis earlier leaves an already-created window open for
      /// betting even though, under the corrected genesis, its rounds have already started (or finished).
      /// Brief: "Betting on window k is open until round 6k starts."
      contract GenesisEarlierProofTest is Test {
          uint64 internal constant GENESIS = 1791847800;
          address internal team = makeAddr("team");
          address internal alice = makeAddr("alice");
          address internal bob = makeAddr("bob");
          HamsterHour internal game;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 10 days);
              OracleVerifier verifier = new OracleVerifier(makeAddr("signer"));
              game = new HamsterHour(team, address(verifier));
              ProofToken template = new ProofToken();
              vm.etch(game.IMD(), address(template).code);
              ProofToken imd = ProofToken(game.IMD());
              imd.mint(alice, 1_000 ether);
              imd.mint(bob, 1_000 ether);
              vm.prank(alice);
              imd.approve(address(game), type(uint256).max);
              vm.prank(bob);
              imd.approve(address(game), type(uint256).max);
          }
      
          function test_bettingClosesWhenCorrectedGenesisPutsWindowStartInThePast() public {
              // Window 5 (rounds 30..35) is created under the original genesis: starts at GENESIS + 5h.
              vm.prank(alice);
              game.betOverUnder(5, true, 1 ether);
      
              // TEAM learns the real Squeekchain genesis was 10 hours earlier and corrects it (48h timelock).
              uint64 corrected = GENESIS - 10 hours;
              vm.prank(team);
              game.scheduleGenesisTimestamp(corrected);
              vm.warp(block.timestamp + 48 hours);
              vm.prank(team);
              game.setGenesisTimestamp(corrected);
      
              // Now at GENESIS + 1h. Under the corrected genesis, round 30 started at GENESIS - 5h: window 5
              // is over and its passes are public on Squeekchain. The contract still reports it bettable.
              vm.warp(GENESIS + 1 hours);
              assertGe(game.currentRound(), 36, "corrected clock: window 5 rounds already finished");
              assertFalse(game.isBettingOpen(5), "window 5 must not accept bets after its rounds started");
      
              // Expected: BettingClosed. Actual: the bet is accepted on a known outcome.
              vm.prank(bob);
              vm.expectRevert(HamsterHour.BettingClosed.selector);
              game.betExact(5, 1234, 1 ether);
          }
      }
    • lowEXACT ties split per ticket, not per stake: bet size is irrelevant and ranks are Sybil-able with minimum betssrc/HamsterHour.sol:474

      _claimBets splits a rank's prize by ticket count (rankRewards[rank] / ties[rank]) while every other market pays pro rata by stake. A 0.5 IMD ticket at the same distance as a 100 IMD ticket receives the same prize, and because one ticket per address is enforced only per address, a bettor can hold N tickets on the same guess from N addresses and take N/(N+1) of the tier (and of the jackpot).

      The brief says 'ties split equally' so this may be the intended reading, but it contradicts '91% to winners pro rata' and makes any EXACT stake above the minimum strictly irrational. Reported for the reviewer to confirm the intended semantics; the existing test test_exactTiesSplitEachDistinctDistanceTierEqually blesses the per-ticket reading.

      alice betExact(0, 1000, 100 IMD); ten attacker addresses each betExact(0, 1000, 0.5 IMD); settle result 1000.

      Pool 105 IMD, EXACT reward 95.55 IMD, rank 0 = 11 tickets.

      Each ticket gets 95.55/11 = 8.686 IMD: alice gets 8.686 IMD for 100 staked, the attacker gets 86.86 IMD for 5 staked.

      Pro rata would give alice 91 IMD.

    • lowAnyone can create windows arbitrarily far in the future, freezing today's line and sourceUrl for themsrc/HamsterHour.sol:293

      _open accepts any window id whose start is in the future (up to ~uint64 range). A 0.5 IMD bet on window 100000 freezes line = currentLine() and source = sourceUrl at creation. A later TEAM setSourceUrl (for example because the Squeekchain host moves) cannot reach such windows, so their oracle question names a dead URL and they can only void at timeout; and their frozen line is stale by the time they run, which makes the OVER/UNDER market one-sided in practice.

      Not exploitable for profit by itself, but it lets one address cheaply pin every future window's terms. Consider bounding how far ahead a window may be created (for example, at most a few windows past currentWindow()).

      betExact(100000, 1, 0.5 IMD) succeeds pre-genesis; window(100000).line == 1000 and .source == the current sourceUrl; after TEAM changes sourceUrl the stored source is unchanged (test_farFutureWindowFreezesCurrentLineAndSourceAtCreation in test/EdgeCases.t.sol pins the current behaviour).

    • infoJust-in-time staking: the 5% share can be captured by staking in the same block as settlement and unstaking immediatelysrc/HamsterHour.sol:614

      The accumulator distributes each settlement's 5% by stake weight at that instant with no lock-up or time weighting. A staker (or the settler itself, which chooses when settle is mined) can deposit immediately before settle and withdraw immediately after, diluting long-term stakers with zero exposure. This is the accumulator pattern the brief prescribes, so it is reported for awareness, not as a deviation.

      alice stakes 1; bob stakes 1e30 in the block before settle, then unstake(1e30) and claimStaking in the next: bob receives ~100% of the window's 5% share (test_accumulatorTracksWeightsAtSettlementNotAtClaim shows weights are read at settlement only).

    • infoAttestations issued up to 300 s before the window ends are accepted, i.e. while round 6k+5 is still runningsrc/HamsterHour.sol:316

      settle rejects an attestation only if issuedAt + ISSUED_AT_TOLERANCE < endsAt, so an attestation issued at endsAt - 300 is valid even though the last round of the window has 300 s left when it was signed. The oracle panel would then answer about an unfinished round. The tolerance exists for clock drift; an honest panel should refuse to answer an incomplete range, so this is a documentation/keeper note rather than a contract defect.

      test_earlyAndPredatedAttestationsRejected in test/Oracle.t.sol: issuedAt = endsAt - 300 settles, issuedAt = endsAt - 301 reverts AttestationPredatesEnd.

    • infoOracle reserve surplus above 0.5 IMD per settlement is locked foreversrc/HamsterHour.sol:345

      3% of every live market accrues to oracleReserve but only 0.5 IMD per settlement is ever drawn from it. With pools above ~16.7 IMD the reserve grows without bound and no path returns the surplus to anyone. This follows the brief (no admin withdraw) and is noted so the README's 'the contract never owes more than it holds' is read correctly: it holds more than it owes, permanently.

      test_allMarketsAndFullSplitConservation: 300 IMD of bets leaves oracleReserve at 8.5 IMD after one reimbursement; nothing can ever withdraw it.

  8. Audit judgeAgent #420found 3 medium, 6 low, 2 info

    The review is complete. The findings file is in place at the repository root, no tracked file was changed, and the only other files I wrote are my scratch tests under test/scratch/, which is git-ignored and not kept.

    What I delivered

    .imd-findings.json holds 11 findings and a 22-row coverage record answering all 20 listed entry points plus two invariants. Three findings carry self-contained Foundry proofs that I ran and watched fail on the current code.

    Medium, with proofs

    • Attestation may predate the window end. settle accepts an attestation whose issuedAt is up to 300 s before endsAt, so a signed total could not include round 6k+5. Five specialists reported this; merged into one. Proof fails with "next call did not revert as expected".
    • Genesis moved earlier keeps created windows bettable. Existing windows check only their stored startsAt, so after a correction, bets are accepted on rounds whose results are already public. Proof fails as described.
    • Jackpot settlement-order race. A lone 0.5 IMD EXACT ticket in a quiet window is a guaranteed winner and, settled first, takes the entire donated jackpot ahead of a contested window. Proof fails with the attacker holding all 1000 prize tokens.

    Low (reproduced with my own scratch tests or by trace): fee-less void settlements still draw the 0.5 IMD reimbursement, write the line history and consume release tranches (two specialist findings merged); same-block stake sandwich takes the 5% share (design the brief prescribes, so flagged as a requester decision without a proof); EXACT per-ticket split lets copy tickets drain a large stake (spec ambiguity); timelocked proposals never lapse; genesis range unbounded; TEAM's staking token choice can route 6% to TEAM (trust assumption).

    Info: unbounded far-future window creation, and the immutable oracle signer. I confirmed the pinned signer against the live IMD oracle API today.

    What I dropped

    Documented, by-design items with no defect: reimbursement to the transaction sender, permanent reserve surplus. Static-analysis leads on reentrancy, uninitialized locals and the assembly shift order were checked and are false positives.

    Verification

    Full existing suite passes (99 tests). Contract sizes and the launch manifest shape, constructor arguments and ABI exports all check out. I could not verify the IMD oracle panel's behaviour on a partial round range, so the first finding's impact rests on that external behaviour, as stated in the file.

    ran onclaude · claude-fable-5-1 · 28 turns · 10m 12s · 418 in · 41.8K out · 1.5M cached
    submission7dc6ac366f6bb742304b68f49fe38b1a29ce0b347b873c5667fa91908e40624d
    device72b617d4b615473ad3b763b0e3d0fbbe45ab980941c095e9f4ea11e135554beb
    started from5cc5b6ca4037af9529791fb8970dfb1bb3b55c00
    bundlenone
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe, 5db79ea46c5b92b61d3f6ebc45a8e6cd197e26ca6e94c308e151f29f2e434226, 3b7f1515b607821a7e05539c59d531c138b1983cf8f38c5ce6b2e72dfcb9a378
    • mediumsettle() accepts an oracle attestation issued up to 300 s before the window's last round endssrc/HamsterHour.sol:316

      The brief settles window k with the oracle's answer for rounds 6k..6k+5 after round 6k+5 ends. settle() only rejects an attestation when issuedAt + ISSUED_AT_TOLERANCE (300 s) < endsAt, so an attestation whose issuedAt is in [endsAt-300, endsAt) is accepted at block.timestamp == endsAt. issuedAt is the attester's signing time and postdates the panel's read of the source, so such an attestation was necessarily produced while round 6k+5 was still running and its final count unpublished.

      Anyone may buy an attestation and settle, so a bettor who requests the question during the last five minutes can submit a signed total that cannot include the sixth round; whether the panel answers a partial sum or refuses is oracle behaviour outside this code, and the contract cannot tell.

      The +300 s tolerance is only needed in OracleVerifier for an oracle clock that is ahead of the chain (issuedAt > now); mirroring it on the lower bound lets the attestation predate the data it attests. README/docs/ORACLE.md document the tolerance as intended but not this consequence. Merged from five specialist reports (write_foundry_tests, audit_math, audit_permissions, audit_economics, audit_flow).

      Fix preserving the design: if (a.issuedAt < w.endsAt) revert AttestationPredatesEnd(); (an honest keeper requests only after the sixth result is published, so nothing legitimate is lost; the verifier's forward tolerance is unchanged).

      Trade-off: an oracle clock behind the chain by up to 300 s would need the keeper to re-request.

      State: alice betOverUnder(5,true,1e18), bob betOverUnder(5,false,1e18); (startsAt,endsAt) = windowTimes(5).

      Build a valid v2 attestation for questionHash(5,100,200), answer 800, panelSize 5, quorum 4, agreed 5, issuedAt = endsAt - 300, expiresAt = endsAt + 1 days, signed by the pinned signer in HamsterHour's domain. vm.warp(endsAt); settle(5, a, sig).

      Expected: revert AttestationPredatesEnd.

      Actual: window 5 is Settled with result 800 (line 316 passes because endsAt-300+300 == endsAt; OracleVerifier accepts issuedAt <= now+300). test/Oracle.t.sol::test_earlyAndPredatedAttestationsRejected confirms issuedAt = endsAt-301 reverts and endsAt-300 settles.

      Run: forge test --match-path test/scratch/P3_IssuedBeforeEnd.t.sol (fails now with 'next call did not revert as expected').

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      import {OracleAttestation} from "src/libraries/OracleAttestation.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 a) external {
              _mint(to, a);
          }
      }
      
      /// Fails on the current code: settle(k) accepts an attestation whose issuedAt is 300 s before
      /// window k's endsAt, i.e. signed before round 6k+5 can have been published. Passes once settle
      /// requires a.issuedAt >= w.endsAt.
      contract IssuedBeforeEndTest is Test {
          uint256 constant KEY = 192837465;
          uint64 constant GENESIS = 1791847800;
          address constant TEAM = address(0xBEEF);
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address keeper = makeAddr("keeper");
          ProofToken imd;
          OracleVerifier verifier;
          HamsterHour game;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 10 days);
              verifier = new OracleVerifier(vm.addr(KEY));
              game = new HamsterHour(TEAM, address(verifier));
              ProofToken t = new ProofToken();
              vm.etch(game.IMD(), address(t).code);
              imd = ProofToken(game.IMD());
              address[2] memory who = [alice, bob];
              for (uint256 i; i < 2; ++i) {
                  imd.mint(who[i], 1e30);
                  vm.prank(who[i]);
                  imd.approve(address(game), type(uint256).max);
              }
          }
      
          function test_attestationIssuedBeforeWindowEndIsRejected() public {
              vm.prank(alice);
              game.betOverUnder(5, true, 1 ether);
              vm.prank(bob);
              game.betOverUnder(5, false, 1 ether);
              (, uint64 end) = game.windowTimes(5);
      
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256("request");
              a.chainId = 1;
              a.questionHash = game.questionHash(5, 100, 200);
              a.answerType = 3;
              a.answer = abi.encode(uint256(800));
              a.figure = 800;
              a.fromBlock = 100;
              a.toBlock = 200;
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 5;
              a.issuedAt = end - 300; // signed while round 6k+5 is still running
              a.expiresAt = end + 1 days;
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, verifier.attestationDigest(a, address(game)));
              bytes memory sig = abi.encodePacked(r, s, v);
      
              vm.warp(end);
              vm.prank(keeper);
              vm.expectRevert(HamsterHour.AttestationPredatesEnd.selector);
              game.settle(5, a, sig);
          }
      }
    • mediumA genesis correction that moves genesis earlier leaves already-created windows open for bets on rounds that have started or finishedsrc/HamsterHour.sol:297

      setGenesisTimestamp (lines 664-672) protects only Uncreated window ids through closedWindowFloor and windowTimes(). A window that already exists keeps its stored startsAt, and both _open (line 297) and isBettingOpen (line 238) check only that stored time.

      Anyone can pre-create windows far ahead with a 0.5 IMD bet, so when TEAM corrects genesisTimestamp to an earlier value (the purpose of the setting is to realign the contract clock with Squeekchain's round numbering), the rounds 6k..6k+5 that an existing window k asks the oracle about may already have started or finished under the corrected clock, with their pass counts public on Squeekchain, while the contract keeps accepting OVER/UNDER, EXACT and SLEEP bets on that window until its stale startsAt.

      Bettors who placed bets in good faith lose to bettors who already know the result, and the EXACT copier also takes the jackpot. The brief says betting on window k is open until round 6k starts; README's 'Never changes existing windows' documents the snapshot but not that it keeps a decided window bettable. Reported by write_foundry_tests; the audit_permissions genesis finding mentions the same consequence.

      Fix preserving the design: in _open and isBettingOpen also require block.timestamp < genesisTimestamp + k * WINDOW_DURATION for an existing Open window (close at the earlier of the stored and the corrected start), or close existing windows whose corrected start has passed when the genesis change executes.

      1. alice betOverUnder(5, true, 1 ether) at GENESIS-10d creates window 5 with startsAt = GENESIS + 5h.

      2. TEAM scheduleGenesisTimestamp(GENESIS - 10h); warp 48h; TEAM setGenesisTimestamp(GENESIS - 10h).

      3. warp to GENESIS + 1h: currentRound() = (11h)/600s = 66 >= 36, so rounds 30..35 (window 5) are finished under the corrected clock.

      Expected: isBettingOpen(5) == false and betExact(5, 1234, 1 ether) reverts BettingClosed.

      Actual: isBettingOpen(5) == true and the bet is accepted (line 297 compares against the stored startsAt = GENESIS + 5h).

      Run: forge test --match-path test/scratch/P1_GenesisEarlier.t.sol (fails now: 'window 5 must not accept bets after its rounds started').

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("IMD", "IMD") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @notice A genesis correction that moves genesis earlier leaves an already-created window open for
      /// betting even though, under the corrected genesis, its rounds have already started (or finished).
      /// Brief: "Betting on window k is open until round 6k starts."
      contract GenesisEarlierProofTest is Test {
          uint64 internal constant GENESIS = 1791847800;
          address internal team = makeAddr("team");
          address internal alice = makeAddr("alice");
          address internal bob = makeAddr("bob");
          HamsterHour internal game;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 10 days);
              OracleVerifier verifier = new OracleVerifier(makeAddr("signer"));
              game = new HamsterHour(team, address(verifier));
              ProofToken template = new ProofToken();
              vm.etch(game.IMD(), address(template).code);
              ProofToken imd = ProofToken(game.IMD());
              imd.mint(alice, 1_000 ether);
              imd.mint(bob, 1_000 ether);
              vm.prank(alice);
              imd.approve(address(game), type(uint256).max);
              vm.prank(bob);
              imd.approve(address(game), type(uint256).max);
          }
      
          function test_bettingClosesWhenCorrectedGenesisPutsWindowStartInThePast() public {
              // Window 5 (rounds 30..35) is created under the original genesis: starts at GENESIS + 5h.
              vm.prank(alice);
              game.betOverUnder(5, true, 1 ether);
      
              // TEAM learns the real Squeekchain genesis was 10 hours earlier and corrects it (48h timelock).
              uint64 corrected = GENESIS - 10 hours;
              vm.prank(team);
              game.scheduleGenesisTimestamp(corrected);
              vm.warp(block.timestamp + 48 hours);
              vm.prank(team);
              game.setGenesisTimestamp(corrected);
      
              // Now at GENESIS + 1h. Under the corrected genesis, round 30 started at GENESIS - 5h: window 5
              // is over and its passes are public on Squeekchain. The contract still reports it bettable.
              vm.warp(GENESIS + 1 hours);
              assertGe(game.currentRound(), 36, "corrected clock: window 5 rounds already finished");
              assertFalse(game.isBettingOpen(5), "window 5 must not accept bets after its rounds started");
      
              // Expected: BettingClosed. Actual: the bet is accepted on a known outcome.
              vm.prank(bob);
              vm.expectRevert(HamsterHour.BettingClosed.selector);
              game.betExact(5, 1234, 1 ether);
          }
      }
    • mediumJackpot goes to whichever window with any EXACT ticket is settled first, so a lone 0.5 IMD ticket in an empty window captures the whole prize ahead of a contested windowsrc/HamsterHour.sol:521

      _snapshotJackpot awards the jackpot token's entire free balance to the window being settled whenever rankCount != 0, i.e. whenever at least one EXACT ticket exists.

      EXACT has no uncontested rule (README: the one-sided rule does not apply to it), so a window whose only EXACT ticket belongs to one address is a guaranteed rank-0 win for that address at a cost of 9% of 0.5 IMD (0.045 IMD) plus one attestation, and with a funded oracle reserve the settler is reimbursed 0.5 IMD on top. Settlement is lazy and order-free: any window that ended in the last 48 h can be settled by anyone at any moment.

      So whenever a jackpot donation lands (or is visible in the mempool), the holder of a lone ticket in any quiet window settles that window first and takes 100% of the jackpot; the contested window with real guessers, settled a moment later, gets w.jackpot == 0.

      If every window has honest EXACT entrants, the attacker can instead fill all 256 EXACT slots of a future window from 256 addresses (ExactFull excludes everyone else; net cost 9% of 128 IMD) and hold a 48 h option on every donation after it ends. The brief's jackpot rewards 'the EXACT winner of each settled window', a guessing contest; the implemented rule lets the prize be taken without any contest and front-runs sponsor donations meant for live windows.

      Victims: honest EXACT bettors and the donor. Reported by audit_economics. Fix needs a design decision: e.g. do not award the jackpot to a window whose EXACT market has a single ticket/single bettor (mirroring the binary one-sided void), and/or bind the jackpot to a window determined before the donation is visible (snapshot the free balance at the window's endsAt and award in window order), which also removes the settlement-order race.

      The attached proof asserts only the narrow property that the lone uncontested ticket receives nothing and the contested window receives the donation.

      Setup: jackpotToken = prize (schedule + 48h + set). alice betExact(6,1000,10e18), bob betExact(6,1200,10e18), carol betExact(6,800,10e18). attacker betExact(5,0,0.5e18) is the only EXACT ticket of window 5.

      Warp to windowTimes(6).endsAt (both windows settleable). prize.mint(game, 1000e18) (sponsor donation). attacker settle(5, attestation(result 123456), sig) ahead of keeper settle(6, attestation(result 1000), sig). attacker claim(5).

      Expected: window 6's closest guesser receives the 1000 prize tokens; window 5 (uncontested) receives none.

      Actual: prize.balanceOf(attacker) == 1000e18, window(6).jackpot == 0; attacker's IMD cost is 0.045 IMD.

      Run: forge test --match-path test/scratch/P2_JackpotRace.t.sol (fails now: 'lone uncontested ticket took the whole jackpot: 1000000000000000000000 != 0').

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      import {OracleAttestation} from "src/libraries/OracleAttestation.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 a) external {
              _mint(to, a);
          }
      }
      
      /// @notice Fails on the current code: the sole EXACT ticket of an otherwise empty window is a
      /// guaranteed winner, and because settlement order is free, its holder settles first and takes a
      /// jackpot donated while a contested window with three real guessers was waiting to be settled.
      contract JackpotRaceProof is Test {
          uint256 constant KEY = 192837465;
          uint64 constant GENESIS = 1791847800;
          address constant TEAM = 0x6CBA4751742F6989fA224B2dC5d5d52fCef98a34;
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address carol = makeAddr("carol");
          address attacker = makeAddr("attacker");
          address keeper = makeAddr("keeper");
          ProofToken imd;
          ProofToken prize;
          OracleVerifier verifier;
          HamsterHour game;
          uint256 nonce;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 10 days);
              verifier = new OracleVerifier(vm.addr(KEY));
              game = new HamsterHour(TEAM, address(verifier));
              ProofToken t = new ProofToken();
              vm.etch(game.IMD(), address(t).code);
              imd = ProofToken(game.IMD());
              prize = new ProofToken();
              address[5] memory who = [alice, bob, carol, attacker, keeper];
              for (uint256 i; i < 5; ++i) {
                  imd.mint(who[i], 1e30);
                  vm.prank(who[i]);
                  imd.approve(address(game), type(uint256).max);
              }
              vm.prank(TEAM);
              game.scheduleJackpotToken(address(prize));
              vm.warp(block.timestamp + 48 hours);
              vm.prank(TEAM);
              game.setJackpotToken(address(prize));
          }
      
          function _settle(address who, uint256 k, uint256 result) internal {
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256(abi.encode(k, ++nonce));
              a.chainId = 1;
              a.questionHash = game.questionHash(k, 100, 200);
              a.answerType = 3;
              a.answer = abi.encode(result);
              a.fromBlock = 100;
              a.toBlock = 200;
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 5;
              a.issuedAt = uint64(block.timestamp);
              a.expiresAt = uint64(block.timestamp + 1 days);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, verifier.attestationDigest(a, address(game)));
              vm.prank(who);
              game.settle(k, a, abi.encodePacked(r, s, v));
          }
      
          function test_uncontestedLoneTicketCannotTakeJackpotAheadOfContestedWindow() public {
              // Contested window 6: three real guessers.
              vm.prank(alice);
              game.betExact(6, 1000, 10e18);
              vm.prank(bob);
              game.betExact(6, 1200, 10e18);
              vm.prank(carol);
              game.betExact(6, 800, 10e18);
              // Attacker is the only EXACT entrant of window 5 (a quiet hour): minimum 0.5 IMD, any guess.
              vm.prank(attacker);
              game.betExact(5, 0, 0.5e18);
              (, uint64 end6) = game.windowTimes(6);
              vm.warp(end6);
              // Sponsor donates 1000 prize tokens while both windows are settleable.
              prize.mint(address(game), 1000e18);
              // Attacker settles its own window first (front-running the keeper's settle of window 6).
              _settle(attacker, 5, 123456);
              _settle(keeper, 6, 1000);
              vm.prank(attacker);
              game.claim(5);
              // Expected: an uncontested single ticket does not take the jackpot; the contested
              // window's closest guesser does. Actual: attacker holds all 1000 prize tokens for 0.045 IMD.
              assertEq(prize.balanceOf(attacker), 0, "lone uncontested ticket took the whole jackpot");
              HamsterHour.Window memory w6 = game.window(6);
              assertEq(w6.jackpot, 1000e18, "contested window received no jackpot");
          }
      }
    • lowSettling a fully void window (fee-less) still draws the 0.5 IMD oracle reimbursement, enters the 7-window line history and consumes a reserve-release tranchesrc/HamsterHour.sol:346

      settle() runs _reserveReimbursement (line 346), recentResults[...] = w.result (line 349) and the release-tranche branch of _distributeStaking (line 601) unconditionally after the fee loop, even when every funded market was voided and stakingShare == oracleShare == 0.

      A lone one-sided 0.5 IMD OVER bet creates a window that settles void and refunds in full, so an actor can place one on every future window at zero net IMD cost, settle each after it ends with an attestation and (a) take 0.5 IMD per window from a reserve it contributed nothing to (bounded leak: 0.5 IMD per hourly window nobody else settles first; profitable only if the external attestation costs less than 0.5 IMD, which is not known here), (b) choose which results enter currentLine(): settling only windows in which the hamster slept drives the line to 0, after which every later-created window snapshots line 0 and UNDER can never win, and (c) burn through the 30 pre-staking release tranches with fee-less settlements.

      The brief funds the reserve with 3% of pools 'that reimburses whoever supplied the settlement attestation' and defines the line as the average over settled windows, so these are weakened guarantees rather than fund loss. Merged from audit_economics' reimbursement and line-history findings (same trigger, one fix).

      Fix: treat a settlement in which no market took fees as not fee-bearing: skip _reserveReimbursement (also in _claim's deferred path), do not record it in recentResults, and do not count it as a release window; or at minimum gate the reimbursement on oracleShare != 0.

      donor fundOracleReserve(10e18).

      For k in 0..19: attacker betOverUnder(k, true, 0.5e18) (sole bettor).

      Then for each k: warp to windowTimes(k).endsAt, attacker settle(k, attestation(result 0), sig), attacker claim(k).

      Expected: void windows take no fee and fund/receive no reimbursement, oracleReserve stays 10e18, the attacker only gets its 20 x 0.5 IMD refunds back, and currentLine() stays 1000 (no fee-bearing settlement).

      Actual (test/scratch/Checks.t.sol::test_voidWindowReimbursementAndLine, passes on current code): oracleReserve == 0, attacker IMD balance up by exactly 20e18 (10 IMD refunds + the whole 10 IMD donation), currentLine() == 0, settledWindows == 20.

      In test_teamMonopolisedStakingToken a run where 30 settlements all voided (result == line) still consumed all 30 release tranches.

    • lowStake weight is read at the settlement instant with no lock or snapshot, so a same-block stake/settle/claim/unstake sandwich takes the whole 5% staker sharesrc/HamsterHour.sol:614

      _distributeStaking credits the window's 5% share plus any released pre-staking tranche and backlog to totalStaked at the instant settle() runs; stake() and unstake() have no lock, delay or snapshot, and settle() is permissionless and can be bundled. An actor who stakes a large (possibly flash-loaned) amount of the staking token in the same block as a settlement, then claimStaking() and unstake(), receives share * X/(X+H) and returns nothing.

      On mainnet any settlement with a non-trivial share can be sandwiched by a builder-side bundle, so long-term stakers earn ~nothing and the 30 pre-token reserve tranches are captured the same way.

      This follows the brief's literal design ('distributed pro rata by stake-weight at each settlement (accumulator pattern)') and README states it deliberately ('no holding-period economics were added'), so it is a design risk requiring the requester's decision rather than an implementation bug, and no proof is attached: fixing it changes the agreed rule (e.g. weight by stake present at the window's startsAt/endsAt, or a minimum staking epoch).

      Merged from write_foundry_tests (info) and audit_economics (medium; its proof .imd/reads/proofs/Proof_4b28309ea1b9.t.sol fails on this code as described).

      Victim: honest stakers' rewards, not principal.

      stakingToken configured. honest stake(100e18) ten days earlier. alice betOverUnder(5,true,1000e18), bob betOverUnder(5,false,1000e18).

      Warp to windowTimes(5).endsAt.

      In one block: attacker stake(1_000_000e18); keeper settle(5, attestation(1500), sig); attacker claimStaking(); attacker unstake(1_000_000e18).

      Expected per the brief's intent: the long-term staker receives ~100 IMD, the transient stake ~0.

      Actual: attacker IMD balance rises by 99990000999900009999 (99.99 IMD), honest pendingStaking == 9999000099990000 (0.00999 IMD), attacker's staking tokens fully returned.

      Run: forge test --match-path test/scratch/P4_FlashStake.t.sol (fails on current code: 'same-block staker captured the staker share: 99990000999900009999 != 0').

    • lowEXACT tier prizes and the jackpot split per ticket, not per stake, so minimum-stake copy tickets drain a large tied stakesrc/HamsterHour.sol:476

      OVER/UNDER and SLEEP pay winners pro rata by stake (_binaryClaim, mulDiv(rewards, weight, side)), but _claimBets pays each EXACT tier equally per ticket (line 476) and _claim pays the jackpot equally per closest ticket (line 452), ignoring exactAmount.

      Every guess is public (Bet event selection, exactGuess view) and betting stays open until the window starts, so anyone can copy a visible large ticket's guess from up to 255 fresh addresses at MIN_BET and take 255/256 of the tier prize and jackpot that the large stake funded; the one-ticket-per-address rule gives no protection, and 256 x 0.5 IMD tickets can also fill the market (ExactFull) to exclude others.

      The brief says both 'ties split equally' and '91% to winners pro rata'; README documents the per-ticket reading ('A larger EXACT stake does not buy a larger tied payout') and test_exactTiesSplitEachDistinctDistanceTierEqually pins it, so this is a specification ambiguity for the requester to settle, not a coding error: under the current rule every EXACT stake above MIN_BET is strictly dominated and parasitised. Merged from write_foundry_tests and audit_permissions.

      Fix if pro rata is intended: weight tier and jackpot shares by exactAmount within a tier like _binaryClaim; otherwise state in the brief/README/UI that EXACT stakes above the minimum carry no additional payout.

      Window 0: alice betExact(0, 1000, 100e18); ten fresh addresses each betExact(0, 1000, 0.5e18); settle(0) with result 1000.

      Pool 105 IMD, EXACT winner allocation 95.55 IMD, rank 0 has 11 tickets.

      Expected under pro rata: alice ~91 IMD.

      Actual: alice claim(0) pays 8686363636363636363 (8.686 IMD = 95.55/11) for 100 IMD staked; the ten copiers receive 86.86 IMD for 5 IMD staked (test/scratch/Checks.t.sol::test_exactPerTicket, passes on current code).

    • lowA scheduled setting never expires: TEAM can execute a 48h-old sourceUrl or genesis proposal at any later moment, including the block of a victim's first betsrc/HamsterHour.sol:700

      _execute requires only block.timestamp >= readyAt and the matching value hash; a scheduled value stays executable forever.

      TEAM can schedule a sourceUrl it controls, wait 48 h, hold it for months, then execute it in the same block (front-running) as a user's first bet on a new window: _open snapshots w.source = sourceUrl (line 294) at that moment, so that window's oracle question names TEAM's site and the panel reads whatever TEAM publishes there, deciding OVER/UNDER, SLEEP, EXACT and the jackpot for every bettor in that window.

      The 48 h notice the brief relies on is then stale and the ConfigChanged event lands in the same block as the bet. Trust gap in a sanctioned TEAM power (reported by audit_permissions); the guard itself is correct. Fix preserving the design: give a proposal an execution window (e.g. readyAt .. readyAt + 7 days, after which it lapses and must be rescheduled), and document that sourceUrl selects the result source.

      TEAM scheduleSourceUrl('https://team-controlled.example'); warp 60 days with no execution; TEAM setSourceUrl('https://team-controlled.example') succeeds (readyAt long passed, no upper bound at line 700); alice betExact(k, 1000, 10e18) in the same block for a fresh future window k.

      Expected: a proposal 58 days past its ready time should not be executable without fresh notice.

      Actual: window(k).source == 'https://team-controlled.example' (test/scratch/Checks.t.sol::test_prearmedSourceUrl passes on current code).

    • lowGenesis change is unbounded: TEAM can stop all new windows or make bets placed after the change lock for a centurysrc/HamsterHour.sol:679

      The brief grants TEAM a timelocked genesis correction so the contract clock tracks Squeekchain; _validateGenesis rejects only 0 and uint64 overflow. (1) genesis = type(uint64).max - 3600 - 172800 makes windowTimes revert InvalidWindow for every k >= 1 while k < closedWindowFloor is BettingClosed, so no window can be created until a further 48 h-delayed change: an effective pause, which the brief excludes ('No other TEAM powers').

      (2) genesis 100 years ahead keeps betting open on every uncreated window, and any bet placed after the change is held until that schedule's endsAt + 48 h, because the timeout refund derives from the snapshotted schedule. Existing windows are unaffected and no funds already deposited are lost. Trust assumption on a sanctioned power (reported by audit_permissions); the moving-earlier case that reopens decided windows is finding 2.

      Fix preserving the design: bound each change relative to the current value or block.timestamp (e.g. within +-7 days of now) and document that bets after a pending genesis change must re-read windowTimes.

      (1) At GENESIS+1h TEAM scheduleGenesisTimestamp(type(uint64).max-3600-172800); warp 48h; setGenesisTimestamp(same): closedWindowFloor == 50; betOverUnder(49,true,1e18) reverts BettingClosed; betOverUnder(50,true,1e18) reverts InvalidWindow (test/scratch/Checks.t.sol::test_genesisMaxFreezes passes on current code). (2) setGenesisTimestamp(GENESIS + 100*365 days) likewise passes _validateGenesis; alice betOverUnder(60,true,1e18) succeeds; windowTimes(60).startsAt = g + 60h; claim(60) reverts NotFinal until endsAt + 48h in ~2126, then refunds.

    • lowTrust assumption: TEAM's one-shot staking token choice decides who can earn the 5% staker share and the whole pre-staking reservesrc/HamsterHour.sol:629

      setStakingToken is correctly guarded (onlyTeam, 48 h timelock, one-shot) and validates only nonzero, not-self and has-code.

      The actor it permits chooses which token counts as stake; if TEAM selects a token only TEAM holds or can mint, only TEAM can stake, so the 5% of every bet accrued before configuration (preStakingReserve, released over 30 settlements) and the 5% of every later window go to TEAM, i.e. up to 6% of all non-void bets against README's 'TEAM gets 1% of non-void bets and nothing else as a privileged beneficiary' and the brief's 'No other TEAM powers'.

      Documented privileged power, not a bypass (merged from audit_permissions and audit_economics). Handling within the approved design: state the assumption in README/UI and have the review of the eventual scheduleStakingToken proposal check the token's supply control; a design change would be fixing the token in the brief.

      10 windows settle with 200 IMD OU pools before any staking token exists -> preStakingReserve == 100e18.

      TEAM deploys Tok, mints 1e18 to itself only, scheduleStakingToken(Tok), warp 48h, setStakingToken(Tok), stake(1e18).

      30 later windows with 200 IMD pools settle non-void.

      TEAM claimStaking() pays 400000000000000000000 = 100 IMD reserve + 30 x 10 IMD share, i.e. 5% of all 8000 IMD bet, on top of the 1% teamCredit (test/scratch/Checks.t.sol::test_teamMonopolisedStakingToken passes on current code).

    • infoAnyone can create windows arbitrarily far ahead, pinning today's line and sourceUrl for them beyond the reach of a later correctionsrc/HamsterHour.sol:289

      _open accepts any window id whose schedule start is in the future (up to the uint64 bound). A 0.5 IMD bet on window 100000 freezes line = currentLine() and source = sourceUrl at creation. A later TEAM setSourceUrl (e.g. because the Squeekchain host moves) cannot reach such windows, so their oracle question names a dead URL and they can only void at timeout; their frozen line is stale by the time they run.

      No funds are lost (void refunds) and README documents 'There is no betting horizon', so this is recorded for the requester as a design note (reported by write_foundry_tests). Consider bounding how far ahead a window may be created (e.g. a few windows past currentWindow()).

      Pre-genesis: betExact(100000, 1, 0.5e18) succeeds; window(100000).line == 1000 and .source == the current sourceUrl. After TEAM schedules and executes a new sourceUrl, window(100000).source is unchanged (test/EdgeCases.t.sol::test_farFutureWindowFreezesCurrentLineAndSourceAtCreation in the tree pins this behaviour).

    • infoTrust assumption: the oracle signer is immutable with no rotation, so an attester key change permanently disables settlementsrc/OracleVerifier.sol:10

      The brief forbids TEAM powers beyond the four timelocked settings, so OracleVerifier pins one EOA. launch.json passes 0x5598aa9146215bc13eb26f2c692ad1461fd32982; I confirmed on 2026-10-16 that https://api.imdnotdead.fun/oracle/requests?limit=1 reports exactly this address as 'attester' and 'signer'.

      This is the intended design, not a defect, but a material operational dependency: if IMD rotates or loses that key, every settle() reverts BadSignature, every open window voids after 48 h and refunds, and the game can never settle again; a new deployment is the only remedy. No funds are lost. Reported by audit_flow; recorded for the launch owner and the README's 'results only from the IMD oracle' claim.

      Any Open window k past endsAt; the attester signs with key K2 != oracleSigner. settle(k, a, sig_K2) -> OracleVerifier.verify -> tryRecover returns K2's address != oracleSigner -> revert BadSignature. After endsAt + 48h, claim(k)/voidWindow(k) refund every bettor; no window created afterwards can reach Settled.

  9. Build contract projectAgent #3078 files changed

    I fixed all three required findings, and the three reviewer proofs now pass. forge build and forge fmt --check are clean, and forge test with the verifier's fuzz seed passes all 63 tests (59 existing plus 4 new). The proofs were run from copies in test/scratch/. My answer to every finding is in .imd-responses.json. I haven't committed anything.

    Required findings (all reproduced, all fixed in src/HamsterHour.sol):

    • Attestation signed before the window ended (3b45…): settle() now rejects any attestation with issuedAt < endsAt. The 300 s allowance remains only for an oracle clock running ahead of the chain. The cost: if the oracle's clock runs behind the chain, the keeper has to request the attestation again.
    • Moving genesis earlier left windows open (e91c…): a window that already exists now stops taking bets at the earlier of its stored start and its start under the corrected genesis. Its stored end still controls settlement and the 48-hour refund timeout.
    • Lone ticket taking the jackpot (9333…): a window with fewer than two EXACT tickets can no longer take the jackpot, which stays for a later window with real competition. The lone ticket still gets its normal EXACT payout. This only partly closes the hole: someone using two addresses still counts as a contest, and windows can still be settled in any order. Tying the jackpot to window order would be a bigger design change for the requester.

    Advisory findings:

    • Fixed (3a55…): a settlement where every market refunded takes no oracle fee, so it no longer pays the 0.5 IMD reimbursement either. I left the line history and the staking release tranches as they were, because the brief defines both in terms of settled windows.

    • Disputed, no code change:

      • Same-block staking can capture the staker share (31ec…).
      • EXACT ties split per ticket rather than by stake (ba45…).
      • Scheduled settings never lapse (b76…).
      • Genesis changes have no bound (e63c…).
      • TEAM's choice of staking token (e15c…).
      • Windows can be created arbitrarily far ahead (3325…).
      • The oracle signer can't be rotated (047b…).

      Each of these follows the brief's wording, or is a TEAM power the brief allows, so changing it is the requester's call. I added each one to a new "Trust assumptions recorded at review" section in the README.

    Tests and docs: five existing jackpot tests had only one EXACT ticket, so I gave them a second one and adjusted the expected payouts. The predated-attestation test now expects the stricter rule. I added tests for:

    • an earlier genesis closing existing windows;
    • a later genesis keeping the stored start as the close;
    • a lone ticket not getting the jackpot;
    • no reimbursement when nothing took a fee.

    The README, docs/ORACLE.md and docs/VERIFICATION.md describe the new behaviour. The public ABI is unchanged, so docs/abi/ still matches the build.

    ran onclaude · claude-opus-5-5 · 22 turns · 5m 52s · 42 in · 17.7K out · 1.6M cached
    submission4564bb9f42a7260340f225f74c2ebb57c50cdb0ca1189bab7328aaa1c36dded7
    deviceb0001bc8a5c908cd61a4d5cd5abe4fe4d4b0faf3f89216cdf26704913e9b5217
    started from0c95b0371e186ca7b44dafcad442d7108ee46076
    bundle00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe · 167 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 8 files
    README.mddocs/ORACLE.mddocs/VERIFICATION.mdsrc/HamsterHour.soltest/Configuration.t.soltest/Markets.t.soltest/Oracle.t.soltest/StakingJackpot.t.sol
  10. ManifestAgent #442no changes

    The manifest is verified and needs no revision. The assignment said to revise according to findings, but no findings were supplied, and every check I ran confirms the accepted launch.json already describes the accepted source exactly.

    What I verified

    • Schema: launch.json has exactly the keys kind, contracts and notes. Kind is "evm_contracts", both names are short unique identifiers, every argument is a string, and notes is well under the 4000 character limit.
    • OracleVerifier: its constructor takes one address, the signer. The manifest passes the IMD oracle attester key, which matches the README deployment table, docs/references/oracle-consumer.md and the real signed fixture in docs/references. It is an external signer from the oracle scheme, not an owner, so it is correctly a static address rather than $owner.
    • HamsterHour: its constructor takes team_ then verifier_. The manifest passes $owner then $contract:OracleVerifier, in declaration order, with the verifier deployed first so the backward reference resolves.
    • Privileges: neither constructor is payable, calls another contract, or assigns anything to msg.sender or tx.origin. TEAM is immutable and set only from the owner argument, which satisfies the owner floor test.
    • Build and tests: forge build succeeds and the full suite passes with the verifier's fuzz seed.
    CheckResult
    forge test, 8 suites61 passed, 0 failed
    scratch manifest deployment on empty chain id 1passed

    The scratch test lived in test/scratch and is not part of the submission. No tracked file changed, so the working tree is identical to the accepted round. Nothing further is needed from the requester.

    ran onclaude · claude-fable-5-1 · 11 turns · 1m 51s · 194 in · 4.2K out · 297.8K cached
    submission39d02583fd0cc8b5b71920c6116018c3ca41bea88553cef4a5f83139a57a23bb
    deviceea89e16822824c6f2a87d26cbd52d3a3bab2b7664b8d92898f6fb5bf24f419ec
    started from74a71d9a53332e126977abdc9bed4a20c32d2657
    bundle3b7f1515b607821a7e05539c59d531c138b1983cf8f38c5ce6b2e72dfcb9a378 · 170 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    may write
    launch.json
  11. Write foundry testsAgent #8773 files changed

    Done. The suite builds and passes, and the one defect I found is reported rather than tested around.

    What changed in the source since my previous round (by the implementation assignment): betting on an existing window now also closes at its start under the current genesis, settlement requires issuedAt >= endsAt with no tolerance, a settlement where every market refunded earns no reimbursement, and a lone EXACT ticket never takes the jackpot.

    What I did in my three files only (test/EdgeCases.t.sol, test/Properties.t.sol, test/GameInvariant.t.sol):

    • Repaired the three tests that encoded the old behaviour: the end-of-window settlement test now re-issues the attestation at the end and asserts the stale one is refused atomically, and the two jackpot tests use two tickets per window so they are contested.
    • Added nine unit edge cases: issuance at exactly the end accepted, five minutes ahead accepted and one second more refused, a predated attestation refused for the whole 48 hours, a lone ticket beside live binary markets and after a contested window, a tie of two tickets counting as a contest, a late second ticket not making a contest, fully refunded settlements never reimbursing even after later funding, one live market being enough to reimburse, an equal-line refund taking no reimbursement, and genesis corrections closing at the exact corrected second while settlement and timeout keep the stored schedule.
    • Added three fuzz properties: the view and all three bet entries agree with the earlier-of-snapshot-and-corrected-start rule for genesis moved either way, issuance before the end is never accepted while issuance in the valid range always is, and a lone ticket never reserves a jackpot of any size and the next contested window takes all of it.
    • Extended the invariant handler with an action that moves genesis earlier, so random sequences now cover both directions of correction alongside bets, settlements, timeouts, claims, staking and donations.

    Results with the verifier's fuzz seed:

    CheckOutcome
    forge buildclean
    forge test, excluding scratch111 passed, 0 failed
    Invariant handler, earlier-genesis action522 calls, 0 reverts

    One finding reported in .imd-findings.json at low severity, with a self-contained proof test that fails on the current code. A window closed by an earlier genesis correction reopens for betting when TEAM later postpones genesis again, because the close rule compares against the current genesis instead of latching. That lets someone bet on rounds whose results are already public. It needs two timelocked TEAM proposals, so it is a hardening gap rather than an exploit against an honest TEAM. The proof lives in test/scratch/, which is not submitted.

    ran onclaude · claude-fable-5-1 · 31 turns · 8m 19s · 610 in · 29K out · 1.6M cached
    submission2f205097124fc5880a1d0337a6d7d6cb6aa7fc1e5043635313cb8eb65e179c3b
    devicefeba2a869621cab2a2068364376f8299e33ba3242048d977089026e407177586
    started fromc323d26a3d695f43eef59c1d7efbc6f1514ad8d6
    bundle5db79ea46c5b92b61d3f6ebc45a8e6cd197e26ca6e94c308e151f29f2e434226 · 185 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe
    changed · 3 files
    test/EdgeCases.t.soltest/GameInvariant.t.soltest/Properties.t.sol
    may write
    testtest/**
    • lowWindow closed by an earlier genesis correction reopens for betting when genesis is later postponedsrc/HamsterHour.sol:304

      The new betting-close rule in _open/isBettingOpen compares block.timestamp with _scheduledStart(k) under the current genesis. It is not a latch.

      If TEAM corrects genesis earlier (closing an existing window whose corrected start has passed) and later executes a second, timelocked correction moving genesis later again, the window reopens for betting until its snapshotted startsAt, although under the corrected clock its six rounds already ran and their results are public on the source.

      A bettor who reads the published rounds can then bet with certainty on OVER/UNDER, SLEEP and EXACT against the earlier honest bettors' pools. The README and brief say betting on window k is open until round 6k starts; a round that has started should never become bettable again.

      Impact is bounded by two TEAM proposals, each with 48-hour notice, and the contract treats TEAM as trusted for configuration, so this is a low-severity hardening gap rather than an exploit against an honest TEAM.

      Suggested fix: record a per-window 'bettingClosed' latch (or store the earliest effective close) when a bet or view first observes block.timestamp >= _scheduledStart(k), or compare against the minimum scheduled start ever applied to the window, so a later postponement cannot reopen it.

      Genesis G=1791847800.

      At G-30d alice bets 1 IMD on window 200 (snapshot start G+200h).

      TEAM schedules then executes genesis G-150h (window 200 now starts at G+50h).

      At G+50h isBettingOpen(200)==false (correct).

      At G+51h TEAM schedules genesis G again; executes at G+99h.

      Expected: isBettingOpen(200)==false and betOverUnder(200,...) reverts BettingClosed.

      Actual: isBettingOpen(200)==true and bob can place 50 IMD on window 200 at G+99h, 48 hours after its rounds ended under the corrected clock.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      
      contract ReopenToken {
          string public constant name = "IMD";
          string public constant symbol = "IMD";
          uint8 public constant decimals = 18;
          uint256 public totalSupply;
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
      
          function mint(address to, uint256 amount) external {
              totalSupply += amount;
              balanceOf[to] += amount;
          }
      
          function approve(address spender, uint256 amount) external returns (bool) {
              allowance[msg.sender][spender] = amount;
              return true;
          }
      
          function transfer(address to, uint256 amount) external returns (bool) {
              balanceOf[msg.sender] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      
          function transferFrom(address from, address to, uint256 amount) external returns (bool) {
              allowance[from][msg.sender] -= amount;
              balanceOf[from] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      }
      
      /// @notice A window closed by an earlier genesis correction reopens for betting when TEAM later
      /// postpones genesis again, although under the corrected clock its rounds have already run.
      contract GenesisReopenTest is Test {
          uint64 constant GENESIS = 1791847800;
          address constant TEAM = address(0xBEEF);
          HamsterHour game;
          ReopenToken imd;
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 30 days);
              OracleVerifier verifier = new OracleVerifier(vm.addr(1));
              game = new HamsterHour(TEAM, address(verifier));
              ReopenToken template = new ReopenToken();
              vm.etch(game.IMD(), address(template).code);
              imd = ReopenToken(game.IMD());
              imd.mint(alice, 100 ether);
              imd.mint(bob, 100 ether);
              vm.prank(alice);
              imd.approve(address(game), type(uint256).max);
              vm.prank(bob);
              imd.approve(address(game), type(uint256).max);
          }
      
          function _setGenesis(uint64 g) internal {
              vm.prank(TEAM);
              game.scheduleGenesisTimestamp(g);
              vm.warp(block.timestamp + 48 hours);
              vm.prank(TEAM);
              game.setGenesisTimestamp(g);
          }
      
          function test_onceClosedByCorrectionAWindowMustStayClosed() public {
              uint256 k = 200; // snapshotted start GENESIS + 200h
              vm.prank(alice);
              game.betOverUnder(k, true, 1 ether);
              // Correction: the real clock is 150 hours ahead of the supplied genesis. Executes at GENESIS - 28d.
              _setGenesis(GENESIS - 150 hours);
              // Under the corrected clock window 200 starts at GENESIS + 50h. Betting closes then.
              vm.warp(GENESIS + 50 hours);
              assertFalse(game.isBettingOpen(k));
              // Under the corrected clock all six rounds of window 200 have ended by GENESIS + 51h.
              vm.warp(GENESIS + 51 hours);
              // TEAM moves genesis back to the original value (48 hours notice, executes at GENESIS + 99h).
              _setGenesis(GENESIS);
              // Expected: the window stayed closed; its rounds ran 48 hours ago and results are public.
              // Actual: betting is open again until the snapshotted start at GENESIS + 200h.
              assertFalse(game.isBettingOpen(k), "window closed by a correction reopened after a postponement");
              vm.expectRevert(HamsterHour.BettingClosed.selector);
              vm.prank(bob);
              game.betOverUnder(k, false, 50 ether);
          }
      }
  12. Audit judgeAgent #900found 1 low

    The review is complete and the findings file is written. Recap:

    Settled from last round. Every proof was run against the revised tree.

    • Fixed, confirmed: predated attestation (settle now rejects issuedAt below endsAt at line 326), earlier genesis keeping decided windows bettable (lines 239 and 304 close at the earlier of stored and corrected start), lone EXACT ticket taking the jackpot (line 534 requires two tickets), and fee-less settlement drawing the 0.5 IMD reimbursement (line 357). All three earlier proofs pass now, and the full suite of 111 tests is green.
    • Disputed lows, accepted and not restated: same-block stake sandwich still reproduces but follows the brief's stake-weight-at-settlement rule, EXACT per-ticket ties follow the brief's "ties split equally", and the non-lapsing proposals, unbounded genesis, staking-token choice, far-future windows and immutable signer are now recorded in the README trust section. These are requester design decisions, not implementation defects.

    One new finding, low. The write_foundry_tests specialist's reopen case reproduces: its proof fails on this tree. If TEAM moves genesis earlier, which closes an existing window, and later postpones genesis again, that window becomes bettable until its stored start even though its rounds already ran under the corrected clock. It needs two timelocked TEAM actions, so it is a hardening gap, not an exploit against an honest TEAM. The fix is a per-window latch or storing the earliest observed scheduled start.

    Checked and dropped. The static-analysis leads do not hold: the assembly shift order at line 558 is correct Yul, the state write before the verifier call at line 327 targets an immutable trusted contract, and the strict equality in the timelock is a benign zero check. Other specialists' reports were duplicates of the settled items.

    Coverage. All 20 entry points answered: 16 hold, 4 reference the new finding (the three bet functions and setGenesisTimestamp). Seven extra rows record each prior finding's settlement. The scratch directory was removed, so the only file in the tree is .imd-findings.json.

    ran onclaude · claude-fable-5-1 · 16 turns · 3m 46s · 194 in · 12.6K out · 558.8K cached
    submission5b6abaa4178cea66cef9fe5cbaa19d760924b0db45b76e8607a92d26cdd21535
    device1f9cb1750ebf47ed3be909220d4cf61dbc3b9cdb889c7f01c48bffe1ffde44d9
    started from2ab25dec766d1acdfcbf669f0277abf474b40d90
    bundlenone
    applied on00e14aa1a66e1314ce3df0410e568ab6afba7fb64f2a52b25b53e48ade93d5fe, 5db79ea46c5b92b61d3f6ebc45a8e6cd197e26ca6e94c308e151f29f2e434226, 3b7f1515b607821a7e05539c59d531c138b1983cf8f38c5ce6b2e72dfcb9a378
    • lowA window closed by an earlier genesis correction reopens for betting when genesis is later postponed againsrc/HamsterHour.sol:304

      The betting-close rule added this round (_open line 304, isBettingOpen line 239) compares block.timestamp with _scheduledStart(k) under the current genesis each time; it is not a latch.

      If TEAM moves genesis earlier (closing an existing window whose corrected start has passed) and later executes a second timelocked change moving genesis later again, the window is bettable again until its snapshotted startsAt, although its six rounds already ran under the corrected clock and their pass counts are public on the source. A bettor who reads the published rounds then bets OVER/UNDER, SLEEP and EXACT with certainty against the earlier honest pools.

      Requires two TEAM proposals with 48 h notice each, so it is a hardening gap in a trusted-configuration path rather than an exploit against an honest TEAM; existing funds are not at risk beyond the one-sided information edge. Reported by write_foundry_tests; its proof fails on this tree.

      Fix preserving the design: store per window the earliest scheduled start ever observed (set in _open on creation and lowered whenever a bet or view sees _scheduledStart(k) below it), or latch a bettingClosed flag when _open/isBettingOpen first observes block.timestamp >= _scheduledStart(k), so a later postponement cannot reopen a window whose rounds have run.

      GENESIS = 1791847800.

      At GENESIS-30d alice betOverUnder(200, true, 1e18): window 200 stores startsAt = GENESIS+200h.

      TEAM scheduleGenesisTimestamp(GENESIS-150h), warp 48h, setGenesisTimestamp(GENESIS-150h): window 200 now starts at GENESIS+50h. warp GENESIS+50h: isBettingOpen(200) == false (correct). warp GENESIS+51h: TEAM scheduleGenesisTimestamp(GENESIS); warp 48h (GENESIS+99h); setGenesisTimestamp(GENESIS).

      Expected: isBettingOpen(200) == false and bob betOverUnder(200, false, 50e18) reverts BettingClosed (rounds 1200..1205 ended under the corrected clock 48 h ago).

      Actual: isBettingOpen(200) == true and the 50 IMD bet is accepted.

      Run: forge test --match-path test/scratch/Proof_4389e5ae6153.t.sol fails with 'window closed by a correction reopened after a postponement'.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {HamsterHour} from "src/HamsterHour.sol";
      import {OracleVerifier} from "src/OracleVerifier.sol";
      
      contract ReopenToken {
          string public constant name = "IMD";
          string public constant symbol = "IMD";
          uint8 public constant decimals = 18;
          uint256 public totalSupply;
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
      
          function mint(address to, uint256 amount) external {
              totalSupply += amount;
              balanceOf[to] += amount;
          }
      
          function approve(address spender, uint256 amount) external returns (bool) {
              allowance[msg.sender][spender] = amount;
              return true;
          }
      
          function transfer(address to, uint256 amount) external returns (bool) {
              balanceOf[msg.sender] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      
          function transferFrom(address from, address to, uint256 amount) external returns (bool) {
              allowance[from][msg.sender] -= amount;
              balanceOf[from] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      }
      
      /// @notice A window closed by an earlier genesis correction reopens for betting when TEAM later
      /// postpones genesis again, although under the corrected clock its rounds have already run.
      contract GenesisReopenTest is Test {
          uint64 constant GENESIS = 1791847800;
          address constant TEAM = address(0xBEEF);
          HamsterHour game;
          ReopenToken imd;
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(GENESIS - 30 days);
              OracleVerifier verifier = new OracleVerifier(vm.addr(1));
              game = new HamsterHour(TEAM, address(verifier));
              ReopenToken template = new ReopenToken();
              vm.etch(game.IMD(), address(template).code);
              imd = ReopenToken(game.IMD());
              imd.mint(alice, 100 ether);
              imd.mint(bob, 100 ether);
              vm.prank(alice);
              imd.approve(address(game), type(uint256).max);
              vm.prank(bob);
              imd.approve(address(game), type(uint256).max);
          }
      
          function _setGenesis(uint64 g) internal {
              vm.prank(TEAM);
              game.scheduleGenesisTimestamp(g);
              vm.warp(block.timestamp + 48 hours);
              vm.prank(TEAM);
              game.setGenesisTimestamp(g);
          }
      
          function test_onceClosedByCorrectionAWindowMustStayClosed() public {
              uint256 k = 200; // snapshotted start GENESIS + 200h
              vm.prank(alice);
              game.betOverUnder(k, true, 1 ether);
              // Correction: the real clock is 150 hours ahead of the supplied genesis. Executes at GENESIS - 28d.
              _setGenesis(GENESIS - 150 hours);
              // Under the corrected clock window 200 starts at GENESIS + 50h. Betting closes then.
              vm.warp(GENESIS + 50 hours);
              assertFalse(game.isBettingOpen(k));
              // Under the corrected clock all six rounds of window 200 have ended by GENESIS + 51h.
              vm.warp(GENESIS + 51 hours);
              // TEAM moves genesis back to the original value (48 hours notice, executes at GENESIS + 99h).
              _setGenesis(GENESIS);
              // Expected: the window stayed closed; its rounds ran 48 hours ago and results are public.
              // Actual: betting is open again until the snapshotted start at GENESIS + 200h.
              assertFalse(game.isBettingOpen(k), "window closed by a correction reopened after a postponement");
              vm.expectRevert(HamsterHour.BettingClosed.selector);
              vm.prank(bob);
              game.betOverUnder(k, false, 50 ether);
          }
      }
  13. Deployed2 contracts on Ethereum mainnettransaction
    rebuilt
    HamsterHour, OracleAttestation, OracleQuestion, OracleVerifier · verifier 0.1.0 · solc 0.8.26
    gates
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-1951-workflow-contract-stage-context
    commit
    7ab2a01b3d9a15e2acca8b317e77058e6f2d41e9
    attestation
    b917945ed7708748cd8eaa1f0167ffd922a021b7c08f32b99ca4d2c8adc13ad6
    manifest
    e81867636400070975cf5d5b7c0f26c7f85b33455d82d075e9fbe83999fcd09b
    constructor
    OracleVerifier: 0x5598aa9146215bc13eb26f2c692ad1461fd32982
    constructor
    HamsterHour: $owner, $contract:OracleVerifier
    tree
    a828a5ce483f8bd9964ccef8759f0aa4a146c72e
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    HamsterHour
    src/HamsterHour.sol · 21484 bytes
    creation 50b9a3621ca11063af1f9b3eabe6ee07b0bb3af8d6ca659c90b529cf23557afc
    abi e7f29a8b124c75bd841e5e60e96135f01b7f14125f2c289b89b562752d544f40
    metadata fe9655ef4e3dd896f581a8c2e41c3c525ebf514cf6aab9f24e8f1c3854753fda
    onchain at 0xc521…64b1, block 26,204,211 · creation code matches
    contract
    OracleAttestation
    src/libraries/OracleAttestation.sol · 94 bytes
    creation 03f00af6a2c1e216c5142290f5a7c5a73b7dca9ff4182f298fb7a6b46fc82bef
    abi 518674ab2b227e5f11e9084f615d57663cde47bce1ba168b4c19c7ee22a73d70
    metadata ed70fcfe57f51757427e8dceac0f051b4413349bb11000f441de72610fc46011
    contract
    OracleQuestion
    src/libraries/OracleQuestion.sol · 94 bytes
    creation 03f00af6a2c1e216c5142290f5a7c5a73b7dca9ff4182f298fb7a6b46fc82bef
    abi 518674ab2b227e5f11e9084f615d57663cde47bce1ba168b4c19c7ee22a73d70
    metadata bbea0b598dbb82197e78318b770429b6d134494c255f0cd590843ba71681bca3
    contract
    OracleVerifier
    src/OracleVerifier.sol · 3148 bytes
    creation cd2a18d597a1635598c59a27442d774b0ad98830674bf61c211d0d6f000758bb
    abi f9bbf3c804c63e5ff0b155f43a412745e7623689907bbd3ba1b00aaf40ec2173
    metadata 8be5016cba56bd6b5482498e778a460140e9e657f43d9955b4f116d32e03d406
    onchain at 0xdcd7…1f7d, block 26,204,211 · creation code matches
  14. Frontend for contractAgent #70566 files changed2 attempts
    #68Codexruntime erroron the agent's machine: Your workspace is out of credits.

    Failed on the agent's machine.

    Your workspace is out of credits. Ask your workspace owner to refill in order to continue.

    ran oncodex · gpt-6-astra · 7 turns · 29m 46s
    submissiona7e3b02fadaa1d1d9e2d8fd82b11e401125a59578aca89dc1249af5295485e52
    device0fce87924985c9baa5157fddf1f0a6b9eeaaa64356c663e736c73b940f8b0f30
    started from7ab2a01b3d9a15e2acca8b317e77058e6f2d41e9
    bundlenone
    #705Codex66 files changed

    Built the frontend, static export, and validation documentation.

    Build/typecheck, 10 unit tests, 14 browser scenarios, ABI hashes, and asset checks passed. The stream feed returned 404; no real transactions were sent.

    Git staging/commit was blocked by the read-only .git directory. Files are ready for the worker’s submission step.

    ran oncodex · gpt-6-astra · 12 turns · 41m 53s · 217.8K in · 84.5K out · 8.9M cached
    submissionf07741f9614aed25812b1ae5b2cfdf6eceede65e24a7a2da36e8179fddd3e502
    devicef74e4c7229c480f9666f4d67ed1c428d4ab5da222c398629cef79c47757fc033
    started from7ab2a01b3d9a15e2acca8b317e77058e6f2d41e9
    bundle0e14f2e3ce4fc24adbdbe03162c84d10c8fcb563d23492ed6faefa193a3e77ef · 631 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 66 files
    dist/abi/HamsterHour.jsondist/abi/OracleVerifier.jsondist/assets/ccip-NlJQI3Yh.jsdist/assets/dist-CC2yHjne.jsdist/assets/index-DFURROpn.cssdist/assets/index-DSjZPsWS.jsdist/favicon.svgdist/imd-deployment.jsondist/index.htmldist/share.pngdist/share.svgdist/wallets/browser.svgdist/wallets/coinbase.svgdist/wallets/metamask.svgdist/wallets/phantom.svgdist/wallets/rabby.svgdocs/DESIGN.mddocs/frontend/1440.pngdocs/frontend/390.pngdocs/frontend/ATTRIBUTION.mddocs/frontend/BETTER-INTERFACE-LICENSE.txtdocs/frontend/ETH-FRONTEND-UX-LICENSE.txtdocs/frontend/RAINBOWKIT-LICENSE.txtdocs/frontend/VALIDATION.mddocs/frontend/accessibility-results.jsondocs/frontend/interaction-results.jsondocs/frontend/live-read.jsondocs/frontend/mobile-wallet-picker.pngdocs/frontend/package-check.jsondocs/frontend/taste-summary.jsonweb/.gitignoreweb/README.mdweb/deployment/handoff.jsonweb/deployment/network.jsonweb/index.htmlweb/package-lock.jsonweb/package.jsonweb/public/favicon.svgweb/public/share.pngweb/public/share.svgweb/public/wallets/browser.svgweb/public/wallets/coinbase.svgweb/public/wallets/metamask.svgweb/public/wallets/phantom.svgweb/public/wallets/rabby.svgweb/scripts/export.mjsweb/src/App.tsxweb/src/Bet.tsxweb/src/History.tsxweb/src/Keeper.tsxweb/src/Settings.tsxweb/src/Stake.tsxweb/src/Stream.tsxweb/src/Wallet.tsxweb/src/config.tsweb/src/data.tsweb/src/main.tsxweb/src/model.tsweb/src/styles.cssweb/src/transactions.tsxweb/src/ui.tsxweb/tests/accessibility.mjsweb/tests/browser.mjsweb/tests/model.test.tsweb/tsconfig.jsonweb/vite.config.ts
    may write
    web/**dist/**docs/**web/.gitignore
  15. Design criticAgent #1763found 1 medium, 3 low2 attempts
    #652Codexbudget exhaustedon the agent's machine: wall-clock budget exhausted

    Failed on the agent's machine.

    wall-clock budget exhausted

    ran oncodex · gpt-6.1-sol · 7 turns · 39m 58s
    submission650ca8e7ba8271a323ca3cdfe00cd719830b4542261faa6fcaca3e59263c26c3
    device08261d0cc6850dafd118f8c47db593b9ab2df28810e3a3a9b3510c8b9372a982
    started from33fe273714ec984a1c54db66aeca5d2a63f02b52
    bundlenone
    applied on0e14f2e3ce4fc24adbdbe03162c84d10c8fcb563d23492ed6faefa193a3e77ef
    #1763Claudefound 1 medium, 3 low
    AreaCount
    Brief requirements14 met, 0 missing, 0 faked, 2 after review, 2 need the requester
    Broken features0
    Product on the first screen at 390yes
    taste@1 checks passed15 of 16 (the one failure is the brief's monospace choice)
    Copy findings1
    Share cardpresent (title, favicon, og:title, og:image, description)

    Findings are in .imd-findings.json: one medium and three low. No high. Nothing was changed in the tree and the probe's scratch output was removed.

    Requirements, one line each

    • "dark console style like sepolia.squeekchain.com, monospace": met, web/src/styles.css root tokens and SFMono stack. The probe's type-faces failure (monospace prose) is overridden by this quote.
    • "live Squeekchain stream embedded full width (YouTube id from …current.json)": met in code, web/src/Stream.tsx fetches the feed and embeds youtube-nocookie. Needs the requester: the feed answered 404 and without a CORS header during this review, so the page showed its honest "Wheel cam unavailable" state. Medium finding: the phone layout crops the iframe to 160px.
    • "countdown to the current window close": met, "Betting closes in" stat and "Window ends in" strip in web/src/App.tsx.
    • "the line": met, "The line 1000 passes" stat read from currentLine.
    • "live pool sizes and implied payouts per side": met, side buttons show each side's pool and multiple, and "Inside the pools" lists the three markets. They sit in the Bet tab rather than the top strip.
    • "jackpot balance": met, "Available jackpot" stat, honestly "Not configured" while no jackpot token is set.
    • "Tabs: Bet (three markets, connect wallet, approve IMD, place bet, claim)": met, web/src/Bet.tsx with approve-then-bet flow and the Claims section. Sending a transaction needs a wallet, so it was not exercised.
    • "Stake ('staking opens with the token' until set, then stake, unstake, claim)": met, web/src/Stake.tsx shows "Staking opens with the token" and the reserve, with the full form once set.
    • "History (settled windows with links to the oracle attestation on explorer.imdnotdead.fun)": met, web/src/History.tsx links to explorer.imdnotdead.fun jobs and the signed attestation. Zero settled windows exist yet, so the empty state showed.
    • "Mobile first": met, no horizontal scroll at 320, 390 or 1440, product and the Connect button on the first 390 screen.
    • "No fake data": met, every value comes from chain reads, and unavailable values say so.
    • Deployment config loaded at runtime, ABIs verified by hash: met, web/src/config.ts loads imd-deployment.json and checks keccak of each ABI. The page reported "Block 26204779 · synced", so verification passed against mainnet.
    • Addresses and chain match the handoff: met, manifest contracts, chain 1, sourceCommit and attestationHash equal the handoff. All 15 asset SHA-256 values match the files. ABI files are byte-identical to docs/abi.
    • Share card: met, og:image is a relative share.png, which is fine before hosting.
    • GitHub source publishing and IPFS hosting: after review.
    • Oracle evidence links depend on api.imdnotdead.fun and explorer.imdnotdead.fun answering: needs the requester.

    The idea: a dark monospace betting console under a live hamster cam, carried by one lime accent on the selected side and the bet button.

    Not verified: WebKit was not available on this machine, so the Safari pass did not run. The CSS uses only widely supported features, and the probe ran in Chromium at all three widths.

    ran onclaude · claude-fable-5-1 · 28 turns · 4m 11s · 322 in · 14.6K out · 804.6K cached
    submission28efc19db6f0f133f19384845c9372c6098d464da8fb59b3ffc6223bded90852
    device7c0191a32541eb746c94deddf06264811dcb25a6c776b6b15a4a6ef0ff78717d
    started from33fe273714ec984a1c54db66aeca5d2a63f02b52
    bundlenone
    applied on0e14f2e3ce4fc24adbdbe03162c84d10c8fcb563d23492ed6faefa193a3e77ef
    may write
    web/**dist/**docs/**web/.gitignore
    • mediumfidelity: the live stream is cropped to 160px tall on phones, letterboxing the wheel camweb/src/styles.css:1233

      Now: the second max-width 620px block (styles.css:1232-1235) overrides the earlier 16:9 rule at styles.css:1005-1007 and fixes the YouTube iframe at 160px tall. At 390 the embed is 358px wide, so a 16:9 feed needs 201px; YouTube letterboxes the live picture to about 284x160 with black bars either side, and at 320 the video drops to about 252px wide inside a 296px frame.

      Change to: delete the four lines 1232-1235 so the earlier .stream iframe { aspect-ratio: 16/9 } rule applies; the overview strip moves down 41px at 390.

      Why: the brief asks for 'the live Squeekchain stream embedded full width' at the top of the page, and the phone layout is the mobile-first one.

      Width 390.

      Load dist/index.html with a feed that lists a YouTube id (media.squeekchain.com/live/current.json answered 404 during this review, so the empty state showed instead).

      Expected: the iframe box is 358x201 (16:9).

      Actual by the shipped CSS: computed height of .stream iframe is 160px at any width up to 620px because the later @media (max-width: 620px) block at styles.css:1201 sets height: 160px; aspect-ratio: auto, overriding the aspect-ratio: 16/9 at styles.css:1005-1007 in the earlier block of the same media query.

    • lowtaste: the Bet tab carries the same arrow glyph the page uses for external linksweb/src/App.tsx:139

      Now: each tab has a decorative glyph: 'Bet ↗', 'Stake +', 'History ↶'. On the same page '↗' marks every outbound link ('Open source ↗', 'Connect wallet ↗', 'Deployment record ↗', 'Oracle evidence ↗'), so the selected Bet tab reads as a link that leaves the page, and '+' and '↶' carry no meaning.

      Change to: delete the span on App.tsx:139 and the .tabs button span rules at styles.css:331-333 and 1040-1042; the tab labels stay left-aligned in their 48px buttons and nothing else moves.

      Why: one glyph means one thing across a page; the reference asks to delete the element before adding structure.

      Width 390 and 1440.

      Load dist/index.html and look at the tab bar under the sync line.

      Evidence: test/scratch/taste/shots/390.png shows 'Bet ↗' selected beside 'Stake +' and 'History ↶', while the header shows 'Connect wallet' and the stream bar 'Open source ↗' using the same arrow for a link that opens sepolia.squeekchain.com.

    • lowtaste: two slogans sit inside a tool page, one over the pool fee line and one in the footerweb/src/Bet.tsx:342

      Now: the market aside has an h3 'Shared pools. Shared upside.' above the fee split sentence, and the footer opens with 'One wheel. Open rules.' (App.tsx:175).

      The page is a betting console read to place a bet, so headings should name the task; these two are voice lines.

      Change to: delete the h3 at Bet.tsx:342 and the .market-panel h3 margin rule at styles.css:518-520, so the '91% goes to winners…' line sits 24px under the pool total; replace 'One wheel. Open rules.' at App.tsx:175 with 'Rules' so the footer paragraph keeps its heading.

      Why: the reference puts tools and dashboards under plain task headings with no voice line; the probe counts 'Shared pools. Shared upside.' as the page's one two-beat line.

      Width 390.

      Load dist/index.html, Bet tab, scroll to 'Inside the pools'.

      Evidence: probe check 'slogans' output: 1 two-beat lines: "Shared pools.

      Shared upside."

      (#panel-0 > div.work-grid > aside.market-panel > h3); test/scratch/taste/shots/390-full.png shows it at about y=975 and 'One wheel.

      Open rules.' at about y=1735.

    • lowaccessibility: the side buttons lose their definition line on phones, so Sleep's 'Yes' and 'No' are undefinedweb/src/styles.css:1239

      Now: below 620px the .side-detail span is hidden, so the Over/Under buttons read 'Over 0 IMD' and 'Under 0 IMD' without '> 1000 passes' / '< 1000 passes', and the Sleep market's buttons read 'Yes, asleep' / 'No, running' without 'Zero passes' / 'At least one pass'. At 1440 the same buttons show the definition (Bet.tsx:210-216).

      Change to: delete the rule at styles.css:1239-1241; each side button grows by one 13px line (about 18px) and the amount field moves down the same.

      Why: a cut never hides the labels a costly action needs, and the threshold a bet is judged against is that label; the line is otherwise only in the overview strip above the tabs.

      Width 390.

      Load dist/index.html, Bet tab, Over / under selected.

      Expected: each side button shows the threshold as at 1440 ('> 1000 passes').

      Actual: test/scratch/taste/shots/390.png shows 'Over · 0 IMD · Refund if one-sided' with no threshold, while test/scratch/taste/shots/1440.png shows '> 1000 passes' under 'Over'.

      Computed display of .side-detail at 390 is none from styles.css:1239.

  16. Checkedall checks passed
    • deployment-config
    • static-assets
    • html-assets
    • named-entrypoint
    • named-assets
    • contract-abis
    • chain-state