ADR 0006 — On-Chain Attestation Publisher (Sprint C.3)
Status: accepted · 2026-04-17 Author: Raj Kanikiya Related: ADR 0002 (soulbound Hatcher NFA), ADR 0003 (scoring architecture)
Context
Sprint C.3 publishes every complete Hatch Score to the HatchAttest contract
on BSC (ERC-8004-style attestation). We need a publisher that is:
- idempotent (replays can't double-publish the same result),
- deterministic (same input → same attestation id),
- safe under partial signal availability (refuses to attest when any of the six signals are still stubbed — see C.2.2 / C.2.3 blockers),
- replay-verifiable by any third party (the on-chain payload must contain enough to reconstruct + check the score).
Decision
- viem as the Ethereum client. Already installed for contract tests; lightweight; tree-shakeable; first-class TypeScript.
- Canonical JSON digest. A
ScoreResultis canonicalized (signals sorted by name; transient fields dropped) and hashed with keccak256. The resultingbytes32serves both as (a) the idempotency key in theattestationsDB table, and (b) a field inside the on-chain payload so verifiers can recompute. - ABI payload shape for schema
hatch.score.v1:(uint16 aggregate, uint8 bandCode, bytes32 digest, string promptVersion, uint16[6] signalScores, bool hasStubs). Signal scores are ordered bySIGNAL_TYPESto keep slots stable across releases. Bumping the shape requires a new schema id (hatch.score.v2). - Refuse stubs.
publisher.publish()throwshas_stubswhenresult.hasStubs === true. This keeps the attestation surface honest: a partial score never looks authoritative on-chain. The UI (PublishPanel) explicitly renders a degraded state in this case. - Idempotency flow.
- Caller computes digest from result.
findExisting(digest)hook hits theattestationstable; if a row exists, short-circuit withidempotentHit: true.- Otherwise
writeContract(attest, subject, schemaId, data)+ wait for receipt + extract theAttested(attestationId indexed)topic. onPublished(row)persistsattestationId,txHash,digest,chainId,subject,schemaIdtransactionally.
- Fail-closed wiring. The publisher is built from env only when
BSC_RPC_URL,HATCH_ATTEST_ADDRESS, andATTESTER_PRIVATE_KEYare all set. Missing any → the/v1/score/:id/publishroute returns 503 withnot_configured. The UI surfaces this as a human-readable message.
Alternatives considered
- Queue-based async publisher. Rejected for C.3 — the scoring path is already async-friendly (the user never waits on a chain write), and adding BullMQ/Queues state for a single write per score is premature. When we hit real launch throughput, batch publishing on a cron is the upgrade path (see spec §6.4 "cost-optimized batch publishing"); the digest-keyed idempotency works identically under batching.
- EAS (Ethereum Attestation Service). Rejected: EAS's BSC deployment is not an established dependency, adds a third-party trust surface, and our own contract is already deployed + tested. We can bridge to EAS later via a schema-translation layer.
- Storing the full
ScoreResultJSON on-chain. Rejected: blobs are expensive on BSC and pin-worthy metadata belongs on IPFS/S3. The on-chain payload carries the minimum needed to verify the digest; everything else lives in thescore_requestsDB row (addressable via the digest).
Risks
- Replay of stale scores. A malicious user could POST an old result id and re-trigger publish. Mitigation: idempotency table short-circuits; the second attempt returns the same attestation without a second tx.
- Attester private key leakage. Mitigation: stored as Railway secret;
rotated via
HatchAttest.ATTESTER_ROLEgrant/revoke; never in repo. - Chain reorg after receipt. viem's
waitForTransactionReceiptwaits for one confirmation; BSC finality is ~3 blocks. For mainnet launch we should bump toconfirmations: 3— tracked in C.7 hardening.
Follow-ups
- C.2.2 / C.2.3 remove stubs → real attestations start flowing.
- C.4 (this sprint) renders the published attestation inline in the
/score/:idpage; the UI refuses the publish button whenhasStubs. - C.7 wires BSC mainnet RPC + mainnet
HatchAttest; rotates attester. - H.1 public API exposes
GET /v1/score/:idincluding the attestation record so third-party clients can verify on-chain.