Skip to content

feat: a verifiable payment audit trail on HCS - #28

Merged
edycutjong merged 7 commits into
mainfrom
feat/hcs-audit-trail
Sep 9, 2026
Merged

edycutjong merged 7 commits into
mainfrom
feat/hcs-audit-trail

Conversation

@edycutjong

Copy link
Copy Markdown
Owner

Hedera's AI & Agentic Payments bounty lists verifiable payment audit trails on HCS as an
extra-points bullet. This builds it, additively, without touching the deployed contract.

The problem it solves

An x402 settlement and the subscribeFor call it pays for are two unrelated transactions on the
ledger: a CRYPTOTRANSFER to the seller, and, seconds later, a CONTRACTCALL. Nothing on Hedera
ties them together. A reader sees both and has to take the seller's word that the second happened
because of the first.

So the resource server publishes the join to a public Hedera Consensus Service topic.

The topic — real, on testnet

0.0.10440194

Admin key absent it can never be updated or deleted, by anyone, us included
Submit key seller 0.0.10402910 only the account that takes the payments can append
Messages 6 at time of writing from three real paid requests through the gate
curl -s "https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10440194/messages?order=asc" \
  | jq -r '.messages[] | "\(.sequence_number) \(.consensus_timestamp) \(.message | @base64d)"'

Or have the claims checked against the chain they describe — no keys, no local state, and it
never talks to Retainer's own server:

yarn verify:audit-trail
# 6 message(s), 3 settled payment(s), 0 failed check(s)

It cannot break a payment

That was the requirement stated before any code, because the trail is bolted onto the money path.

  • both writes go through after(), so they run once the response is on the wire
  • submitAuditEvent never rejects — unset topic, unfunded operator, HCS unreachable all
    resolve { ok: false }
  • it is off unless HCS_AUDIT_TOPIC_ID is set; with it unset the paid path is byte-identical
  • it borrows the seller account that already signs on-chain forwards, so enabling it is one
    variable, not a second key to manage

19 unit tests cover exactly that, and the interesting cases are all failures.

Two honest notes, both in the docs

  • The two records of one request can reach consensus in either order — the second run put
    subscription.opened at sequence 3 and its own payment.settled at 4. They are submitted
    independently on purpose, so a stuck payment write cannot suppress the subscription write. The
    join is the settlement id, never the sequence number, and the verifier decodes the whole topic
    before checking any link. A forward pass would have called a good trail broken.
  • The record is the seller's claim. HCS makes it ordered, timestamped and unretractable; it does
    not make it true. What makes it checkable is that every claim names a transaction anyone can
    look up — which is why the verifier ships with it, and why the topic has no admin key.

Scope

Additive only. No contract change: 0.0.10415845 is untouched. HCS_AUDIT_TOPIC_ID is already
set on the Vercel production project, so this starts writing on merge with no further config.

yarn ci green: 64 hardhat, 258 vitest (239 + 19 new), both lints, both type checks.

An x402 settlement and the subscribeFor call it pays for are two unrelated
transactions on the ledger. Nothing ties them together, so a reader has to take
the seller's word that the second happened because of the first.

This adds the writer for a public HCS topic that records the join. Three
choices are deliberate:

- the writer never rejects. Every failure — unset topic, unfunded operator,
  Hedera down — resolves as { ok: false }. A service whose payments break when
  its own bookkeeping breaks is worse than one with no bookkeeping.
- it is off unless HCS_AUDIT_TOPIC_ID is set, and borrows the seller account
  that already signs on-chain forwards, so enabling it is one variable.
- one record per consensus message, never chunked, so one message id is one
  event. The only unbounded field (an error string) is truncated to fit.

The topic itself is created by scripts/hcs-create-topic.ts with a submit key
and no admin key: only the seller can append, and nobody — including the
seller — can update or delete it.
Two records per paid request: payment.settled carries the facilitator's
settlement id, and subscription.opened names the subscribeFor transaction that
settlement turned into. They share the settlement id, so one identifier walks a
reader from the x402 payment to the on-chain access it bought.

