// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title CortiqRunReceipt /// @notice Public receipts for workflow runs. A run is planned and executed off-chain (in the user's browser); when it /// finishes, the person who reviewed it can anchor three facts here: the keccak-256 hash of the plan, the /// keccak-256 hash of the action log, and how many steps (and approved actions) the run had. Anyone holding /// the exported run file can later recompute both hashes and find the receipt, which proves the file existed /// unchanged at the anchoring block and that this address anchored it. /// A receipt is a statement by its runner. The contract does not check that the plan was sensible, that the /// steps really ran, or that their results are correct. No plan text or log text is stored or emitted: only /// hashes and counts, so nothing private reaches the chain unless the runner publishes the file. /// @dev No owner, no admin, no fee, no pause, no upgrade path, no constructor arguments. The contract never holds /// funds: no function is payable and there is no receive or fallback, so ETH transfers revert. /// Receipts are permanent: there is no edit and no delete. One runner can anchor a given log hash once. contract CortiqRunReceipt { struct Receipt { address runner; uint32 stepCount; uint32 actionCount; uint64 anchoredBlock; uint64 anchoredAt; bytes32 planHash; bytes32 logHash; } /// @notice Most steps one run may report. uint32 public constant MAX_STEPS = 64; /// @notice Most ids returned by one paged read. uint256 public constant MAX_PAGE = 100; /// @notice Number of receipts ever anchored. Receipt ids start at 1. uint256 public receiptCount; mapping(uint256 id => Receipt) private _receipts; /// @dev The receipt a runner anchored for a log hash (one per pair). mapping(bytes32 key => uint256) private _byRunner; /// @dev The first receipt that anchored a log hash, whoever the runner. mapping(bytes32 logHash => uint256) private _firstByLog; /// @dev Receipt ids per runner, in anchoring order. mapping(address runner => uint256[]) private _runnerReceipts; event RunAnchored( uint256 indexed id, address indexed runner, bytes32 indexed logHash, bytes32 planHash, uint32 stepCount, uint32 actionCount, uint64 anchoredBlock ); error ZeroHash(); error BadStepCount(uint32 stepCount); error ActionsExceedSteps(uint32 actionCount, uint32 stepCount); error AlreadyAnchored(uint256 id); error UnknownReceipt(uint256 id); /// @notice Anchors a run receipt for the caller. /// @param planHash keccak-256 of the canonical plan JSON. Must not be zero. /// @param logHash keccak-256 of the canonical action log JSON. Must not be zero. /// @param stepCount Steps in the run, 1 to MAX_STEPS. /// @param actionCount Steps that needed and received human approval, at most `stepCount`. /// @return id The new receipt id. function anchor(bytes32 planHash, bytes32 logHash, uint32 stepCount, uint32 actionCount) external returns (uint256 id) { if (planHash == bytes32(0) || logHash == bytes32(0)) revert ZeroHash(); if (stepCount == 0 || stepCount > MAX_STEPS) revert BadStepCount(stepCount); if (actionCount > stepCount) revert ActionsExceedSteps(actionCount, stepCount); bytes32 key = keccak256(abi.encode(msg.sender, logHash)); uint256 prior = _byRunner[key]; if (prior != 0) revert AlreadyAnchored(prior); id = ++receiptCount; _byRunner[key] = id; if (_firstByLog[logHash] == 0) _firstByLog[logHash] = id; _runnerReceipts[msg.sender].push(id); _receipts[id] = Receipt({ runner: msg.sender, stepCount: stepCount, actionCount: actionCount, anchoredBlock: uint64(block.number), anchoredAt: uint64(block.timestamp), planHash: planHash, logHash: logHash }); emit RunAnchored(id, msg.sender, logHash, planHash, stepCount, actionCount, uint64(block.number)); } /// @notice The receipt with id `id`. Reverts for an id that does not exist. function receipt(uint256 id) external view returns (Receipt memory) { if (id == 0 || id > receiptCount) revert UnknownReceipt(id); return _receipts[id]; } /// @notice The receipt `runner` anchored for `logHash`, or 0 when there is none. function receiptOf(address runner, bytes32 logHash) external view returns (uint256) { return _byRunner[keccak256(abi.encode(runner, logHash))]; } /// @notice The earliest receipt for `logHash` by any runner, or 0 when there is none. function firstReceiptOf(bytes32 logHash) external view returns (uint256) { return _firstByLog[logHash]; } /// @notice How many receipts `runner` has anchored. function runnerReceiptCount(address runner) external view returns (uint256) { return _runnerReceipts[runner].length; } /// @notice Up to `limit` (max MAX_PAGE) receipt ids by `runner`, newest first, skipping the `offset` newest. function runnerReceipts(address runner, uint256 offset, uint256 limit) external view returns (uint256[] memory ids) { uint256[] storage all = _runnerReceipts[runner]; uint256 n = all.length; if (limit > MAX_PAGE) limit = MAX_PAGE; if (offset >= n || limit == 0) return new uint256[](0); uint256 count = n - offset; if (count > limit) count = limit; ids = new uint256[](count); for (uint256 i = 0; i < count; i++) { ids[i] = all[n - 1 - offset - i]; } } }