Skip to content

Protocol

Jobs & escrow

A job is one client deposit held by the treasury and a list of tasks the operator commits against it. The contract will not let committed fees pass the deposit, will not pay a task that was never completed, and will hand the whole escrow back to the client for as long as the job is unsettled. This page is the reference for those rules as contracts/AetherisAgency.sol actually enforces them.

The agency (0x16fA…bc81) owns the job and task records; the treasury (0x1036…5598) owns the tokens. Every function that moves money on the treasury is onlyAgency, and the agency is the only address wired in through setAgency. Statuses and event signatures are frozen in contracts/interfaces/IAetherisEvents.sol, which the subgraph indexes verbatim.

Function signatures

Copied from contracts/AetherisAgency.sol. onlyOwner is the operator; anything else is open to the caller named in the comment.

contracts/AetherisAgency.sol
// client: must have approved THIS contract (the agency) for deposit of token
function createJob(address token, uint256 deposit, string calldata specURI)
    external nonReentrant returns (uint256 jobId);

// operator
function assignSubAgent(uint256 jobId, address subAgent, uint256 fee, string calldata role)
    external onlyOwner returns (uint256 taskId);

// the task's sub-agent, or the operator on its behalf
function completeTask(
    uint256 jobId,
    uint256 taskId,
    bytes32 resultHash,
    string calldata hcsTopicId,
    uint64 hcsSequenceNumber
) external;

// operator
function cancelTask(uint256 jobId, uint256 taskId) external onlyOwner;

// operator
function settleJob(uint256 jobId)
    external onlyOwner nonReentrant returns (uint256 paidToSubAgents, uint256 netMargin);

// the job's client, or the operator
function refundJob(uint256 jobId) external nonReentrant returns (uint256 refunded);

// views
function getJob(uint256 jobId) external view returns (Job memory);
function getTask(uint256 jobId, uint256 taskId) external view returns (Task memory);
function getTasks(uint256 jobId) external view returns (Task[] memory);
function taskLength(uint256 jobId) external view returns (uint256);
function jobStatus(uint256 jobId) external view returns (JobStatus);
uint256 public jobCount;

Job ids are 1-based (jobId = ++jobCount); task ids are the 0-based index into the job's task array. taskLength counts every task ever pushed, including cancelled ones, while Job.taskCount is decremented by cancelTask.

createJob: approve the agency, not the treasury

The client is whoever calls createJob. The function pulls the deposit with IERC20(token).safeTransferFrom(msg.sender, address(treasury), deposit), so the allowance has to be granted to the agency address even though the tokens end up in the treasury. An allowance on the treasury does nothing. The browser flow in lib/write.ts (approveAndCreateJob) reads the current allowance for the agency first and only sends the approve transaction when it is short, then signs createJob on chain 296.

Once the tokens have moved, the agency calls treasury.recordEscrow(jobId, token, deposit). The treasury does not trust the amount it is told: it adds the deposit to totalObligations[token], reads its own balanceOf, and reverts with SolvencyCheckFailed(token, held, required) if the balance does not cover every obligation it now carries (contracts/AetherisTreasury.sol). A fee-on-transfer token therefore fails at funding time instead of leaving a job under-collateralised.

createJob inputs
tokenAny ERC-20-compatible address. On testnet that is the HTS token aUSD 0x0000…fBC1 (6 decimals) or the test ERC-20 aUSDC 0x21DC…5961 (6 decimals). For an HTS token the treasury must already be associated; see Settlement rails.
depositGross deposit in the token's smallest unit. Zero reverts with ZeroAmount.
specURIOff-chain description of the work. Stored on the job and emitted in JobCreated; the contract never reads it.

The contract treats specURI as an opaque string, so the value is a convention between the client and the sub-agents. Three forms are in use: hcs://<topicId>/<sequenceNumber> points at a JobBrief frame anchored on the audit topic (title, role, client, the brief text and its keccak256), which is what scripts/agent-demo.js writes and the worker reads back from the mirror node before inferring (see Job briefs); ipfs:// content ids for a specification stored off-chain; or any other opaque string, such as an HTTPS URL, which is stored and displayed as-is. Nothing on-chain validates the form, and a job funded with an unreadable URI is still a valid job.

assignSubAgent and FeeExceedsDeposit

Each call reserves one task's fee against the deposit. The check is a single line:

contracts/AetherisAgency.sol
uint256 committed = job.committedFees + fee;
if (committed > job.deposit) revert FeeExceedsDeposit(jobId, committed, job.deposit);

Because the sum of reserved fees can never exceed what the client put in, a job is solvent before any sub-agent starts work - there is no later top-up step and no way for the operator to promise more than the escrow holds. The first assignment moves the job from Funded to Dispatched; further assignments are allowed while the job is Funded or Dispatched, and refused once it is Completed, Settled or Refunded (InvalidJobStatus). The role string is free text the operator chooses, for example security-audit or code-generation in the seeded jobs.

The test refuses to commit more in fees than the client deposited in test/aetheris.test.js assigns a fee of DEPOSIT + 1 and expects the custom error.

completeTask