A failed forward is recorded too. An audit trail that only logs successes is a
record of the seller's best days, not its books.

Both writes go through after(), so they happen once the response is already on
the wire, and submitAuditEvent never rejects. The paid path is byte-identical
whether HCS is healthy, misconfigured or down.
19 cases, and the interesting ones are all failures: no topic, nothing to sign
with, Hedera throwing, a receipt that never arrives, a record too long for one
consensus message. Each must resolve { ok: false } — a rejected promise inside
after() is an unhandled rejection in a serverless runtime.

Also covers the round trip a reader actually performs (base64 out of the mirror
node, JSON.parse back), and that truncating an oversized failure reason leaves
every identifying field intact.

Nothing here mocks a chain to claim coverage of one. The trail's existence is
proved by scripts/verify-audit-trail.ts against the public mirror node.
yarn verify:audit-trail takes no keys, reads no local state and never talks to
Retainer's own server. It asks the public mirror node what the topic says, then
follows every identifier the topic names to the transaction it claims: the
settlement id must resolve to a SUCCESS transfer, and subscribeFor must resolve
to a SUCCESS contract call that went to the contract the record names. Any
mismatch exits non-zero, so a stale or invented trail cannot pass quietly.

It prints the topic's own provenance first — admin key absent, submit key
restricted — because a trail on a mutable topic is a log, not evidence.

The join is by settlement id, not sequence number. The two records of one paid
request are submitted independently, and the second run put subscription.opened
ahead of its own payment.settled: a forward pass would have called a perfectly
good trail broken.
README gets the topic, both record shapes verbatim off the mirror node, the
two ways to read them back, and the reason the trail cannot break a payment.
JUDGE.md gets a step 3b and a links row. docs/proof.md gets artifact 7: the
four real messages from two paid requests, each identifier resolved.

Two things are stated rather than hidden. The record is the seller's claim —
HCS makes it ordered, timestamped and unretractable, not true; what makes it
checkable is that every claim names a transaction anyone can look up. And the
two records of one request can reach consensus in either order, because they
are submitted independently on purpose.
after() throws when there is no request scope, which is exactly what a unit
test calling the route handler directly has — and seven existing tests started
failing with an unhandled rejection the moment the trail was wired in. The
audit trail breaking the payment path, in the one place we can most cheaply
check that it does not.

publishAudit falls back to a detached write. submitAuditEvent never rejects, so
neither branch can produce an unhandled rejection.
The route changed after the first two runs (publishAudit), so the shipped code
was exercised again end to end: 402, settle through Blocky402, subscribeFor,
two more records. Six messages, three settled payments, verifier still exits 0.
Stated as "at the time of writing" — the topic is append-only and live.
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
build Ready Ready Preview Sep 9, 2026 2:35pm UTC

@edycutjong

Copy link
Copy Markdown
Owner Author

Merge-order note — this PR makes #24 stale by 19.

The 19 new vitest cases here move the suite 239 → 258. This branch was cut from main before #24, so it still carries the pre-#24 text (10 passing). That means:

Deliberately not fixed here by editing those lines to 258: doing so would collide with #24 on the same lines and turn a clean merge into a conflict.

Suggested order: merge #24#25#26#27#28, then one small follow-up commit on main setting the vitest figure to 258 in README.md (prose, CI table, tree listing), JUDGE.md (table + runbook block), app/page.tsx (the 64 + 239 stat), app/judge/page.tsx (table + code block), AI-USAGE.md and docs/proof.md.

Re-verify before writing the number — don't inherit it:

cd packages/nextjs && yarn vitest run   # Tests <N> passed
cd ../hardhat && yarn test              # <N> passing

@edycutjong
edycutjong merged commit 5bc0828 into main Sep 9, 2026
9 checks passed
@edycutjong
edycutjong deleted the feat/hcs-audit-trail branch September 9, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant