Verify a decision

Verifying a Heron decision without asking Heron

This page is written for someone who does not trust us and does not want to call us. Everything below runs against static bytes on a CDN and a public archive RPC. Nothing here talks to our API, and tools/vdl-replay has no dependency that could.

If any step disagrees, the decision did not happen the way we said it did.


What you are checking, and what you are not

The argument has three parts, and a fourth thing it deliberately does not claim.

  1. Inclusion: this record is in the tree with this root.
  2. A signed checkpoint: someone committed to that root at that size.
  3. A consistency proof N→M: the tree served to you now is an append-only extension of the tree served before.

⛔ None of the three detects omission. A record that was never written breaks no proof in this system: there is no gap to see. Witness cosignatures make equivocation. serving two different trees to two different readers. provable, because a witness that cosigned checkpoint N refuses to cosign any later checkpoint inconsistent with it. They say nothing about a record that was silently withheld. The only defence against that is the on-chain policy module, which bounds what an unrecorded decision could have done.

Also not established by anything below:

  • which model ran: that would be EigenAI, and it is UNIMPLEMENTED;
  • that the process was untampered at runtime: that would be a TEE, and the attestation path exists but the captured document's leaf certificate has expired;
  • that the decision was correct. Nothing here is a statement about quality.

The replayer prints all three of those as unverified on every run, including the successful ones.


0. What you need

HERON_MIRRORthe mirror base URL, e.g. https://mirror.heron.example
HERON_ARCHIVE_RPCan archive endpoint that serves historical eth_call

⚠️ Do not use mainnet.base.org. It is rate-limited, HTTP-only and undocumented for archive access. A reproduction recipe that points at it looks runnable and is decorative. Use an archive provider you chose and can hold to an SLA.


1. Replay a decision from the mirror alone

# needs $HERON_MIRROR
node tools/vdl-replay/src/cli.ts "$PLAN_HASH" --mirror "$HERON_MIRROR"

Exit 0 means every step reproduced. The command prints each step, and then prints what it could not check. A green verdict with no such list would be an overclaim.

To prove to yourself that it cannot reach us, run it with egress blocked to everything except the mirror host. Our own test does exactly this at the socket layer, not with a proxy setting:

# needs $HERON_MIRROR
pnpm --filter @heron/vdl-replay exec vitest run test/no-api-dependency.test.ts

2. Reproduce the canonical bytes yourself, in two languages

The record hash is SHA-256(canonical(record)). The canonical form is specified in packages/schema/spec/canonical.md and is language-neutral: UTF-16 code-unit key order, raw UTF-8, no floats, integers as strings, absent ≠ null, and NFC applied before serialization by the caller.

# runnable
node packages/schema/scripts/digest-vectors.mjs
# runnable
uv run --directory ai python -m heron_ai.canonical.digest_vectors

Both must reproduce packages/schema/spec/vectors/canonical-digests.json byte for byte. They are diffed against that committed file rather than against each other, on two operating systems, two Node minors and two CPython patches, because two implementations that drift together produce a green cross-check and a silently different ledger.


3. Re-derive plan_hash

plan_hash is an EIP-712 digest over the Plan struct, with verifyingContract set to the policy module. The struct carries vdlChainHead, which is what binds the enforceable commitment to a specific position in the reasoning chain. Without that field a plan_hash proves an action was authorised and says nothing about which reasoning produced it.

# needs foundry
cd contract && forge eip712 src/policy/PlanTypes.sol --json

The type string and type hash printed there must match packages/schema/spec/vectors/eip712/plan.json. No digest literal is hand-written anywhere in this repository; the value is derived three independent ways and asserted equal.


4. Check the anchors

The daily Merkle root is timestamped on Base with EAS.timestamp(bytes32) at the predeploy 0x4200000000000000000000000000000000000021. It is first-writer-wins. a second call reverts AlreadyTimestamped, and the publisher cannot revoke it.

Stored alongside the root are L1Block.number() and L1Block.hash() from 0x4200000000000000000000000000000000000015.

⚠️ This pair is the answer to "the sequencer backdated it". That objection is not silly: the sequencer is a single party. The L1 blockhash is unpredictable in advance, so a root committed alongside a specific L1 blockhash cannot have been authored before that L1 block existed.

# needs $HERON_ARCHIVE_RPC
cast call 0x4200000000000000000000000000000000000015 "hash()(bytes32)" \
  --rpc-url $HERON_ARCHIVE_RPC

The same root is also submitted to OpenTimestamps, which is free, Bitcoin-anchored, and has no operator who could collude with us.


5. Fetch the mirror without trusting a path

Every object is served at /<sha256>. The alias is the name, so a reader handed a digest can fetch the object without trusting a path, a version, or an index we control.

# needs $HERON_MIRROR
curl -sS "$HERON_MIRROR/$DIGEST" | shasum -a 256

The output must equal $DIGEST.

For IPFS the manifest publishes a CAR URL and the exact ipfs add flags: chunker, raw-leaves, CID version and codec. ⚠️ A bare CID is not reproducible without them: "here is a CID" reads as verifiable and is not.

