{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"d1691973-2b6e-414d-a7e7-86262ed7ad3a","kind":"audit","nodes":[{"acceptedSubmissionHash":"dc7e921a9fe873eddb8282c5fbde399bdfadd5fb3ee269acbd700465d3825cf8","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"a51967a5bb67c939ffb3882aeab07ffa830de5a3ebe61f70a4c13df037235b85","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"1a2e4c544573ae32934808dfd48737c9993b222af323c3f9ad4c05dfda6cded8","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"c1dc97bae129bcb90c9de12c4d606bb2b02f096ce77376350062fdbb06445bbc","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"4d286f64985681ddc15cc4fae075d87ed20299844a02f075beec3e963c1c5d1f","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the contracts as they are, with special attention to whether a caller's on-chain track record (hits, misses, streaks) can be gamed, and to any power the owner has over results.","parentJobId":null,"planHash":"e8cc1a4047712ef64d7338a6e685be35f097263cbe20c18824834223fcb787d8","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"d1691973-2b6e-414d-a7e7-86262ed7ad3a","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51430","feedbackHash":"99fdb82052e698b99cf6c88fc6417ab779f09aead76f4d71e7f58cc9fc321460","nodeKey":"audit_economics","submissionHash":"dc7e921a9fe873eddb8282c5fbde399bdfadd5fb3ee269acbd700465d3825cf8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51331","feedbackHash":"d6a45058ed1c8b2376937a2b9b252c9844d55b068f6f142a451bc531c5989b47","nodeKey":"audit_flow","submissionHash":"a51967a5bb67c939ffb3882aeab07ffa830de5a3ebe61f70a4c13df037235b85","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51023","feedbackHash":"5673964a34f5197936e34559708983e0b21460fb48a6eb658451445a6f15d4c8","nodeKey":"audit_judge","submissionHash":"1a2e4c544573ae32934808dfd48737c9993b222af323c3f9ad4c05dfda6cded8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51340","feedbackHash":"924abd91b557a9f2780d23b66938e7d536015ad238c74c659fe22c07ba9d99b6","nodeKey":"audit_math","submissionHash":"c1dc97bae129bcb90c9de12c4d606bb2b02f096ce77376350062fdbb06445bbc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51432","feedbackHash":"0f5380abbad45a6ae5123e527daf7db62f14224161d31e188ddfec1d99990688","nodeKey":"audit_permissions","submissionHash":"4d286f64985681ddc15cc4fae075d87ed20299844a02f075beec3e963c1c5d1f","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"ec8cb8ab8fff12a422128b54ae626aa83780dc341d9ab150cd1137d0a5d1bec8","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ca080fd306399669","findings":[{"citation":"resolved","description":"Merged from audit_economics 5099d0db, audit_math 657115d2, audit_permissions dc6f3f03 and audit_flow 88049a0c; all four reproduced. commit() snapshots the feed's latest round, which may be up to MAX_PRICE_AGE = 3 hours old and in normal operation lags the market by the feed's deviation threshold. The reveal-time side check (line 274) only requires the target to be strictly beyond that snapshot. _settle then accepts any round with updatedAt in [committedAt, expiry], lower bound inclusive. Two reachable states make a call a certainty rather than a prediction. (1) Same block: Chainlink transmit transactions are visible in the public mempool before they land. A caller who sees a pending transmit carrying answer A places commit() ahead of it in the same block; _freshPrice still returns the old round, the transmit lands with updatedAt == block.timestamp == committedAt, and that round passes the window check. A target between the stale snapshot and A is a guaranteed HIT. A round with updatedAt == committedAt can never be a legitimate proof: if it had landed before the commit it would be the snapshot round itself, whose answer equals commitPrice and so cannot reach a target the side check accepted. Rejecting it costs nothing. (2) Next round: when the on-chain answer lags the market (deviation not yet crossed, or a heartbeat gap), the very next round reports the price the caller already saw off-chain; a target one minor unit past the snapshot is then proven by it. Either way hits, hit rate and streaks are inflated for the cost of gas, which voids the contract's only product (a trustworthy record) and the README claim that the reveal is 'checked against the price the caller actually saw'. The existing test test_settle_hitAtExactTargetAndBoundaries (test/CallBook.t.sol:420-427) asserts the same-block behaviour as desired, so the suite enshrines the defect. Fix: make the lower bound strict (`if (updatedAt <= c.committedAt || updatedAt > c.expiry) revert RoundOutsideWindow();`), update README '[committedAt, expiry] (both inclusive)' and REVIEW.md item 3, and adjust the existing boundary test. That closes case (1) at no cost to the design. Case (2) needs a scope decision the requester must make: record the snapshot roundId per feed at commit and require proofRoundId to be at least two rounds later, or require updatedAt >= committedAt + a minimum lead (e.g. one feed heartbeat) with MIN_DURATION above it, and/or tighten MAX_PRICE_AGE toward the heartbeat. The attached proof fails on the current code and passes with either the strict bound or a lead-time rule.","line":314,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal aggregator double: every round is complete (answeredInRound == roundId).\ncontract FeedStub is AggregatorV3Interface {\n    struct R {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => R) internal rounds;\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = R(answer, updatedAt);\n    }\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"STUB\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[latest];\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice A Chainlink round mined in the same block as the commit, after it, has\n/// `updatedAt == committedAt` and is accepted as proof. The commit snapshot still holds the previous\n/// answer, so a target one unit beyond it is on the \"correct side\" and is proven by a round the\n/// caller already saw in the mempool. Nothing after the commit had to be predicted.\ncontract CommitBlockProofTest is Test {\n    CallBook internal book;\n    FeedStub internal eth;\n    FeedStub internal btc;\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new FeedStub();\n        btc = new FeedStub();\n        eth.push(2_000e8, T0 - 10 minutes);\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function test_roundInCommitBlockMustNotProveHit() public {\n        uint64 expiry = uint64(T0 + 1 hours);\n        int256 target = 2_000e8 + 1; // one minor unit above the snapshot\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n\n        // Alice front-runs the pending Chainlink transmit (answer 2_010e8) with her commit.\n        vm.prank(alice);\n        uint256 id = book.commit(h, expiry);\n        assertEq(book.commitPriceOf(id, address(eth)), 2_000e8);\n\n        // The transmit lands later in the same block: updatedAt == committedAt == T0.\n        uint80 pending = eth.push(2_010e8, T0);\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        try book.revealAndSettle(id, address(eth), CallBook.Direction.UP, target, SALT, pending) {} catch {}\n\n        // Expected: a round that was already in flight when the call was made cannot prove it.\n        // Actual on current code: status == Hit, hits == 1.\n        assertTrue(book.getCall(id).status != CallBook.Status.Hit, \"round at committedAt accepted as HIT proof\");\n        assertEq(book.getStats(alice).hits, 0, \"free hit recorded\");\n    }\n}","reproduction":"State: ETH/USD latest round answer 2_000e8 at T0-10min; BTC fresh. A Chainlink transmit with answer 2_010e8 is pending in the mempool. Block T0: (1) alice calls commit(keccak256(abi.encode(alice, ETH, UP, 2_000e8+1, T0+1h, salt)), T0+1h) -> id 1, commitPriceOf(1, ETH) == 2_000e8; (2) the transmit is mined later in the same block -> round R with updatedAt == T0 == committedAt, answer 2_010e8. At T0+1h alice calls revealAndSettle(1, ETH, UP, 2_000e8+1, salt, R). Expected: RoundOutsideWindow, since R was already in flight when the call was published and reports no information the caller lacked. Actual: status Hit, getStats(alice) = {calls 1, hits 1, misses 0, currentStreak 1, bestStreak 1}. Lagging-round variant (test_nextRoundProvesOneTickTarget in my scratch harness): same commit, round posted at T0+12 with answer 2_009e8 -> also Hit. Proof test/scratch/Proof_88049a0c338b.t.sol fails on this code with 'round at committedAt accepted as HIT proof' and passes on a copy with the strict lower bound; the specialists' Proof_657115d2b09a and Proof_5099d0db127f also fail here for the same reason.","severity":"high","snippet":"        if (updatedAt < c.committedAt || updatedAt > c.expiry) revert RoundOutsideWindow();","title":"HIT proof window starts at committedAt inclusive: a feed round posted in the commit block (or the first lagging round after it) proves a call whose outcome the caller already knew"},{"citation":"resolved","description":"Merged from audit_math 9125b632 (high), audit_flow a2e8dc87 (medium), audit_economics cada7234 (low) and audit_permissions a9cb062a (low); reproduced. _finish updates the caller's streak at the moment each call is settled. The caller controls that moment for every one of their own calls: a HIT can be settled by the caller from expiry onward, a MISS can be taken by the caller at any time after reveal via settle(id, 0) or deferred until expiry + REVEAL_WINDOW (no third party may record a MISS before then, lines 304-306 and 326), and calls with overlapping reveal windows can be revealed and settled in any order in one transaction. For calls that have expired the outcomes are already fixed; only the order in which they are written differs. The caller sorts hits before misses and bestStreak becomes the number of hits in the batch regardless of the real sequence, and currentStreak stays at that value for a further 24 hours while the losers sit unsettled (keepers are unpaid, so in practice longer). README.md documents settlement-order streaks as deliberate 'because settlement is the first moment an outcome is known', but does not note that the order is adversarially chosen; the streak figures in getStats are then a property of transaction ordering, not of the calls. This is reported as a violation of the stated purpose (a tamper-proof record) rather than a preference for a different design. Fix that preserves the Stats ABI: fold outcomes into the streak strictly in commit (id) order. Push each id into a per-caller array in commit() and keep a per-caller cursor; in _finish, after setting the status, walk from the cursor while the call at the cursor is Hit or Miss, applying increment/reset, and stop at the first call still Committed/Revealed. A hit is then never counted while an earlier call is pending. Alternatives: refuse out-of-order settlement per caller, or drop the streak fields and derive streaks off-chain from Settled events keyed by id (ABI change). The attached proof passes with any of the first two.","line":337,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal aggregator double: every round is complete (answeredInRound == roundId).\ncontract StreakFeed is AggregatorV3Interface {\n    struct Round {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => Round) internal rounds;\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"STREAK / USD\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = Round(answer, updatedAt);\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[latest];\n        require(r.updatedAt != 0, \"No data present\");\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice bestStreak must reflect the caller's calls in the order they were made (call id order),\n/// never an order the caller picks at settlement.\n///\n/// Alice commits five calls in this order: a (hit), b (hit), c (miss), d (hit), e (miss). In call\n/// order the longest run of hits is 2 (a, b). She settles d before c; on the current code\n/// bestStreak becomes 3. Settlements are wrapped in try/catch so a fix that refuses out-of-order\n/// settlement also passes (d simply stays pending there).\ncontract JudgeStreakOrderTest is Test {\n    CallBook internal book;\n    StreakFeed internal eth;\n    StreakFeed internal btc;\n\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new StreakFeed();\n        btc = new StreakFeed();\n        eth.push(2_000e8, T0 - 10 minutes);\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function _commit(int256 target, uint64 expiry) internal returns (uint256 id) {\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n        vm.prank(alice);\n        id = book.commit(h, expiry);\n    }\n\n    function _trySettle(uint256 id, int256 target, uint80 roundId) internal {\n        vm.prank(alice);\n        try book.revealAndSettle(id, address(eth), CallBook.Direction.UP, target, SALT, roundId) {} catch {}\n    }\n\n    function test_bestStreak_cannotBeInflatedBySettlementOrder() public {\n        uint64 expiry = uint64(T0 + 1 days);\n        uint256 a = _commit(2_100e8, expiry); // id 1, hit\n        uint256 b = _commit(2_200e8, expiry); // id 2, hit\n        uint256 c = _commit(9_000e8, expiry); // id 3, miss\n        uint256 d = _commit(2_250e8, expiry); // id 4, hit\n        uint256 e = _commit(9_500e8, expiry); // id 5, miss\n        uint80 up = eth.push(2_300e8, T0 + 2 hours);\n\n        vm.warp(expiry);\n        // Alice settles her hits first, then her misses.\n        _trySettle(a, 2_100e8, up);\n        _trySettle(b, 2_200e8, up);\n        _trySettle(d, 2_250e8, up);\n        _trySettle(c, 9_000e8, 0);\n        _trySettle(e, 9_500e8, 0);\n\n        CallBook.Stats memory s = book.getStats(alice);\n        assertEq(s.calls, 5);\n        assertGe(s.hits, 2);\n        // Longest run of hits in the order the calls were made is a, b = 2.\n        assertLe(s.bestStreak, 2, \"bestStreak inflated by caller-chosen settlement order\");\n    }\n}","reproduction":"Alice, ETH snapshot 2_000e8, all expiries T0+1d. Commit in order: id1 UP 2_100e8, id2 UP 2_200e8, id3 UP 9_000e8, id4 UP 2_250e8, id5 UP 9_500e8. One round at T0+2h answers 2_300e8, so ids 1, 2, 4 hit and 3, 5 miss; in call order the longest run of hits is 2. At expiry alice calls revealAndSettle for ids 1, 2, 4 with that round, then for ids 3 and 5 with roundId 0. Expected: bestStreak == 2. Actual: getStats(alice) = {calls 5, hits 3, misses 2, currentStreak 0, bestStreak 3}. The shipped test test_stats_streaksFollowSettlementOrder settles the same calls in id order and gets 2; only the order changed. Hedged amplifier (test_hedgedOneTickPairs_100of100 in my scratch harness): 50 UP at 2_000e8+1 and 50 DOWN at 2_000e8-1 over 30 days, settled in any order, give bestStreak 100. Proof test/scratch/JudgeStreakOrder.t.sol fails on this code with 'bestStreak inflated by caller-chosen settlement order: 3 > 2' and passes on a copy that applies outcomes in id order; the specialist's Proof_9125b6322f57 fails here for the same reason.","severity":"medium","snippet":"        if (hit) {\n            ++s.hits;\n            ++s.currentStreak;\n            if (s.currentStreak > s.bestStreak) s.bestStreak = s.currentStreak;","title":"currentStreak/bestStreak are applied in settlement order, which the caller alone chooses, so any streak up to the number of hits can be fabricated by settling hits before misses"},{"citation":"resolved","description":"Merged from audit_flow 9f78bf77 (medium), audit_economics 49d9a6a0 (low) and audit_math dcdf280e (low); reproduced. commit() silently skips every feed that is disabled at execution time and accepts the commit as long as one other feed is recorded. The caller's hash already binds the feed before the transaction is sent. If the owner's disableFeed(feed) is mined first (a deliberate front-run, or a routine disable of a deprecated feed racing honest commits in the mempool), the commit succeeds, stats.calls is incremented, commitPriceOf[id][feed] stays 0, and _reveal reverts FeedNotRecorded (line 272) for the only preimage that matches. The call can never leave Committed, and after expiry + 24h anyone calls markUnrevealed and the caller takes a MISS with forced = true that resets their streak, even if the call was right. The caller cannot cancel a commit and cannot detect the problem except by inspecting the receipt for a missing CommitPriceRecorded event. The contract's own NatSpec (lines 16-17), REVIEW.md item 9 and the README state the owner cannot affect results; this is the one path where it can, so it is reported as a violation of an explicit requirement rather than as the documented feed-list trust. Impact is bounded to wrongful misses on individual records. Fix options that keep the owner's feed power: (a) in commit, still snapshot a listed-but-disabled feed when its latest round is fresh and skip it only when stale, so disabling only stops commits on a broken feed; (b) at reveal, when the hash verifies but commitPriceOf is zero, move the call to a terminal Void status that counts as neither hit nor miss and touches no streak (markUnrevealed must then reject it); (c) let commit take a caller-supplied list of feeds that must be recorded and revert otherwise. The attached proof passes with (a) or (b); (c) changes the commit signature and needs the test adjusted.","line":216,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal aggregator double: every round is complete (answeredInRound == roundId).\ncontract RaceFeed is AggregatorV3Interface {\n    struct Round {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => Round) internal rounds;\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"RACE / USD\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = Round(answer, updatedAt);\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[latest];\n        require(r.updatedAt != 0, \"No data present\");\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice An owner's disableFeed that lands before an already-signed commit on that feed must not turn\n/// the call into a forced MISS. The contract promises that nothing the owner does changes the result\n/// of a call. On the current code the commit succeeds with no snapshot for the feed, reveal reverts\n/// FeedNotRecorded forever, and anyone records a forced MISS after the window.\ncontract JudgeDisableRaceTest is Test {\n    CallBook internal book;\n    RaceFeed internal eth;\n    RaceFeed internal btc;\n\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    address internal keeper = makeAddr(\"keeper\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new RaceFeed();\n        btc = new RaceFeed();\n        eth.push(2_000e8, T0 - 10 minutes);\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function test_ownerDisableBeforeInFlightCommitMustNotForceAMiss() public {\n        uint64 expiry = uint64(T0 + 1 days);\n        int256 target = 2_500e8;\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n\n        // Block T0: owner's disableFeed(ETH) is mined first, alice's pending commit second.\n        vm.prank(owner);\n        book.disableFeed(address(eth));\n        vm.prank(alice);\n        uint256 id = book.commit(h, expiry);\n\n        // The call was right: ETH prints 2,600 inside the window.\n        uint80 proof = eth.push(2_600e8, T0 + 12 hours);\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        try book.reveal(id, address(eth), CallBook.Direction.UP, target, SALT) {} catch {}\n        try book.settle(id, proof) {} catch {}\n\n        // Reveal window closes; anyone tries to record a forced MISS.\n        vm.warp(uint256(expiry) + book.REVEAL_WINDOW() + 1);\n        vm.prank(keeper);\n        try book.markUnrevealed(id) {} catch {}\n\n        CallBook.Stats memory s = book.getStats(alice);\n        assertEq(s.misses, 0, \"owner feed disable forced a MISS on an in-flight commit\");\n        assertTrue(book.getCall(id).status != CallBook.Status.Miss, \"call recorded as MISS\");\n    }\n}","reproduction":"Block T0: owner calls disableFeed(ETH) (mined first); alice's already-broadcast commit(keccak256(abi.encode(alice, ETH, UP, 2_500e8, T0+1d, salt)), T0+1d) is mined next and succeeds because BTC is still enabled: commitPriceOf(id, ETH) == 0, getStats(alice).calls == 1. ETH/USD prints 2_600e8 at T0+12h, so the call was correct. At T0+1d alice calls reveal(id, ETH, UP, 2_500e8, salt): revert FeedNotRecorded; settle(id, proof): revert NotRevealed. At T0+2d+1s keeper calls markUnrevealed(id). Expected: the commit is rejected, or the call is void and not counted against the caller. Actual: status Miss, forced == true, getStats(alice) = {calls 1, hits 0, misses 1, currentStreak 0}. Proof test/scratch/JudgeDisableRace.t.sol fails on this code with 'owner feed disable forced a MISS on an in-flight commit: 1 != 0' and passes on a copy that voids such a call at reveal.","severity":"medium","snippet":"            if (!feedEnabled[feed]) continue;","title":"Owner's disableFeed landing before an already-signed commit on that feed turns the call into an unrevealable forced MISS, contradicting 'Nothing the owner does changes the result of a call'"},{"citation":"resolved","description":"Merged from audit_economics a0c4158b (medium), audit_flow bed6087d (medium) and audit_permissions fbc5775a (low); reproduced. The only constraint on the hidden target is that it lies strictly on the correct side of commitPrice, by as little as one minor unit (1e-8 USD), and commitPrice may itself be up to 3 hours behind the market. A HIT needs just one round anywhere in a window of up to 30 days that touches the target. A caller can therefore commit, in one transaction, UP at snapshot+1 and DOWN at snapshot-1 with 30-day expiries: both settle as HITs as soon as any round above and any round below the snapshot exist, which for ETH or BTC over a month is practically certain, so hits == calls with zero forecasting. Even with a short expiry, when the market has drifted above a lagging on-chain answer, UP at snapshot+1 is proven by the next scheduled round (see the high finding on the proof window, which this survives: a strict lower bound does not help against a round posted minutes later). Stats {calls, hits, misses, currentStreak, bestStreak} and Settled carry neither target distance nor duration, so a reader of getStats, the number the README advertises as the 'real hit rate', cannot distinguish a +1-unit 30-day call from a +25% one-day call without re-deriving every Revealed event. This is a design limitation, not a coding slip, and any fix changes the agreed economic rules, so it needs the requester's decision: enforce a minimum relative distance between target and snapshot at reveal (e.g. a basis-point floor per feed, at least twice the feed's deviation threshold, set at addFeed), and/or make duration count (shorter MAX_DURATION, or settle against the price at expiry rather than any touch), and/or record per-call distance and duration (or per-feed counters) so difficulty-weighted stats can be derived. At minimum the README should state that hit rate and streaks are not skill measures without reading target distance.","line":274,"path":"src/CallBook.sol","reproduction":"State: ETH snapshot 2_000e8 at T0. alice commits 50 x UP target 2_000e8+1 and 50 x DOWN target 2_000e8-1, all expiry T0+30d (100 commits, distinct salts). Feed posts a round with answer 2_000e8+1 at T0+3d and one with answer 2_000e8-1 at T0+9d (ordinary noise). At expiry alice calls revealAndSettle on each with the matching round. Expected: a record that reflects forecasting skill, or a rejection of a 1e-8 USD target. Actual (test_hedgedOneTickPairs_100of100 in my scratch harness, passes on the current code): getStats(alice) = {calls 100, hits 100, misses 0, currentStreak 100, bestStreak 100}. Short-window variant (test_nextRoundProvesOneTickTarget): UP 2_000e8+1 with expiry T0+1h, round 2_009e8 at T0+12 -> Hit.","severity":"medium","snippet":"        if (direction == Direction.UP ? targetPrice <= commitPrice : targetPrice >= commitPrice) {","title":"No minimum distance between target and the lagging commit snapshot: one-tick targets on both sides are near-certain hits over 30 days and Stats carry no measure of difficulty"},{"citation":"resolved","description":"From audit_flow 39e4af56 (medium) and the duplicate-hash remark in audit_permissions fbc5775a and audit_economics a0c4158b; reproduced. Kept separate from the target-distance finding because the mechanism (no cost or uniqueness per call, records keyed by msg.sender) and the fix differ. commit() has no stake, fee, rate limit, per-caller cap, or uniqueness check on the hidden call (even the identical commitHash is accepted twice), and nothing binds a person to an address. Two consequences: (1) duplication: one correct call committed k times under k salts (or the same hash k times) settles as k HITs from the same proving round, so hits and bestStreak grow by k from a single forecast; (2) survivorship: address A commits UP and fresh address B commits DOWN with one-unit targets, exactly one hits, the loser address is abandoned (its forced MISS lands on an identity nobody publishes) and the winner is re-paired with a new throwaway, so after k rounds some address shows calls k, hits k, misses 0, bestStreak k at the cost of 2k commits' gas. Neither is detectable on-chain or in getStats. Rejecting a reused commitHash per caller is cheap and closes only the degenerate identical-hash case; the salt variant and survivorship need a per-call cost (stake forfeited on MISS, or a fee) or an identity requirement, both of which change the agreed economics and need the requester's decision. Without one, the README's trustworthiness claims should be qualified and readers advised to weight records by distinct calls, target distance and age. Rated low because it is a documented-design gap with gas as the only cost and no funds at risk.","line":200,"path":"src/CallBook.sol","reproduction":"State: ETH snapshot 2_000e8. alice commits (ETH, UP, 2_500e8, T0+1d) 20 times with salts 0..19, then commits the exact same hash (salt 0) a 21st time: all 21 commits succeed, getStats(alice).calls == 21. One round at T0+12h answers 2_600e8. At expiry alice calls revealAndSettle on all 21 with that round. Expected: one forecast contributes one unit of record (or the duplicate hash is rejected). Actual (test_duplicateCall_oneForecastManyHits in my scratch harness, passes on the current code): getStats(alice) = {calls 21, hits 21, misses 0, bestStreak 21}. Survivorship variant follows from the same code path with two addresses and opposite one-unit targets; the losing address takes a forced MISS that never appears on the surviving record.","severity":"low","snippet":"    function commit(bytes32 commitHash, uint64 expiry) external returns (uint256 id) {","title":"Commits are free, unlimited and unconstrained per address, so one forecast can be duplicated into N hits and a flawless record can be manufactured by survivorship across throwaway addresses"},{"citation":"resolved","description":"Merged from audit_math 7f3c24a1 (low), audit_permissions 55af3ae5 (low) and audit_economics 6a1c964f (info); reproduced. A HIT needs an on-chain proof, but a MISS from a non-caller needs none, only that block.timestamp > expiry + REVEAL_WINDOW; the contract cannot check that no qualifying round exists. A Revealed call whose caller did not settle inside the window is then a race between settle(id, 0) from anyone and settle(id, proofRound) from anyone; the first to land wins, and once Miss is written the proof is permanently rejected with NotRevealed. The README documents the 24-hour burden and revealAndSettle is the mitigation, so the race itself is a known trade-off. The residual defect is that such a MISS is written with forced = false and Settled carries no settler, so readers of the 'tamper-proof' record cannot tell 'caller conceded' from 'nobody applied the proof in time', and a griefer who wants to reset a rival's currentStreak has an incentive to watch for this state (a caller who reveals at the very end of the window and settles in the next block is exposed). Minimal fix without changing settlement rules: mark a non-caller no-proof MISS distinctly (set forced = true, or add a status/flag and emit the settler). Larger design options for the requester: allow a later valid HIT proof to overturn a non-caller no-proof MISS (hits++, misses--; streaks cannot be recomputed), or give the caller a short grace period after a third-party MISS request.","line":304,"path":"src/CallBook.sol","reproduction":"State: alice commits UP 2_500e8 on ETH at T0, expiry T0+1d; round R answers 2_600e8 at T0+12h (a valid proof). At T0+1d alice calls reveal() only. At T0+2d+1s bob calls settle(id, 0). Expected: a call with an on-chain proof is not recorded as an ordinary conceded miss, or is at least marked as third-party. Actual (test_thirdPartyMissBeatsExistingProof in my scratch harness, passes on the current code): status Miss, forced == false, getStats(alice) = {misses 1, currentStreak 0}; alice's subsequent settle(id, R) reverts NotRevealed.","severity":"low","snippet":"            if (msg.sender != c.caller && block.timestamp <= uint256(c.expiry) + REVEAL_WINDOW) {\n                revert RevealWindowOpen();\n            }","title":"After the reveal window a third party's no-proof MISS is final even when a valid HIT round exists on-chain, and it is stored with forced = false, indistinguishable from the caller's own concession"},{"citation":"resolved","description":"Merged from audit_economics bcb47490 (info) and 31962f08 (low), audit_math dfa97fef (info), audit_permissions 516631cb (info) and audit_flow 8c06a872 (info); reproduced. Recorded as the powers and failure modes of the agreed owner role (REVIEW.md items 9, 10, 12), not as a permission bypass. Owner powers were traced end to end: the owner can only addFeed and disableFeed, no owner input reaches _reveal, _settle, markUnrevealed or _finish, and disabling a feed does not block reveal or settlement of calls already committed on it (the one exception, disabling ahead of an in-flight commit, is the separate medium finding). The indirect powers are: (a) addFeed only checks that decimals() answers, so the owner can list a contract it controls; any account committing on it can be proven a HIT on whatever rounds that contract reports, and those hits land in the same per-caller Stats as Chainlink hits with no per-feed breakdown, so a reader of getStats cannot exclude them without replaying Revealed events; the fake feed's price is also snapshotted into every other caller's commit (harmless to their reveals). (b) The same check passes for non-aggregators, including this project's own LaunchToken (decimals() == 18); once enabled, every commit() reverts in _freshPrice until the owner disables it, so the owner can halt new commits at will. (c) commit() reverts if any enabled feed is older than 3 hours, deprecated or non-positive; only the owner can disable it, owner is immutable with no transfer or recovery, and Chainlink testnet feeds are retired without notice. If the key is lost, the first feed to go stale closes the contract to new calls permanently (existing calls remain settleable). (d) Listing is permanent and capped at MAX_FEEDS = 16. Hardening that keeps the role, if wanted: in addFeed require a successful, fresh, positive latestRoundData(); keep per-feed hit/miss counters or key Stats by (caller, feed); allow anyone to disable a feed whose latest round is older than a generous bound (e.g. 24h); consider a two-step transferable owner. Document in the README that stats are only meaningful per feed and that listing is a trusted action.","line":165,"path":"src/CallBook.sol","reproduction":"Scratch harness, all pass on the current code. (a) test_ownerControlledFeedFabricatesHits: owner deploys a controllable aggregator F reporting 100e8, calls addFeed(F); bob commits UP target 1e30 on F with expiry T0+1h; F posts 1e30 at T0+30min; bob's revealAndSettle records Hit, getStats(bob).hits == 1, identical in shape to an ETH/USD hit. (b) test_ownerListsNonAggregator_blocksCommits: owner calls addFeed(address(new LaunchToken())) and it succeeds; alice's commit(bytes32(1), T0+1d) then reverts; owner's disableFeed(token) restores commits. (c) test_staleFeedBlocksAllCommits: with both feeds last updated at T0-10min, at T0+4h alice's commit reverts StalePrice(ETH) and keeper's disableFeed(ETH) reverts NotOwner; no non-owner action can restore commits.","severity":"info","snippet":"        AggregatorV3Interface(feed).decimals();","title":"Owner trust assumptions: addFeed accepts any contract answering decimals(), a listed feed decides hits on itself and can halt all commits, one stale enabled feed blocks every commit, and the immutable"},{"citation":"resolved","description":"From audit_flow cb92f0e3 (info); reproduced. If an operator runs the script without CALLBOOK_OWNER and without a signer, msg.sender is Foundry's default sender (0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) and the deployed CallBook's immutable owner is an address nobody controls; with a signer but no env var, the owner becomes the deployer key rather than the policy owner. Since owner is immutable, neither can be corrected after deployment, and addFeed/disableFeed (the only way to recover from a stale feed) become unusable. The production launch goes through the ProjectFactory with $owner (launch.json), so this only affects operator-run deployments, and the README example sets the variable. Suggest requiring it explicitly (vm.envAddress) and reverting on address(0) or the default sender.","line":36,"path":"script/Deploy.s.sol","reproduction":"Run `EXPECTED_CHAIN_ID=0 forge script script/Deploy.s.sol:Deploy --offline -vvvv` with no CALLBOOK_OWNER and no --sender. The trace shows `VM::envOr(\"CALLBOOK_OWNER\", DefaultSender: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38])` followed by `new CallBook@...`, so the constructed CallBook has owner() == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38. Expected: the script refuses to deploy without an explicit owner. Actual: it deploys with an uncontrolled owner.","severity":"info","snippet":"            owner: vm.envOr(\"CALLBOOK_OWNER\", msg.sender),","title":"Deploy script falls back to msg.sender as the immutable owner when CALLBOOK_OWNER is unset, which is Foundry's default sender in a dry run"}],"hash":"1a2e4c544573ae32934808dfd48737c9993b222af323c3f9ad4c05dfda6cded8","nodeId":"f7685d88-e9a7-444b-9015-047fdb90f0ad","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 8 merged findings, all reproduced against `src/CallBook.sol` and `script/Deploy.s.sol`; the 24 specialist findings collapsed into these without losing any supported mechanism.\n\n**What I kept, by severity**\n\n- **High, proof window includes committedAt** (`src/CallBook.sol:314`). A round mined in the commit block, or the first lagging round after it, proves a hit the caller already knew. All four specialists reported it. I ran all three attached proofs and they fail here for the stated reason. I attached the audit_flow proof because it is fix-agnostic, and confirmed it passes on a copy with a strict lower bound.\n- **Medium, streaks follow caller-chosen settlement order** (line 337). Four specialists, severities from low to high. I wrote a try/catch version of the proof so it also passes if the author simply refuses out-of-order settlement. It fails here with bestStreak 3 instead of 2 and passes on a patched copy that applies outcomes in id order.\n- **Medium, owner disableFeed racing an in-flight commit forces a MISS** (line 216). Three specialists. This contradicts the contract's own guarantee at lines 16 and 17, so I classed it as a requirement violation rather than a trust note. My proof fails here and passes on a copy that voids such calls at reveal.\n- **Medium, no minimum target distance** (line 274). Hedged one-tick pairs over 30 days produce 100 hits from 100 calls in my harness. Design-level, flagged as needing a scope decision.\n- **Low, free unlimited commits** (line 200). Duplication and survivorship. Kept separate from the distance finding because mechanism and fix differ. Reproduced with 21 hits from one forecast, including an identical hash accepted twice.\n- **Low, third-party no-proof MISS is final and stored with forced = false** (line 304). Reproduced.\n- **Info, owner trust assumptions** (line 165). Merged five specialist notes: addFeed accepts anything with decimals(), including LaunchToken, which then blocks all commits; a stale feed blocks commits until the immutable owner acts.\n- **Info, deploy script owner fallback** (`script/Deploy.s.sol:36`). Confirmed by dry run: without CALLBOOK_OWNER the trace shows Foundry's default sender passed as owner.\n\n**Dropped or downgraded.** Nothing failed to reproduce. The audit_economics liveness item was folded into the owner trust finding, and the audit_math \"high\" on streaks was set to medium since the README documents settlement-order streaks and the issue is record integrity, not funds.\n\nSnippets were verified against the tree, and the three embedded proofs were re-extracted and re-run to confirm they fail on the current code. Scratch files remain under `test/scratch/` and are not part of the deliverable.","treeHash":null,"usage":{"cachedInputTokens":1086419,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":30701,"runtime":"claude","turns":24,"wallClockMs":390218}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"03f15d1296244279","findings":[{"citation":"resolved","description":"The side check at reveal compares the target with the snapshot taken at commit, which is the feed's latest round at that moment (up to MAX_PRICE_AGE = 3 hours old). The HIT proof, however, accepts any round whose updatedAt is >= committedAt, i.e. a round posted in the very same block as the commit. Chainlink transmit transactions are visible in the public mempool (Sepolia has one), and a transmit is only sent when the off-chain price has moved by at least the deviation threshold from the last on-chain answer. A caller who sees a pending transmit with answer A places commit(UP, target = A - 1 wei) in front of it: the side check passes against the stale snapshot, and the transmit that lands in the same block (or the next one) is a valid proof. The call is decided before it is made, yet it is recorded as a hit on a target that looks several deviation steps away. Combined with MIN_DURATION only bounding expiry (not the earliest proof), the caller's hits, hit rate and streaks are inflated without any forecasting, which defeats the contract's stated purpose of a tamper-proof track record and the README claim that 'the reveal can be checked against the price the caller actually saw'. Access/trust gap: the commit guard (snapshot side check) and the settle guard (window check) are each correct alone but are asymmetric about which round counts as 'seen'. Minimal fix that keeps the design: also record the snapshot roundId per feed at commit and require the proof round to satisfy roundId > snapshotRoundId and updatedAt > committedAt (strictly after the commit block). This closes the same-block case; a one-block-ahead mempool front-run remains and should either be documented or closed by a design change (compare the target against the first round after commit, i.e. snapshotRoundId + 1, instead of the last round before it).","line":314,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal controllable aggregator (self-contained; nothing from other test files).\ncontract ScratchFeed is AggregatorV3Interface {\n    struct Round {\n        int256 answer;\n        uint256 updatedAt;\n        bool exists;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => Round) internal rounds;\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = Round(answer, updatedAt, true);\n    }\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"SCRATCH / USD\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[id];\n        require(r.exists, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[latest];\n        require(r.exists, \"No data present\");\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice A Chainlink round posted in the same block as the commit (updatedAt == committedAt) is\n/// accepted as a HIT proof, although the commit snapshot (the \"price the caller saw\") predates it.\n/// A caller who sees the pending transmit in the mempool places the commit just before it and the\n/// side check is run against the stale snapshot, so the call is guaranteed before it is made.\n/// Expected: a round that is not strictly after the commit cannot prove a HIT.\n/// Actual on current code: settle() records a HIT.\ncontract SameBlockRoundProofTest is Test {\n    uint256 internal constant T0 = 1_800_000_000;\n    bytes32 internal constant SALT = keccak256(\"salt\");\n\n    CallBook internal book;\n    ScratchFeed internal eth;\n    ScratchFeed internal btc;\n    address internal alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new ScratchFeed();\n        btc = new ScratchFeed();\n        eth.push(2_000e8, T0 - 10 minutes); // stale-but-fresh-enough snapshot the contract will use\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(makeAddr(\"owner\"), address(eth), address(btc));\n    }\n\n    function test_roundInCommitBlockMustNotProveHit() public {\n        uint64 expiry = uint64(T0 + 1 hours);\n        int256 target = 2_099e8; // 5% above the snapshot: looks like a bold call\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n\n        // Commit lands at T0 with the snapshot at 2000 ...\n        vm.prank(alice);\n        uint256 id = book.commit(h, expiry);\n        assertEq(book.commitPriceOf(id, address(eth)), 2_000e8);\n\n        // ... and the pending Chainlink transmit lands in the same block (updatedAt == committedAt).\n        uint80 pending = eth.push(2_100e8, T0);\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        book.reveal(id, address(eth), CallBook.Direction.UP, target, SALT);\n\n        // Expected: this round is not a valid proof for this call.\n        vm.expectRevert();\n        book.settle(id, pending);\n        assertEq(uint8(book.getCall(id).status), uint8(CallBook.Status.Revealed));\n        assertEq(book.getStats(alice).hits, 0);\n    }\n}","reproduction":"State: ETH/USD latest round R1 = 2000e8 at T0-10min. Alice watches the mempool and sees a pending transmit with answer 2100e8. Input: at T0 Alice calls commit(keccak256(abi.encode(alice, eth, UP, 2099e8, T0+1h, salt)), T0+1h); commitPriceOf(id, eth) = 2000e8. The transmit lands in the same block: round R2 = 2100e8, updatedAt = T0 == committedAt. At T0+1h Alice calls reveal(id, eth, UP, 2099e8, salt) (2099e8 > 2000e8 passes) then settle(id, R2). Expected: R2 is not a proof for a call it was not made before (settle reverts, status stays Revealed, hits = 0). Actual: updatedAt(T0) >= committedAt(T0) passes RoundOutsideWindow, 2100e8 >= 2099e8, status = Hit, hits = 1, currentStreak = 1. The scratch test test/scratch/SameBlockRoundProof.t.sol fails on the current code and passes when line 314 uses updatedAt <= c.committedAt.","severity":"medium","snippet":"        if (updatedAt < c.committedAt || updatedAt > c.expiry) revert RoundOutsideWindow();","title":"Proof window includes the commit timestamp: an oracle round landing in the commit block proves a hit the caller already knew"},{"citation":"resolved","description":"The only constraint on a target is that it is strictly on the correct side of the commit snapshot. A caller can commit, in the same transaction batch, an UP call at commitPrice + 1 and a DOWN call at commitPrice - 1 with a 30-day expiry. Any round that moves one minor unit in either direction proves the matching call; over 30 days both almost surely hit, so both are recorded as hits. Repeating this yields calls = hits, misses = 0 and an unbounded bestStreak with zero forecasting skill. The same commit hash may also be committed repeatedly (no duplicate check), multiplying 'hits' for one prediction. The README assumptions acknowledge the snapshot only enforces the side, but getStats(caller) - the record the README says anyone can read 'instead of trusting screenshots' - is exactly the number being gamed, and nothing in Stats distinguishes a +1 wei call from a +25% call. This is a design limitation rather than a permission bypass; fix options that preserve the commit/reveal design: require a minimum distance between target and commit price (e.g. a basis-point floor per feed), or expose a difficulty-weighted or per-feed breakdown in Stats so readers can discount trivial calls, and reject a commitHash already used by the same caller.","line":274,"path":"src/CallBook.sol","reproduction":"State: ETH/USD snapshot 2000e8. Input: alice commits UP target 2000e8+1 and DOWN target 2000e8-1, both expiry T0+30d. Rounds 2000e8+1 at T0+1h and 2000e8-1 at T0+2h are posted (ordinary noise). After expiry alice calls revealAndSettle for both with those rounds. Expected (per the contract's purpose): a record that reflects forecasting skill. Actual: getStats(alice) = {calls 2, hits 2, misses 0, currentStreak 2, bestStreak 2}; repeated N times gives hits = 2N, misses = 0. Verified by test_trivialTargetsBothSidesHit in the scratch harness (passes on current code).","severity":"low","snippet":"        if (direction == Direction.UP ? targetPrice <= commitPrice : targetPrice >= commitPrice) {","title":"Hit rate can be driven to 100% with trivially close opposite-side targets; stats do not weight target distance"},{"citation":"resolved","description":"currentStreak and bestStreak are updated in _finish in the order calls are settled, not in the order outcomes occurred. The caller decides when each of their own calls settles: a MISS can be taken at any time after reveal via settle(id, 0), a HIT can be held in Revealed state until the end of the reveal window, and an unrevealed miss lands only when someone pays gas for markUnrevealed after the window (no incentive exists for keepers, so absent them misses stay pending indefinitely while hits are settled promptly). With calls whose reveal windows overlap (expiries within 24 hours of each other) the caller settles every miss first and every hit afterwards, so bestStreak equals the number of hits in the batch instead of the longest chronological run. Third parties can also perturb a streak by choosing when to submit someone else's HIT proof or post-window MISS. The README documents settlement-order streaks as deliberate, so this is reported as a trust/asymmetry note: the streak figures in getStats are not a property of the calls but of transaction ordering. Fix within the design: compute streaks in expiry order (e.g. only advance the streak for a call whose expiry is later than the last streak-counted call, and treat an out-of-order settlement as not extending the streak), or drop streaks from on-chain Stats and leave them to indexers that can order by expiry.","line":339,"path":"src/CallBook.sol","reproduction":"State: three calls by alice with the same expiry T0+1d: h1 = UP 2100e8, m = UP 9000e8, h2 = DOWN 1900e8; rounds 2200e8 at T0+2h and 1800e8 at T0+4h. Chronological outcome order is hit, miss (never reached), hit, so the honest best streak is 1. Input at expiry: alice calls revealAndSettle(m, ..., 0) first, then revealAndSettle(h1, ..., rUp), then revealAndSettle(h2, ..., rDown). Expected: bestStreak 1. Actual: getStats(alice) = {hits 2, misses 1, currentStreak 2, bestStreak 2}. Verified by test_streakReorderedBySettlingMissesFirst in the scratch harness (passes on current code).","severity":"low","snippet":"            ++s.currentStreak;","title":"Streaks follow settlement order, which the caller (and any third party) controls: settle misses first, then hits"},{"citation":"resolved","description":"A HIT needs an on-chain proof; a MISS from a non-caller needs none, only that the reveal window has closed, and _finish makes the status terminal. A caller who reveals with reveal() at the end of the window (allowed up to and including expiry + 24h) and submits settle(id, proof) in the following block is raced by anyone calling settle(id, 0): the griefer's transaction records a MISS, the caller's proof then reverts with NotRevealed, and the valid proof can never correct the record. The same race exists for any caller whose proof transaction is delayed past the boundary by a few seconds. The miss is stored with forced = false, so readers cannot tell a third-party unprovable MISS from a MISS the caller admitted. The README tells callers to settle before the window closes, so the operational risk is documented, but the record itself can be made wrong by an unprivileged actor at the cost of gas. Fix preserving the design: (a) record the settler, or set forced = true for any non-caller no-proof MISS, so the two kinds of miss are distinguishable; (b) optionally allow a later valid HIT proof to overturn a non-caller no-proof MISS (misses--, hits++), accepting that streaks cannot be recomputed; or (c) give the caller a short grace period after a non-caller MISS request before it becomes final.","line":304,"path":"src/CallBook.sol","reproduction":"State: alice committed UP 2500e8 with expiry E = T0+1d; round 2600e8 at T0+12h exists (a valid proof). Input: at E + 24h alice calls reveal(id, eth, UP, 2500e8, salt) (accepted: timestamp == expiry + REVEAL_WINDOW). In the next block (E + 24h + 12s) griefer calls settle(id, 0) before alice's settle(id, proof). Expected: a call with an existing on-chain proof is not recorded as a miss, or the miss is at least marked as third-party/forced. Actual: status = Miss, forced = false, misses = 1; alice's settle(id, proof) reverts NotRevealed. Verified by test_thirdPartyMissBeatsExistingProof in the scratch harness (passes on current code).","severity":"low","snippet":"            if (msg.sender != c.caller && block.timestamp <= uint256(c.expiry) + REVEAL_WINDOW) {","title":"A third party's no-proof MISS after the reveal window is final even when a proving round exists, and is indistinguishable from the caller's own admission"},{"citation":"resolved","description":"Owner powers were traced end to end: the owner can only addFeed and disableFeed, ownership is immutable, no owner input reaches _reveal, _settle, markUnrevealed or _finish, and disabling a feed does not block reveal or settlement of calls already committed on it. The owner therefore has no power over the result of any call on the two Chainlink feeds. The remaining owner power over results is indirect: addFeed only checks that the address answers decimals(), so the owner can list a contract they control, and any account committing on it can be proven a HIT on whatever rounds that contract reports. Those hits land in the same per-caller Stats as Chainlink hits; getStats has no per-feed breakdown, so a reader of the aggregate cannot see that an account's record came from an owner-controlled feed without replaying Revealed events. The owner can also halt all new commits (by listing a feed that reverts or goes stale, or by disabling every feed), but cannot stop reveals or settlements. These are the intended, documented trust assumptions (REVIEW.md items 9, 10, 12) and are recorded here as such, not as a bypass. If the requester wants to narrow them: keep per-feed hit/miss counters in Stats (or key Stats by (caller, feed)), and/or publish the feed list policy so readers filter by feed.","line":165,"path":"src/CallBook.sol","reproduction":"State: owner deploys MockAggregatorV3-like contract F reporting 100e8 and calls addFeed(F) (decimals() succeeds). Input: bob commits UP target 1000000e8 on F with expiry T0+1h; F reports 1000000e8 at T0+30min; at expiry bob calls revealAndSettle(id, F, UP, 1000000e8, salt, thatRound). Expected: nothing in the aggregate record flags the hit as coming from a non-Chainlink feed. Actual: getStats(bob).hits = 1, identical in shape to a hit on ETH/USD. Verified by test_ownerFeedFabricatesHitsInAggregateStats in the scratch harness. Conversely, no sequence of owner calls changes the status or stats of an already committed call on ETH/USD or BTC/USD (checked by tracing every owner-callable function and by the existing test test_reveal_andSettleStillWorkAfterFeedDisabled).","severity":"info","snippet":"        AggregatorV3Interface(feed).decimals();","title":"Trust assumption: an owner-added feed can fabricate hits for chosen accounts and getStats aggregates across feeds"}],"hash":"4d286f64985681ddc15cc4fae075d87ed20299844a02f075beec3e963c1c5d1f","nodeId":"69fc9196-d61a-4cfd-8fc6-ec0c57a8810a","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` at the repository root, with no tracked file modified.\n\n**Outcome.** The permission model holds: every owner, caller, and third-party guard was traced from entry point to effect, and no permission bypass exists. The owner has no power over the result of any call on the Chainlink feeds. The track record, however, can be gamed by an unprivileged caller in several ways, one of which is a genuine code-level gap.\n\n**Findings written, most severe first:**\n\n- **Medium, with Foundry proof.** The HIT proof window starts at the commit timestamp inclusive, while the side check uses the stale pre-commit snapshot. A caller who sees a pending Chainlink transmit in the mempool commits just ahead of it with a target one unit below the incoming answer, and the round landing in the same block proves the hit. The call is decided before it is made. The proof test in `test/scratch/SameBlockRoundProof.t.sol` fails on the current code and passes when the lower bound becomes strict. The write-up also notes that a strict bound leaves a one-block-ahead variant and describes the design change that closes it.\n- **Low.** Trivially close opposite-side targets give a 100% hit rate and unbounded streaks with no forecasting. The README partly acknowledges this, but the aggregate stats are exactly what readers are told to trust.\n- **Low.** Streaks follow settlement order, which the caller controls. Settling misses first and hits afterwards turns a chronological best streak of 1 into the full hit count. Keepers have no incentive, so pending misses can also be left unsettled.\n- **Low.** A third party's no-proof MISS after the reveal window is final even when a proving round exists, and it is stored with `forced = false`, so it looks like the caller's own admission.\n- **Info, trust assumption.** An owner-added feed can fabricate hits for chosen accounts, and `getStats` does not separate feeds. Recorded as the intended trust in the feed list, not as a bypass.\n\n**Coverage.** All of `src/`, the deploy script, tests, README, REVIEW.md and launch.json were read. Access-control, trust-gap and asymmetry passes were done in depth; the remaining Pashov specialties were applied as lighter passes and surfaced nothing further. LaunchToken is a plain fixed-supply ERC-20 with no defects found. Slither was not run, as the task does not provide it. Each low and info finding was confirmed by a scratch test that passes on the current code, in `test/scratch/Gaming.t.sol`.","treeHash":null,"usage":{"cachedInputTokens":1063077,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":39016,"runtime":"claude","turns":24,"wallClockMs":529108}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d2d5a117dd72f6b4","findings":[{"citation":"resolved","description":"_settle accepts any round whose updatedAt lies in [committedAt, expiry] with both ends inclusive. Chainlink rounds carry updatedAt = block.timestamp of the transmit transaction, so a transmit that lands in the same block as the commit but after it has updatedAt == committedAt and passes this check, while commit() snapshotted the previous round. A caller who watches the mempool sees the pending transmit (the new median is in its calldata), front-runs it with commit(hash, expiry) where the hidden call is in the direction of the pending update with targetPrice one minor unit beyond the stale snapshot, and after expiry settles with that very round. Nothing after the commit has to be predicted, so hits, hit rate and streaks are inflated at will. The existing test test_settle_hitAtExactTargetAndBoundaries (test/CallBook.t.sol:420-427) asserts this behaviour as desired ('Round exactly at commit time also counts'), so the suite enshrines the defect rather than catching it. Fix: require the proving round to be strictly later than the commit (updatedAt > committedAt), and preferably also record the snapshot round id per feed at commit and require proofRoundId > snapshotRoundId, so a round that was already in flight when the call was published can never prove it. The REVIEW.md item 3 ('inclusive bounds') should be revisited accordingly; see also the separate finding on minimum target distance, which the strict bound alone does not address.","line":314,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal aggregator double: every round is complete (answeredInRound == roundId).\ncontract FeedStub is AggregatorV3Interface {\n    struct R {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => R) internal rounds;\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = R(answer, updatedAt);\n    }\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"STUB\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[latest];\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice A Chainlink round mined in the same block as the commit, after it, has\n/// `updatedAt == committedAt` and is accepted as proof. The commit snapshot still holds the previous\n/// answer, so a target one unit beyond it is on the \"correct side\" and is proven by a round the\n/// caller already saw in the mempool. Nothing after the commit had to be predicted.\ncontract CommitBlockProofTest is Test {\n    CallBook internal book;\n    FeedStub internal eth;\n    FeedStub internal btc;\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new FeedStub();\n        btc = new FeedStub();\n        eth.push(2_000e8, T0 - 10 minutes);\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function test_roundInCommitBlockMustNotProveHit() public {\n        uint64 expiry = uint64(T0 + 1 hours);\n        int256 target = 2_000e8 + 1; // one minor unit above the snapshot\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n\n        // Alice front-runs the pending Chainlink transmit (answer 2_010e8) with her commit.\n        vm.prank(alice);\n        uint256 id = book.commit(h, expiry);\n        assertEq(book.commitPriceOf(id, address(eth)), 2_000e8);\n\n        // The transmit lands later in the same block: updatedAt == committedAt == T0.\n        uint80 pending = eth.push(2_010e8, T0);\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        try book.revealAndSettle(id, address(eth), CallBook.Direction.UP, target, SALT, pending) {} catch {}\n\n        // Expected: a round that was already in flight when the call was made cannot prove it.\n        // Actual on current code: status == Hit, hits == 1.\n        assertTrue(book.getCall(id).status != CallBook.Status.Hit, \"round at committedAt accepted as HIT proof\");\n        assertEq(book.getStats(alice).hits, 0, \"free hit recorded\");\n    }\n}","reproduction":"State: ETH/USD latest round answer 2_000e8, updatedAt T0-10min; a Chainlink transmit with answer 2_010e8 is pending. Block T0: (1) alice calls commit(keccak256(abi.encode(alice, ETH, UP, 2_000e8+1, T0+1h, salt)), T0+1h) -> commitPriceOf(id, ETH) = 2_000e8; (2) the transmit is mined in the same block -> round R with updatedAt == T0 == committedAt, answer 2_010e8. At T0+1h alice calls revealAndSettle(id, ETH, UP, 2_000e8+1, salt, R). Expected: RoundOutsideWindow (the round was not produced after the call existed). Actual: status == Hit, getStats(alice) = {calls 1, hits 1, misses 0, currentStreak 1, bestStreak 1}. Proof test test/scratch/CommitBlockProof.t.sol fails on the current code with 'round at committedAt accepted as HIT proof' and passes once the lower bound is strict.","severity":"high","snippet":"        if (updatedAt < c.committedAt || updatedAt > c.expiry) revert RoundOutsideWindow();","title":"Proof window starts at committedAt inclusive: an oracle round mined in the commit block (a transmit already visible in the mempool) proves a HIT"},{"citation":"resolved","description":"commit() silently skips every feed that is disabled at the moment it executes and accepts the commit as long as one other feed is recorded. The caller's hash already binds the feed, so if the owner's disableFeed(feed) is mined before the caller's pending commit, the commit succeeds with no snapshot for that feed, `calls` is incremented, and _reveal later reverts with FeedNotRecorded (line 272) for the only preimage that matches. The call can never be revealed and after expiry + 24h anyone records a forced MISS against the caller (markUnrevealed). This contradicts the contract's own guarantee at lines 16-17 ('Nothing the owner does changes the result of a call that has already been made') and REVIEW.md item 9: the owner, by front-running a specific commit (or merely by a routine disable racing honest commits in the mempool), decides the outcome of a call and damages the caller's record and streak. The caller can detect the missing CommitPriceRecorded event in the receipt but has no way to cancel. This is a concrete owner power over results, distinct from the documented trust in the feed list. Fix options that preserve the design: (a) in commit, still snapshot a listed-but-disabled feed when its latest round is fresh and skip it only when stale, so disabling only stops commits on a broken feed; or (b) let a reveal whose feed has no snapshot resolve the call to a neutral 'Void' status that counts as neither hit nor miss and resets nothing; or (c) have commit accept a caller-supplied minimum set/mask of feeds that must be recorded and revert otherwise. Option (a) is the smallest change.","line":216,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\ncontract FeedStub is AggregatorV3Interface {\n    struct R {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => R) internal rounds;\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = R(answer, updatedAt);\n    }\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"STUB\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[latest];\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice The owner disables ETH/USD in the block before Alice's already-broadcast commit lands.\n/// The commit is accepted (BTC/USD is still enabled) but no ETH/USD snapshot is taken, so the call can\n/// never be revealed and ends as a forced MISS on Alice's record. The owner thus changed the result of a\n/// call, which the contract's own documentation says cannot happen.\ncontract OwnerDisableProofTest is Test {\n    CallBook internal book;\n    FeedStub internal eth;\n    FeedStub internal btc;\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    address internal keeper = makeAddr(\"keeper\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new FeedStub();\n        btc = new FeedStub();\n        eth.push(2_000e8, T0 - 10 minutes);\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function test_disableBeforeInFlightCommitMustNotForceMiss() public {\n        uint64 expiry = uint64(T0 + 1 days);\n        int256 target = 2_500e8;\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n\n        // Owner's disable is mined first (front-run, or an unlucky race with a routine disable).\n        vm.prank(owner);\n        book.disableFeed(address(eth));\n\n        uint256 id;\n        vm.prank(alice);\n        try book.commit(h, expiry) returns (uint256 id_) {\n            id = id_;\n        } catch {\n            // A commit that reverts leaves no record; acceptable.\n            return;\n        }\n\n        // The market actually reached the target: a correct call.\n        uint80 proof = eth.push(2_600e8, T0 + 12 hours);\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        try book.reveal(id, address(eth), CallBook.Direction.UP, target, SALT) {} catch {}\n        vm.prank(alice);\n        try book.settle(id, proof) {} catch {}\n\n        vm.warp(uint256(expiry) + book.REVEAL_WINDOW() + 1);\n        vm.prank(keeper);\n        try book.markUnrevealed(id) {} catch {}\n\n        // Expected: an owner action cannot turn Alice's correct call into a MISS.\n        // Actual on current code: reveal reverts FeedNotRecorded, markUnrevealed records a forced MISS.\n        CallBook.Stats memory s = book.getStats(alice);\n        assertEq(s.misses, 0, \"owner feed disable forced a MISS on an in-flight commit\");\n    }\n}","reproduction":"Block T0: owner calls disableFeed(ETH) (mined first); alice's already-broadcast commit(keccak256(abi.encode(alice, ETH, UP, 2_500e8, T0+1d, salt)), T0+1d) is mined next and succeeds because BTC is still enabled: commitPriceOf(id, ETH) == 0, getStats(alice).calls == 1. ETH/USD later prints 2_600e8 at T0+12h, so the call was correct. At T0+1d alice calls reveal(id, ETH, UP, 2_500e8, salt). Expected: the call is revealable (or was rejected at commit). Actual: revert FeedNotRecorded; settle(id, proof) reverts NotRevealed; at T0+1d+24h+1s anyone calls markUnrevealed(id) and getStats(alice) becomes {calls 1, hits 0, misses 1, currentStreak 0}. Proof test test/scratch/OwnerDisableProof.t.sol fails on the current code with 'owner feed disable forced a MISS on an in-flight commit: 1 != 0'.","severity":"medium","snippet":"            if (!feedEnabled[feed]) continue;","title":"Owner can turn an in-flight commit into an unrevealable forced MISS by disabling its feed before the commit is mined"},{"citation":"resolved","description":"_finish updates currentStreak/bestStreak in the order calls are settled. The caller fully controls that order: a HIT can be settled by the caller at any time from expiry, a MISS can be taken by the caller at any time after reveal or deferred until expiry + 24h (markUnrevealed), and third parties cannot record a MISS before the window closes. A caller therefore commits N UP calls and N DOWN calls in the same block with targets one minor unit on either side of the snapshot (both directions pass the TargetWrongSide check); whichever way the next round moves, exactly one direction hits. The caller then settles the N hits consecutively and only afterwards the N misses, recording bestStreak == N while in commit order the results strictly alternate and no genuine streak exceeds 1. currentStreak can likewise be kept at N for a further 24h by leaving the losers unsettled. README.md documents that streaks follow settlement order but does not note that the order is adversarially chosen, so the displayed streak carries no information. Fix: compute streaks in commit order (e.g. keep each caller's call ids in an array and only allow _finish on a caller's calls in id order, or recompute the streak over the ordered list when a call settles), or drop the streak fields from Stats and leave streak computation to off-chain readers who can order by id. The proof test is written so that any rule yielding commit-order streaks passes it, including simply enforcing in-order settlement per caller.","line":339,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\ncontract FeedStub is AggregatorV3Interface {\n    struct R {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => R) internal rounds;\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = R(answer, updatedAt);\n    }\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"STUB\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        R memory r = rounds[latest];\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice Alice makes six calls in one block, strictly alternating UP / DOWN with targets one unit\n/// away from the snapshot. Whatever the price does, exactly one direction hits, so in commit order her\n/// results alternate H M H M H M and no honest streak is longer than 1. Because streaks follow\n/// settlement order and the caller chooses that order, she settles the three hits first and records\n/// bestStreak == 3 with a 50% hit rate.\ncontract StreakOrderProofTest is Test {\n    CallBook internal book;\n    FeedStub internal eth;\n    FeedStub internal btc;\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    uint256[] internal ids;\n    CallBook.Direction[] internal dirs;\n    int256[] internal targets;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new FeedStub();\n        btc = new FeedStub();\n        eth.push(2_000e8, T0 - 10 minutes);\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function _trySettle(uint256 i, uint80 round) internal {\n        vm.prank(alice);\n        try book.revealAndSettle(ids[i], address(eth), dirs[i], targets[i], SALT, round) {} catch {}\n    }\n\n    function test_settlementOrderMustNotInflateBestStreak() public {\n        uint64 expiry = uint64(T0 + 1 hours);\n        for (uint256 i; i < 6; ++i) {\n            CallBook.Direction d = i % 2 == 0 ? CallBook.Direction.UP : CallBook.Direction.DOWN;\n            int256 t = d == CallBook.Direction.UP ? int256(2_000e8 + 1) : int256(2_000e8 - 1);\n            bytes32 h = keccak256(abi.encode(alice, address(eth), d, t, expiry, SALT));\n            vm.prank(alice);\n            ids.push(book.commit(h, expiry));\n            dirs.push(d);\n            targets.push(t);\n        }\n\n        // The next round ticks up by one unit: every UP call hits, every DOWN call misses.\n        uint80 up = eth.push(2_000e8 + 1, T0 + 30 minutes);\n\n        vm.warp(expiry);\n        // Gaming order: all hits first (ids 1,3,5), then all misses (ids 2,4,6).\n        for (uint256 i; i < 6; i += 2) _trySettle(i, up);\n        for (uint256 i = 1; i < 6; i += 2) _trySettle(i, 0);\n        // Clean up anything a stricter ordering rule may have left unsettled, in commit order.\n        for (uint256 i; i < 6; ++i) {\n            if (book.getCall(ids[i]).status == CallBook.Status.Committed) {\n                _trySettle(i, dirs[i] == CallBook.Direction.UP ? up : 0);\n            }\n        }\n\n        CallBook.Stats memory s = book.getStats(alice);\n        assertEq(s.hits, 3);\n        assertEq(s.misses, 3);\n        // Expected: results alternate in the order the calls were made, so no streak exceeds 1.\n        // Actual on current code: bestStreak == 3.\n        assertEq(s.bestStreak, 1, \"bestStreak inflated by caller-chosen settlement order\");\n    }\n}","reproduction":"Block T0, snapshot ETH 2_000e8: alice commits ids 1..6 with expiry T0+1h, directions UP,DOWN,UP,DOWN,UP,DOWN and targets 2_000e8+1 / 2_000e8-1 (different salts are not even needed). Round R at T0+30min answers 2_000e8+1. At T0+1h alice calls revealAndSettle for ids 1,3,5 with roundId R (HIT each), then for ids 2,4,6 with roundId 0 (MISS each). Expected: results alternate H M H M H M in the order the calls were made, so bestStreak == 1. Actual: getStats(alice) = {calls 6, hits 3, misses 3, currentStreak 0, bestStreak 3}. Proof test test/scratch/StreakOrderProof.t.sol fails on the current code with 'bestStreak inflated by caller-chosen settlement order: 3 != 1'.","severity":"medium","snippet":"            ++s.currentStreak;","title":"bestStreak and currentStreak are computed in caller-chosen settlement order, so hedged pairs settled hits-first produce an arbitrary streak with a 50% hit rate"},{"citation":"resolved","description":"The only constraint on the hidden target is that it lies strictly on the correct side of commitPrice, which is the feed's latest on-chain answer and may be up to MAX_PRICE_AGE (3 hours) old. Chainlink answers only update on a deviation threshold or heartbeat, so the on-chain answer lags the market by up to the deviation (0.5%-1% on the launch feeds) for up to a heartbeat. A caller who sees the market already above the posted answer commits UP with targetPrice = commitPrice + 1 (one 1e-8 USD unit) and is proven by the next round posted inside the window, which will almost surely be at least one unit higher; no forecast is involved. The 3-hour staleness allowance makes this worse during feed outages, when the gap between market and snapshot can be several percent. Combined with free commits (separate finding) this yields a near-100% hit rate and unbounded streaks on an address, which defeats the purpose stated in the README ('read the caller's real hit rate instead of trusting screenshots'). The strict lower bound proposed for the committed-block finding does not address this. Fix requires a scope decision: enforce a minimum relative distance between target and snapshot (for example at least 2x the feed's deviation threshold, configurable per feed by the owner at addFeed), and/or require the proving round to be posted at least some minimum delay after committedAt, and/or tighten MAX_PRICE_AGE to the feed heartbeat. Any of these changes the agreed economic rules and must be chosen by the requester; at minimum the limitation should be documented and surfaced to readers alongside targetPrice and commitPrice.","line":274,"path":"src/CallBook.sol","reproduction":"State: ETH/USD last on-chain round 2_000e8 at T0-50min (heartbeat 1h, deviation 0.5%); the market has drifted to 2_009 (0.45%, below the deviation threshold, so no new round yet). T0: alice commits UP, targetPrice 2_000e8+1, expiry T0+1h. T0+10min: the heartbeat round posts 2_009e8. T0+1h: alice revealAndSettle(id, ETH, UP, 2_000e8+1, salt, heartbeatRound). Expected: a call that merely restates the already-known market price should not count as a correct prediction. Actual: status Hit, hits 1. Repeating this at every heartbeat yields an address with hits == calls and bestStreak == calls.","severity":"medium","snippet":"        if (direction == Direction.UP ? targetPrice <= commitPrice : targetPrice >= commitPrice) {","title":"No minimum distance between target and the lagging commit snapshot: a target one unit past a stale on-chain price is proven by the next scheduled round"},{"citation":"resolved","description":"commit() has no stake, fee, rate limit, uniqueness constraint on the hidden call, or binding between a person and an address. Records are per msg.sender. Two gaming mechanisms follow directly. (1) Survivorship: address A commits UP and address B commits DOWN on the same feed with targets one unit from the snapshot; exactly one hits. The loser is abandoned (its forced MISS lands on an address nobody will ever publish) and the winner repeats the pairing with a new throwaway address. After k rounds an address exists with hits == k, misses == 0, bestStreak == k, having never made a real forecast. The cost is gas for 2k commits and k reveals. (2) Duplication: one correct call committed k times with k different salts settles as k HITs from the same round, giving hits == k and bestStreak == k from a single forecast. Neither can be detected on-chain or by a reader of getStats, so the number the project advertises as 'real hit rate' is unconstrained by skill. This is a design-level gap rather than a coding slip: any fix (a per-call stake forfeited on MISS or forced MISS, a commit fee, a per-caller cap per feed per expiry, or an on-chain identity/registration requirement) changes the agreed economics and needs the requester's decision. If no economic cost is added, the README's claims about trustworthiness should be qualified and readers advised to weight records by number of distinct calls, target distance and age.","line":200,"path":"src/CallBook.sol","reproduction":"Snapshot ETH 2_000e8 at T0. Round 1: address A commits UP target 2_000e8+1 and fresh address B commits DOWN target 2_000e8-1, both expiry T0+1h. The next round prints 2_001e8: A settles a HIT (hits 1); B is abandoned and takes a forced MISS that nobody will ever publish. Round 2: A commits UP again and fresh address C commits DOWN; whichever of A or C wins is kept as the public identity and re-paired with a new throwaway. After k rounds the surviving address shows calls k, hits k, misses 0, bestStreak k, at a cost of 2k commits' gas and no forecasting. Duplication variant: alice commits the same (ETH, UP, 2_500e8, expiry) under 100 different salts; one round at 2_600e8 inside the window settles all 100 as HIT: getStats(alice) = {calls 100, hits 100, misses 0, bestStreak 100} from one forecast. Expected: one forecast contributes one unit of record. Actual: record size is bounded only by gas.","severity":"medium","snippet":"    function commit(bytes32 commitHash, uint64 expiry) external returns (uint256 id) {","title":"Commits are free and unlimited, so a perfect record is manufactured by survivorship (hedging across fresh addresses) or by duplicating one call"},{"citation":"resolved","description":"Documented as accepted (REVIEW.md items 10 and 12) and reported here as trust assumptions, not as a bypass. The sanity check at addFeed is satisfied by any contract exposing decimals(), so the owner can list an arbitrary contract. Powers that follow: (a) a feed the owner or an accomplice controls returns whatever getRoundData the settler needs, so calls committed on that feed are provably 'won' at will (readers must filter by feed address in Revealed events, as the self-review notes); (b) a feed whose latestRoundData reverts or returns stale/non-positive data makes every commit on the book revert with StalePrice/BadAnswer until the owner disables it again, i.e. the owner can halt new commits at any time, and a stale real feed does the same until the owner acts; (c) listing is permanent and capped at MAX_FEEDS = 16, so an owner that lists 16 addresses can never add another feed. Ownership is immutable and there is no two-step transfer, so loss of the owner key freezes the feed set. The owner cannot change the outcome of a call whose snapshot exists (settle reads c.feed, not the enabled flag); the separate medium finding covers the one case where it can (disabling before an in-flight commit). No code change is required for the design as agreed; consider documenting these powers in the README's Operational responsibilities and, if desired, validating latestRoundData freshness in addFeed so a broken feed cannot be enabled by mistake.","line":165,"path":"src/CallBook.sol","reproduction":"owner calls addFeed(X) where X is a contract with decimals() returning 8 and latestRoundData() reverting: every subsequent commit(hash, expiry) by any caller reverts inside _freshPrice until owner calls disableFeed(X). owner calls addFeed(Y) where Y returns (rid, 1e30, t, t, rid) for any rid: a friend commits DOWN on Y with target 1e8, later settles with any round id and records a HIT; getStats shows the hit indistinguishably from one on ETH/USD except via the Revealed event's feed field.","severity":"info","snippet":"        AggregatorV3Interface(feed).decimals();","title":"Owner trust assumptions: addFeed only checks decimals(), so any contract can be listed as a 'feed'; a listed feed decides hits on itself and can block all commits"},{"citation":"resolved","description":"Deployment-phase check from the temporal threat profile. If an operator runs the script without CALLBOOK_OWNER and without a signer, msg.sender is Foundry's default sender (0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) and the immutable owner of the deployed CallBook is an address nobody controls; with a signer, the owner becomes the deployer key rather than the policy owner, and since owner is immutable this cannot be corrected after deployment. The production launch goes through the ProjectFactory with $owner, so this only affects operator-run deployments, but the README example depends on the env var being set. Suggest requiring CALLBOOK_OWNER explicitly (vm.envAddress) and reverting on address(0)/default sender.","line":36,"path":"script/Deploy.s.sol","reproduction":"EXPECTED_CHAIN_ID=31337 forge script script/Deploy.s.sol:Deploy --rpc-url <anvil> --broadcast without CALLBOOK_OWNER and without --sender/--private-key: deployed CallBook.owner() == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38; addFeed/disableFeed are permanently unusable. Expected: the script refuses to deploy without an explicit owner.","severity":"info","snippet":"            owner: vm.envOr(\"CALLBOOK_OWNER\", msg.sender),","title":"Deploy script falls back to msg.sender as owner when CALLBOOK_OWNER is unset, which is the Foundry default sender in a dry run"}],"hash":"a51967a5bb67c939ffb3882aeab07ffa830de5a3ebe61f70a4c13df037235b85","nodeId":"76c0356c-9211-4e5a-aa2f-5af7564d363d","outcome":"completed","summary":"The findings file is written and the environment ignores it from git along with test/scratch, as intended. No source, config or library file was changed.\n\n## Review of CallBook: results\n\nI audited `src/CallBook.sol`, `src/LaunchToken.sol`, the deploy script, the tests and the docs against the Execution Trace, Periphery and First Principles passes, with the entry-point inventory and the x-ray oracle and deployment profiles. Seven findings are in `.imd-findings.json`. Three carry Foundry proofs under test/scratch that fail on the current code and pass under a minimal patched copy I built and discarded.\n\n**High**\n- **A round mined in the commit block proves a hit** (`src/CallBook.sol:314`). The proof window is inclusive at `committedAt`. A caller who sees a pending Chainlink transmit in the mempool front-runs it with a commit whose hidden target is one unit past the stale snapshot, then settles with that very round. Nothing is predicted. The existing suite asserts this behaviour as correct. Proof attached.\n\n**Medium**\n- **Owner can force a MISS on an in-flight commit** (`src/CallBook.sol:216`). Disabling a feed in the block before a broadcast commit lands makes the commit succeed with no snapshot for that feed. The call can never be revealed and becomes a forced MISS. This contradicts the contract's own claim that the owner cannot change results. Proof attached.\n- **Streaks follow caller-chosen settlement order** (`src/CallBook.sol:339`). Six alternating hedged calls settled hits-first give bestStreak 3 with a 50 percent hit rate. Proof attached.\n- **No minimum target distance** (`src/CallBook.sol:274`). A target one unit past the lagging on-chain price is proven by the next heartbeat round. Fix needs a scope decision, so no proof.\n- **Free unlimited commits** (`src/CallBook.sol:200`). Survivorship across throwaway addresses or duplicating one call under many salts manufactures a perfect record. Design-level, flagged for the requester's decision.\n\n**Info**\n- Owner trust assumptions around `addFeed` accepting any contract with a `decimals()` function, plus the permanent 16-feed cap.\n- Deploy script defaults the immutable owner to the script sender when the env var is unset.\n\n**Leads rejected after checking.** Reentrancy through a malicious listed feed is impossible because the interface marks the feed calls `view`, so they go out as STATICCALL. Hash flexibility, round-window bounds, stale-round rejection, double settlement and LaunchToken conservation all held up. LaunchToken is clean.\n\n**Coverage gaps worth noting.** The test suite never exercises a round landing in the commit block as a defect, never varies settlement order adversarially, and never races an owner disable against a commit.","treeHash":null,"usage":{"cachedInputTokens":1305921,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":43146,"runtime":"claude","turns":30,"wallClockMs":631342}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3b260b68e9ad6a37","findings":[{"citation":"resolved","description":"_finish applies each outcome to the caller's streak at the moment that call is settled. The caller alone decides when each of their own calls is settled: a Revealed call can be settled as a HIT by the caller at any time, a MISS can be deferred until expiry + REVEAL_WINDOW (no third party may record a MISS before then, lines 303-306 and 326), and calls sharing an expiry can be revealed and settled in any order within one transaction. The README calls settlement-order streaks deliberate because 'settlement is the first moment an outcome is known', but for calls that have expired the outcome of every one of them is already fixed; only the order in which the caller chooses to write them differs. The caller therefore sorts hits before misses and bestStreak becomes the count of hits in the batch, independent of the real sequence. The gaming is amplified by the absence of any minimum target distance: UP at commitPrice+1 and DOWN at commitPrice-1 (one 1e-8 USD tick) are both valid at reveal (strict inequality only, line 274), so hedged pairs committed in one block yield exactly one guaranteed hit per pair on any posted round whose answer differs from the snapshot. N hedged pairs settled hits-first give calls=2N, hits=N, misses=N, bestStreak=N with zero skill; if the N losers are never revealed and no keeper calls markUnrevealed, the record shows hits=N, misses=0 until someone pays gas to say otherwise. Streaks are the headline metric of a 'tamper-proof track record'; this makes them worthless as evidence. Impact is on record integrity, not funds, which is the only asset this contract protects. Fix (preserves the design): fold outcomes into the streak strictly in commit (id) order. Keep a per-caller array of call ids pushed in commit() and a cursor; in _finish, after setting status, walk from the cursor while the call at the cursor is Hit or Miss, applying hit/reset to currentStreak/bestStreak, and stop at the first call that is still Committed/Revealed. A hit is then never counted while an earlier call is pending, and settlement order cannot reorder the streak. (Alternatively drop the on-chain streak fields and derive streaks off-chain from Settled events keyed by id; that changes the Stats ABI.) The hedge amplifier can be bounded separately with a minimum relative target distance, which is a design decision for the requester.","line":336,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal aggregator double: complete rounds only, fully controllable updatedAt.\ncontract StreakFeed is AggregatorV3Interface {\n    struct Round {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => Round) internal rounds;\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"STREAK / USD\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = Round(answer, updatedAt);\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[latest];\n        require(r.updatedAt != 0, \"No data present\");\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice bestStreak must reflect the caller's calls in the order they were made (call id order),\n/// not in whatever order the caller chooses to settle them.\n///\n/// Alice commits five calls in this order: a (hit), b (hit), c (miss), d (hit), e (miss).\n/// In call order the longest run of hits is 2 (a, b). Alice settles d before c and gets a\n/// bestStreak of 3 on the code as it stands.\ncontract StreakOrderTest is Test {\n    CallBook internal book;\n    StreakFeed internal eth;\n    StreakFeed internal btc;\n\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new StreakFeed();\n        btc = new StreakFeed();\n        eth.push(2_000e8, T0 - 10 minutes);\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function _commit(CallBook.Direction dir, int256 target, uint64 expiry) internal returns (uint256 id) {\n        bytes32 h = keccak256(abi.encode(alice, address(eth), dir, target, expiry, SALT));\n        vm.prank(alice);\n        id = book.commit(h, expiry);\n    }\n\n    function test_bestStreak_followsCallOrderNotSettlementOrder() public {\n        uint64 expiry = uint64(T0 + 1 days);\n        uint256 a = _commit(CallBook.Direction.UP, 2_100e8, expiry); // id 1, will hit\n        uint256 b = _commit(CallBook.Direction.UP, 2_200e8, expiry); // id 2, will hit\n        uint256 c = _commit(CallBook.Direction.UP, 9_000e8, expiry); // id 3, will miss\n        uint256 d = _commit(CallBook.Direction.UP, 2_250e8, expiry); // id 4, will hit\n        uint256 e = _commit(CallBook.Direction.UP, 9_500e8, expiry); // id 5, will miss\n        uint80 up = eth.push(2_300e8, T0 + 2 hours);\n\n        vm.warp(expiry);\n        vm.startPrank(alice);\n        // Alice settles her three hits first, then her two misses.\n        book.revealAndSettle(a, address(eth), CallBook.Direction.UP, 2_100e8, SALT, up);\n        book.revealAndSettle(b, address(eth), CallBook.Direction.UP, 2_200e8, SALT, up);\n        book.revealAndSettle(d, address(eth), CallBook.Direction.UP, 2_250e8, SALT, up);\n        book.revealAndSettle(c, address(eth), CallBook.Direction.UP, 9_000e8, SALT, 0);\n        book.revealAndSettle(e, address(eth), CallBook.Direction.UP, 9_500e8, SALT, 0);\n        vm.stopPrank();\n\n        CallBook.Stats memory s = book.getStats(alice);\n        assertEq(s.calls, 5);\n        assertEq(s.hits, 3);\n        assertEq(s.misses, 2);\n        // Longest run of hits in the order the calls were actually made: a, b = 2.\n        assertEq(s.bestStreak, 2, \"bestStreak must not be inflatable by settlement order\");\n    }\n}","reproduction":"Alice, ETH/USD snapshot 2000e8, expiry = commit + 1 day. Commit in this order: a UP 2100e8 (id 1), b UP 2200e8 (id 2), c UP 9000e8 (id 3), d UP 2250e8 (id 4), e UP 9500e8 (id 5). One round posts answer 2300e8 inside the window, so a, b, d hit and c, e miss. In call order the longest hit run is 2 (a, b). At expiry Alice calls revealAndSettle for a, b, d with the proving round, then revealAndSettle for c and e with roundId 0. Expected: getStats(alice).bestStreak == 2. Actual: bestStreak == 3 (the existing test test_stats_streaksFollowSettlementOrder settles a,b,c,d,e and gets 2; only the order changed). Hedged variant, verified in a scratch test: commit UP 2000e8+1 and DOWN 2000e8-1 at T0 with expiry T0+1h; a round with answer 2000e8+37 (a 0.00000037 USD move) at T0+30min proves the UP call; after revealAndSettle, stats read hits=1, currentStreak=1, misses=0, and the DOWN call sits unrevealed until a keeper spends gas on markUnrevealed.","severity":"high","snippet":"        Stats storage s = _stats[c.caller];\n        if (hit) {\n            ++s.hits;\n            ++s.currentStreak;\n            if (s.currentStreak > s.bestStreak) s.bestStreak = s.currentStreak;\n        } else {\n            ++s.misses;\n            s.currentStreak = 0;\n        }","title":"currentStreak/bestStreak follow settlement order, which the caller controls, so any streak can be fabricated"},{"citation":"resolved","description":"The commit snapshot is the feed's latest round at commit time, which may be up to MAX_PRICE_AGE = 3 hours old (line 233) and in normal operation lags the market by up to the feed's deviation threshold. A HIT proof is any round with updatedAt in [committedAt, expiry], lower bound inclusive. Chainlink transmit transactions are visible in the public mempool before they land. A caller who sees a pending transmit carrying a new answer submits commit() with a higher priority fee so it executes earlier in the same block: _freshPrice still returns the old round (the new one has not landed), so commitPriceOf records the stale price; the transmit then lands with updatedAt == block.timestamp == committedAt, and that round passes the window check with the new answer. The caller commits a target between the stale snapshot and the already-known new answer and is guaranteed a HIT. Every such call is a free entry on the record and, combined with the streak issue, a free streak extension. The round at updatedAt == committedAt can never be a legitimate proof anyway: if it landed before the commit it IS the snapshot round and its answer equals commitPrice, which the strict side check at reveal (line 274) already excludes from hitting; so rejecting it costs nothing. Fix: make the lower bound strict, `updatedAt <= c.committedAt` reverts RoundOutsideWindow (and update README 'both inclusive'). Residual: a transmit that is delayed to the next block can still be front-run; a stronger version adds a minimum lead time constant (e.g. proof rounds must satisfy updatedAt >= committedAt + 1 heartbeat) or a minimum relative target distance, both of which are design choices for the requester.","line":314,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal aggregator double: complete rounds only, fully controllable updatedAt.\ncontract WindowFeed is AggregatorV3Interface {\n    struct Round {\n        int256 answer;\n        uint256 updatedAt;\n    }\n\n    uint80 public latest;\n    mapping(uint80 => Round) internal rounds;\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"WINDOW / USD\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function push(int256 answer, uint256 updatedAt) external returns (uint80 id) {\n        id = ++latest;\n        rounds[id] = Round(answer, updatedAt);\n    }\n\n    function getRoundData(uint80 id) external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[id];\n        require(r.updatedAt != 0, \"No data present\");\n        return (id, r.answer, r.updatedAt, r.updatedAt, id);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = rounds[latest];\n        require(r.updatedAt != 0, \"No data present\");\n        return (latest, r.answer, r.updatedAt, r.updatedAt, latest);\n    }\n}\n\n/// @notice A Chainlink round posted in the same block as the commit, but after the commit\n/// transaction, must not prove a HIT. The caller saw the pending transmit (answer 2,100) in the\n/// mempool, committed ahead of it against the stale snapshot (2,000), and the round's updatedAt\n/// equals committedAt, which the inclusive lower bound accepts.\ncontract SameBlockRoundTest is Test {\n    CallBook internal book;\n    WindowFeed internal eth;\n    WindowFeed internal btc;\n\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    bytes32 internal constant SALT = keccak256(\"salt\");\n    uint256 internal constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new WindowFeed();\n        btc = new WindowFeed();\n        eth.push(2_000e8, T0 - 50 minutes); // stale-but-accepted snapshot (heartbeat round)\n        btc.push(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function test_roundPostedInCommitBlockAfterCommitIsNotAProof() public {\n        uint64 expiry = uint64(T0 + 1 hours);\n        int256 target = 2_050e8; // +2.5% over the snapshot; the pending transmit already says 2,100\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n        vm.prank(alice);\n        uint256 id = book.commit(h, expiry);\n        assertEq(book.commitPriceOf(id, address(eth)), 2_000e8);\n\n        // Chainlink transmit lands later in the same block: updatedAt == committedAt == T0.\n        uint80 frontRun = eth.push(2_100e8, T0);\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        book.reveal(id, address(eth), CallBook.Direction.UP, target, SALT);\n\n        // Expected: a round that is not strictly after the commit is outside the call's window.\n        vm.expectRevert(CallBook.RoundOutsideWindow.selector);\n        book.settle(id, frontRun);\n    }\n}","reproduction":"ETH/USD latest round: answer 2000e8, updatedAt T0-50min (accepted, under 3h). A Chainlink transmit with answer 2100e8 is pending. At T0 Alice's commit(hash(UP, 2050e8, expiry=T0+1h), expiry) executes first in the block: commitPriceOf(id, ETH) = 2000e8. The transmit lands in the same block: round R, answer 2100e8, updatedAt = T0 = committedAt. At T0+1h Alice reveals (2050e8 > 2000e8 passes) and calls settle(id, R). Expected: the round is not after the commit and should be rejected (RoundOutsideWindow). Actual: updatedAt < committedAt is false, 2100e8 >= 2050e8, call is recorded as HIT with proofRoundId R.","severity":"medium","snippet":"        if (updatedAt < c.committedAt || updatedAt > c.expiry) revert RoundOutsideWindow();","title":"Proof window includes committedAt, so a Chainlink round front-run in the commit block proves a 'prediction' of a move that already happened"},{"citation":"resolved","description":"settle(id, 0) from a non-caller is accepted once block.timestamp > expiry + 24h with no check that a proving round is absent (the contract cannot check that). A Revealed call whose caller proved nothing within 24h of expiry is then a race between whoever submits settle(id, 0) and whoever submits settle(id, proofRound); the first transaction wins and the outcome written to a 'tamper-proof' record depends on mempool ordering, not on prices. Once Miss is written the proof is permanently rejected (NotRevealed). The README documents the 24h burden on the caller, so this is a known trade-off; the defect that remains is that such a MISS is written with forced = false and the Settled event carries no settler, so readers cannot distinguish 'caller conceded' from 'nobody applied the proof in time', and a griefer who wants to reset a rival's currentStreak has an incentive to watch for this state. Minimal fix without changing the settlement rules: record third-party no-proof settlements distinctly (set forced = true, or add a separate flag/enum value and emit the settler), so the record carries the information that the outcome was not acknowledged by the caller. A larger design option is to let a later valid HIT proof overturn a third-party MISS (not a caller MISS), restoring the hit and adjusting stats.","line":304,"path":"src/CallBook.sol","reproduction":"Alice commits UP 2500e8 on ETH at T0, expiry T0+1d. Round R: answer 2600e8 at T0+12h. At expiry Alice calls reveal() but does not settle. At T0+2d+1s Bob calls settle(id, 0): accepted, status = Miss, forced = false, alice.misses = 1, alice.currentStreak = 0. Then settle(id, R) reverts NotRevealed although R is a valid proof inside the window. Expected: a call with a valid proof is not recorded as an ordinary conceded miss. Actual: it is, indistinguishable from Alice having called settle(id, 0) herself.","severity":"low","snippet":"            if (msg.sender != c.caller && block.timestamp <= uint256(c.expiry) + REVEAL_WINDOW) {\n                revert RevealWindowOpen();\n            }\n            _finish(id, c, false, 0, false);","title":"After the reveal window a third party can record a MISS on a revealed call that has a valid on-chain HIT proof, and the record cannot tell it apart from a caller-acknowledged miss"},{"citation":"resolved","description":"commit() snapshots only feeds enabled at execution time (line 216 skips disabled ones). The caller's hash fixes the feed before the transaction is sent. If the owner's disableFeed(feed) for that feed executes first (ordinary operations, e.g. reacting to a stale feed; no malice needed), the caller's commit succeeds, increments stats.calls, but records no price for the hashed feed. reveal() then reverts FeedNotRecorded on the only preimage that matches, the call can never leave Committed, and after expiry + 24h anyone calls markUnrevealed and the caller takes a MISS with forced = true even if the call was right. The caller has no way to detect this at commit time (commit does not return which feeds were recorded) and no way to withdraw a call. Impact is a wrongful miss and streak reset on an honest record; preconditions are an owner action racing a user transaction, which the pashov validation gate accepts as an unprivileged-race amplifier. Fix options that keep the owner's power: (a) in commit, accept an optional list of feeds the caller requires to be recorded and revert if any of them was not snapshotted (the hash still hides which one is the real target); or (b) at reveal, if the hash verifies but commitPriceOf is zero, move the call to a Void status that counts in neither hits nor misses and does not touch the streak.","line":271,"path":"src/CallBook.sol","reproduction":"Block N: owner.disableFeed(BTC). Same block, later: Alice.commit(hash(alice, BTC, DOWN, 55000e8, expiry), expiry) with expiry = N.timestamp + 1 day; commitPriceOf(id, BTC) == 0, stats.calls == 1. BTC posts 50000e8 at +2h, so the call was right. At expiry Alice.reveal(id, BTC, DOWN, 55000e8, salt) reverts FeedNotRecorded. At expiry + 24h + 1 anyone calls markUnrevealed(id). Expected: a call the contract could never judge is not a miss. Actual: status Miss, forced = true, alice.misses == 1, currentStreak reset.","severity":"low","snippet":"        int256 commitPrice = commitPriceOf[id][feed];\n        if (commitPrice == 0) revert FeedNotRecorded();","title":"disableFeed landing ahead of a pending commit makes that call unrevealable, so it is recorded as a forced MISS the caller could not avoid"},{"citation":"resolved","description":"Documented trust assumption, recorded here as the adapter requires rather than as a permission bypass. The owner is the only privileged actor and cannot touch an existing call, but: (1) addFeed accepts any address whose decimals() does not revert. A feed the owner controls lets anyone the owner favours record arbitrary HITs on that feed (readers must filter by feed, which getStats does not do: the aggregate hits/misses/streaks mix all feeds). (2) The same check passes for contracts that are not aggregators at all, including this project's own LaunchToken (decimals() == 18); once such an address is enabled every commit() reverts in _freshPrice until the owner disables it, since commit reads every enabled feed. (3) owner is immutable with no transfer or renounce. If the key is lost and an enabled feed goes stale for more than MAX_PRICE_AGE, or Chainlink deprecates the Sepolia aggregator, commit() reverts StalePrice forever and the contract is permanently closed to new calls; existing calls remain settleable. None of these are permission bypasses; they are the powers and failure modes of the agreed owner role. Suggested hardening that keeps the role: check latestRoundData() succeeds with a fresh positive answer in addFeed, and consider either a two-step owner transfer or no owner at all with a fixed feed list, as a documented scope decision.","line":165,"path":"src/CallBook.sol","reproduction":"Scratch test: owner.addFeed(address(new LaunchToken())) succeeds (decimals() returns 18). alice.commit(bytes32(1), now + 1 day) then reverts (LaunchToken has no latestRoundData and no fallback). owner.disableFeed(token) restores commits. For (3): with owner key lost and the ETH/USD aggregator not updated for 3h + 1s, every commit() reverts StalePrice(ETH_USD) and no call can ever re-enable or disable the feed.","severity":"info","snippet":"        AggregatorV3Interface(feed).decimals();\n        _enable(feed);","title":"Owner trust assumptions: any contract answering decimals() can be listed as a feed, which fabricates results on that feed or blocks all commits, and the immutable owner has no recovery path"}],"hash":"c1dc97bae129bcb90c9de12c4d606bb2b02f096ce77376350062fdbb06445bbc","nodeId":"81ad761d-efd4-4c6d-bae3-bdf046d250f5","outcome":"completed","summary":"The review is complete. No tracked files were changed; the findings file is at the repository root and the two proof tests live under test/scratch.\n\n**Scope covered.** Both contracts in src, the deploy script, all three test files, the mock aggregator, README, REVIEW.md and launch.json. The assigned area was covered first: the contract contains no multiplication or division, so the math audit reduced to comparison boundaries, type widths, the zero sentinel for recorded prices, sign handling, and time-window edges. I checked each against the Pashov Math Precision, Boundary and Numerical Gap guides, then the owner's powers and every way a caller can shape their own record. Pure arithmetic is sound: casts are bounded, counters are checked, the zero sentinel is unreachable as a real price, and the reveal and settle windows agree at every boundary.\n\n**Findings written to .imd-findings.json (5):**\n\n- **High: streaks follow settlement order, which the caller controls.** A caller with hits and misses in one batch settles hits first and bestStreak becomes the batch's hit count. With hedged pairs at commitPrice plus and minus one tick, which the strict side check accepts, N pairs produce N guaranteed hits and bestStreak N with no skill. Proof test fails on current code (3 vs expected 2) and passes with a commit-order streak cursor I verified on a patched copy.\n- **Medium: the proof window's inclusive lower bound accepts a Chainlink round posted in the commit block.** A caller who front-runs a visible transmit commits against the stale snapshot and is proven by the round that lands seconds later with updatedAt equal to committedAt. Proof test fails now and passes with a strict lower bound. The excluded round can never be a legitimate proof, so the fix costs nothing.\n- **Low: a third party can record a MISS on a revealed call that has a valid on-chain proof**, and the record marks it exactly like a caller-conceded miss. Documented trade-off; the defect is the missing distinction.\n- **Low: disableFeed landing ahead of a pending commit** leaves the hashed feed unrecorded, the call can never be revealed, and the caller takes a forced MISS they could not avoid.\n- **Info: owner trust assumptions.** Any contract answering decimals, including the project's own LaunchToken, can be listed as a feed; that either fabricates results on that feed or reverts every commit until disabled. The owner is immutable with no recovery, so a lost key plus one stale feed closes the contract to new calls permanently.\n\n**Not found.** No permission bypass, no reentrancy path (feed reads are STATICCALLs), no way for the owner to alter an existing call, and no defect in LaunchToken.\n\n**Test gaps worth noting for the requester.** The suite never settles calls out of commit order with a different expectation, never posts a round at exactly committedAt after the commit, and never races an owner action against a user transaction. Those are the three edges where the defects above live.","treeHash":null,"usage":{"cachedInputTokens":1400391,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":38749,"runtime":"claude","turns":28,"wallClockMs":1045139}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2a5d68f89de314cb","findings":[{"citation":"resolved","description":"The commit snapshot (`_freshPrice`, lines 228-235) is the feed's *latest* round, which is allowed to be up to MAX_PRICE_AGE = 3 hours old and, even when fresh, lags the real market by the feed's deviation threshold (a Chainlink round is only posted when the price moves more than the threshold or the heartbeat elapses). The reveal-time side check (line 274) only requires the target to be strictly beyond that snapshot. `_settle` then accepts ANY round with `updatedAt >= committedAt` as a HIT proof, including the very next round the feed posts, which merely reports the price the caller could already see off-chain at commit time. Three reachable states make the call a certainty rather than a prediction: (1) the feed's latest round is stale (up to 3h) while the market has moved; (2) the market has moved by less than the deviation threshold since the last round and the heartbeat update is pending; (3) on Sepolia's public mempool the caller sees Chainlink's `transmit` transaction carrying the new answer and places `commit` ahead of it in the same block (`updatedAt == committedAt` passes the inclusive bound). In all three the caller commits UP with target just below the already-known new price, and the round that lands seconds later proves the HIT. No capital, no price risk; every such call is a hit, the streak counters grow without bound, and the contract's only product (a trustworthy hit rate) is void. REVIEW.md item 5 ('target already reached at commit ... rejected at reveal') only holds against the on-chain snapshot, not against the price the caller actually saw. Minimal fix that preserves the design: require a proof round to be posted a minimum delay after the commit, e.g. `if (updatedAt < uint256(c.committedAt) + PROOF_DELAY || updatedAt > c.expiry) revert RoundOutsideWindow();` with PROOF_DELAY at least the feed heartbeat (1 hour for the launch feeds); a strict `>` alone does not help because the next block's round is equally known. Alternatively store the snapshot round id per feed and require `proofRoundId` to be at least two rounds later. Either way MIN_DURATION should exceed the delay, and MAX_PRICE_AGE should be tightened toward the heartbeat so the snapshot cannot be hours behind the market.","line":314,"path":"src/CallBook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CallBook} from \"src/CallBook.sol\";\nimport {AggregatorV3Interface} from \"src/interfaces/AggregatorV3Interface.sol\";\n\n/// @dev Minimal controllable aggregator (self-contained; no other test files imported).\ncontract ProofAggregator is AggregatorV3Interface {\n    struct Round {\n        int256 answer;\n        uint256 updatedAt;\n        bool exists;\n    }\n\n    uint80 public latestRound;\n    mapping(uint80 => Round) private _rounds;\n\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function description() external pure returns (string memory) {\n        return \"PROOF / USD\";\n    }\n\n    function version() external pure returns (uint256) {\n        return 4;\n    }\n\n    function pushRound(int256 answer, uint256 updatedAt) external returns (uint80 roundId) {\n        roundId = ++latestRound;\n        _rounds[roundId] = Round(answer, updatedAt, true);\n    }\n\n    function getRoundData(uint80 roundId) external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = _rounds[roundId];\n        require(r.exists, \"No data present\");\n        return (roundId, r.answer, r.updatedAt, r.updatedAt, roundId);\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        Round memory r = _rounds[latestRound];\n        require(r.exists, \"No data present\");\n        return (latestRound, r.answer, r.updatedAt, r.updatedAt, latestRound);\n    }\n}\n\n/// @title Risk-free HIT from the first feed round posted after a commit.\n///\n/// The commit snapshot is the feed's *latest* round, which lags the real market (deviation threshold,\n/// heartbeat, up to MAX_PRICE_AGE = 3h of staleness). The very next round the feed posts therefore\n/// reports a price the caller already knew at commit time, yet `_settle` accepts any round with\n/// `updatedAt >= committedAt` as a HIT proof. A caller who commits while the on-chain price lags\n/// (or who front-runs a pending Chainlink `transmit` in the public mempool) gets a guaranteed HIT.\n///\n/// On the current code both negative tests record a HIT (so the expectRevert fails). After a fix that\n/// refuses rounds posted \"immediately\" after the commit (e.g. require `updatedAt >= committedAt + delay`,\n/// or a proof round at least two rounds after the snapshot round) they pass; the positive control\n/// guards against an over-fix.\ncontract FirstRoundProofTest is Test {\n    CallBook internal book;\n    ProofAggregator internal eth;\n    ProofAggregator internal btc;\n\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n\n    uint256 internal constant T0 = 1_800_000_000;\n    bytes32 internal constant SALT = keccak256(\"salt\");\n\n    function setUp() public {\n        vm.warp(T0);\n        eth = new ProofAggregator();\n        btc = new ProofAggregator();\n        // On-chain ETH/USD is $2,000, last posted 10 minutes ago. The real market has since moved to $2,100\n        // (within a deviation threshold or a stale window); the next round will report it.\n        eth.pushRound(2_000e8, T0 - 10 minutes);\n        btc.pushRound(60_000e8, T0 - 10 minutes);\n        book = new CallBook(owner, address(eth), address(btc));\n    }\n\n    function _commitUp(int256 target, uint64 expiry) internal returns (uint256 id) {\n        bytes32 h = keccak256(abi.encode(alice, address(eth), CallBook.Direction.UP, target, expiry, SALT));\n        vm.prank(alice);\n        id = book.commit(h, expiry);\n    }\n\n    /// Round posted in the same block as the commit (updatedAt == committedAt), e.g. the caller's commit\n    /// is ordered just before Chainlink's transmit in that block.\n    function test_sameBlockRoundCannotProveHit() public {\n        uint64 expiry = uint64(T0 + 1 days);\n        uint256 id = _commitUp(2_050e8, expiry); // snapshot is 2,000; target 2,050 passes the side check\n        uint80 r1 = eth.pushRound(2_100e8, T0); // same block, already-known price\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        book.reveal(id, address(eth), CallBook.Direction.UP, 2_050e8, SALT);\n\n        vm.expectRevert();\n        book.settle(id, r1);\n        assertEq(uint8(book.getCall(id).status), uint8(CallBook.Status.Revealed));\n        assertEq(book.getStats(alice).hits, 0);\n    }\n\n    /// Round posted one block (12s) after the commit: the ordinary case when the on-chain price lags.\n    function test_nextBlockRoundCannotProveHit() public {\n        uint64 expiry = uint64(T0 + 1 days);\n        uint256 id = _commitUp(2_050e8, expiry);\n        uint80 r1 = eth.pushRound(2_100e8, T0 + 12);\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        book.reveal(id, address(eth), CallBook.Direction.UP, 2_050e8, SALT);\n\n        vm.expectRevert();\n        book.settle(id, r1);\n        assertEq(uint8(book.getCall(id).status), uint8(CallBook.Status.Revealed));\n        assertEq(book.getStats(alice).hits, 0);\n    }\n\n    /// Positive control: a round posted well after the commit, inside the window, must still prove a HIT.\n    function test_laterRoundStillProvesHit() public {\n        uint64 expiry = uint64(T0 + 1 days);\n        uint256 id = _commitUp(2_050e8, expiry);\n        eth.pushRound(2_100e8, T0 + 12); // the lagging update\n        uint80 r2 = eth.pushRound(2_100e8, T0 + 12 hours); // genuinely later price\n\n        vm.warp(expiry);\n        vm.prank(alice);\n        book.reveal(id, address(eth), CallBook.Direction.UP, 2_050e8, SALT);\n        book.settle(id, r2);\n        assertEq(uint8(book.getCall(id).status), uint8(CallBook.Status.Hit));\n        assertEq(book.getStats(alice).hits, 1);\n    }\n}","reproduction":"State: ETH/USD latest round = 2_000e8 at T0-10min (fresh enough to snapshot); real market already 2_100e8 (feed stale, deviation not yet triggered, or transmit pending in the mempool). 1) alice: commit(keccak256(abi.encode(alice, ETH, UP, 2_050e8, T0+1d, salt)), T0+1d) at T0 -> id=1, commitPriceOf(1, ETH) = 2_000e8. 2) Feed posts round r1 = (answer 2_100e8, updatedAt T0) in the same block (or T0+12 in the next block). 3) At T0+1d alice: reveal(1, ETH, UP, 2_050e8, salt) passes the side check (2_050e8 > 2_000e8). 4) settle(1, r1): updatedAt T0 >= committedAt T0, answer 2_100e8 >= 2_050e8 -> status Hit, stats hits=1, currentStreak=1. Expected: a round reporting the price already known at commit time cannot prove a prediction; settle(1, r1) should revert and only a genuinely later round (e.g. at T0+12h) should count. Actual: HIT recorded. Repeat for every commit to build an arbitrarily long flawless record. Proof: test/scratch/FirstRoundProof.t.sol (2 of 3 tests fail now; all pass with the PROOF_DELAY fix above, verified against a scratch copy of the contract with `updatedAt < committedAt + 1 hours` added).","severity":"high","snippet":"        if (updatedAt < c.committedAt || updatedAt > c.expiry) revert RoundOutsideWindow();","title":"Risk-free HIT: the first feed round posted after a commit is accepted as proof, so a lagging or pending price update guarantees the call"},{"citation":"resolved","description":"The only constraint on a target is that it lies strictly beyond the snapshot price, by as little as one minor unit (1e-8 USD). A HIT needs just one round anywhere in a window of up to 30 days that touches the target. A caller can therefore commit, in the same transaction, UP at snapshot+1 and DOWN at snapshot-1 with 30-day expiries: both settle as HITs as soon as any round above and any round below the snapshot are posted, which for ETH or BTC over a month is practically certain. Nothing prevents committing the identical preimage many times, so one such pair becomes N hits and a streak of N. `Stats` (calls/hits/misses/streaks) and `Settled` carry no notion of target distance or duration, so a reader of getStats cannot tell this record from a genuine one; only a reader parsing every `Revealed` event and re-deriving distance could. This is distinct from the previous finding (which is about proof timing); even with that fixed, hedged one-tick pairs remain certain hits. Fix options (design decisions, the requester must choose): enforce a minimum target distance from the snapshot in basis points (e.g. >= 100 bps) at reveal, and/or make duration count (e.g. shorter MAX_DURATION, or settle against the price at expiry rather than any touch), and/or record per-call distance/duration so aggregate difficulty-weighted stats can be derived on-chain.","line":274,"path":"src/CallBook.sol","reproduction":"State: ETH snapshot P = 2_000e8. alice commits 50 x UP target P+1 and 50 x DOWN target P-1, all expiry T0+30d (100 commits). Feed posts any round with answer P+1 (e.g. at T0+3d) and any round with answer P-1 (e.g. at T0+9d). At expiry alice calls revealAndSettle on each with the matching round. Result on current code (verified in a scratch test): getStats(alice) = {calls 100, hits 100, misses 0, currentStreak 100, bestStreak 100}. Expected: a record that reflects forecasting skill; a 1e-8 USD move in either direction over 30 days is not a forecast and should be rejected or at least be distinguishable in the stats.","severity":"medium","snippet":"        if (direction == Direction.UP ? targetPrice <= commitPrice : targetPrice >= commitPrice) {","title":"Hedged one-tick calls (UP at price+1 and DOWN at price-1) over a 30-day window give a near-certain 100% hit rate; stats carry no difficulty measure"},{"citation":"resolved","description":"Streaks follow settlement order (documented), but settlement order is entirely under the caller's control: a HIT can be settled by the caller at expiry, while no third party may record a MISS (settle(id,0) or markUnrevealed) until expiry + REVEAL_WINDOW has passed (lines 304-306, 326). The caller therefore always settles every hit in a batch before any miss, so bestStreak equals the largest number of hits sharing a reveal window regardless of how the calls actually alternated, and for at least 24 hours after each expiry the record shows the hits with the misses still 'pending'. Because keepers are unpaid, the misses stay pending until someone bothers. Fix (design choice): compute streaks in call-id (commit) order, e.g. keep a per-caller pointer and only advance the streak when the next id in sequence is settled, or expose per-call outcomes so readers can order them themselves; and document that `hits/(hits+misses)` is meaningless while `calls - hits - misses > 0`.","line":339,"path":"src/CallBook.sol","reproduction":"State: alice commits ids 1..4 with the same expiry T0+1d: id1 UP 2_100e8 (hit), id2 UP 9_000e8 (miss), id3 UP 2_200e8 (hit), id4 UP 9_500e8 (miss); feed posts 2_300e8 at T0+6h. At T0+1d alice settles id1 and id3 as hits; keeper's markUnrevealed(2) reverts RevealWindowOpen; getStats(alice) = {hits 2, misses 0, currentStreak 2}. After T0+2d+1s the keeper forces id2 and id4: bestStreak stays 2 although in commit order the outcomes were H M H M (longest run 1). Verified in a scratch test.","severity":"low","snippet":"            ++s.currentStreak;","title":"Caller controls settlement order for 24h after expiry, so streaks reflect the caller's chosen ordering and misses can be deferred"},{"citation":"resolved","description":"commit() cannot know which feed the hidden call is on, so it simply skips disabled feeds. If the owner's disableFeed(F) lands in the same block (or any time) before a caller's already-signed commit on F, the commit succeeds, `commitPriceOf[id][F]` stays 0, reveal reverts FeedNotRecorded (line 272) forever, and after the window anyone records a forced MISS that resets the caller's streak. The README states 'Nothing the owner does changes the result of a call that has already been made'; here the owner (maliciously, or routinely when retiring a deprecated feed) decides the outcome of calls that were already signed, and the caller has no way to cancel. Fix that keeps the feed hidden: let the caller bind the enabled-feed set, e.g. commit(commitHash, expiry, bytes32 feedSetHash) reverting unless keccak256 of the enabled-feed list (or the number of recorded feeds) matches, so a feed change between signing and inclusion reverts the commit instead of dooming it.","line":216,"path":"src/CallBook.sol","reproduction":"State: BTC/USD enabled, alice has signed commit(H, T0+1d) with H = hash(alice, BTC, DOWN, 50_000e8, T0+1d, salt). 1) owner: disableFeed(BTC) (same block, earlier). 2) alice's commit executes: id=1, commitPriceOf(1, BTC) == 0, stats.calls = 1. 3) Feed posts 49_000e8 at T0+2h (the call was right). 4) At T0+1d reveal(1, BTC, DOWN, 50_000e8, salt) and revealAndSettle(...) both revert FeedNotRecorded. 5) At T0+2d+1s anyone: markUnrevealed(1) -> status Miss, forced = true, misses = 1, currentStreak = 0. Expected: either the commit reverts or the call is not counted against the caller. Verified in a scratch test.","severity":"low","snippet":"            if (!feedEnabled[feed]) continue;","title":"A disableFeed ordered before an in-flight commit on that feed silently turns the commit into a guaranteed forced MISS"},{"citation":"resolved","description":"Trust assumption to document, not a permission bypass. commit() snapshots every enabled feed and reverts if any one is stale, deprecated or returns a non-positive answer. Only `owner` can disable a feed, `owner` is immutable with no transfer or recovery, and Chainlink testnet feeds are retired without notice. If the owner key is lost or the holder is unresponsive, the first feed to go stale stops all new calls permanently; conversely the owner can halt all commits at will (disable every feed -> NoFeeds, or addFeed of a contract that reverts on latestRoundData). Mitigation within the design: allow the caller to pass the subset of feeds to snapshot (reverting only if the named feeds are stale), or let anyone disable a feed whose latest round is older than a generous bound (e.g. 24h), and consider a two-step transferable owner for key rotation.","line":233,"path":"src/CallBook.sol","reproduction":"State: both launch feeds last updated at T0-10min. At T0+4h (feed heartbeat missed or feed retired) alice calls commit(bytes32(1), T0+4h+1d): reverts StalePrice(ETH). No non-owner action can restore commits; if the owner key is unavailable this is permanent. Verified in a scratch test.","severity":"low","snippet":"        if (block.timestamp - updatedAt > MAX_PRICE_AGE) revert StalePrice(feed);","title":"Liveness depends on one immutable key: any enabled feed older than 3 hours blocks every commit until the owner disables it"},{"citation":"resolved","description":"Trust assumption (REVIEW.md item 10 accepts it). addFeed only checks that `decimals()` answers; any contract implementing AggregatorV3Interface is accepted, including one whose rounds the owner or an accomplice writes. Calls committed on such a feed can be proven HIT with fabricated rounds, and the fabricated feed's price is also snapshotted into every other caller's commit (harmless to their reveal, but it appears in CommitPriceRecorded). Readers who aggregate `getStats` across feeds cannot exclude it. If this is kept, document that stats are only meaningful per feed and that feed listing is a trusted action; optionally restrict addFeed to addresses whose `description()`/`version()` match a Chainlink proxy and emit the feed list in a way front-ends must filter on.","line":165,"path":"src/CallBook.sol","reproduction":"State: owner deploys FakeAgg with decimals()=8 and a pushRound(answer, updatedAt) setter, calls addFeed(FakeAgg). Accomplice commits UP target 1e30 on FakeAgg with expiry T0+1h; owner pushes round (1e30, T0+30min). At expiry revealAndSettle(..., thatRound) records a HIT. Expected per README: 'Nothing the owner does changes the result of a call'; actual: the owner fully controls results on feeds it lists.","severity":"info","snippet":"        AggregatorV3Interface(feed).decimals();","title":"Owner can list an aggregator it controls, which lets calls on that feed prove any outcome"},{"citation":"resolved","description":"Documented caller responsibility, listed because the task asks whether a record can be damaged by others. A proof-less MISS from a non-caller is accepted after expiry + 24h with no check that no qualifying round exists, and a MISS is final: a later settle(id, proofRound) reverts NotRevealed. A caller who reveals without settling (e.g. reveal via the two-step path and then goes offline) takes a permanent MISS on a call that provably hit, resetting the streak. The README mitigation (use revealAndSettle) works; if the design should be more forgiving, allow a valid HIT proof to overturn a non-forced third-party MISS, accepting that hits/misses counters then need an adjustment path.","line":304,"path":"src/CallBook.sol","reproduction":"State: alice commits UP 2_500e8 expiry T0+1d; feed posts 2_600e8 at T0+12h. At T0+1d alice calls reveal() only. At T0+2d+1s keeper calls settle(id, 0): status Miss, misses=1, currentStreak=0. Alice then calls settle(id, spikeRound): reverts NotRevealed. Expected by a reader of the record: the call hit (the round is on-chain); actual: permanent MISS.","severity":"info","snippet":"            if (msg.sender != c.caller && block.timestamp <= uint256(c.expiry) + REVEAL_WINDOW) {","title":"A revealed call with a valid HIT round on-chain can still be recorded as a MISS by any third party once the reveal window closes"}],"hash":"dc7e921a9fe873eddb8282c5fbde399bdfadd5fb3ee269acbd700465d3825cf8","nodeId":"fdf0c48b-0528-4d40-90f6-4e4569fd0861","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 7 findings against `src/CallBook.sol`, and no source, config, or library file was changed. The proof test lives in `test/scratch/FirstRoundProof.t.sol`.\n\n**Headline finding (high, with proof).** A hit can be proven by the very first Chainlink round posted after the commit. The commit snapshot is the feed's latest round, which lags the market by the deviation threshold and may be up to 3 hours stale, while settlement accepts any round with `updatedAt >= committedAt`. A caller who sees a stale on-chain price, a pending heartbeat update, or Chainlink's `transmit` in Sepolia's public mempool commits with a target just under the already-known new price and gets a guaranteed hit. The proof test fails on the current code and passes with a scratch copy that requires proof rounds at least one hour after the commit. A strict inequality alone does not fix it, since the next block's round is equally known.\n\n**Other verified findings.**\n- **Medium.** Hedged one-tick pairs (UP at price+1, DOWN at price-1) over 30 days are near-certain hits, and identical preimages can be committed repeatedly. A scratch run produced calls 100, hits 100, bestStreak 100. Stats carry no difficulty measure, so readers cannot tell.\n- **Low.** The caller alone controls settlement order for 24 hours after expiry, so hits are always settled before misses and bestStreak reflects batching, not sequence.\n- **Low.** A `disableFeed` landing before an already-signed commit on that feed turns it into an unavoidable forced miss, contradicting the README's claim that the owner cannot affect made calls.\n- **Low.** One stale enabled feed blocks every commit, and only the immutable owner can clear it, so a lost key is a permanent liveness failure.\n- **Info.** Owner can list a self-controlled aggregator (documented trust assumption), and a revealed but unsettled call with a provable hit can be marked a miss by a third party after the window.\n\n**Coverage.** All passes from the Pashov guide were applied as single-reviewer checks, with depth on the Economic Security, Invariant, and Flow Gap lenses. `LaunchToken` and the deploy script showed no defects. The existing 49-test suite passes offline. Edges the shipped tests leave untested: same-block or next-block proof rounds, hedged pairs, and feed changes racing a commit.","treeHash":null,"usage":{"cachedInputTokens":1572656,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":37647,"runtime":"claude","turns":28,"wallClockMs":522808}}],"verification":[]}