// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; import {CortiqRunReceipt} from "../src/CortiqRunReceipt.sol"; contract CortiqRunReceiptTest is Test { CortiqRunReceipt internal book; address internal alice = address(0xA11CE); address internal bob = address(0xB0B); bytes32 internal constant PLAN = keccak256("plan"); bytes32 internal constant LOG = keccak256("log"); event RunAnchored( uint256 indexed id, address indexed runner, bytes32 indexed logHash, bytes32 planHash, uint32 stepCount, uint32 actionCount, uint64 anchoredBlock ); function setUp() public { book = new CortiqRunReceipt(); vm.roll(1_000); vm.warp(1_700_000_000); } function test_AnchorStoresEverything() public { vm.prank(alice); uint256 id = book.anchor(PLAN, LOG, 5, 2); assertEq(id, 1); assertEq(book.receiptCount(), 1); CortiqRunReceipt.Receipt memory r = book.receipt(1); assertEq(r.runner, alice); assertEq(r.stepCount, 5); assertEq(r.actionCount, 2); assertEq(r.anchoredBlock, 1_000); assertEq(r.anchoredAt, 1_700_000_000); assertEq(r.planHash, PLAN); assertEq(r.logHash, LOG); assertEq(book.receiptOf(alice, LOG), 1); assertEq(book.firstReceiptOf(LOG), 1); assertEq(book.runnerReceiptCount(alice), 1); } function test_EmitsEvent() public { vm.expectEmit(true, true, true, true, address(book)); emit RunAnchored(1, alice, LOG, PLAN, 3, 0, 1_000); vm.prank(alice); book.anchor(PLAN, LOG, 3, 0); } function test_RejectsZeroHashes() public { vm.expectRevert(CortiqRunReceipt.ZeroHash.selector); book.anchor(bytes32(0), LOG, 1, 0); vm.expectRevert(CortiqRunReceipt.ZeroHash.selector); book.anchor(PLAN, bytes32(0), 1, 0); } function test_RejectsBadStepCounts() public { vm.expectRevert(abi.encodeWithSelector(CortiqRunReceipt.BadStepCount.selector, uint32(0))); book.anchor(PLAN, LOG, 0, 0); vm.expectRevert(abi.encodeWithSelector(CortiqRunReceipt.BadStepCount.selector, uint32(65))); book.anchor(PLAN, LOG, 65, 0); // The edge is allowed. book.anchor(PLAN, LOG, 64, 64); } function test_RejectsMoreActionsThanSteps() public { vm.expectRevert(abi.encodeWithSelector(CortiqRunReceipt.ActionsExceedSteps.selector, uint32(4), uint32(3))); book.anchor(PLAN, LOG, 3, 4); } function test_SameRunnerCannotAnchorSameLogTwice() public { vm.startPrank(alice); book.anchor(PLAN, LOG, 2, 0); vm.expectRevert(abi.encodeWithSelector(CortiqRunReceipt.AlreadyAnchored.selector, uint256(1))); book.anchor(keccak256("other plan"), LOG, 2, 0); vm.stopPrank(); } function test_OtherRunnerCanAnchorSameLog_FirstStays() public { vm.prank(alice); book.anchor(PLAN, LOG, 2, 0); vm.prank(bob); uint256 id = book.anchor(PLAN, LOG, 2, 1); assertEq(id, 2); assertEq(book.firstReceiptOf(LOG), 1); assertEq(book.receiptOf(bob, LOG), 2); assertEq(book.receiptOf(alice, LOG), 1); } function test_UnknownReceiptReverts() public { vm.expectRevert(abi.encodeWithSelector(CortiqRunReceipt.UnknownReceipt.selector, uint256(0))); book.receipt(0); vm.expectRevert(abi.encodeWithSelector(CortiqRunReceipt.UnknownReceipt.selector, uint256(1))); book.receipt(1); } function test_MissingLookupsAreZero() public view { assertEq(book.receiptOf(alice, LOG), 0); assertEq(book.firstReceiptOf(LOG), 0); assertEq(book.runnerReceiptCount(alice), 0); assertEq(book.runnerReceipts(alice, 0, 10).length, 0); } function test_PagingNewestFirst() public { vm.startPrank(alice); for (uint256 i = 1; i <= 5; i++) book.anchor(PLAN, keccak256(abi.encode(i)), 1, 0); vm.stopPrank(); uint256[] memory page = book.runnerReceipts(alice, 0, 2); assertEq(page.length, 2); assertEq(page[0], 5); assertEq(page[1], 4); page = book.runnerReceipts(alice, 3, 10); assertEq(page.length, 2); assertEq(page[0], 2); assertEq(page[1], 1); assertEq(book.runnerReceipts(alice, 5, 10).length, 0); assertEq(book.runnerReceipts(alice, 0, 0).length, 0); } function test_PageLimitCapped() public { vm.startPrank(alice); for (uint256 i = 1; i <= 105; i++) book.anchor(PLAN, keccak256(abi.encode(i)), 1, 0); vm.stopPrank(); assertEq(book.runnerReceipts(alice, 0, 1_000).length, 100); } function test_RejectsEth() public { vm.deal(alice, 1 ether); vm.prank(alice); (bool ok,) = address(book).call{value: 1 wei}(""); assertFalse(ok); vm.prank(alice); (ok,) = address(book).call{value: 1 wei}(abi.encodeWithSelector(book.anchor.selector, PLAN, LOG, uint32(1), uint32(0))); assertFalse(ok); assertEq(address(book).balance, 0); } function testFuzz_Anchor(address runner, bytes32 plan, bytes32 log, uint32 steps, uint32 actions) public { vm.assume(plan != bytes32(0) && log != bytes32(0)); steps = uint32(bound(steps, 1, 64)); actions = uint32(bound(actions, 0, steps)); vm.prank(runner); uint256 id = book.anchor(plan, log, steps, actions); CortiqRunReceipt.Receipt memory r = book.receipt(id); assertEq(r.runner, runner); assertEq(r.planHash, plan); assertEq(r.logHash, log); assertEq(r.stepCount, steps); assertEq(r.actionCount, actions); assertEq(book.receiptOf(runner, log), id); assertEq(book.firstReceiptOf(log), id); } function testFuzz_RejectsOutOfRange(uint32 steps, uint32 actions) public { vm.assume(steps == 0 || steps > 64 || actions > steps); vm.expectRevert(); book.anchor(PLAN, LOG, steps, actions); } function test_GasAnchor() public { vm.prank(alice); uint256 g = gasleft(); book.anchor(PLAN, LOG, 8, 1); uint256 used = g - gasleft(); emit log_named_uint("anchor gas (first receipt, warm book)", used); assertLt(used, 260_000); } }