Skip to content

Protocol

Audit log

Every lifecycle step in Aetheris is written to a single Hedera Consensus Service topic as a compact JSON frame before the matching contract call. HCS gives each frame a consensus timestamp and a sequence number that nobody - including the operator - can edit or delete afterwards.

The log answers the question the contracts alone cannot: what did the agency intend to do, in what order, and with which deliverable hash, at the moment it did it. The contracts hold the money; the topic holds the narrative. The two are tied together by the sequence numbers that completeTask stores on-chain.

The topic

Identifiers for the Aetheris audit topic
Topic id0.0.10518320Hedera testnet; append-only
Submit key0.0.10484502The operator account (EVM alias 0x69677C85945796066B449c00F90A0582896F1F9b). Only this key can append frames; anyone can read them.
Mirror nodehttps://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/<seq>
Explorerhttps://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/<seq>
In the app/dashboard#audit and GET /api/hcs?limit=

The writer is submitHcsMessage in lib/hedera.ts: a server-only function that loads @hashgraph/sdk dynamically, signs a TopicMessageSubmitTransaction with HEDERA_OPERATOR_ID / HEDERA_OPERATOR_KEY, and returns the sequence number from the receipt. The reader is readHcsMessages in the same file; it uses the mirror node REST API rather than a gRPC subscription so that a request handler never holds a long-lived stream open.

Record format

A frame is one JSON object, one line, no schema version field. The key set is small and stable:

  • evt - the event name. Mirrors the contract event it precedes: JobCreated, SubAgentAssigned, TaskCompleted, MicroSettlement, JobSettled, ProfitClaimed, and the log-only Correction.
  • jobId, taskId? - the same identifiers the contract uses. Task ids are zero-based indexes within a job.
  • agent?, token?, amount? - the sub-agent, the settlement token and the amount in base units (6 decimals for both aUSD and aUSDC), as strings so nothing is lost to JSON number precision.
  • resultHash? - the 32-byte hash of the deliverable committed by completeTask.
  • tx? - the Hedera EVM transaction hash, present on frames written after a settlement transaction (the seed writes MicroSettlement and JobSettled frames once it has the receipt).
  • ts - the writer's wall clock in ISO 8601. Informational only; the authoritative time is the consensus timestamp the mirror node assigns.

These are real frames decoded from GET /api/hcs?limit=8 on the running app. Frames #21 to #24 are the start of job 7; #19 and #20 close out job 6 over the ERC-20 rail (viaHts: false):

GET /api/hcs - decoded contents
# 24  TaskCompleted   consensus 1789277166.379879104
{"evt":"TaskCompleted","jobId":7,"taskId":0,
 "agent":"0x836d433faffaa8113edb1cdbF7AfB036134e1c61",
 "resultHash":"0x86acc9d1bd5c8e333a5d6066b31d1121829b380a5a7aa3a3d39e43ae9a589e3f",
 "ts":"2026-09-13T05:26:05.802Z"}

# 23  SubAgentAssigned
{"evt":"SubAgentAssigned","jobId":7,"taskId":1,
 "agent":"0xC46b2ecd39741c46f8467B8bF3EF8d5B1757DBC1",
 "amount":"520000","token":"0x21DCc52AbbCAef92B4573dc8B0e1658417c85961",
 "ts":"2026-09-13T05:25:53.440Z"}

# 21  JobCreated
{"evt":"JobCreated","jobId":7,
 "token":"0x21DCc52AbbCAef92B4573dc8B0e1658417c85961","amount":"1800000",
 "ts":"2026-09-13T05:25:29.575Z"}

# 20  JobSettled
{"evt":"JobSettled","jobId":6,"amount":"830000","margin":"470000",
 "token":"0x21DCc52AbbCAef92B4573dc8B0e1658417c85961",
 "tx":"0xf09e95cbcecf5690b8dea4214eefd2361704dd9db61c0f3f1822968ac93ba5e4",
 "ts":"2026-09-13T05:25:26.387Z"}

# 19  MicroSettlement
{"evt":"MicroSettlement","jobId":6,"taskId":1,
 "agent":"0x836d433faffaa8113edb1cdbF7AfB036134e1c61",
 "amount":"380000","token":"0x21DCc52AbbCAef92B4573dc8B0e1658417c85961",
 "viaHts":false,
 "tx":"0xf09e95cbcecf5690b8dea4214eefd2361704dd9db61c0f3f1822968ac93ba5e4",
 "ts":"2026-09-13T05:25:23.686Z"}

The ProfitClaimed frame carries extra fields because a margin sweep has more to prove: the recipient, the block, and the World ID nullifier that authorised it.

GET /api/hcs - frame #26
# 26  ProfitClaimed   (the one real margin sweep: 0.5 aUSD)
{"evt":"ProfitClaimed",
 "token":"0x00000000000000000000000000000000009ffBC1","amount":"500000",
 "to":"0x69677C85945796066B449c00F90A0582896F1F9b",
 "operator":"0x69677C85945796066B449c00F90A0582896F1F9b",
 "tx":"0x830486f9c20907b2db3a73e20a962fc6008b7121c65dbd3e9bfa2cbb81510c50",
 "block":40453585,
 "nullifierHash":"33191758600377127106447200483207683596308527199321386374340904067837022123",
 "ts":"2026-09-13T06:25:33.318Z"}

Anchor first, then call the contract

The order is deliberate. For every step the writer submits the HCS frame, waits for the receipt, and only then sends the contract transaction. For TaskCompleted the relationship is stronger than ordering: the sequence number returned by the receipt is an argument to the contract call.

