// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.30; /// @title PatchMyBox post-repair escrow — unaudited research prototype /// @notice The defender deposits a promised reward AFTER a qualifying repair. /// A local conditional offer grants no advance access permission. This contract /// cannot enforce an unfunded promise or adjudicate forgiveness/legal liability. /// @dev One native-currency award per payer/case ID. No admin, upgrades, tokens, /// role changes, signed vouchers, or production deployment are provided. contract PatchMyBoxEscrow { enum State { None, Funded, ReleaseProposed, Challenged, Released, Refunded } struct Terms { address recipient; address mediator; address appealReviewer; bytes32 agreementHash; bytes32 repairEvidenceHash; uint64 approvalWindow; uint64 challengeWindow; uint64 resolutionWindow; } struct Case { address payer; uint256 amount; Terms terms; State state; uint256 approvalDeadline; uint256 challengeDeadline; uint256 resolutionDeadline; bytes32 decisionHash; bytes32 challengeHash; bytes32 resolutionHash; } uint64 public constant MAX_WINDOW = 30 days; mapping(bytes32 => Case) private cases; mapping(address => uint256) public withdrawable; uint256 public totalLocked; uint256 public totalWithdrawable; bool private withdrawing; event Funded(bytes32 indexed escrowId, bytes32 indexed caseId, address indexed payer, address recipient, uint256 amount, bytes32 agreementHash, bytes32 repairEvidenceHash); event ReleaseProposed(bytes32 indexed escrowId, bytes32 decisionHash, uint256 challengeDeadline); event Challenged(bytes32 indexed escrowId, address indexed challenger, bytes32 challengeHash, uint256 resolutionDeadline); event Resolved(bytes32 indexed escrowId, bool payRecipient, bytes32 resolutionHash); event Credited(bytes32 indexed escrowId, address indexed beneficiary, uint256 amount, State outcome); event Withdrawn(address indexed beneficiary, address indexed destination, uint256 amount); modifier nonReentrant() { require(!withdrawing, "withdrawal reentry"); withdrawing = true; _; withdrawing = false; } /// @notice Namespace IDs by payer, so another account cannot reserve your ID. function escrowIdFor(address payer, bytes32 caseId) public pure returns (bytes32) { return keccak256(abi.encode(payer, caseId)); } /// @notice Funding deposits the post-repair award under these fixed terms. /// @dev Hashes commit to off-chain material; this contract cannot verify a /// repair, owner authority, evidence availability, or absence of exfiltration. /// All four role addresses must be distinct. Windows are 1 second–30 days. function fundAfterRepair(bytes32 caseId, Terms calldata terms) external payable returns (bytes32 escrowId) { require(caseId != bytes32(0) && msg.value > 0, "empty case or funding"); require(terms.agreementHash != bytes32(0) && terms.repairEvidenceHash != bytes32(0), "missing commitment"); require(terms.recipient != address(0) && terms.mediator != address(0) && terms.appealReviewer != address(0), "zero role"); require(terms.recipient != address(this) && terms.mediator != address(this) && terms.appealReviewer != address(this), "escrow cannot be role"); require(msg.sender != terms.recipient && msg.sender != terms.mediator && msg.sender != terms.appealReviewer && terms.recipient != terms.mediator && terms.recipient != terms.appealReviewer && terms.mediator != terms.appealReviewer, "roles must differ"); _validWindow(terms.approvalWindow); _validWindow(terms.challengeWindow); _validWindow(terms.resolutionWindow); escrowId = escrowIdFor(msg.sender, caseId); require(cases[escrowId].state == State.None, "case already funded"); Case storage c = cases[escrowId]; c.payer = msg.sender; c.amount = msg.value; c.terms = terms; c.state = State.Funded; c.approvalDeadline = block.timestamp + terms.approvalWindow; totalLocked += msg.value; emit Funded(escrowId, caseId, msg.sender, terms.recipient, msg.value, terms.agreementHash, terms.repairEvidenceHash); } /// @notice The mediator confirms its decision/evidence is available off chain /// before starting the on-chain challenge clock. No spendable voucher exists. function proposeRelease(bytes32 escrowId, bytes32 decisionHash) external { Case storage c = _case(escrowId); require(msg.sender == c.terms.mediator, "mediator only"); require(c.state == State.Funded, "not awaiting mediator"); require(block.timestamp < c.approvalDeadline, "approval expired"); require(decisionHash != bytes32(0), "missing decision"); c.decisionHash = decisionHash; c.challengeDeadline = block.timestamp + c.terms.challengeWindow; c.state = State.ReleaseProposed; emit ReleaseProposed(escrowId, decisionHash, c.challengeDeadline); } /// @notice A party, or the mediator flagging its own error, can hold funds. /// @dev Only the initial challenge window accepts challenges. A new decision /// is not silently substituted; a held case goes to its fixed appeal reviewer. function challenge(bytes32 escrowId, bytes32 challengeHash) external { Case storage c = _case(escrowId); require(msg.sender == c.payer || msg.sender == c.terms.recipient || msg.sender == c.terms.mediator, "case party only"); require(c.state == State.ReleaseProposed, "no challengeable proposal"); require(block.timestamp < c.challengeDeadline, "challenge expired"); require(challengeHash != bytes32(0), "missing challenge"); c.challengeHash = challengeHash; c.resolutionDeadline = block.timestamp + c.terms.resolutionWindow; c.state = State.Challenged; emit Challenged(escrowId, msg.sender, challengeHash, c.resolutionDeadline); } /// @notice The fixed appeal reviewer chooses only recipient payment or payer /// refund. It cannot choose another address, another amount, or extend time. function resolve(bytes32 escrowId, bool payRecipient, bytes32 resolutionHash) external { Case storage c = _case(escrowId); require(msg.sender == c.terms.appealReviewer, "appeal reviewer only"); require(c.state == State.Challenged, "not challenged"); require(block.timestamp < c.resolutionDeadline, "resolution expired"); require(resolutionHash != bytes32(0), "missing resolution"); c.resolutionHash = resolutionHash; emit Resolved(escrowId, payRecipient, resolutionHash); _credit(escrowId, c, payRecipient); } /// @notice Anyone may finalize an uncontested award after the window closes. function release(bytes32 escrowId) external { Case storage c = _case(escrowId); require(c.state == State.ReleaseProposed, "not releasable"); require(block.timestamp >= c.challengeDeadline, "challenge still open"); _credit(escrowId, c, true); } /// @notice Anyone may trigger the pre-agreed payer refund for mediator or /// appeal-reviewer silence. An uncontested approved award never times out to /// the payer; anyone can release it. Timeouts do not transfer funds directly. function refundExpired(bytes32 escrowId) external { Case storage c = _case(escrowId); if (c.state == State.Funded) { require(block.timestamp >= c.approvalDeadline, "approval still open"); } else if (c.state == State.Challenged) { require(block.timestamp >= c.resolutionDeadline, "resolution still open"); } else { revert("not refundable by timeout"); } _credit(escrowId, c, false); } /// @notice Anyone can deliver credit to its rightful beneficiary only. function withdrawFor(address payable beneficiary) external nonReentrant { _withdraw(beneficiary, beneficiary); } /// @notice Only the beneficiary may choose an alternative destination. This /// lets a contract recipient with a rejecting receive hook recover its credit. function withdrawTo(address payable destination) external nonReentrant { _withdraw(msg.sender, destination); } function getCase(bytes32 escrowId) external view returns (Case memory) { return _case(escrowId); } function _case(bytes32 escrowId) private view returns (Case storage c) { c = cases[escrowId]; require(c.state != State.None, "unknown case"); } function _validWindow(uint64 duration) private pure { require(duration > 0 && duration <= MAX_WINDOW, "invalid window"); } function _credit(bytes32 escrowId, Case storage c, bool payRecipient) private { address beneficiary = payRecipient ? c.terms.recipient : c.payer; c.state = payRecipient ? State.Released : State.Refunded; totalLocked -= c.amount; withdrawable[beneficiary] += c.amount; totalWithdrawable += c.amount; emit Credited(escrowId, beneficiary, c.amount, c.state); } function _withdraw(address beneficiary, address payable destination) private { require(destination != address(0) && destination != address(this), "invalid destination"); uint256 amount = withdrawable[beneficiary]; require(amount != 0, "nothing to withdraw"); withdrawable[beneficiary] = 0; totalWithdrawable -= amount; (bool success,) = destination.call{value: amount}(""); require(success, "withdrawal failed"); emit Withdrawn(beneficiary, destination, amount); } }