feat: a verifiable payment audit trail on HCS - #28
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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
Deliberately not fixed here by editing those lines to Suggested order: merge #24 → #25 → #26 → #27 → #28, then one small follow-up commit on 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 |
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
subscribeForcall it pays for are two unrelated transactions on theledger: a
CRYPTOTRANSFERto the seller, and, seconds later, aCONTRACTCALL. Nothing on Hederaties 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.104401940.0.10402910Or 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.
after(), so they run once the response is on the wiresubmitAuditEventnever rejects — unset topic, unfunded operator, HCS unreachable allresolve
{ ok: false }HCS_AUDIT_TOPIC_IDis set; with it unset the paid path is byte-identicalvariable, 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
subscription.openedat sequence 3 and its ownpayment.settledat 4. They are submittedindependently 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.
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.10415845is untouched.HCS_AUDIT_TOPIC_IDis alreadyset on the Vercel production project, so this starts writing on merge with no further config.
yarn cigreen: 64 hardhat, 258 vitest (239 + 19 new), both lints, both type checks.