Skip to content

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 < b string 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 below 0x20 use the short escape where one exists (\n \t \r \b \f) and \u00XX otherwise. 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's json.dumps with its ensure_ascii=True default) will produce different bytes and a receipt that looks tampered when it isn't — turn that option off.
  • null, true, false serialise as the bare literals.
  • Object keys with an undefined value are dropped, not emitted as null. This only applies to explicit undefined, 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 -0 canonicalises to 0 (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.1 can arrive at a second party as 0.10000000000000001 after 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:

input {"ts":1755000252,"actor":"bob@acme.example","action":"record.delete","recordId":4471} canonical {"action":"record.delete","actor":"bob@acme.example","recordId":4471,"ts":1755000252}

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

leaf = keccak256( 0x00 ‖ salt ‖ utf8(canonical) ) — v2, salted leaf = keccak256( 0x00 ‖ utf8(canonical) ) — v1, legacy unsalted

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

parent = keccak256( 0x01 ‖ left ‖ right )

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:

  1. Pair up adjacent nodes: (0,1), (2,3), (4,5), …
  2. Hash each pair into a parent: parent = keccak256(0x01‖left‖right)
  3. 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.
  4. 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:

  1. 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 to receipt.leaf. Call this eventMatches.
  2. Fold the proof: starting from node = receipt.leaf, for each proof step in order, compute node = siblingIsLeft ? keccak256(0x01‖sibling‖node) : keccak256(0x01‖node‖sibling). After the last step, compare node to receipt.root (case-insensitively — hex casing carries no meaning). Call this proofValid.
  3. 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".
  4. 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.root against the root actually emitted by the Anchored event on-chain, at receipt.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 eventsalt
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

leaf[0] 0x65b00717aab17124e7c2128e6594544bfd0293b08bec9ce18ca2de37ce0adca9 leaf[1] 0xc5596da4238af848c7efb646a548e0a357d386373c922711fd1ec5b7a9ada986 leaf[2] 0xdb7d2b4eec3bdaf8618516d637a9e5d24da57cac722c30866393ea9dcd7cfc36 leaf[3] 0x21bc286a4525141fa162669ae3c623075ac4d177d1c12ce9355fdd126a1adea3

Tree

0x65b007…adca90xb6326c…19a8c 0x6455fe…ba95410x693234…047259
0x75aff6…6eea314e40xbd9775…521db2746
root 0xdf15a6…f7841221d
node[0,1] = keccak256(0x01 ‖ leaf[0] ‖ leaf[1]) = 0xf0f4119c8811f02a502a74a2ffb4c9a8238b7ffd641723f8c360c4f1dca490e9 node[2,3] = keccak256(0x01 ‖ leaf[2] ‖ leaf[3]) = 0x0b03ce9d8f6f30d1d12b2f167a96bf70e623a0b018afdd3fcde8861c1e228610 root = keccak256(0x01 ‖ node[0,1] ‖ node[2,3]) = 0x055423ee3020452fe9c954262abbdc019506573f64f0101557a7afc1129259f4

Inclusion proof for leaf[1] (bob / record.delete)

proof = [ { sibling: leaf[0], siblingIsLeft: true } // leaf[0] is to the left of leaf[1] { sibling: node[2,3], siblingIsLeft: false } // node[2,3] is to the right of node[0,1] ] verify: step 1: keccak256(0x01 ‖ leaf[0] ‖ leaf[1]) = node[0,1] // siblingIsLeft → sibling first step 2: keccak256(0x01 ‖ node[0,1] ‖ node[2,3]) = root // !siblingIsLeft → node first result: 0x055423ee3020452fe9c954262abbdc019506573f64f0101557a7afc1129259f4 == root ✓

Tampering it

Change one field — recordId from 4471 to 9999 — re-canonicalise, re-hash with the same salt:

tampered canonical {"action":"record.delete","actor":"bob@acme.example","recordId":9999,"ts":1755000252} tampered leaf 0x3228ee1c87f2b586b0530209de7a165ef5489aef2259c20318fefc20039c3889 folding the SAME proof against the tampered leaf: step 1: keccak256(0x01 ‖ leaf[0] ‖ tampered leaf) = 0xbc74edfa9620cb4170b1c07f8b70b59b74e0aeff14ba8d0ea5701877848ec2ce (≠ node[0,1]) step 2: keccak256(0x01 ‖ 0xbc74ed… ‖ node[2,3]) = 0x721077214b62de07676aa307b846f30fa996b8251b3109512dd3e7833ca61516 (≠ root) result: does not match root — verification fails

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:

v1 leaf = keccak256(0x00 ‖ utf8(canonical)) = 0xf8a882601cdae4184f1df84705b6ed07bdb4ff64ea63bf2935e0009fc3b138bf