// SPDX-License-Identifier: MIT pragma solidity ^0.8.25; /** * @title VaultfireTaskEscrow * @author Vaultfire Protocol * @notice On-chain task escrow for the Vaultfire Task Marketplace. Agents post tasks * funded with USDC, other registered agents bid, and settlement is handled * automatically on approval — or via governance in the event of a dispute. * @dev Designed to be deployed on Base, Avalanche, Arbitrum, and Polygon. * Works alongside ERC8004IdentityRegistry (identity) and * AIPartnershipBondsV2 (trust bonds) but does not call AIPartnershipBondsV2 * directly; it only needs the registry for agent verification. * * Self-contained — no OpenZeppelin imports. Manual ReentrancyGuard included. */ // --------------------------------------------------------------------------- // Minimal interfaces // --------------------------------------------------------------------------- /// @notice Minimal ERC-20 interface used for USDC interactions. interface IERC20 { function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); function transfer(address recipient, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); } /// @notice Minimal interface for the ERC-8004 Identity Registry. /// An agent is "registered" when isAgentActive returns true. interface IERC8004IdentityRegistry { /// @dev Returns true if `agent` has an active registration. function isAgentActive(address agent) external view returns (bool); } // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- contract VaultfireTaskEscrow { // ----------------------------------------------------------------------- // Types // ----------------------------------------------------------------------- /// @notice All possible lifecycle states for a Task. enum TaskStatus { Created, // Task created, accepting bids Bidding, // At least one bid has been placed (alias to Created in some flows) Assigned, // Poster accepted a bid; worker assigned WorkSubmitted, // Assignee submitted deliverable URI Completed, // Poster approved work; USDC released Disputed, // Either party raised a dispute Cancelled // Poster cancelled before any bid was accepted } /// @notice Core task record stored on-chain. struct Task { uint256 id; address poster; string description; uint256 reward; // total USDC locked (18-dec equivalent; USDC is 6-dec — stored as-is) uint256 deadline; // unix timestamp by which work must be submitted TaskStatus status; address assignee; string deliverableURI; uint256 createdAt; } /// @notice A single bid placed by a registered agent. struct Bid { address bidder; string proposal; uint256 timestamp; } // ----------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------- /// @notice Protocol fee in basis points (100 = 1 %). uint256 public constant FEE_BPS = 100; /// @notice Denominator for basis-point math. uint256 private constant BPS_DENOM = 10_000; // ----------------------------------------------------------------------- // Immutables // ----------------------------------------------------------------------- /// @notice ERC-8004 Identity Registry used to verify agent registration. IERC8004IdentityRegistry public immutable identityRegistry; /// @notice MultisigGovernance address — the only account that can resolve disputes. address public immutable governance; /// @notice USDC token contract used for all escrow payments. IERC20 public immutable usdc; /// @notice Address that receives the 1 % protocol fee on every settlement. address public immutable feeRecipient; // ----------------------------------------------------------------------- // State // ----------------------------------------------------------------------- /// @dev Incrementing task counter. Starts at 1 so 0 is never a valid task id. uint256 private _taskCounter; /// @dev Primary task storage. mapping(uint256 => Task) private _tasks; /// @dev Bids per task. mapping(uint256 => Bid[]) private _bids; // ----------------------------------------------------------------------- // Reentrancy guard (manual — no OpenZeppelin dependency) // ----------------------------------------------------------------------- uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2; uint256 private _reentrancyStatus = _NOT_ENTERED; modifier nonReentrant() { require(_reentrancyStatus != _ENTERED, "VaultfireTaskEscrow: reentrant call"); _reentrancyStatus = _ENTERED; _; _reentrancyStatus = _NOT_ENTERED; } // ----------------------------------------------------------------------- // Events // ----------------------------------------------------------------------- /// @notice Emitted when a new task is created and USDC deposited into escrow. event TaskCreated( uint256 indexed taskId, address indexed poster, uint256 reward, uint256 deadline, string description ); /// @notice Emitted when a registered agent submits a bid. event BidPlaced( uint256 indexed taskId, address indexed bidder, uint256 bidIndex, string proposal ); /// @notice Emitted when the poster accepts a bid and assigns the task. event BidAccepted( uint256 indexed taskId, address indexed assignee, uint256 bidIndex ); /// @notice Emitted when the assignee submits their deliverable. event WorkSubmitted( uint256 indexed taskId, address indexed assignee, string deliverableURI ); /// @notice Emitted when the poster approves the work and USDC is released. event WorkApproved( uint256 indexed taskId, address indexed assignee, uint256 workerAmount, uint256 feeAmount ); /// @notice Emitted when either party raises a dispute. event TaskDisputed( uint256 indexed taskId, address indexed disputedBy, string reason ); /// @notice Emitted when governance resolves a dispute. event DisputeResolved( uint256 indexed taskId, address indexed resolvedBy, address indexed payTo, uint256 amount ); /// @notice Emitted when a poster cancels a task with no accepted bid. event TaskCancelled( uint256 indexed taskId, address indexed poster, uint256 refundAmount ); // ----------------------------------------------------------------------- // Constructor // ----------------------------------------------------------------------- /** * @notice Deploy the VaultfireTaskEscrow. * @param identityRegistry_ Address of the ERC-8004 Identity Registry. * @param governance_ Address of MultisigGovernance (dispute resolver). * @param usdc_ Address of the USDC ERC-20 token. * @param feeRecipient_ Address that receives the 1 % protocol fee. */ constructor( address identityRegistry_, address governance_, address usdc_, address feeRecipient_ ) { require(identityRegistry_ != address(0), "VaultfireTaskEscrow: zero identityRegistry"); require(governance_ != address(0), "VaultfireTaskEscrow: zero governance"); require(usdc_ != address(0), "VaultfireTaskEscrow: zero usdc"); require(feeRecipient_ != address(0), "VaultfireTaskEscrow: zero feeRecipient"); identityRegistry = IERC8004IdentityRegistry(identityRegistry_); governance = governance_; usdc = IERC20(usdc_); feeRecipient = feeRecipient_; } // ----------------------------------------------------------------------- // Modifiers // ----------------------------------------------------------------------- /// @dev Reverts if the caller is not a registered agent in the identity registry. modifier onlyRegisteredAgent() { require( identityRegistry.isAgentActive(msg.sender), "VaultfireTaskEscrow: caller not a registered agent" ); _; } /// @dev Reverts if `taskId` does not correspond to a live task. modifier taskExists(uint256 taskId) { require(taskId > 0 && taskId <= _taskCounter, "VaultfireTaskEscrow: task does not exist"); _; } // ----------------------------------------------------------------------- // Core functions // ----------------------------------------------------------------------- /** * @notice Create a new task and lock the USDC reward in escrow. * @dev Caller must be a registered agent and must have approved this contract * to spend `reward` USDC beforehand. * @param description Human-readable task description. * @param reward Amount of USDC (in token's native decimals) to lock as reward. * @param deadline Unix timestamp by which the assignee must submit work. * @return taskId The newly created task's identifier. */ function createTask( string calldata description, uint256 reward, uint256 deadline ) external nonReentrant onlyRegisteredAgent returns (uint256 taskId) { require(bytes(description).length > 0, "VaultfireTaskEscrow: empty description"); require(reward > 0, "VaultfireTaskEscrow: reward must be > 0"); require(deadline > block.timestamp, "VaultfireTaskEscrow: deadline in the past"); // Pull USDC from poster into escrow bool ok = usdc.transferFrom(msg.sender, address(this), reward); require(ok, "VaultfireTaskEscrow: USDC transferFrom failed"); unchecked { taskId = ++_taskCounter; } _tasks[taskId] = Task({ id: taskId, poster: msg.sender, description: description, reward: reward, deadline: deadline, status: TaskStatus.Created, assignee: address(0), deliverableURI: "", createdAt: block.timestamp }); emit TaskCreated(taskId, msg.sender, reward, deadline, description); } /** * @notice Place a bid on an open task. * @dev Only registered agents may bid. Posters cannot bid on their own task. * Bids are accepted while the task is in Created or Bidding status. * @param taskId The task to bid on. * @param proposal A text proposal describing how the bidder will complete the task. */ function bidOnTask(uint256 taskId, string calldata proposal) external nonReentrant onlyRegisteredAgent taskExists(taskId) { Task storage task = _tasks[taskId]; require( task.status == TaskStatus.Created || task.status == TaskStatus.Bidding, "VaultfireTaskEscrow: task not open for bids" ); require(task.poster != msg.sender, "VaultfireTaskEscrow: poster cannot bid on own task"); require(block.timestamp < task.deadline, "VaultfireTaskEscrow: task deadline passed"); require(bytes(proposal).length > 0, "VaultfireTaskEscrow: empty proposal"); uint256 bidIndex = _bids[taskId].length; _bids[taskId].push(Bid({ bidder: msg.sender, proposal: proposal, timestamp: block.timestamp })); // Advance to Bidding state if still in Created if (task.status == TaskStatus.Created) { task.status = TaskStatus.Bidding; } emit BidPlaced(taskId, msg.sender, bidIndex, proposal); } /** * @notice Accept a bid and assign the task to the chosen bidder. * @dev Only the poster may call this. Task must be in Bidding status. * @param taskId The task to assign. * @param bidIndex Index into the bids array for this task. */ function acceptBid(uint256 taskId, uint256 bidIndex) external nonReentrant taskExists(taskId) { Task storage task = _tasks[taskId]; require(task.poster == msg.sender, "VaultfireTaskEscrow: only poster can accept bids"); require(task.status == TaskStatus.Bidding, "VaultfireTaskEscrow: no bids to accept"); require(bidIndex < _bids[taskId].length, "VaultfireTaskEscrow: invalid bid index"); require(block.timestamp < task.deadline, "VaultfireTaskEscrow: deadline passed"); Bid storage chosen = _bids[taskId][bidIndex]; task.assignee = chosen.bidder; task.status = TaskStatus.Assigned; emit BidAccepted(taskId, chosen.bidder, bidIndex); } /** * @notice Submit the completed work deliverable. * @dev Only the task assignee may call this before the deadline. * @param taskId The assigned task. * @param deliverableURI URI pointing to the work deliverable (e.g. IPFS CID). */ function submitWork(uint256 taskId, string calldata deliverableURI) external nonReentrant taskExists(taskId) { Task storage task = _tasks[taskId]; require(task.assignee == msg.sender, "VaultfireTaskEscrow: only assignee can submit work"); require(task.status == TaskStatus.Assigned, "VaultfireTaskEscrow: task not in Assigned state"); require(block.timestamp <= task.deadline, "VaultfireTaskEscrow: deadline passed"); require(bytes(deliverableURI).length > 0, "VaultfireTaskEscrow: empty deliverableURI"); task.deliverableURI = deliverableURI; task.status = TaskStatus.WorkSubmitted; emit WorkSubmitted(taskId, msg.sender, deliverableURI); } /** * @notice Approve the submitted work and release USDC to the worker. * @dev Only the poster may call this. Releases `reward * 99%` to the assignee * and `reward * 1%` to the protocol feeRecipient. * @param taskId The task whose work is being approved. */ function approveWork(uint256 taskId) external nonReentrant taskExists(taskId) { Task storage task = _tasks[taskId]; require(task.poster == msg.sender, "VaultfireTaskEscrow: only poster can approve work"); require(task.status == TaskStatus.WorkSubmitted, "VaultfireTaskEscrow: work not yet submitted"); task.status = TaskStatus.Completed; uint256 fee = (task.reward * FEE_BPS) / BPS_DENOM; uint256 workerAmount = task.reward - fee; // Transfer to worker bool ok1 = usdc.transfer(task.assignee, workerAmount); require(ok1, "VaultfireTaskEscrow: worker transfer failed"); // Transfer fee bool ok2 = usdc.transfer(feeRecipient, fee); require(ok2, "VaultfireTaskEscrow: fee transfer failed"); emit WorkApproved(taskId, task.assignee, workerAmount, fee); } /** * @notice Raise a dispute on a task. * @dev Either the poster or the assignee may dispute a task that is in * Assigned or WorkSubmitted status. * @param taskId The task to dispute. * @param reason A human-readable explanation for the dispute. */ function disputeTask(uint256 taskId, string calldata reason) external nonReentrant taskExists(taskId) { Task storage task = _tasks[taskId]; require( msg.sender == task.poster || msg.sender == task.assignee, "VaultfireTaskEscrow: only poster or assignee can dispute" ); require( task.status == TaskStatus.Assigned || task.status == TaskStatus.WorkSubmitted, "VaultfireTaskEscrow: task cannot be disputed in current state" ); require(bytes(reason).length > 0, "VaultfireTaskEscrow: empty dispute reason"); task.status = TaskStatus.Disputed; emit TaskDisputed(taskId, msg.sender, reason); } /** * @notice Resolve a disputed task by directing the escrowed USDC to a given address. * @dev Only MultisigGovernance may call this. `payTo` must be either the poster * or the assignee. The full reward (no fee deducted) is sent to the winner; * governance may choose to send to either party at its discretion. * @param taskId The disputed task. * @param payTo Address to receive the escrowed USDC (poster or assignee). */ function resolveDispute(uint256 taskId, address payTo) external nonReentrant taskExists(taskId) { require(msg.sender == governance, "VaultfireTaskEscrow: only governance can resolve disputes"); Task storage task = _tasks[taskId]; require(task.status == TaskStatus.Disputed, "VaultfireTaskEscrow: task is not disputed"); require( payTo == task.poster || payTo == task.assignee, "VaultfireTaskEscrow: payTo must be poster or assignee" ); task.status = TaskStatus.Completed; uint256 amount = task.reward; bool ok = usdc.transfer(payTo, amount); require(ok, "VaultfireTaskEscrow: dispute resolution transfer failed"); emit DisputeResolved(taskId, msg.sender, payTo, amount); } /** * @notice Cancel a task and refund the poster's USDC. * @dev The poster may cancel only while no bid has been accepted * (i.e. status is Created or Bidding). Once a bid is accepted the * poster must either approve work or raise a dispute. * @param taskId The task to cancel. */ function cancelTask(uint256 taskId) external nonReentrant taskExists(taskId) { Task storage task = _tasks[taskId]; require(task.poster == msg.sender, "VaultfireTaskEscrow: only poster can cancel"); require( task.status == TaskStatus.Created || task.status == TaskStatus.Bidding, "VaultfireTaskEscrow: cannot cancel after bid is accepted" ); task.status = TaskStatus.Cancelled; uint256 refund = task.reward; bool ok = usdc.transfer(task.poster, refund); require(ok, "VaultfireTaskEscrow: refund transfer failed"); emit TaskCancelled(taskId, msg.sender, refund); } // ----------------------------------------------------------------------- // View functions // ----------------------------------------------------------------------- /** * @notice Retrieve the full Task record for a given task ID. * @param taskId The task to query. * @return task The Task struct. */ function getTask(uint256 taskId) external view taskExists(taskId) returns (Task memory task) { task = _tasks[taskId]; } /** * @notice Retrieve all bids placed on a task. * @param taskId The task to query. * @return bids Array of Bid structs ordered by placement time. */ function getTaskBids(uint256 taskId) external view taskExists(taskId) returns (Bid[] memory bids) { bids = _bids[taskId]; } /** * @notice Returns the total number of tasks ever created (including cancelled ones). * @return count Current task counter value. */ function taskCount() external view returns (uint256 count) { count = _taskCounter; } }