Callable by the task's subAgent or by the operator; anyone else gets NotTaskOwner. The task must be Assigned, and hcsTopicId may not be empty (EmptyHcsTopic) - a completion without an audit anchor is not a completion. The call stores resultHash, marks the task Completed, and emits both TaskCompleted and HcsLogAnchored. When completedCount reaches taskCount the job flips to Completed. How the sequence number is obtained before the call is covered in Audit log.

Statuses

Job

Job statuses and the calls allowed from each
StatusEnumMeaningAllowed next calls
None0Never created. Any call on this id reverts with UnknownJob.createJob
Funded1Client deposit is in the treasury; no task assigned yet.assignSubAgent, refundJob
Dispatched2At least one sub-agent has a task.assignSubAgent, completeTask, cancelTask, settleJob, refundJob
Completed3completedCount == taskCount: every live task reported complete.settleJob
Settled4Completed tasks paid, remainder promoted to retained margin. Terminal.-
Refunded5Escrow returned to the client; open tasks cancelled. Terminal.-

Task

Task statuses and the calls allowed from each
StatusEnumMeaningAllowed next calls
None0Index out of range; getTask reverts with UnknownTask.-
Assigned1Fee reserved against the deposit, work not yet reported.completeTask, cancelTask
Completed2Result hash and HCS coordinates stored; not yet paid.settleJob
Paid3Fee streamed to the sub-agent during settleJob. Terminal.-
Cancelled4Cancelled by the operator, or swept up by a refund. Fee never paid. Terminal.-

The enum values are the integers the subgraph and the dashboard read back from jobStatus(jobId) and getTask(jobId, taskId).status.

settleJob: pay what was completed, keep the rest

Allowed while the job is Dispatched or Completed, so the operator can settle a job with unfinished tasks on it. The function sets Settled before the payout loop (a malicious token cannot re-enter through another path), then walks every task and pays only those in Completed state via treasury.settleSubAgent(jobId, taskId, subAgent, fee), marking each Paid. Tasks still Assigned are skipped and their fee is never paid. Finally treasury.closeEscrow(jobId) promotes whatever escrow remains to retainedMargin[token] and the agency emits JobSettled(jobId, grossDeposit, paidToSubAgents, netMargin).

Refunds: who and when

refundJob(jobId) may be called by the job's client or by the operator; any other caller reverts with NotClientOrOperator(jobId, caller). It is allowed only while the job is Funded or Dispatched. A job in Completed state - every task reported done but not yet settled - cannot be refunded, and neither can one that is already Settled or Refunded.

  • The job is marked Refunded first. Every task still Assigned or Completed is set to Cancelled and a TaskCancelled(jobId, taskId, subAgent) event fires for each.
  • treasury.refundEscrow(jobId, client) returns the entire remaining escrow to the client, zeroes the record and reduces totalObligations. The transfer goes through the same HTS-first, ERC-20-fallback path as a payout.
  • The agency emits JobRefunded(jobId, client, token, amount).

In the app, the Client view lists the connected wallet's refundable escrow and signs refundJob in the browser (lib/write.ts, refundJob, with a 1,000,000 gas limit because the HTS path costs far more than a plain transfer). The test refunds an unsettled job back to its client checks the client's balance returns to its pre-deposit value and totalObligations drops to zero.

cancelTask

cancelTask(jobId, taskId) is operator-only and works on an Assigned task only; a Completed, Paid or Cancelled task reverts with InvalidTaskStatus. It sets the task Cancelled, subtracts the fee from committedFees (freeing that room for another assignment) and decrements taskCount. If the remaining tasks are all complete the job becomes Completed. The job itself is not refunded by a cancel - the freed fee stays in escrow and, at settlement, becomes margin.

Custom errors

AetherisAgency custom errors
ZeroAddress()token or subAgent was the zero address.
ZeroAmount()deposit or fee was zero.
UnknownJob(jobId)No job with that id (status None).
UnknownTask(jobId, taskId)taskId is past the end of the job's task array.
InvalidJobStatus(jobId, actual)The job's current status does not permit this call; see the status table.
InvalidTaskStatus(jobId, taskId, actual)The task's current status does not permit this call.
FeeExceedsDeposit(jobId, committed, deposit)committedFees + fee would pass the deposit.
NotTaskOwner(jobId, taskId, caller)completeTask from an address that is neither the task's sub-agent nor the operator.
NotClientOrOperator(jobId, caller)refundJob from an address that is neither the job's client nor the operator.
EmptyHcsTopic()completeTask with an empty hcsTopicId.

The treasury adds its own: SolvencyCheckFailed on funding, InsufficientEscrow if a payout exceeds the job's balance, EscrowNotOpen / EscrowAlreadyOpen on double use of a job id, and NotAgency for any caller other than the wired agency (contracts/AetherisTreasury.sol).

On chain today

Seven jobs exist on the testnet agency, four of them settled. They were driven by scripts/seed.js (npx hardhat run scripts/seed.js --network hederaTestnet), which funds in aUSD and aUSDC, assigns tasks to the seeded sub-agents, completes them with real HCS sequence numbers and settles. The sub-agent work itself is simulated by the script: it commits a result hash, and no model call is part of the contracts.