Skip to content

Roles

The operator

The operator is the human who runs the agency: the address that deployed both contracts, dispatches work, settles jobs and is the only party who can take margin out of the treasury. On this deployment that is one key, and every step below is what that key actually does.

Who the operator is

scripts/deploy.js passes the deployer as initialOwner of contracts/AetherisAgency.sol and as owner of contracts/AetherisTreasury.sol, so "operator" and "deployer" are the same address. The app learns it from the Operator entity the subgraph indexes from AgencyDeployed, falling back to NEXT_PUBLIC_AETHERIS_OPERATOR_ADDRESS with a caveat when the subgraph has not answered (components/aetheris-server.ts).

The operator on Hedera testnet
Hedera account0.0.10484502
EVM address0x69677C85945796066B449c00F90A0582896F1F9b
OwnsAetherisAgency (0x16fA…bc81) and AetherisTreasury (0x1036…5598), both OpenZeppelin Ownable.
Signs withPRIVATE_KEY for every EVM call (deploy, relay, faucet) and HEDERA_OPERATOR_KEY for HCS submits. The topic's submit key is this account.
Only the operator mayassignSubAgent, cancelTask, settleJob, setWorldId, claimProfit. completeTask is open to the sub-agent or the operator; refundJob to the client or the operator.

What Mission Control shows

/dashboard is rendered per request by app/(app)/dashboard/page.tsx. The workspace is chosen by components/app/role-context.tsx: if the connected wallet equals the agency's operator the Operator view is shown; if it has funded at least one indexed job the Client view is shown; the Operator | Client toggle in the sidebar overrides either and remembers the choice in localStorage under aetheris.role. Signed out, the Operator view is the default.

The Operator view is, top to bottom:

  • Header. The agency's ENS name or short address, the operator address, a "Hedera testnet - 296" pill, the LIVE / DEMO DATA pill and the World ID provenance badge (below).
  • Stat row (components/app/stat-row.tsx): treasury margin retained after payouts, jobs in flight with lifetime and settled counts, total paid to sub-agents with the roster size, and the HCS anchor count with average consensus finality.
  • Job pipeline (#jobs): every indexed job with its tasks, filterable by all / in flight / settled, linking to the agency contract on HashScan.
  • Sub-agent leaderboard (#agents): earnings and latency indexed from MicroSettlement events by the subgraph.
  • Treasury (#treasury, #swap): holdings read straight from AetherisTreasury over the relay plus the HBAR balance, and the 1inch quote form described in Integrations.
  • Human operator required (#operator): the World ID gate and the margin sweep button, both owned by components/treasury-panel.tsx.
  • HCS audit stream (#audit): the latest frames from topic 0.0.10518320 with corrections applied.

The World ID badge never overstates

components/operator-verification-badge.tsx combines two on-chain reads from lib/operator-registry.ts - isVerifiedOperator(operator) and worldIdVerificationBypassed() - and shows exactly one of: "World ID verified" (router present, proof checked on-chain), "Verified - bypass mode - no ZK proof on-chain", "Verified - bypass mode - seed nullifier" (the registration came from scripts/seed.js, not from a relayed proof), "Operator unverified" or "World ID status unknown". On Hedera testnet today the honest answer is one of the two bypass labels.

The World ID gate

Sweeping margin is gated on proof of personhood. The browser half lives in components/worldid-gate.tsx, the server half in app/api/worldid/rp-context/route.ts, lib/worldid.ts and app/api/operator/verify/route.ts. The flow is IDKit v4 end to end:

  1. Request context. "Verify with World ID" POSTs {action} to /api/worldid/rp-context. The route signs a nonce with WORLD_ID_RP_SIGNING_KEY (signRequest from @worldcoin/idkit-core, 300 s TTL) and returns {rp_id, nonce, created_at, expires_at, signature}. The key never reaches the browser. Without WORLD_ID_RP_ID and the key the route answers 503.
  2. Prove. IDKitRequestWidget opens with the app id, the action aetheris-operator, that rp_context, the proofOfHuman preset committed to the operator address as signal, and allow_legacy_proofs=false. The user scans with World App.
  3. Relay. The raw IDKit 4.0 result is POSTed as {result, signal, ensName?} to /api/operator/verify. The route rate-limits per IP (5 per minute), validates the shape and extracts the RP-scoped nullifier from responses[0].nullifier.
  4. Verify server-side. verifyWorldIdV4 forwards the result unchanged to POST https://developer.worldcoin.org/api/v4/verify/{rp_id} (base overridable with WORLD_ID_API_BASE). A 4xx becomes 401 PROOF_REJECTED; nothing is sent on-chain.
  5. Check the nullifier. nullifierHashUsed(nullifier) is read from the agency over the relay (ethers with batchMaxCount: 1, because Hashio rejects batched calls). If it is already burned the route answers 409 NULLIFIER_ALREADY_USED.
  6. Burn it on-chain. The deployer key sends verifyOperator(signal, 0, nullifier, [0 x 8], ensName ?? "aetheris.eth"). The contract reverts ZeroAddress / InvalidNullifier / NullifierAlreadyUsed first, then sets nullifierHashUsed before any external call, then marks isVerifiedOperator[signal], stores the nullifier and ENS name, and emits OperatorVerified.
  7. Show the receipt. The gate renders the HashScan link for the verifyOperator transaction, the nullifier, and the on-chain note. A second proof from the same human is shown as "Nullifier already burned on-chain" from the 409.
POST /api/operator/verify responses
200Relayed. Body carries txHash, hashscan, nullifierHash, ensName, blockNumber and worldIdBypassed.
400Malformed body: no result or legacy proof, a zero nullifier, a signal that is not an EVM address, an invalid ensName, or an unexpected field.
401 PROOF_REJECTEDWorld ID's verifier rejected the proof. Nothing was sent on-chain.
409 NULLIFIER_ALREADY_USEDThe nullifier is already burned in the agency - checked with nullifierHashUsed before sending, and again if the transaction reverts with NullifierAlreadyUsed.
429 RATE_LIMITEDMore than 5 relays per minute from one IP. The relay spends the deployer's HBAR.
502The verifier or the Hedera relay was unreachable, or the transaction reverted for another reason.
503 WORLD_ID_NOT_CONFIGUREDNEXT_PUBLIC_WORLD_ID_APP_ID is unset. The route never pretends success.
503 RELAYER_NOT_CONFIGUREDPRIVATE_KEY or NEXT_PUBLIC_AETHERIS_AGENCY_ADDRESS is missing or malformed.

Announced bypass mode

There is no World ID router contract on Hedera, so the agency was deployed with WORLD_ID_ROUTER_ADDRESS unset. The constructor emits WorldIdBypassActive(agency, "WORLD_ID_ROUTER_UNSET: zero-knowledge proofs are NOT verified on this deployment"), and inside verifyOperator the branch that would call router.verifyProof instead emits OperatorVerifiedWithoutProof(signal, nullifierHash). Only the ZK check is skipped: the nullifier is burned exactly as it would be with a router, so replay protection holds. The view worldIdVerificationBypassed() reports the mode and the relay echoes it as worldIdBypassed: true. setWorldId(router, groupId) lets the owner attach a router later; clearing it emits WorldIdBypassActive again.

Claiming margin

AetherisTreasury.claimProfit(token, amount, to) is onlyOwner and additionally requires IAetherisOperatorRegistry(agency).isVerifiedOperator(msg.sender), otherwise it reverts OperatorNotVerified. It checks retainedMargin[token] (InsufficientMargin if short), debits it and totalObligations, pays through the same HTS-or-ERC-20 _payout used for sub-agents, and emits ProfitClaimed(operator, token, amount, nullifierHash) with the nullifier that verified the operator, so the subgraph can tie the payout to the proof. Escrow is untouchable by this path; only closed-job margin is claimable.

The one real claim

scripts/claim-margin.js performed a single claim of 0.5 aUSD (500000 base units of 0x0000…fBC1) to the operator in transaction 0x8304…0c50 at block 40453585. The script then anchored frame #26 to the topic after the receipt - for a claim the chain is the source of truth, so the frame quotes the transaction hash it describes - and appended Correction #27 voiding frame #25, a test frame that had no matching on-chain event. Readers (lib/hedera.ts) hide #25 and keep the correction visible. The frame links open the raw mirror-node record for each sequence number; HashScan lists the same frames under the topic's Messages tab.

Scripts the operator runs

shell
npm run compile                      # hardhat compile
npm run test:contracts               # 37 tests in test/aetheris.test.js
npm run deploy:hedera                # scripts/deploy.js - prints .env + subgraph.yaml blocks
npx hardhat run scripts/seed.js --network hederaTestnet
npx hardhat run scripts/claim-margin.js --network hederaTestnet
  • scripts/seed.js drives a full lifecycle against the deployed contracts: Job A settled in aUSD over HTS with three tasks to the three Hedera-native sub-agents, Job B settled in aUSDC over ERC-20 with two tasks, and Job C left Dispatched with one task completed and one outstanding. For JobCreated, SubAgentAssigned and TaskCompleted the HCS frame is written first and its sequence number passed into completeTask; settlement frames are written from the receipt's events. If the HTS stage fails the ERC-20 stages still run.
  • scripts/claim-margin.js audits every ProfitClaimed frame against the treasury's events since block 40396776, claims min(0.5 aUSD, retainedMargin(aUSD)) with a 1,000,000 gas limit, anchors the truthful frame, and appends a Correction for any frame with no on-chain event.
  • scripts/hcs.js is the shared helper both use to submit frames with HEDERA_OPERATOR_ID / HEDERA_OPERATOR_KEY; the app's POST /api/hcs does the same from the server.