// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; // ============================================================================ // REFERENCE IMPLEMENTATION — TESTNET ONLY. // This contract encodes the normative behavior of spec Section B (demand.json // escrow settlement). It has NOT been audited. It MUST pass a professional // audit (plus Slither/Foundry invariant tests) before holding third-party // funds on mainnet. Until then: Base Sepolia, small-caps mode. // The in-process simulation in server/src/demand/escrow.ts mirrors this state // machine exactly (rail "escrow:dev"). // ============================================================================ import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; /// @title DemandEscrow v0.1 — conditional settlement for demand.json engagements /// @notice Small-caps mode: per-escrow and global caps until audited at scale. contract DemandEscrow is ReentrancyGuard, Pausable, Ownable2Step { using SafeERC20 for IERC20; IERC20 public immutable usdc; address public arbiter; // index in v1; per-escrow override possible in v2 uint16 public protocolFeeBps = 100; // 1.00% on release; hard cap below uint16 public constant MAX_FEE_BPS = 300; uint256 public perEscrowCap = 2_000e6; // 2,000 USDC (6 decimals) — small-caps mode uint256 public totalLockedCap = 50_000e6; uint256 public totalLocked; enum State { None, Created, Funded, Delivered, Disputed, Released, Refunded, Resolved } struct Escrow { address buyer; address seller; uint96 amount; // USDC (6d) fits easily uint40 fundDeadline; // unfunded past this -> void/cancel uint40 deliverDeadline; // undelivered past this -> buyer refund uint40 reviewPeriod; // after deliver: buyer must release/dispute, else auto-release uint40 deliveredAt; bytes32 termsHash; // EIP-712 hash of (demandId, offerHash, amount, acceptanceRef) State state; } mapping(bytes32 => Escrow) public escrows; // id = keccak256(buyer,seller,termsHash,nonce) mapping(address => uint256) public withdrawable; // pull-payment balances event Created(bytes32 id, address buyer, address seller, uint96 amount, bytes32 termsHash); event Funded(bytes32 id); event Delivered(bytes32 id, bytes32 deliveryRef); // e.g. hash/URI of delivered artifact event Released(bytes32 id, uint256 toSeller, uint256 fee); // reputation registries consume these event Disputed(bytes32 id, address by, string reasonURI); event Resolved(bytes32 id, uint256 toSeller, uint256 toBuyer); event Refunded(bytes32 id); error BadState(); error NotParty(); error TooLarge(); error TooEarly(); error TooLate(); constructor(IERC20 _usdc, address _arbiter) Ownable(msg.sender) { usdc = _usdc; arbiter = _arbiter; } function create(address seller, uint96 amount, bytes32 termsHash, uint40 fundDeadline, uint40 deliverDeadline, uint40 reviewPeriod, uint256 nonce) external whenNotPaused returns (bytes32 id) { if (amount == 0 || amount > perEscrowCap) revert TooLarge(); id = keccak256(abi.encode(msg.sender, seller, termsHash, nonce)); if (escrows[id].state != State.None) revert BadState(); escrows[id] = Escrow(msg.sender, seller, amount, fundDeadline, deliverDeadline, reviewPeriod, 0, termsHash, State.Created); emit Created(id, msg.sender, seller, amount, termsHash); } function fund(bytes32 id) external nonReentrant whenNotPaused { Escrow storage e = escrows[id]; if (e.state != State.Created || msg.sender != e.buyer) revert BadState(); if (block.timestamp > e.fundDeadline) revert TooLate(); if (totalLocked + e.amount > totalLockedCap) revert TooLarge(); e.state = State.Funded; totalLocked += e.amount; usdc.safeTransferFrom(msg.sender, address(this), e.amount); emit Funded(id); } function deliver(bytes32 id, bytes32 deliveryRef) external whenNotPaused { Escrow storage e = escrows[id]; if (e.state != State.Funded || msg.sender != e.seller) revert BadState(); if (block.timestamp > e.deliverDeadline) revert TooLate(); e.state = State.Delivered; e.deliveredAt = uint40(block.timestamp); emit Delivered(id, deliveryRef); } function release(bytes32 id) public nonReentrant { Escrow storage e = escrows[id]; bool buyerOk = (msg.sender == e.buyer && e.state == State.Delivered); bool timeoutOk = (e.state == State.Delivered && block.timestamp > uint256(e.deliveredAt) + e.reviewPeriod); // anyone may trigger if (!buyerOk && !timeoutOk) revert BadState(); _payout(id, e, e.amount, 0); emit Released(id, withdrawable[e.seller], (uint256(e.amount) * protocolFeeBps) / 10_000); } function refundExpired(bytes32 id) external nonReentrant { // seller never delivered Escrow storage e = escrows[id]; if (e.state != State.Funded || block.timestamp <= e.deliverDeadline) revert TooEarly(); e.state = State.Refunded; totalLocked -= e.amount; withdrawable[e.buyer] += e.amount; emit Refunded(id); } function dispute(bytes32 id, string calldata reasonURI) external { Escrow storage e = escrows[id]; if (e.state != State.Delivered) revert BadState(); if (msg.sender != e.buyer && msg.sender != e.seller) revert NotParty(); if (block.timestamp > uint256(e.deliveredAt) + e.reviewPeriod) revert TooLate(); e.state = State.Disputed; emit Disputed(id, msg.sender, reasonURI); } /// @notice Arbiter can ONLY split between the two parties — structural custody limit. function resolve(bytes32 id, uint96 toSeller) external nonReentrant { Escrow storage e = escrows[id]; if (e.state != State.Disputed || msg.sender != arbiter) revert BadState(); if (toSeller > e.amount) revert TooLarge(); _payout(id, e, toSeller, e.amount - toSeller); e.state = State.Resolved; emit Resolved(id, toSeller, e.amount - toSeller); } function withdraw() external nonReentrant { uint256 bal = withdrawable[msg.sender]; withdrawable[msg.sender] = 0; usdc.safeTransfer(msg.sender, bal); } function _payout(bytes32, Escrow storage e, uint256 toSeller, uint256 toBuyer) internal { uint256 fee = (toSeller * protocolFeeBps) / 10_000; totalLocked -= e.amount; if (toSeller > fee) withdrawable[e.seller] += toSeller - fee; if (toBuyer > 0) withdrawable[e.buyer] += toBuyer; if (fee > 0) withdrawable[owner()] += fee; if (e.state == State.Delivered) e.state = State.Released; } // ---- owner ops (timelock these in production; wire pause() to the global kill switch) ---- function setFee(uint16 bps) external onlyOwner { if (bps > MAX_FEE_BPS) revert TooLarge(); protocolFeeBps = bps; } function setArbiter(address a) external onlyOwner { arbiter = a; } function setCaps(uint256 perEscrow, uint256 total) external onlyOwner { perEscrowCap = perEscrow; totalLockedCap = total; } function pause() external onlyOwner { _pause(); } function unpause() external onlyOwner { _unpause(); } }