scripts/seed.js
// scripts/seed.js
// TaskCompleted - HCS first; the returned sequence number is what the contract anchors.
const msg = await anchor({ evt: "TaskCompleted", jobId, taskId: i, agent, resultHash });
await agency.completeTask(jobId, i, resultHash, TOPIC, msg.sequenceNumber, { gasLimit: 600_000 });

AetherisAgency.completeTask(jobId, taskId, resultHash, hcsTopicId, hcsSequenceNumber) stores the result hash and emits two events: TaskCompleted and HcsLogAnchored(jobId, messageHash, topicId, sequenceNumber). So for every completed task there are three records of the same (topicId, sequenceNumber) pair:

  1. the frame on the mirror node, at that sequence number;
  2. the HcsLogAnchored event in the transaction receipt on Hedera EVM;
  3. the HcsAnchor and Task.hcsSequenceNumber entities in the subgraph, indexed from that event.

Anyone can join them. The seed script does exactly that at the end of a run - its "HCS anchors - mirror node vs on-chain vs subgraph" table reads each anchored pair back from the emitted event and fetches the same sequence number from the mirror node, and reports a match only when all three agree. The contract also refuses an empty topic id (EmptyHcsTopic), so a task cannot be completed without naming its anchor.

Corrections are append-only

HCS is immutable, so a mistaken frame can never be deleted. Instead the operator appends a Correction frame that names the sequence numbers it retracts and says why. The topic contains one today. Frame #25 was a ProfitClaimed frame composed by the POST /api/hcs relay (you can tell from src: "aetheris/api/hcs" and the formatted $0.00 amount) with no corresponding on-chain event. Frame #27 voids it:

mirror node - message 27 (https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/27)
# 27  Correction   consensus 1789280736.723520104
{"evt":"Correction","voids":[25],"reason":"test frame; no on-chain event",
 "tx":null,"ts":"2026-09-13T06:25:35.933Z"}
mirror node - message 25 (retracted)
# 25  (voided by #27; still on the mirror node, dropped by readers)
{"evt":"ProfitClaimed",
 "operator":"0x69677c85945796066b449c00f90a0582896f1f9b",
 "amount":"$0.00",
 "nullifier":"33191758600377127106447200483207683596308527199321386374340904067837022123",
 "agency":"0x16fa9cc838ab5380f0ebe3c261a2f57e0fbabc81",
 "ts":"2026-09-13T06:10:21.632Z","src":"aetheris/api/hcs"}

Both frames stay on the topic forever. What changes is how readers treat them.

How readers apply corrections

readHcsMessages in lib/hedera.ts fetches the newest frames from the mirror node, decodes each base64 payload, reassembles any frame the SDK split into 1,024-byte chunks (the chunks share an initial_transaction_id; the whole frame keeps the sequence number of chunk 1, which is where an on-chain anchor points), and passes the list through applyHcsCorrections:

lib/hedera.ts
// lib/hedera.ts
export function applyHcsCorrections(messages: HcsMessage[]): HcsMessage[] {
  const voided = new Set<string>();
  const corrections = new Set<string>();
  for (const m of messages) {
    const c = parseHcsCorrection(m);
    if (!c) continue;
    corrections.add(m.sequenceNumber);
    for (const seq of c.voids) voided.add(seq);
  }
  if (voided.size === 0) return messages;
  return messages.filter((m) => corrections.has(m.sequenceNumber) || !voided.has(m.sequenceNumber));
}
  • A frame counts as a correction only if parseHcsCorrection accepts it: evt must be exactly "Correction" and voids must be an array; entries that are not plain non-negative integers (as numbers or numeric strings) are ignored. Anything else is an ordinary frame.
  • Voided frames are dropped from the result. The correction itself is kept and rendered in the feed like any other frame, so what was retracted, and why, stays visible.
  • A correction can never void itself or another correction: the filter keeps every sequence number in the corrections set regardless of what any voids list says. Retractions are themselves on the record.
  • Because dropping frames would otherwise shorten the page, the reader over-fetches by CORRECTION_OVERFETCH = 10 rows (capped at the mirror node's 100) and slices back to the requested limit afterwards.

This is why GET /api/hcs?limit=3 returns #27, #26 and #24 - #25 is filtered out between them. A reader that goes straight to the mirror node sees #25 and must apply the same rule itself.

Every frame has a stable URL on HashScan that takes the topic id and the sequence number:

Per-frame link pattern (mirror node; HashScan lists the topic at /topic/<id>/messages)
https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/<seq>

# examples
https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/24   TaskCompleted, job 7 task 0
https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/26   ProfitClaimed, 0.5 aUSD
https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/27   Correction voiding #25

The same pair resolves on the mirror node at https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10518320/messages/<seq>, which returns the raw record with the payload base64-encoded in message. The app builds these links with hashscanUrl("topic", id) from lib/hedera.ts.

Reading and writing through the app

GET /api/hcs?limit=n (1 to 50, default 12) returns { topicId, source, messages[] } with corrections already applied. When the topic is unset, empty or the mirror node is unreachable the route degrades to source: "demo" with a notice explaining why - it never presents demo frames as live ones, and the dashboard shows the corresponding pill.

POST /api/hcs exists so the browser can anchor a frame without holding the operator key, and it is deliberately narrow: it writes only to the configured topic, accepts only a ProfitClaimed frame with the fixed field set {evt, operator, amount, nullifier}, re-composes the frame server-side (extra fields are rejected, the nullifier is read from the contract rather than the request), requires the named operator to be World-ID-verified in AetherisAgency, and is rate-limited to 5 writes per minute per IP. The full request and response shapes are on the Contracts & HTTP API page.