// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; contract MilestoneEscrow is Ownable2Step, ReentrancyGuard { using SafeERC20 for IERC20; enum JobStatus { None, // job does not exist Active, // funded, work in progress Disputed, // dispute raised, only the arbiter can act Completed, // all milestones released Cancelled, // unreleased funds refunded to the client Resolved // dispute settled by the arbiter } enum MilestoneStatus { Pending, // funded, not yet requested ReleaseRequested, // freelancer asked the client to release Released, // paid out to the freelancer Settled // closed by cancellation or dispute resolution } struct Milestone { uint128 amount; MilestoneStatus status; } struct Job { address client; address freelancer; address arbiter; address token; // address(0) = native ETH uint16 feeBps; // fee snapshot taken at job creation JobStatus status; bool started; // true once any milestone was requested or released uint256 totalAmount; // sum of all milestones uint256 releasedAmount; // gross amount already paid out (incl. fees) } uint256 public constant BPS_DENOMINATOR = 10_000; uint256 public constant MAX_FEE_BPS = 500; uint256 public constant MAX_MILESTONES = 50; uint16 public feeBps; address public feeRecipient; uint256 public jobCount; mapping(address token => uint256 amount) public totalEscrowed; mapping(address account => uint256 amount) public pendingWithdrawals; uint256 public totalPendingWithdrawals; mapping(uint256 jobId => Job) private _jobs; mapping(uint256 jobId => Milestone[]) private _milestones; event JobCreated( uint256 indexed jobId, address indexed client, address indexed freelancer, address arbiter, address token, uint256 totalAmount, uint256 milestoneCount, uint16 feeBps ); event ReleaseRequested(uint256 indexed jobId, uint256 indexed milestoneIndex); event MilestoneReleased(uint256 indexed jobId, uint256 indexed milestoneIndex, uint256 netAmount, uint256 fee); event JobCompleted(uint256 indexed jobId); event JobCancelled(uint256 indexed jobId, address indexed cancelledBy, uint256 refund); event DisputeRaised(uint256 indexed jobId, address indexed raisedBy); event DisputeResolved( uint256 indexed jobId, uint16 freelancerBps, uint256 freelancerNet, uint256 fee, uint256 clientRefund ); event FeeUpdated(uint16 oldFeeBps, uint16 newFeeBps); event FeeRecipientUpdated(address indexed oldRecipient, address indexed newRecipient); event PayoutDeferred(address indexed account, uint256 amount); event PendingWithdrawn(address indexed account, address indexed to, uint256 amount); error ZeroAddress(); error InvalidParties(); error NoMilestones(); error TooManyMilestones(uint256 count); error ZeroAmount(); error IncorrectEthAmount(uint256 expected, uint256 received); error UnexpectedEth(); error TransferAmountMismatch(uint256 expected, uint256 received); error JobNotFound(uint256 jobId); error InvalidJobStatus(JobStatus status); error InvalidMilestone(uint256 milestoneIndex); error InvalidMilestoneStatus(MilestoneStatus status); error NotClient(); error NotFreelancer(); error NotArbiter(); error NotParty(); error Unauthorized(); error JobAlreadyStarted(); error FeeTooHigh(uint256 feeBps); error InvalidBps(uint256 bps); error NothingToWithdraw(); error EthTransferFailed(); constructor(address initialOwner, address initialFeeRecipient, uint16 initialFeeBps) Ownable(initialOwner) { if (initialFeeRecipient == address(0)) revert ZeroAddress(); if (initialFeeBps > MAX_FEE_BPS) revert FeeTooHigh(initialFeeBps); feeRecipient = initialFeeRecipient; feeBps = initialFeeBps; emit FeeRecipientUpdated(address(0), initialFeeRecipient); emit FeeUpdated(0, initialFeeBps); } function createJob(address freelancer, address arbiter, address token, uint128[] calldata amounts) external payable nonReentrant returns (uint256 jobId) { if (freelancer == address(0) || arbiter == address(0)) revert ZeroAddress(); if (freelancer == msg.sender || arbiter == msg.sender || arbiter == freelancer) revert InvalidParties(); uint256 count = amounts.length; if (count == 0) revert NoMilestones(); if (count > MAX_MILESTONES) revert TooManyMilestones(count); uint256 total = 0; jobId = ++jobCount; Milestone[] storage ms = _milestones[jobId]; for (uint256 i; i < count; ++i) { uint128 amount = amounts[i]; if (amount == 0) revert ZeroAmount(); total += amount; ms.push(Milestone({amount: amount, status: MilestoneStatus.Pending})); } _jobs[jobId] = Job({ client: msg.sender, freelancer: freelancer, arbiter: arbiter, token: token, feeBps: feeBps, status: JobStatus.Active, started: false, totalAmount: total, releasedAmount: 0 }); totalEscrowed[token] += total; emit JobCreated(jobId, msg.sender, freelancer, arbiter, token, total, count, feeBps); if (token == address(0)) { if (msg.value != total) revert IncorrectEthAmount(total, msg.value); } else { if (msg.value != 0) revert UnexpectedEth(); IERC20 erc20 = IERC20(token); uint256 balanceBefore = erc20.balanceOf(address(this)); erc20.safeTransferFrom(msg.sender, address(this), total); uint256 received = erc20.balanceOf(address(this)) - balanceBefore; if (received != total) revert TransferAmountMismatch(total, received); } } function requestRelease(uint256 jobId, uint256 milestoneIndex) external { Job storage job = _activeJob(jobId); if (msg.sender != job.freelancer) revert NotFreelancer(); Milestone storage m = _milestone(jobId, milestoneIndex); if (m.status != MilestoneStatus.Pending) revert InvalidMilestoneStatus(m.status); m.status = MilestoneStatus.ReleaseRequested; job.started = true; emit ReleaseRequested(jobId, milestoneIndex); } function releaseMilestone(uint256 jobId, uint256 milestoneIndex) external nonReentrant { Job storage job = _activeJob(jobId); if (msg.sender != job.client) revert NotClient(); Milestone storage m = _milestone(jobId, milestoneIndex); if (m.status != MilestoneStatus.Pending && m.status != MilestoneStatus.ReleaseRequested) { revert InvalidMilestoneStatus(m.status); } uint256 amount = m.amount; m.status = MilestoneStatus.Released; job.started = true; job.releasedAmount += amount; totalEscrowed[job.token] -= amount; uint256 fee = (amount * job.feeBps) / BPS_DENOMINATOR; uint256 net = amount - fee; bool completed = job.releasedAmount == job.totalAmount; if (completed) job.status = JobStatus.Completed; emit MilestoneReleased(jobId, milestoneIndex, net, fee); if (completed) emit JobCompleted(jobId); _payout(job.token, job.freelancer, net); _payout(job.token, feeRecipient, fee); } function cancelJob(uint256 jobId) external nonReentrant { Job storage job = _existingJob(jobId); if (msg.sender == job.arbiter) { if (job.status != JobStatus.Active && job.status != JobStatus.Disputed) { revert InvalidJobStatus(job.status); } } else if (msg.sender == job.client) { if (job.status != JobStatus.Active) revert InvalidJobStatus(job.status); if (job.started) revert JobAlreadyStarted(); } else { revert Unauthorized(); } uint256 refund = _settleOpenMilestones(jobId, job); job.status = JobStatus.Cancelled; emit JobCancelled(jobId, msg.sender, refund); _payout(job.token, job.client, refund); } function raiseDispute(uint256 jobId) external { Job storage job = _activeJob(jobId); if (msg.sender != job.client && msg.sender != job.freelancer) revert NotParty(); job.status = JobStatus.Disputed; emit DisputeRaised(jobId, msg.sender); } function resolveDispute(uint256 jobId, uint16 freelancerBps) external nonReentrant { Job storage job = _existingJob(jobId); if (msg.sender != job.arbiter) revert NotArbiter(); if (job.status != JobStatus.Disputed) revert InvalidJobStatus(job.status); if (freelancerBps > BPS_DENOMINATOR) revert InvalidBps(freelancerBps); uint256 remaining = _settleOpenMilestones(jobId, job); uint256 freelancerGross = (remaining * freelancerBps) / BPS_DENOMINATOR; uint256 clientRefund = remaining - freelancerGross; uint256 fee = (freelancerGross * job.feeBps) / BPS_DENOMINATOR; uint256 freelancerNet = freelancerGross - fee; job.releasedAmount += freelancerGross; job.status = JobStatus.Resolved; emit DisputeResolved(jobId, freelancerBps, freelancerNet, fee, clientRefund); _payout(job.token, job.freelancer, freelancerNet); _payout(job.token, feeRecipient, fee); _payout(job.token, job.client, clientRefund); } function withdrawPending(address payable to) external nonReentrant { if (to == address(0)) revert ZeroAddress(); uint256 amount = pendingWithdrawals[msg.sender]; if (amount == 0) revert NothingToWithdraw(); pendingWithdrawals[msg.sender] = 0; totalPendingWithdrawals -= amount; emit PendingWithdrawn(msg.sender, to, amount); (bool ok,) = to.call{value: amount}(""); if (!ok) revert EthTransferFailed(); } function setFeeBps(uint16 newFeeBps) external onlyOwner { if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(newFeeBps); emit FeeUpdated(feeBps, newFeeBps); feeBps = newFeeBps; } function setFeeRecipient(address newRecipient) external onlyOwner { if (newRecipient == address(0)) revert ZeroAddress(); emit FeeRecipientUpdated(feeRecipient, newRecipient); feeRecipient = newRecipient; } function getJob(uint256 jobId) external view returns (Job memory) { return _jobs[jobId]; } function getMilestones(uint256 jobId) external view returns (Milestone[] memory) { return _milestones[jobId]; } function unreleasedAmount(uint256 jobId) public view returns (uint256 amount) { JobStatus s = _jobs[jobId].status; if (s != JobStatus.Active && s != JobStatus.Disputed) return 0; Milestone[] storage ms = _milestones[jobId]; for (uint256 i; i < ms.length; ++i) { if (ms[i].status == MilestoneStatus.Pending || ms[i].status == MilestoneStatus.ReleaseRequested) { amount += ms[i].amount; } } } function _existingJob(uint256 jobId) private view returns (Job storage job) { job = _jobs[jobId]; if (job.status == JobStatus.None) revert JobNotFound(jobId); } function _activeJob(uint256 jobId) private view returns (Job storage job) { job = _existingJob(jobId); if (job.status != JobStatus.Active) revert InvalidJobStatus(job.status); } function _milestone(uint256 jobId, uint256 index) private view returns (Milestone storage) { Milestone[] storage ms = _milestones[jobId]; if (index >= ms.length) revert InvalidMilestone(index); return ms[index]; } function _settleOpenMilestones(uint256 jobId, Job storage job) private returns (uint256 remaining) { Milestone[] storage ms = _milestones[jobId]; for (uint256 i; i < ms.length; ++i) { MilestoneStatus s = ms[i].status; if (s == MilestoneStatus.Pending || s == MilestoneStatus.ReleaseRequested) { remaining += ms[i].amount; ms[i].status = MilestoneStatus.Settled; } } totalEscrowed[job.token] -= remaining; } function _payout(address token, address to, uint256 amount) private { if (amount == 0) return; if (token == address(0)) { (bool ok,) = payable(to).call{value: amount}(""); if (!ok) { pendingWithdrawals[to] += amount; totalPendingWithdrawals += amount; emit PayoutDeferred(to, amount); } } else { IERC20(token).safeTransfer(to, amount); } } }