Protocol
Contracts & HTTP API
Two contracts on Hedera testnet hold the money and the rules; a handful of Next.js route handlers keep the keys that cannot live in a browser. This page is the reference for both: addresses, every external function, the frozen event set, and each HTTP route with its exact body, success shape and error codes.
Sources: contracts/ (AetherisAgency.sol, AetherisTreasury.sol, HederaTokenServiceLib.sol, interfaces/*.sol), covered by 37 Hardhat tests in test/aetheris.test.js. Route handlers are under app/api/**/route.ts; the shared error envelope is app/api/_lib/http.ts.
Addresses and blocks
| Network | Hedera testnet, chain id 296JSON-RPC relay https://testnet.hashio.io/api (rejects eth_getLogs inside JSON-RPC batches); mirror node https://testnet.mirrornode.hedera.com |
|---|---|
| AetherisAgency | 0x16fA9CC838Ab5380F0Ebe3C261a2F57E0FBAbc81deploy block 40,396,781 |
| AetherisTreasury | 0x10360383a6b43Fd22BE257bE334E9A9ad83B5598deploy block 40,396,776 |
| HTS system contract | 0x0000000000000000000000000000000000000167same address on every Hedera network; returns int64 response codes, SUCCESS = 22 |
| aUSD (HTS token) | 0.0.104846736 decimals; EVM alias 0x00000000000000000000000000000000009ffBC1 |
| aUSDC (test ERC-20) | 0x21DCc52AbbCAef92B4573dc8B0e1658417c859616 decimals; open mint (used by the faucet) |
| Operator | 0.0.10484502EVM alias 0x69677C85945796066B449c00F90A0582896F1F9b; owner of both contracts, deployer, relayer, faucet and HCS submit key |
| Seeded sub-agents | 0.0.104846740.0.104846760.0.10484678 |
| HCS audit topic | 0.0.10518320 |
State today: 7 jobs created, 4 settled, one real claimProfit of 0.5 aUSD. The measured cost of a settlement is 2,360,527 gas over HTS against 229,111 gas over ERC-20.
AetherisAgency
The agency owns the job and task state machines and the World ID operator registry. It is Ownable (the owner is the operator) and ReentrancyGuarded where it hands control to token contracts. Job statuses: Funded, Dispatched, Completed, Settled, Refunded. Task statuses: Assigned, Completed, Paid, Cancelled.
- Function
- constructor(initialOwner, treasury, worldIdRouter, worldIdGroupId, worldIdAppId, worldIdAction, ensName)
- Who may call
- Deployer
- Emits
- AgencyDeployed WorldIdBypassActive (if router = 0)
- Notes
- Binds the agency to its treasury and derives
externalNullifierHashfrom the World ID app id and action.AgencyDeployedcarries nullifier0; personhood is established afterwards.
- Function
- verifyOperator(address signal, uint256 root, uint256 nullifierHash, uint256[8] proof, string ensName)
- Who may call
- Anyone (the app relays it with the deployer key)
- Emits
- OperatorVerified OperatorVerifiedWithoutProof (bypass)
- Notes
- Burns
nullifierHashfirst (NullifierAlreadyUsedon replay,InvalidNullifierfor 0), then calls the router'sverifyProofif one is set. Markssignalverified and records its ENS name.
- Function
- setWorldId(address router, uint256 groupId)
- Who may call
- Owner
- Emits
- WorldIdRouterUpdated WorldIdBypassActive (if router = 0)
- Notes
- Points the agency at a World ID router, or clears it to re-enter bypass mode.
- Function
- createJob(address token, uint256 deposit, string specURI) returns (uint256 jobId)
- Who may call
- Anyone - the caller becomes the client
- Emits
- JobCreated
- Notes
nonReentrant. PullsdepositviasafeTransferFromstraight into the treasury, then callstreasury.recordEscrow. The client must approve the agency, not the treasury. Job ids start at 1.
- Function
- assignSubAgent(uint256 jobId, address subAgent, uint256 fee, string role) returns (uint256 taskId)
- Who may call
- Owner
- Emits
- SubAgentAssigned
- Notes
- Job must be Funded or Dispatched. Reverts with
FeeExceedsDepositif committed fees would exceed the deposit, so a job is always solvent before work begins. Moves the job to Dispatched.
- Function
- completeTask(uint256 jobId, uint256 taskId, bytes32 resultHash, string hcsTopicId, uint64 hcsSequenceNumber)
- Who may call
- The task's sub-agent or the owner
- Emits
- TaskCompleted HcsLogAnchored
- Notes
- Task must be Assigned;
EmptyHcsTopicif the topic id is empty. Marks the job Completed when every task is complete. The sequence number is the one returned by the HCS receipt - see Audit log.
- Function
- cancelTask(uint256 jobId, uint256 taskId)
- Who may call
- Owner
- Emits
- TaskCancelled
- Notes
- Task must be Assigned. Releases its fee back into the job's uncommitted balance (future margin).
- Function
- settleJob(uint256 jobId) returns (uint256 paidToSubAgents, uint256 netMargin)
- Who may call
- Owner
- Emits
- JobSettled (+ one MicroSettlement per paid task, from the treasury)
- Notes
nonReentrant. Job must be Dispatched or Completed; status is set to Settled before any payout. Pays every Completed task throughtreasury.settleSubAgent, thencloseEscrowsweeps the remainder into retained margin. Unfinished work is never paid.
- Function
- refundJob(uint256 jobId) returns (uint256 refunded)
- Who may call
- The client or the owner
- Emits
- TaskCancelled (per open task) JobRefunded
- Notes
nonReentrant. Job must be Funded or Dispatched (InvalidJobStatusotherwise); any other caller getsNotClientOrOperator. Cancels Assigned and Completed tasks and returns the whole escrow to the client.
- Function
- getJob(jobId) / getTask(jobId, taskId) / getTasks(jobId) / taskLength(jobId) / jobStatus(jobId)
- Who may call
- View
- Emits
- -
- Notes
JobandTaskstructs and counts.UnknownJob/UnknownTaskon bad ids.
- Function
- isVerifiedOperator(address) / operatorNullifier(address) / operatorEnsName(address) / nullifierHashUsed(uint256)
- Who may call
- View
- Emits
- -
- Notes
- The operator registry the treasury reads before releasing profit (
IAetherisOperatorRegistry).
- Function
- worldIdVerificationBypassed() / externalNullifierHash() / worldIdGroupId() / jobCount()
- Who may call
- View
- Emits
- -
- Notes
worldIdVerificationBypassed()is true whenever no router is configured - it is true on the testnet deployment.
Custom errors
ZeroAddress, ZeroAmount, UnknownJob(jobId), UnknownTask(jobId, taskId), InvalidJobStatus(jobId, actual), InvalidTaskStatus(jobId, taskId, actual), FeeExceedsDeposit(jobId, committed, deposit), NotTaskOwner(jobId, taskId, caller), NotClientOrOperator(jobId, caller), NullifierAlreadyUsed(nullifierHash), InvalidNullifier, EmptyHcsTopic
AetherisTreasury
The treasury holds every token the system touches and enforces one invariant: for each token, balanceOf(this) >= totalObligations[token], where obligations are open escrows plus retained margin. Only the wired agency can open, spend or close an escrow (onlyAgency); only the owner can move margin, and only if the agency says that owner is a verified human.
- Function
- constructor(address initialOwner)
- Who may call
- Deployer
- Emits
- -
- Notes
- The owner is the human operator who will own the retained margin.
- Function
- setAgency(address newAgency)
- Who may call
- Owner
- Emits
- AgencyUpdated
- Notes
- Wires the one contract allowed to open, spend and close escrows. Also the registry consulted by
claimProfit.
- Function
- setHtsEnabled(bool enabled)
- Who may call
- Owner
- Emits
- HtsEnabledUpdated
- Notes
- Circuit breaker. When false, every payout goes straight to ERC-20 transfer.
- Function
- associateToken(address token) returns (int64 responseCode)
- Who may call
- Owner
- Emits
- TokenAssociated
- Notes
- Must be called once per HTS token before the treasury can be funded in it. Accepts response code 22 (SUCCESS) or 194 (TOKEN_ALREADY_ASSOCIATED_TO_ACCOUNT). Reverts
HtsCallFailedon a chain without the HTS system contract.
- Function
- dissociateToken(address token) returns (int64 responseCode)
- Who may call
- Owner
- Emits
- TokenDissociated
- Notes
- Fails at the HTS level if the treasury still holds a balance of the token.
- Function
- recordEscrow(uint256 jobId, address token, uint256 amount)
- Who may call
- Agency only
- Emits
- EscrowRecorded
- Notes
- Called after the agency has moved the deposit in. Adds to
totalObligations[token]and revertsSolvencyCheckFailedifbalanceOf(this)is below the new total - the transfer is proven by balance, not trusted.
- Function
- settleSubAgent(uint256 jobId, uint256 taskId, address subAgent, uint256 amount) returns (bool viaHts)
- Who may call
- Agency only
- Emits
- MicroSettlement HtsPayoutFallback (if HTS failed)
- Notes
nonReentrant. Debits the job's escrow and pays HTS-first with an ERC-20 fallback;viaHtsrecords which rail moved the funds.InsufficientEscrowif the escrow cannot cover it.
- Function
- closeEscrow(uint256 jobId) returns (uint256 margin)
- Who may call
- Agency only
- Emits
- -
- Notes
- Moves whatever is left in the escrow into
retainedMargin[token]. Obligations are unchanged - the funds stay owed, now to the operator instead of the job.
- Function
- refundEscrow(uint256 jobId, address client) returns (uint256 refunded)
- Who may call
- Agency only
- Emits
- EscrowRefunded
- Notes
nonReentrant. Returns the remaining escrow to the client over the same dual rail.
- Function
- claimProfit(address token, uint256 amount, address to)
- Who may call
- Owner, and must be a verified operator in the agency
- Emits
- ProfitClaimed
- Notes
nonReentrant.AgencyNotSetif no agency is wired,OperatorNotVerifiedifisVerifiedOperator(msg.sender)is false,InsufficientMarginifretainedMargin[token]is short. The event echoes the operator's nullifier so the payout links back to the proof that authorised it.
- Function
- rebalance(address fromToken, address toToken, uint256 amountIn, uint256 amountOut, uint256 dstChainId, bytes32 oneInchTxHash)
- Who may call
- Owner
- Emits
- TreasuryRebalanced
- Notes
nonReentrant. Accounting record for a 1inch swap executed off-chain: debitsamountInof margin infromTokenand, for same-chain swaps only, creditsamountOutintoToken. No token movement happens in this call.
- Function
- sweepSurplus(address token, address to) returns (uint256 surplus)
- Who may call
- Owner
- Emits
- SurplusSwept
- Notes
- Moves only
balanceOf(this) - totalObligations[token], so it can never touch escrow or retained margin. For airdrops and accidental transfers.
- Function
- escrowToken(jobId) / escrowBalance(jobId) / isEscrowOpen(jobId)
- Who may call
- View
- Emits
- -
- Notes
- Per-job escrow state.
- Function
- htsAvailable() / isHtsToken(address) / retainedMargin(address) / totalObligations(address) / agency() / htsEnabled()
- Who may call
- View
- Emits
- -
- Notes
htsAvailable()probes0x167with astaticcalland is false on Hardhat and every non-Hedera chain.isHtsTokenrequires a full 64-byte(int64, bool)reply with code 22.
Custom errors
NotAgency(caller), ZeroAddress, ZeroAmount, AgencyNotSet, EscrowAlreadyOpen(jobId), EscrowNotOpen(jobId), InsufficientEscrow(jobId, requested, available), InsufficientMargin(token, requested, available), SolvencyCheckFailed(token, held, required), OperatorNotVerified(operator), TokenMismatch(expected, actual); from HederaTokenServiceLib: HtsCallFailed(selector, responseCode), HtsAmountOverflow(amount)
The dual rail lives in the private _payout: if htsEnabled and HederaTokenServiceLib.isHtsToken(token) both hold, it tries transferToken on 0x167; otherwise, or if HTS answers anything but 22, it emits HtsPayoutFallback with the response code and uses SafeERC20. The library maps empty or malformed return data to UNKNOWN = 21 rather than 0, so a call to an address with no code (every non-Hedera chain) can never look like success. See Settlement rails.
Events
Ten event signatures are frozen in contracts/interfaces/IAetherisEvents.sol. The contracts implement them verbatim and subgraph/subgraph.yaml indexes them verbatim; neither side may change a name, arity, type or indexed flag without updating the interface first and regenerating the subgraph ABI.
// contracts/interfaces/IAetherisEvents.sol - FROZEN. Implemented verbatim by contracts/, // indexed verbatim by subgraph/. Never change a signature without regenerating both. // Identity & governance event AgencyDeployed(address indexed agency, address indexed operator, string ensName, uint256 nullifierHash); event OperatorVerified(address indexed operator, uint256 nullifierHash, string ensName); // Job lifecycle event JobCreated(uint256 indexed jobId, address indexed client, address indexed token, uint256 deposit, string specURI); event SubAgentAssigned(uint256 indexed jobId, uint256 indexed taskId, address indexed subAgent, address token, uint256 fee, string role); event TaskCompleted(uint256 indexed jobId, uint256 indexed taskId, bytes32 resultHash, string hcsTopicId, uint64 hcsSequenceNumber); event MicroSettlement(uint256 indexed jobId, uint256 indexed taskId, address indexed subAgent, address token, uint256 amount, bool viaHts); event JobSettled(uint256 indexed jobId, uint256 grossDeposit, uint256 paidToSubAgents, uint256 netMargin); // Treasury operations event TreasuryRebalanced(address indexed fromToken, address indexed toToken, uint256 amountIn, uint256 amountOut, uint256 dstChainId, bytes32 oneInchTxHash); event ProfitClaimed(address indexed operator, address indexed token, uint256 amount, uint256 nullifierHash); event HcsLogAnchored(uint256 indexed jobId, bytes32 messageHash, string topicId, uint64 sequenceNumber);
Each contract also emits operational events that are visible on HashScan but outside the frozen set, and therefore not indexed by the subgraph:
// AetherisAgency.sol - operational events, not part of the frozen set and not indexed event WorldIdBypassActive(address indexed agency, string reason); event OperatorVerifiedWithoutProof(address indexed operator, uint256 nullifierHash); event WorldIdRouterUpdated(address indexed router, uint256 groupId); event JobRefunded(uint256 indexed jobId, address indexed client, address indexed token, uint256 amount); event TaskCancelled(uint256 indexed jobId, uint256 indexed taskId, address indexed subAgent); // AetherisTreasury.sol event TokenAssociated(address indexed token, int64 responseCode); event TokenDissociated(address indexed token, int64 responseCode); event HtsPayoutFallback(address indexed token, address indexed to, uint256 amount, int64 responseCode); event AgencyUpdated(address indexed previousAgency, address indexed newAgency); event HtsEnabledUpdated(bool enabled); event EscrowRecorded(uint256 indexed jobId, address indexed token, uint256 amount); event EscrowRefunded(uint256 indexed jobId, address indexed client, address indexed token, uint256 amount); event SurplusSwept(address indexed token, address indexed to, uint256 amount);
HTTP API
Every route runs on the Node runtime with dynamic = "force-dynamic". Bodies must be JSON objects (arrays, primitives and malformed JSON are a 400). Errors share one envelope with a real status code and a stable code:
{
"error": {
"code": "BAD_REQUEST",
"message": "`limit` must be an integer between 1 and 50.",
"details": "optional, human-readable"
}
}400 BAD_REQUEST- validation;401 PROOF_REJECTED- World ID said no;403 OPERATOR_NOT_VERIFIED/NULLIFIER_MISMATCH- HCS write by an unverified operator;405 METHOD_NOT_ALLOWED;409 NULLIFIER_ALREADY_USED;429 RATE_LIMITED(with aretry-afterheader where the limiter is per-IP);502 UPSTREAM_ERROR- Hedera, World ID or 1inch failed;503 NOT_CONFIGUREDand its specific variants - a required env var is missing, and nothing was attempted.- Rate limiters are in-memory per process: 5 per minute per IP for
POST /api/hcsandPOST /api/operator/verify; 3 per address per hour and 30 per hour overall for the faucet.
GET /api/hcs
| File | app/api/hcs/route.ts |
|---|---|
| Query | limit integer 1 to 50 (default 12); topicId optional shard.realm.num, defaults to HEDERA_HCS_TOPIC_ID |
| Success | 200 { topicId, source: "live" | "demo", notice?, messages[] } - frames newest first with corrections already applied |
| Errors | 400 on a bad limit or topicId. Never 5xx: an unset topic, an empty topic or an unreachable mirror node all answer 200 with source: "demo" and a notice. |
{
"topicId": "0.0.10518320",
"source": "live", // or "demo", with a "notice" explaining why
"messages": [
{ "sequenceNumber": "27", "contents": "{\"evt\":\"Correction\",...}", "consensusTimestamp": "1789280736.723520104" }
]
}POST /api/hcs
| Body | { message, topicId? } - message is a JSON string of at most 1024 characters matching exactly {evt, operator, amount, nullifier} with evt = "ProfitClaimed"; amount is a formatted USD figure; nullifier a uint256 string or null. topicId, if present, must equal the configured topic. |
|---|---|
| Checks | isVerifiedOperator(operator) must be true on-chain, and a supplied nullifier must equal operatorNullifier(operator). The server composes the frame (adds agency, ts, src: "aetheris/api/hcs") and signs with the operator key. |
| Success | 201 { topicId, sequenceNumber, transactionId, frame } |
| Errors | 400 (shape, extra fields, wrong topic); 403 OPERATOR_NOT_VERIFIED, NULLIFIER_MISMATCH; 429 RATE_LIMITED; 502 UPSTREAM_ERROR (RPC read or HCS submit failed); 503 NOT_CONFIGURED (no topic or no agency address). |
// request
{ "message": "{\"evt\":\"ProfitClaimed\",\"operator\":\"0x6967...1F9b\",\"amount\":\"$0.50\",\"nullifier\":\"3319...2123\"}" }
// 201
{ "topicId": "0.0.10518320", "sequenceNumber": "28", "transactionId": "0.0.10484502@...", "frame": "<the server-composed JSON that was anchored>" }POST /api/faucet
| File | app/api/faucet/route.ts |
|---|---|
| Body | { address } |
| Effect | Sends 1 HBAR if the wallet holds under 0.5, then mints 10 aUSDC via the test token's open mint. Signs with PRIVATE_KEY. Refuses unless the RPC reports chain 296. |
| Success | 200 { ok, to, hbar, token } |
| Errors | 400; 429 RATE_LIMITED (3 drips per address per hour); 503 FAUCET_NOT_CONFIGURED, WRONG_CHAIN; 502 UPSTREAM_ERROR. |
// request
{ "address": "0x..." }
// 200
{
"ok": true,
"to": "0x...",
"hbar": { "amount": "1", "tx": "0x...", "hashscan": "https://hashscan.io/testnet/transaction/0x..." },
// or { "skipped": "wallet already holds >= 0.5 HBAR" }
"token": { "symbol": "aUSDC", "amount": "10", "address": "0x21DCc52AbbCAef92B4573dc8B0e1658417c85961", "tx": "0x...", "hashscan": "..." }
}POST /api/worldid/rp-context
| File | app/api/worldid/rp-context/route.ts |
|---|---|
| Body | { action } - non-empty, at most 256 characters |
| Effect | Signs an IDKit v4 request context with WORLD_ID_RP_SIGNING_KEY (TTL 300 s) so the key never reaches the browser. |
| Success | 200 { rp_context: { rp_id, nonce, created_at, expires_at, signature } } |
| Errors | 400; 503 NOT_CONFIGURED when WORLD_ID_RP_ID or the signing key is unset, or signing fails. |
// request
{ "action": "aetheris-operator" }
// 200
{ "rp_context": { "rp_id": "...", "nonce": "...", "created_at": 1789280000, "expires_at": 1789280300, "signature": "..." } }POST /api/operator/verify
| File | app/api/operator/verify/route.ts |
|---|---|
| Body | { result, signal, ensName? } (IDKit 4.0) or { proof, signal, ensName? } (legacy). signal is the operator's EVM address; ensName must look like an ENS name. No other fields. |
| Effect | Verifies at https://developer.worldcoin.org/api/v4/verify/{rp_id} (or the legacy verifier), checks nullifierHashUsed, then relays verifyOperator(signal, 0, nullifierHash, [0 x 8], ensName) with the deployer key and waits one confirmation. |
| Success | 200 { ok, txHash, nullifierHash, hashscan, signal, ensName, blockNumber, worldIdBypassed, verificationLevel } |
| Errors | 400 BAD_REQUEST; 401 PROOF_REJECTED; 409 NULLIFIER_ALREADY_USED (pre-check or revert); 429 RATE_LIMITED; 502 UPSTREAM_ERROR; 503 WORLD_ID_NOT_CONFIGURED, RELAYER_NOT_CONFIGURED. GET answers 405. |
// request (World ID 4.0)
{ "result": { "protocol_version": "4.0", "responses": [ ... ] }, "signal": "0x<operator address>", "ensName": "aetheris.eth" }
// or legacy (3.0)
{ "proof": { "merkle_root": "...", "nullifier_hash": "...", "proof": "...", "verification_level": "orb" }, "signal": "0x...", "ensName": "..." }
// 200
{
"ok": true,
"txHash": "0x...",
"nullifierHash": "0x...", // 32-byte hex
"hashscan": "https://hashscan.io/testnet/transaction/0x...",
"signal": "0x...",
"ensName": "aetheris.eth", // defaults to aetheris.eth when omitted
"blockNumber": 40453000,
"worldIdBypassed": true, // contract ran without a router - true on testnet
"verificationLevel": "orb"
}POST /api/verify-worldid
| File | app/api/verify-worldid/route.ts |
|---|---|
| Body | An IDKit 4.0 result (bare or as { result }), or legacy { proof: { merkle_root, nullifier_hash, proof, verification_level }, signal } |
| Effect | Verification only - nothing is sent on-chain. The operator flow uses /api/operator/verify instead. |
| Success | 200 { success: true, version?, nullifierHash, verificationLevel } |
| Errors | 400; 401 PROOF_REJECTED; 502 UPSTREAM_ERROR; 503 NOT_CONFIGURED. GET answers 405. |
// request: an IDKit 4.0 result, either as the body itself or wrapped as { "result": ... }
// 200
{ "success": true, "version": "4", "nullifierHash": "...", "verificationLevel": "orb" }
// request: legacy { "proof": {...}, "signal": "<string>" }
// 200
{ "success": true, "nullifierHash": "...", "verificationLevel": "orb" }POST /api/swap/quote and POST /api/swap/build
| Files | app/api/swap/quote/route.ts, app/api/swap/build/route.ts |
|---|---|
| Body | { chainId, src, dst, amount }; build adds from and optional slippage (greater than 0, at most 50). amount is a decimal string in base units; src and dst must differ. |
| Chains | Ethereum 1, Arbitrum 42161, Base 8453, Optimism 10, Polygon 137. Hedera (296) is not supported by 1inch and is rejected with a 400 listing the supported chains. |
| Success | quote: 200 { quote, chainId }; build: 200 { tx: { to, data, value, gas? }, chainId } - unsigned calldata, never broadcast by the server |
| Errors | 400; 502 UPSTREAM_ERROR (upstream failure, including a missing ONEINCH_API_KEY); 503 NOT_CONFIGURED only if the 1inch module itself fails to load. The key never reaches the browser. |
// POST /api/swap/quote
{ "chainId": 8453, "src": "0x...", "dst": "0x...", "amount": "1000000" }
// 200
{ "quote": { "srcToken": "0x...", "dstToken": "0x...", "srcAmount": "1000000", "dstAmount": "...", "estimatedGas": "...", "protocols": [...] }, "chainId": 8453 }
// POST /api/swap/build
{ "chainId": 8453, "src": "0x...", "dst": "0x...", "amount": "1000000", "from": "0x<wallet>", "slippage": 1 }
// 200 - unsigned; nothing is broadcast
{ "tx": { "to": "0x...", "data": "0x...", "value": "0", "gas": "..." }, "chainId": 8453 }GET /api/ens
| File | app/api/ens/route.ts |
|---|---|
| Query | exactly one of address (20-byte hex) or name (dot-separated ENS name) |
| Success | 200 { address, name, avatar, source: "live" | "unconfigured" } |
| Errors | 400 when neither or a malformed parameter is given. Resolution failures are 200 with null fields. |
// GET /api/ens?name=vitalik.eth or /api/ens?address=0x...
// 200 (cache-control: public, max-age=300, stale-while-revalidate=1800)
{ "address": "0x...", "name": "vitalik.eth", "avatar": "https://..." | null, "source": "live" }
// unresolvable names answer 200 with null fields and "source": "unconfigured"