⚠️ For Arweave, the transaction ID is not a content hash: it is the hash of the transaction signature. The content commitment is the separate data_root, and our own sha256(record) is carried in an ANS-104 tag, indexable via GraphQL.


6. What we cannot reproduce on-chain, stated rather than left for you to find

Some inputs are not reproducible on-chain, and pretending otherwise would make this page worse than useless:

  • USD prices. Any figure denominated in dollars comes from an off-chain price source at a moment in time. The balances are reproducible; the dollar figure is not. This is the same limitation DefiLlama has, where the USD leg comes from CoinGecko even when the on-chain balances are exact.
  • Oracle staleness at decision time. The record carries the round and the observed age, but how stale a feed was at the instant we read it depends on when we read it, and that is our assertion bracketed by two anchors rather than a chain fact.

Everything else in a record. block heights, slots, transaction outcomes, revert codes. is on-chain and reproducible with the archive RPC above.


7. If something disagrees

Tell us, and tell somebody else at the same time. The tiles and the checkpoint history are mirrored to a public git repository and submitted to Software Heritage, whose submissions cannot be retracted, so a disagreement between what you hold and what we serve is a comparison anybody can run.


8. The risk page: every number on it is from one block

Added in P14. Sections 0–7 above are P6's and cover a decision. This one covers the public /risk page, where the claim is narrower and stronger: every figure on the page is read in a single Multicall3.tryBlockAndAggregate call, so it comes from one block, and each provenance chip prints that block's number and hash.

tryBlockAndAggregate at 0xcA11bde05977b3631167028862bE2a173976CA11 returns the block number and hash with the results. That is why it is used rather than aggregate3, which returns results alone: a page built on aggregate3 can name a height it cannot pin.

⛔ Pin by hash, never by height. A height is a position; after a reorg the canonical chain still has a block at height N, a different one, and a command pinned to the height reads whichever your endpoint kept, silently.

⚠️ Do not point these at mainnet.base.org. Same reason as §0, and castCommand() in fe/src/features/risk/ProvenanceChip.tsx keeps it on a deny-list and throws rather than emitting a command that names it. A recipe that cannot run is worse than no recipe: it spends a skeptic's goodwill and returns nothing for it.

# needs $HERON_ARCHIVE_RPC
cast block "$BLOCK_HASH" --rpc-url "$HERON_ARCHIVE_RPC" --field number
# needs $HERON_ARCHIVE_RPC
cast call "$CONTRACT" "totalSupply()(uint256)" \
  --rpc-url "$HERON_ARCHIVE_RPC" \
  --block "$BLOCK_HASH"

Resolving a refusal to the rule that caused it

# needs $HERON_ARCHIVE_RPC
cast run "$TX_HASH" --rpc-url "$HERON_ARCHIVE_RPC" --trace-printer

Walk to the innermost frame with both non-empty output and an error, take the first four bytes of that output, and look them up in packages/schema/generated/errors.ts.

⚠️ This yields evidence, not proof, and the reason is in the EVM rather than in our implementation. Custom errors are not in the calling contract's ABI and bubble up unchanged, so any contract can return data matching any error signature: a hostile venue can revert with four bytes that decode to a Heron rule. Check the innermost frame's to against our published addresses; a refusal resolving elsewhere is marked forgeable on the page.

⚠️ A revert raised with require(bool) carries no data at all, so it has no selector. Anything shorter than 0x plus four bytes is reported as NO_SELECTOR and never guessed at.

Recomputing the scorecard without us

# needs $HERON_MIRROR
node scripts/recompute-scorecard.mjs --mirror "$HERON_MIRROR"

It reads mirror tiles and a public archive RPC and nothing of ours. Pointing --mirror at our own origin is refused outright. recomputation that reads our API is not recomputation, it is asking us for the answer.

The exact ipfs add flags

§5 says a bare CID is not reproducible without its flags. Here they are, so that sentence is actionable rather than merely correct:

# needs ipfs
ipfs add --cid-version 1 --raw-leaves --chunker size-262144 --hash sha2-256 "$FILE"

Or take the CAR, which carries its own structure and needs no flags at all.

⚠️ Our own non-reproducible leg on this page, stated by us

§6 covers this for a decision. Saying it again for the risk page, in the page's own terms, because it is the number a reader is most likely to try to check:

TVL in USD is not reproducible on-chain, even where every balance in it is. The balances come from the chain and the commands above check them. The dollar figure does not: it is DefiLlama's, and DefiLlama's USD leg comes from CoinGecko: an off-chain aggregator with its own methodology, its own venue set and its own revisions, publishing no block-pinned historical series cast can read. TVL here is reproducible up to the price and no further.

The same holds for oracle staleness (you can read the updatedAt we read, at the block we read it, but not reproduce the feed's decision about when to publish) and for the off-chain cost terms in days-to-recover: inference cost, and the L1 data component of gas at the moment of execution, are measured by us and recorded.