// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { NativeAssetLedgerV3 } from "./common/NativeAssetLedgerV3.sol"; import { EmergencyControlsV3 } from "./common/EmergencyControlsV3.sol"; /// @notice Bilateral, native-only task escrow with fixed milestones and deterministic timeout recovery. contract TaskMilestoneEscrowV3 is NativeAssetLedgerV3, EmergencyControlsV3 { bool public constant UNTYPED_COMPATIBILITY_DEPRECATED = true; enum TaskState { None, Offered, Active, Submitted, Complete, Refunded, Rejected, Disputed } enum MilestoneKind { Deliverable, Service, Outcome } enum Ruling { None, Creator, Worker } struct MilestoneTerms { MilestoneKind kind; bytes32 specificationHash; uint96 amount; uint64 submissionPeriod; uint64 reviewPeriod; uint64 disputePeriod; } struct Submission { bytes32 commitment; bytes32 evidenceSchema; uint64 submittedAt; uint32 revision; } struct AdjudicationPolicy { address[] adjudicators; uint8 threshold; uint16 fallbackWorkerBps; } struct Task { address creator; address worker; bytes32 criteriaRoot; uint96 remaining; uint64 acceptDeadline; uint64 actionDeadline; uint16 nextMilestone; TaskState state; address cancellationRequester; bytes32 configHash; uint64 disputeDeadline; uint96 maximumLoss; uint16 fallbackWorkerBps; uint8 adjudicatorThreshold; uint8 workerVotes; uint8 creatorVotes; bool typedConfiguration; } uint256 public immutable maxTaskPrincipal; uint256 public immutable maxAggregateLiability; uint16 public immutable maxMilestones; uint256 public nextTaskId = 1; mapping(uint256 => Task) private _tasks; mapping(uint256 => uint96[]) private _amounts; mapping(uint256 => uint64[]) private _submissionPeriods; mapping(uint256 => uint64[]) private _reviewPeriods; mapping(uint256 => mapping(uint16 => bytes32)) public workProvenance; mapping(uint256 => MilestoneTerms[]) private _typedMilestones; mapping(uint256 => mapping(uint16 => Submission)) private _submissions; mapping(uint256 => mapping(address => bool)) public isAdjudicator; mapping(uint256 => mapping(uint16 => mapping(address => bool))) public adjudicatorVoted; error BadTask(); error BadState(); error Unauthorized(); error DeadlinePassed(); error DeadlineNotPassed(); error CapExceeded(); error BadMilestones(); error BadAdjudication(); event TaskOpenedV3( uint256 indexed taskId, address indexed creator, address indexed worker, uint256 principal, bytes32 criteriaRoot, uint64 acceptDeadline ); event TaskAcceptedV3(uint256 indexed taskId, uint64 firstSubmissionDeadline); event MilestoneSubmittedV3( uint256 indexed taskId, uint16 indexed milestone, bytes32 provenanceHash, uint64 reviewDeadline ); event MilestoneApprovedV3(uint256 indexed taskId, uint16 indexed milestone, uint256 amount, bool byTimeout); event TaskRefundedV3(uint256 indexed taskId, uint256 amount, address indexed caller); event MilestoneRejectedV3( uint256 indexed taskId, uint16 indexed milestone, uint256 refundedPrincipal, bytes32 reasonHash ); event CancellationRequestedV3(uint256 indexed taskId, address indexed requester); event TaskConfigurationSnapshottedV3(uint256 indexed taskId, bytes32 indexed configHash, uint96 maximumLoss); event DisputeOpenedV3(uint256 indexed taskId, uint16 indexed milestone, uint64 deadline, bytes32 reasonHash); event DisputeVoteV3(uint256 indexed taskId, uint16 indexed milestone, address indexed adjudicator, Ruling ruling); event DisputeResolvedV3( uint256 indexed taskId, uint16 indexed milestone, Ruling ruling, uint256 workerCredit, uint256 creatorCredit, bool byTimeout ); constructor( address governance_, address guardian_, uint64 minimumPauseDuration_, uint256 maxTaskPrincipal_, uint256 maxAggregateLiability_, uint16 maxMilestones_ ) EmergencyControlsV3(governance_, guardian_, minimumPauseDuration_) { if ( maxTaskPrincipal_ == 0 || maxTaskPrincipal_ > type(uint96).max || maxAggregateLiability_ < maxTaskPrincipal_ || maxMilestones_ == 0 || maxMilestones_ > 64 ) revert BadTask(); maxTaskPrincipal = maxTaskPrincipal_; maxAggregateLiability = maxAggregateLiability_; maxMilestones = maxMilestones_; } /// @notice Deprecated compatibility entry point retained for existing integrations. /// @dev Not part of the reviewed pilot API. New callers must use openTypedTask so milestone /// specifications, evidence schemas and bounded adjudication are snapshotted on-chain. function openTask( address worker, bytes32 criteriaRoot, uint64 acceptDeadline, uint96[] calldata amounts, uint64[] calldata submissionPeriods, uint64[] calldata reviewPeriods ) external payable whenNotPaused returns (uint256 taskId) { uint256 count = amounts.length; if ( worker == address(0) || worker == msg.sender || criteriaRoot == bytes32(0) || acceptDeadline <= block.timestamp || count == 0 || count > maxMilestones || submissionPeriods.length != count || reviewPeriods.length != count ) revert BadTask(); uint256 sum = 0; for (uint256 i; i < count; ++i) { if (amounts[i] == 0 || submissionPeriods[i] == 0 || reviewPeriods[i] == 0) revert BadMilestones(); sum += amounts[i]; } if ( sum != msg.value || sum > maxTaskPrincipal || sum > type(uint96).max || accountedLiabilities() + msg.value > maxAggregateLiability ) revert CapExceeded(); taskId = nextTaskId++; _tasks[taskId] = Task({ creator: msg.sender, worker: worker, criteriaRoot: criteriaRoot, remaining: uint96(sum), acceptDeadline: acceptDeadline, actionDeadline: 0, nextMilestone: 0, state: TaskState.Offered, cancellationRequester: address(0), configHash: keccak256( abi.encode( block.chainid, address(this), msg.sender, worker, criteriaRoot, acceptDeadline, amounts, submissionPeriods, reviewPeriods ) ), disputeDeadline: 0, maximumLoss: uint96(sum), fallbackWorkerBps: 0, adjudicatorThreshold: 0, workerVotes: 0, creatorVotes: 0, typedConfiguration: false }); _amounts[taskId] = amounts; _submissionPeriods[taskId] = submissionPeriods; _reviewPeriods[taskId] = reviewPeriods; _lockNewValue(msg.value); emit TaskOpenedV3(taskId, msg.sender, worker, sum, criteriaRoot, acceptDeadline); emit TaskConfigurationSnapshottedV3(taskId, _tasks[taskId].configHash, uint96(sum)); } /// @notice Opens an immutable typed position with a snapshotted bounded-dispute policy. function openTypedTask( address worker, bytes32 criteriaRoot, uint64 acceptDeadline, MilestoneTerms[] calldata milestones, AdjudicationPolicy calldata policy ) external payable whenNotPaused returns (uint256 taskId) { uint256 count = milestones.length; if ( worker == address(0) || worker == msg.sender || criteriaRoot == bytes32(0) || acceptDeadline <= block.timestamp || count == 0 || count > maxMilestones || policy.threshold == 0 || policy.adjudicators.length < policy.threshold || policy.adjudicators.length > 16 || policy.fallbackWorkerBps > 10_000 ) revert BadTask(); uint256 sum = 0; uint96[] memory amounts = new uint96[](count); uint64[] memory submissions = new uint64[](count); uint64[] memory reviews = new uint64[](count); for (uint256 i; i < count; ++i) { MilestoneTerms calldata milestone = milestones[i]; if ( milestone.specificationHash == bytes32(0) || milestone.amount == 0 || milestone.submissionPeriod == 0 || milestone.reviewPeriod == 0 || milestone.disputePeriod == 0 || milestone.submissionPeriod > 365 days || milestone.reviewPeriod > 365 days || milestone.disputePeriod > 90 days ) revert BadMilestones(); sum += milestone.amount; amounts[i] = milestone.amount; submissions[i] = milestone.submissionPeriod; reviews[i] = milestone.reviewPeriod; } if ( sum != msg.value || sum > maxTaskPrincipal || sum > type(uint96).max || accountedLiabilities() + msg.value > maxAggregateLiability ) revert CapExceeded(); for (uint256 i; i < policy.adjudicators.length; ++i) { address adjudicator = policy.adjudicators[i]; if (adjudicator == address(0) || adjudicator == worker || adjudicator == msg.sender) { revert BadAdjudication(); } for (uint256 j; j < i; ++j) { if (policy.adjudicators[j] == adjudicator) revert BadAdjudication(); } } bytes32 configHash = keccak256( abi.encode( block.chainid, address(this), msg.sender, worker, criteriaRoot, acceptDeadline, milestones, policy.adjudicators, policy.threshold, policy.fallbackWorkerBps, uint96(sum) ) ); taskId = nextTaskId++; _tasks[taskId] = Task({ creator: msg.sender, worker: worker, criteriaRoot: criteriaRoot, remaining: uint96(sum), acceptDeadline: acceptDeadline, actionDeadline: 0, nextMilestone: 0, state: TaskState.Offered, cancellationRequester: address(0), configHash: configHash, disputeDeadline: 0, maximumLoss: uint96(sum), fallbackWorkerBps: policy.fallbackWorkerBps, adjudicatorThreshold: policy.threshold, workerVotes: 0, creatorVotes: 0, typedConfiguration: true }); _amounts[taskId] = amounts; _submissionPeriods[taskId] = submissions; _reviewPeriods[taskId] = reviews; for (uint256 i; i < count; ++i) { _typedMilestones[taskId].push(milestones[i]); } for (uint256 i; i < policy.adjudicators.length; ++i) { isAdjudicator[taskId][policy.adjudicators[i]] = true; } _lockNewValue(msg.value); emit TaskOpenedV3(taskId, msg.sender, worker, sum, criteriaRoot, acceptDeadline); emit TaskConfigurationSnapshottedV3(taskId, configHash, uint96(sum)); } function acceptTask(uint256 taskId) external whenNotPaused { Task storage task = _tasks[taskId]; if (task.state != TaskState.Offered) revert BadState(); if (msg.sender != task.worker) revert Unauthorized(); if (block.timestamp > task.acceptDeadline) revert DeadlinePassed(); task.state = TaskState.Active; task.actionDeadline = uint64(block.timestamp) + _submissionPeriods[taskId][0]; emit TaskAcceptedV3(taskId, task.actionDeadline); } /// @notice Submission remains available while paused so a worker cannot be timed out by an incident pause. function submitMilestone(uint256 taskId, bytes32 provenanceHash) external { _submit(taskId, provenanceHash, keccak256("vaultfire.submission.legacy/v1")); } function submitMilestone(uint256 taskId, bytes32 submissionCommitment, bytes32 evidenceSchema) external { _submit(taskId, submissionCommitment, evidenceSchema); } function _submit(uint256 taskId, bytes32 provenanceHash, bytes32 evidenceSchema) private { Task storage task = _tasks[taskId]; if (task.state != TaskState.Active || provenanceHash == bytes32(0) || evidenceSchema == bytes32(0)) { revert BadState(); } if (msg.sender != task.worker) revert Unauthorized(); if (block.timestamp > task.actionDeadline) revert DeadlinePassed(); uint16 milestone = task.nextMilestone; workProvenance[taskId][milestone] = provenanceHash; Submission storage submission = _submissions[taskId][milestone]; submission.commitment = provenanceHash; submission.evidenceSchema = evidenceSchema; submission.submittedAt = uint64(block.timestamp); submission.revision += 1; task.state = TaskState.Submitted; task.actionDeadline = uint64(block.timestamp) + _reviewPeriods[taskId][milestone]; emit MilestoneSubmittedV3(taskId, milestone, provenanceHash, task.actionDeadline); } /// @notice Creator approval remains available while paused and only releases this task's locked principal. function approveMilestone(uint256 taskId) external { Task storage task = _tasks[taskId]; if (task.state != TaskState.Submitted) revert BadState(); if (msg.sender != task.creator) revert Unauthorized(); if (block.timestamp > task.actionDeadline) revert DeadlinePassed(); _approve(taskId, false); } /// @notice Creator rejects the currently submitted milestone during review. /// @dev Previously approved milestone credits remain payable. The current milestone and every later /// milestone receive no worker credit; all still-locked principal becomes creator pull credit. function rejectMilestone(uint256 taskId, bytes32 reasonHash) external { Task storage task = _tasks[taskId]; if (task.state != TaskState.Submitted) revert BadState(); if (msg.sender != task.creator) revert Unauthorized(); if (block.timestamp > task.actionDeadline) revert DeadlinePassed(); if (reasonHash == bytes32(0)) revert BadTask(); if (task.typedConfiguration) { _openDispute(taskId, task, reasonHash); return; } uint16 milestone = task.nextMilestone; uint96 amount = task.remaining; task.remaining = 0; task.state = TaskState.Rejected; task.actionDeadline = 0; task.cancellationRequester = address(0); _lockedToCredit(task.creator, amount); emit MilestoneRejectedV3(taskId, milestone, amount, reasonHash); } /// @notice The creator may challenge a typed submitted milestone; adjudicators were fixed at creation. /// @dev A worker cannot self-open and wait for a non-zero fallback award. Worker protection is the inclusive /// review deadline followed by permissionless default approval. function openDispute(uint256 taskId, bytes32 reasonHash) external { Task storage task = _tasks[taskId]; if (!task.typedConfiguration || task.state != TaskState.Submitted || msg.sender != task.creator) { revert Unauthorized(); } if (block.timestamp > task.actionDeadline) revert DeadlinePassed(); if (reasonHash == bytes32(0)) revert BadTask(); _openDispute(taskId, task, reasonHash); } function voteDispute(uint256 taskId, Ruling ruling) external { Task storage task = _tasks[taskId]; if (task.state != TaskState.Disputed || !isAdjudicator[taskId][msg.sender]) revert Unauthorized(); if (block.timestamp > task.disputeDeadline) revert DeadlinePassed(); uint16 milestone = task.nextMilestone; if (ruling == Ruling.None || adjudicatorVoted[taskId][milestone][msg.sender]) revert BadAdjudication(); adjudicatorVoted[taskId][milestone][msg.sender] = true; if (ruling == Ruling.Worker) task.workerVotes += 1; else task.creatorVotes += 1; emit DisputeVoteV3(taskId, milestone, msg.sender, ruling); if (task.workerVotes >= task.adjudicatorThreshold) _resolveDispute(taskId, task, Ruling.Worker, false); else if (task.creatorVotes >= task.adjudicatorThreshold) _resolveDispute(taskId, task, Ruling.Creator, false); } /// @notice Permissionless, pause-safe fallback guarantees no disputed principal can remain stranded. function forceResolveDispute(uint256 taskId) external { Task storage task = _tasks[taskId]; if (task.state != TaskState.Disputed || block.timestamp <= task.disputeDeadline) revert DeadlineNotPassed(); _resolveDispute(taskId, task, Ruling.None, true); } /// @notice Permissionless terminal progress for every deadline-bearing state, including while paused. function forceExit(uint256 taskId) external { Task storage task = _tasks[taskId]; if (task.state == TaskState.Disputed) { if (block.timestamp <= task.disputeDeadline) revert DeadlineNotPassed(); _resolveDispute(taskId, task, Ruling.None, true); } else if (task.state == TaskState.Submitted) { if (block.timestamp <= task.actionDeadline) revert DeadlineNotPassed(); _approve(taskId, true); } else { bool timedOut = (task.state == TaskState.Offered && block.timestamp > task.acceptDeadline) || (task.state == TaskState.Active && block.timestamp > task.actionDeadline); if (!timedOut) revert DeadlineNotPassed(); _refund(taskId, task); } } /// @notice Permissionless only after the creator's review boundary, never at equality. function defaultApprove(uint256 taskId) external { Task storage task = _tasks[taskId]; if (task.state != TaskState.Submitted) revert BadState(); if (block.timestamp <= task.actionDeadline) revert DeadlineNotPassed(); _approve(taskId, true); } /// @notice Recovers an unaccepted offer or a milestone the worker failed to submit. function recoverTimedOut(uint256 taskId) external { Task storage task = _tasks[taskId]; bool unaccepted = task.state == TaskState.Offered && block.timestamp > task.acceptDeadline; bool unsubmitted = task.state == TaskState.Active && block.timestamp > task.actionDeadline; if (!unaccepted && !unsubmitted) revert DeadlineNotPassed(); _refund(taskId, task); } /// @notice Creator may cancel an offer before acceptance without an adjudicator. function cancelOffer(uint256 taskId) external { Task storage task = _tasks[taskId]; if (task.state != TaskState.Offered || msg.sender != task.creator) revert Unauthorized(); _refund(taskId, task); } /// @notice Active cancellation requires an explicit matching request from both participants. function requestMutualCancellation(uint256 taskId) external { Task storage task = _tasks[taskId]; if (task.state != TaskState.Active) revert BadState(); if (msg.sender != task.creator && msg.sender != task.worker) revert Unauthorized(); address prior = task.cancellationRequester; if (prior == address(0) || prior == msg.sender) { task.cancellationRequester = msg.sender; emit CancellationRequestedV3(taskId, msg.sender); return; } _refund(taskId, task); } function taskOf(uint256 taskId) external view returns (Task memory) { return _tasks[taskId]; } function milestoneCount(uint256 taskId) external view returns (uint256) { return _amounts[taskId].length; } function milestoneTerms(uint256 taskId, uint256 index) external view returns (uint96 amount, uint64 submissionPeriod, uint64 reviewPeriod) { return (_amounts[taskId][index], _submissionPeriods[taskId][index], _reviewPeriods[taskId][index]); } function typedMilestoneTerms(uint256 taskId, uint256 index) external view returns (MilestoneTerms memory) { return _typedMilestones[taskId][index]; } function submissionOf(uint256 taskId, uint16 milestone) external view returns (Submission memory) { return _submissions[taskId][milestone]; } function _approve(uint256 taskId, bool byTimeout) private { Task storage task = _tasks[taskId]; uint16 milestone = task.nextMilestone; uint96 amount = _amounts[taskId][milestone]; task.remaining -= amount; task.nextMilestone = milestone + 1; task.cancellationRequester = address(0); _lockedToCredit(task.worker, amount); if (task.nextMilestone == _amounts[taskId].length) { task.state = TaskState.Complete; task.actionDeadline = 0; } else { task.state = TaskState.Active; task.actionDeadline = uint64(block.timestamp) + _submissionPeriods[taskId][task.nextMilestone]; } emit MilestoneApprovedV3(taskId, milestone, amount, byTimeout); } function _refund(uint256 taskId, Task storage task) private { uint96 amount = task.remaining; task.remaining = 0; task.state = TaskState.Refunded; task.actionDeadline = 0; _lockedToCredit(task.creator, amount); emit TaskRefundedV3(taskId, amount, msg.sender); } function _openDispute(uint256 taskId, Task storage task, bytes32 reasonHash) private { uint16 milestone = task.nextMilestone; task.state = TaskState.Disputed; task.workerVotes = 0; task.creatorVotes = 0; task.disputeDeadline = uint64(block.timestamp) + _typedMilestones[taskId][milestone].disputePeriod; task.actionDeadline = 0; emit DisputeOpenedV3(taskId, milestone, task.disputeDeadline, reasonHash); } function _resolveDispute(uint256 taskId, Task storage task, Ruling ruling, bool byTimeout) private { uint16 milestone = task.nextMilestone; uint96 current = _amounts[taskId][milestone]; uint96 remaining = task.remaining; uint256 workerAmount = 0; if (ruling == Ruling.Worker) workerAmount = current; else if (ruling == Ruling.None) workerAmount = uint256(current) * task.fallbackWorkerBps / 10_000; uint256 creatorAmount = uint256(remaining) - workerAmount; task.remaining = 0; task.state = ruling == Ruling.Worker && current == remaining ? TaskState.Complete : TaskState.Rejected; task.actionDeadline = 0; task.disputeDeadline = 0; if (workerAmount != 0) _lockedToCredit(task.worker, workerAmount); if (creatorAmount != 0) _lockedToCredit(task.creator, creatorAmount); emit DisputeResolvedV3(taskId, milestone, ruling, workerAmount, creatorAmount, byTimeout); } }