// SPDX-License-Identifier: MIT pragma solidity 0.8.37; /// @title ResearchEscrow — one testnet research milestone, native ETH only /// @notice Unreviewed prototype: NOT audited, NOT a legally sufficient agreement, /// and NOT intended for mainnet or assets of value. A hash does not verify science. /// @dev Immutable roles and terms; no proxy, upgrades, delegatecall, or loops. /// Deployment is permitted only on chain 31337 and Sepolia 11155111. /// This is a chain-ID check, not proof that a network is an authentic testnet. contract ResearchEscrow { enum State { AwaitingDeposit, Funded, Submitted, Disputed, Resolved } enum Resolution { None, ClientAcceptance, MissedDeadline, Arbitration } address public immutable client; address public immutable researcher; address public immutable arbiter; bytes32 public immutable termsHash; uint256 public immutable amountWei; uint64 public immutable submissionDeadline; State public state; Resolution public resolution; bytes32 public evidenceHash; bytes32 public disputeHash; bytes32 public decisionHash; address public beneficiary; uint256 public withdrawable; bool private withdrawing; error UnsupportedChain(uint256 chainId); error InvalidParties(); error Unauthorized(); error InvalidHash(); error InvalidAmount(); error InvalidDeadline(); error WrongDeposit(uint256 expected, uint256 received); error InvalidState(State actual, State expected); error DeadlinePassed(); error DeadlineNotReached(); error EvidenceMismatch(); error DisputeUnavailable(); error InvalidBeneficiary(); error NothingToWithdraw(); error InvalidRecipient(); error ReentrantWithdrawal(); error WithdrawalFailed(); error DirectPaymentsDisabled(); event EscrowCreated( address indexed client, address indexed researcher, address indexed arbiter, bytes32 termsHash, uint256 amountWei, uint64 submissionDeadline ); event Deposited(address indexed payer, uint256 amountWei); event EvidenceSubmitted(address indexed researcher, bytes32 evidenceHash); event DisputeOpened(address indexed party, bytes32 disputeHash); event SettlementRecorded(address indexed beneficiary, uint256 amountWei, Resolution resolution, bytes32 decisionHash); event Withdrawn(address indexed beneficiary, address indexed recipient, uint256 amountWei); modifier onlyClient() { if (msg.sender != client) revert Unauthorized(); _; } modifier onlyResearcher() { if (msg.sender != researcher) revert Unauthorized(); _; } modifier onlyArbiter() { if (msg.sender != arbiter) revert Unauthorized(); _; } modifier inState(State expected) { if (state != expected) revert InvalidState(state, expected); _; } /// @param client_ Must also be the constructor caller; roles must be distinct. /// @param termsHash_ Nonzero commitment to agreed, separately retained terms. /// @param amountWei_ Exact amount accepted by deposit(), denominated in wei. /// @param submissionDeadline_ Unix seconds; submission is allowed AT this time. constructor( address client_, address researcher_, address arbiter_, bytes32 termsHash_, uint256 amountWei_, uint64 submissionDeadline_ ) { if (block.chainid != 31337 && block.chainid != 11155111) revert UnsupportedChain(block.chainid); if ( client_ == address(0) || researcher_ == address(0) || arbiter_ == address(0) || client_ == researcher_ || client_ == arbiter_ || researcher_ == arbiter_ ) revert InvalidParties(); if (msg.sender != client_) revert Unauthorized(); if (termsHash_ == bytes32(0)) revert InvalidHash(); if (amountWei_ == 0) revert InvalidAmount(); if (submissionDeadline_ <= block.timestamp) revert InvalidDeadline(); client = client_; researcher = researcher_; arbiter = arbiter_; termsHash = termsHash_; amountWei = amountWei_; submissionDeadline = submissionDeadline_; emit EscrowCreated(client_, researcher_, arbiter_, termsHash_, amountWei_, submissionDeadline_); } /// @notice Fund once, before the deadline, with exactly the agreed principal. function deposit() external payable onlyClient inState(State.AwaitingDeposit) { if (block.timestamp >= submissionDeadline) revert DeadlinePassed(); if (msg.value != amountWei) revert WrongDeposit(amountWei, msg.value); state = State.Funded; emit Deposited(msg.sender, msg.value); } /// @notice One immutable evidence commitment. Never pass private text on-chain. /// @dev Submission does not prove that the linked work exists or is correct. function submitEvidence(bytes32 evidenceHash_) external onlyResearcher inState(State.Funded) { if (block.timestamp > submissionDeadline) revert DeadlinePassed(); if (evidenceHash_ == bytes32(0)) revert InvalidHash(); evidenceHash = evidenceHash_; state = State.Submitted; emit EvidenceSubmitted(msg.sender, evidenceHash_); } /// @notice Accept the exact submitted commitment; credit payment for withdrawal. /// @dev This remains available after the submission deadline, unless disputed. function accept(bytes32 expectedEvidenceHash) external onlyClient inState(State.Submitted) { if (expectedEvidenceHash != evidenceHash) revert EvidenceMismatch(); _settle(researcher, Resolution.ClientAcceptance, bytes32(0)); } /// @notice Either party may freeze settlement pending the immutable arbiter. /// @dev No new dispute over unsubmitted work after the deadline. A submitted /// milestone can be disputed after the deadline and cannot expire to a refund. function openDispute(bytes32 disputeHash_) external { if (msg.sender != client && msg.sender != researcher) revert Unauthorized(); if (state != State.Funded && state != State.Submitted) revert DisputeUnavailable(); if (state == State.Funded && block.timestamp > submissionDeadline) revert DeadlinePassed(); if (disputeHash_ == bytes32(0)) revert InvalidHash(); disputeHash = disputeHash_; state = State.Disputed; emit DisputeOpened(msg.sender, disputeHash_); } /// @notice Award the whole principal to one party, never the arbiter or a third party. /// @dev No deadline can override an unresolved dispute. Arbiter unavailability /// can therefore lock the principal indefinitely; agree on this risk beforehand. function resolve(address beneficiary_, bytes32 decisionHash_) external onlyArbiter inState(State.Disputed) { if (beneficiary_ != client && beneficiary_ != researcher) revert InvalidBeneficiary(); if (decisionHash_ == bytes32(0)) revert InvalidHash(); _settle(beneficiary_, Resolution.Arbitration, decisionHash_); } /// @notice Refund only when NO evidence was submitted and no dispute is open. /// @dev At the exact deadline, researcher submission still takes precedence /// over expiry eligibility; refund is allowed only in a later timestamp. function refundAfterDeadline() external onlyClient inState(State.Funded) { if (block.timestamp <= submissionDeadline) revert DeadlineNotReached(); _settle(client, Resolution.MissedDeadline, bytes32(0)); } /// @notice The credited party pulls its principal to a chosen payable address. /// @dev Checks-effects-interactions plus a local guard. A failed recipient call /// reverts the entire withdrawal and restores the credit. No external calls /// occur in deposit, acceptance, refund, or arbitration. function withdraw(address payable recipient) external inState(State.Resolved) { if (withdrawing) revert ReentrantWithdrawal(); if (msg.sender != beneficiary) revert Unauthorized(); if (recipient == address(0) || recipient == address(this)) revert InvalidRecipient(); uint256 value = withdrawable; if (value == 0) revert NothingToWithdraw(); withdrawing = true; withdrawable = 0; (bool success, ) = recipient.call{value: value}(""); if (!success) revert WithdrawalFailed(); withdrawing = false; emit Withdrawn(msg.sender, recipient, value); } function _settle(address beneficiary_, Resolution resolution_, bytes32 decisionHash_) private { state = State.Resolved; resolution = resolution_; beneficiary = beneficiary_; decisionHash = decisionHash_; // Forced Ether is not principal and never changes the entitlement. withdrawable = amountWei; emit SettlementRecorded(beneficiary_, amountWei, resolution_, decisionHash_); } receive() external payable { revert DirectPaymentsDisabled(); } fallback() external payable { revert DirectPaymentsDisabled(); } }