The verification algorithm, specified precisely
02 · Verification algorithm
This is the most important page on the site. The product's entire claim is "verify it
yourself, don't trust us" — which is only true if this algorithm is complete, public, and
precise enough that you never have to guess what we meant. Every hash on this page is real
output from engine/src/anchor/{merkle,receipt}.ts, cross-checked independently
with Foundry's cast keccak (a separate implementation from the one that
produced them).
Reference implementations
Two independent implementations exist precisely so neither is "the" implementation: the
engine's own code (engine/src/anchor/merkle.ts,
engine/src/anchor/receipt.ts) that issues receipts, and a standalone,
from-scratch client-side verifier (packages/verify, ~120
lines, dependency-free besides a Keccak-256 primitive) built specifically to be
reimplementable in an afternoon — try it yourself at
the public verifier. A conformance test suite pins the two against
identical vectors so they cannot silently diverge. This page describes the algorithm both
of them implement.
Pipeline overview
event (JSON) │ ▼ canonicalise — sort keys, strip whitespace, integers only canonical (string) │ ▼ leaf = keccak256(0x00 ‖ salt ‖ utf8(canonical)) leaf (32 bytes) │ siblings from the batch's other leaves ▼ fold through the proof: parent = keccak256(0x01 ‖ left ‖ right) root (32 bytes) │ ▼ compare to the root read from the Anchored event on-chain match ⇒ this exact event, unaltered, was anchored no later than that block
Step 1 — Canonical JSON
JSON.stringify is not sufficient on its own: key order follows insertion
order, and the same logical value can serialise to different bytes across languages,
libraries, or a database round-trip. If two honest parties canonicalise the same event
differently, the proof appears to fail — which for a product whose pitch is "your records
are provably intact" is indistinguishable from tampering. So events are canonicalised
before hashing, in the shape of
RFC 8785 (JSON Canonicalisation
Scheme). The exact rules:
- Object keys are sorted by UTF-16 code unit value (plain
a < bstring comparison — no locale, no case-folding), recursively at every nesting level. - No insignificant whitespace: objects serialise as
{"k":v,"k2":v2}, arrays as[v,v2], with no spaces anywhere. - Array order is preserved — it's data, not a canonicalisation concern. Arrays are recursively canonicalised element-by-element.
- Strings use standard JSON string escaping:
"and\are backslash-escaped, control characters below0x20use the short escape where one exists (\n \t \r \b \f) and\u00XXotherwise. Non-ASCII characters are emitted as raw UTF-8, not\uXXXX-escaped —"café"canonicalises to"café", not"café". A reimplementation using a serialiser that escapes non-ASCII by default (e.g. Python'sjson.dumpswith itsensure_ascii=Truedefault) will produce different bytes and a receipt that looks tampered when it isn't — turn that option off. null,true,falseserialise as the bare literals.- Object keys with an
undefinedvalue are dropped, not emitted asnull. This only applies to explicitundefined, which doesn't exist in JSON itself — relevant if your source language distinguishes "key absent" from "key present with an empty value" before serialising to JSON. - Numbers must be integers. Serialised as plain decimal
(
value.toString()) — so-0canonicalises to0(negative zero is not distinct), and there is no exponential notation, no leading zeros, no trailing decimal point.
Floats are rejected, not rounded
A non-integer number (0.1, 1e-1, NaN,
Infinity) throws a CanonicalisationError at write time rather than
being silently coerced into some canonical form. Two reasons, both load-bearing:
- IEEE-754 floats have multiple valid textual representations of the same
value (
0.1,1.0e-1,.1, ...) and no universally agreed canonical one across languages — pick a rule and some other honest implementation will pick a different one. - Floats lose precision across languages and serialisation round-trips. A
value that started as
0.1can arrive at a second party as0.10000000000000001after passing through a different language's float parser, which would make an honest party look like a tamperer.
Refusing the input at ingest — before it's ever hashed — is safer than accepting it and
producing a receipt that later turns out to be unverifiable. If your value is fractional,
send it as a string ("19.99") or a scaled integer (1999 cents).
Integers beyond JavaScript's safe integer range (253−1) should also be sent as a
string or handled via a JSON parser that preserves arbitrary precision — a plain
number that large has typically already lost precision by the time it's parsed,
before canonicalisation even runs.
Worked canonicalisation example
An event submitted with unsorted keys and reordered on receipt:
Both key orderings above produce the identical canonical string — that's the entire point.
Step 2 — The salt
Before hashing, every event is assigned 32 bytes of CSPRNG output (crypto.getRandomValues),
generated once at ingest and stored alongside the event. Salting matters more here than in
a typical hashing context because audit events are low-entropy structured
JSON: field names are known, the schema is public (it's on this page), timestamps
fall in a narrow range, and the part that actually varies is often one guessable value —
an email address, a record ID. An unsalted hash of that is dictionary-attackable: someone
could enumerate plausible events and find which one matches your published leaf, even
after you'd deleted the original payload. See erasure
& retention for why that specifically defeats erasure. The Electronic Data
Protection Board's Guidelines 02/2025 on blockchain
(adopted 2025-04-08) make the same point generally: unsalted or unkeyed hashes should not be
considered sufficient confidentiality protection.
Receipts issued before salting existed (version 1, two anchors on Base
mainnet during initial rollout) omit the salt; all current receipts are
version 2 and salted. A verifier must branch on receipt.version
— hashing a v2 event without its salt silently produces a leaf that looks tampered.
Step 3 — The leaf hash
‖ is byte concatenation. 0x00 is a single fixed byte — the
leaf domain separator, present in every leaf hash regardless of salting.
Its purpose: without it, an attacker could take an internal tree node (which has the same
32-byte shape as a leaf) and present it as a leaf, "proving" inclusion of data
that was never submitted — a second-preimage attack. Prefixing every leaf with a byte no
internal node hash starts with closes that off. Hash function: Keccak-256 (the Ethereum
variant — not NIST SHA3-256; they use different padding and produce
different output for the same input).
Step 4 — The parent (internal node) hash
0x01 is the internal-node domain separator — distinct from
the leaf's 0x00, for the same second-preimage reason. left and
right are 32-byte child hashes concatenated in order:
hashing is not commutative here. keccak256(0x01‖A‖B) ≠
keccak256(0x01‖B‖A) for distinct A and B. This is deliberate — some Merkle trees
(OpenZeppelin's default, for one) sort each pair before hashing to simplify proof
construction slightly, but doing that discards the events' relative order, and for an
audit log the order events happened in is itself part of the evidence.
Step 5 — Building the tree
Leaves are ordered by arrival (oldest received first, ties broken by event id) — that order becomes the leaf order the root commits to. Then, level by level:
- Pair up adjacent nodes:
(0,1), (2,3), (4,5), … - Hash each pair into a parent:
parent = keccak256(0x01‖left‖right) - If a level has an odd number of nodes, the last, unpaired node is promoted unchanged to the next level — it is not duplicated and re-hashed with itself.
- Repeat until one node remains: the root.
Why promotion, not duplication
Duplicating the odd node out (padding [a,b,c] to [a,b,c,c]
before hashing) is a common shortcut, but it means a 3-leaf tree and a 4-leaf tree with a
repeated last element can land on the same root — which would let someone
construct a valid-looking proof for a leaf that was never actually in the original batch.
Promoting the unpaired node unchanged keeps every distinct leaf set on a distinct root.
An empty batch (no events) yields a fixed sentinel root, keccak256(0x) —
distinct from any real root — and is never anchored; anchoring it would spend gas to commit
to nothing, and the AuditAnchor contract explicitly rejects a zero root for the
same reason (see reading the anchor on-chain). A
single-event batch is its own root, with no internal nodes and an empty proof.
Step 6 — The inclusion proof
A proof for the leaf at position i is built by walking up the tree from that leaf. At each level: if the current node has a sibling (it was paired, not promoted), record one proof step — the sibling's hash, and whether that sibling sits to the left or right. If the node was promoted unchanged (no sibling at that level), no step is recorded, and its position at the next level up is simply its position halved.
Recording each sibling's side is what makes proofs position-aware: because
parent hashing is order-sensitive (step 4), verification must reproduce the exact left/right
arrangement that originally produced the root, not just "some" arrangement. A proof step is
the pair (sibling, siblingIsLeft); a full proof is an ordered list of these,
bottom to top. Proof length is ⌈log₂(n)⌉ for an n-leaf batch —
around 20 steps for a million-event batch.
Step 7 — Verification
Given an event, a receipt, and (separately) a root read from the chain:
- Recompute the leaf from the event: canonicalise it, then hash it exactly
as in steps 1–3, using the receipt's own
salt(or no salt, for a v1 receipt). Compare toreceipt.leaf. Call thiseventMatches. - Fold the proof: starting from
node = receipt.leaf, for each proof step in order, computenode = siblingIsLeft ? keccak256(0x01‖sibling‖node) : keccak256(0x01‖node‖sibling). After the last step, comparenodetoreceipt.root(case-insensitively — hex casing carries no meaning). Call thisproofValid. - The receipt is offline-valid only if both hold. They're kept as two
separate booleans rather than one, deliberately: a proof can be perfectly self-consistent
over the wrong event (wrong
eventMatches), and collapsing the two into one result would let that read as "verified". - Neither of the above proves anchoring on its own. A receipt can be
entirely self-consistent and never have been anchored anywhere. The final step —
non-optional — is comparing
receipt.rootagainst the root actually emitted by theAnchoredevent on-chain, atreceipt.chainId/receipt.contract, read from a public RPC you choose. See reading the anchor on-chain.
Worked example, with real hashes
Four events, batched together and put through the exact pipeline above. Every hash below
is genuine output of buildTree / getProof /
hashEvent in engine/src/anchor/{merkle,receipt}.ts, run against
fixed salts so the vectors are reproducible, and independently cross-checked with
cast keccak (Foundry's implementation — unrelated code path from the engine's
viem-based one).
The batch
| # | canonical event | salt |
|---|---|---|
| 0 | {"action":"user.login","actor":"alice@acme.example","ts":1755000000} | 0x0101…0101 |
| 1 | {"action":"record.delete","actor":"bob@acme.example","recordId":4471,"ts":1755000252} | 0x0202…0202 |
| 2 | {"action":"export.download","actor":"carol@acme.example","recordId":12045,"ts":1755000475} | 0x0303…0303 |
| 3 | {"action":"permission.grant","actor":"root@acme.example","ts":1755000723} | 0x0404…0404 |
Salts shown truncated for the table; full value is the byte repeated 32 times, e.g. salt 1 is 0x0101010101010101010101010101010101010101010101010101010101010101.
Leaves
Tree
Inclusion proof for leaf[1] (bob / record.delete)
Tampering it
Change one field — recordId from 4471 to 9999 — re-canonicalise, re-hash with the same salt:
The four unrelated bytes are enough — as expected of a collision-resistant hash, the tampered path diverges completely from the original at the very first hash, not partially.
v1 (legacy, unsalted) leaf for the same event
For context — this is what event[1] would hash to under the pre-salting v1 scheme, included because two receipts anchored on mainnet before salting was introduced use it: