# Vitahash daily Merkle anchor, merkle version 1
Every article published on Vitahash in one UTC day contributes one leaf to one
tree. The root of that tree is written to the Ethereum Attestation Service on
Base once, in a single transaction. Each article keeps the handful of sibling
hashes that walk its own leaf back up to that root.
That is the whole design, and it buys two things. Cost stops depending on
volume: one day is one attestation whether ten articles were published or ten
thousand. And an article's evidence stops depending on Vitahash: a reader with
the article, its proof, and a block explorer can reach the anchored root without
asking us anything.
This document is the specification. The implementation follows it, not the
other way round.
**Implementation:** `packages/shared/src/domain/merkle.ts`.
**Byte-identical copy:** `contracts/src/merkle.ts`, checked by
`apps/api/src/stamping/vendored-eas.spec.ts`.
**Golden vector tests:** `packages/shared/src/domain/merkle.test.ts` and
`contracts/test/merkle.ts`.
The per-article content hash this tree is built over is a separate frozen
artifact, specified in [CANONICALIZATION.md](CANONICALIZATION.md). Nothing here
changes it. The tree is built over those hashes and never recomputes one.
---
## 1. Version
```
MERKLE_VERSION = 1
```
Stored on `stamp_batches.merkle_version`, and also written into the on-chain
attestation as its own field. Both, deliberately: the column says which rules to
verify a batch under, and the field on chain means the rules a root was produced
under can be read without trusting the database at all.
Only version 1 exists. Nothing in the system writes any other value.
## 2. The hash function
SHA-256, everywhere. The content hash is already SHA-256
(CANONICALIZATION.md §6), so a reader needs exactly one algorithm to walk the
whole chain from article to root, and `shasum -a 256` is the only tool required.
Every hash in this document is 32 bytes, written as 64 lowercase hexadecimal
characters with a `0x` prefix.
## 3. Domain separation
Two prefix bytes, in the style of RFC 6962 §2.1:
```
leaf hash = SHA-256( 0x00 || leafPreimage )
internal node = SHA-256( 0x01 || left || right )
```
**Why this is not optional.** In a tree that hashes internal nodes as plain
`SHA-256(left || right)`, an internal node is nothing but the hash of a 64 byte
string. An attacker can therefore present that 64 byte string as if it were a
leaf, hand over the shorter proof from that node upward, and it verifies against
the same root. The tree would then attest to a "document" nobody published. This
is the classic second-preimage attack on Merkle trees, and the two prefix bytes
close it: a leaf pre-image always begins `0x00` and a node pre-image always
begins `0x01`, so no byte string can hash as both.
A future `MERKLE_VERSION` must change these prefix bytes as well as its rules,
so a tree built under version 2 can never collide with one built under version 1
either.
## 4. The leaf
```
leaf = SHA-256( 0x00 || SHA-256(utf8(stampId)) || contentHashBytes )
```
Exactly 65 bytes go into that hash: one prefix byte, the 32 byte digest of the
stamp ID, and the 32 raw bytes of the article's content hash.
`contentHashBytes` is `articles.content_hash` with its `0x` stripped and decoded
from hex. It is **not** re-hashed.
### Why the stamp ID is in the leaf
The alternative is a leaf that is just the content hash, which is simpler to
explain and lets a reader recognise their own hash in the tree. It was rejected.
An article's proof lives in its own database row, and the database is exactly
what the verification page exists to distrust. If the leaf were the content hash
alone, then an attacker with write access could take article A's content and
proof, write both onto article B's row, and `/verify/{B}` would report verified
while showing A's text. Binding the identifier into the leaf makes that fail:
B's leaf contains B's stamp ID, so A's proof no longer reaches the root. The
test is `a proof does not verify when copied onto a row carrying identical
content`.
The legibility cost is real and is paid on the page rather than in the spec. The
verification payload exposes the stamp ID digest and the leaf as their own
fields, so a reader can see the two-step derivation instead of being told it.
### Why the stamp ID is hashed first
The pre-image is then a fixed 65 bytes regardless of what the identifier looks
like. Concatenating a variable-length string directly would make the pre-image
ambiguous the moment the stamp ID format changed, and the format check lives in
a database constraint that a future migration could widen. One extra SHA-256
removes that class of problem permanently.
## 5. Leaf ordering
**Ascending by `stampId`, compared byte by byte.**
Stamp IDs are ASCII (`STAMP-{YYYY}-{MMDD}-{BASE32x8}`), so byte order, code unit
order and code point order are the same thing here.
The sort key has to be reproducible from stored rows years later, and the stamp
ID is the only column that is all three of unique, immutable and already in the
reader's hand:
- **Unique** by `articles_stamp_id_key`. No tie-break rule is needed, which
matters because a tie-break is where a reimplementation drifts.
- **Immutable.** It is minted at publish and articles are locked after publish.
- **Public.** A reader given the day's stamp IDs can sort them and rebuild the
tree without any Vitahash-internal identifier.
Two duplicates in one batch are an **error**, not a tie. Falling back to arrival
order would make the root depend on what the query planner happened to return
first.
**Rejected alternatives.** `published_at` ascending needs a tie-break, and the
value has microsecond precision in Postgres but only millisecond precision in a
JavaScript `Date`, so the sort could differ between two correct implementations.
The article's UUID primary key is unique and immutable but means nothing to a
reader and is not published anywhere.
## 6. Building the tree
Level 0 is the leaf hashes, in the order fixed by §5. Each level above it is
built from the level below, left to right:
1. Take nodes in pairs. Each pair `(left, right)` becomes
`SHA-256(0x01 || left || right)`.
2. If the level has an odd number of nodes, the last one is **promoted
unchanged** to the next level. It is not hashed again and it is not paired
with anything.
Repeat until a level holds exactly one node. That node is the root.
A tree of one leaf has its leaf as its root. That is well defined and safe:
a leaf hash begins its life under the `0x00` prefix and a root of two or more
leaves is always a `0x01` hash, so the two can never be confused.
### Why promotion and not duplication
Bitcoin duplicates the unpaired node, hashing it with itself. That carries
**CVE-2012-2459**: a leaf set and the same set with its last leaf repeated
produce the same root, so a root does not uniquely determine its leaves. In an
anchor whose purpose is to say precisely which articles were published on a day,
a root that fits two different day's-worth of articles is a hole in the claim.
Promotion has no such collision. `contracts/test/anchor.ts` and
`merkle.test.ts` both assert that every distinct leaf set in the vector corpus
gives a distinct root.
**Rejected alternative.** Padding the leaf level up to a power of two with a
sentinel leaf also avoids the CVE, and gives every proof the same length. It was
rejected because it needs a rule for what the sentinel is and for how a verifier
knows a leaf is padding, which is a second thing to get right for no benefit
that the anchored `leafCount` does not already provide.
## 7. The proof
An article's proof is the ordered list of siblings needed to climb from its leaf
to the root, **bottom level first**. Each step records:
- `sibling`: the 32 byte hash to combine with.
- `side`: `left` or `right`, describing where the **sibling** sits.
Combining at each step:
```
side = "left" -> next = SHA-256( 0x01 || sibling || current )
side = "right" -> next = SHA-256( 0x01 || current || sibling )
```
A level at which the node was promoted contributes **no step**, because nothing
was combined with it there.
The value after the final step is the root.
### Proof length
A proof has at most `ceil(log2(leafCount))` steps.
| Articles in the day | Longest proof |
| --- | --- |
| 1 | 0 |
| 2 | 1 |
| 300 | 9 |
| 1,000 | 10 |
| 10,000 | 14 |
| 25,000 | 15 |
`MAX_LEAVES_PER_ANCHOR` is 25,000, so a proof never exceeds 15 steps and the
verification payload is bounded at roughly 5 KB. Articles beyond that ceiling in
one day roll into the next day's tree, which is the same path a failed batch
already takes.
## 8. Golden vectors
Reproduce these before trusting any reimplementation.
### 8.1 One article
Using the content hash from CANONICALIZATION.md §7, so the two documents chain
together:
```
stampId STAMP-2026-0725-A1B2C3D4
contentHash 0xf24c2b854bc13a68b48d6f27a0a7cba232caa6d46d0042568262b2711deea8ea
```
```
SHA-256(utf8(stampId))
= 0x2d3dae1f9c82db9591248492fa8cbd736a7b114a4a6e396459a4cad6f22c3743
leaf
= 0x3dbaaa37380b176eecfca835880cd278f259f6e592465a2f5b042a02bf83e8e4
```
A day containing only this article anchors `0x3dbaaa37...` as its root.
### 8.2 The vector corpus
Article `i`, counting from zero, is:
```
stampId "STAMP-2026-0725-" + base32(i)
contentHash SHA-256(utf8("vitahash-merkle-vector-" + i))
```
where `base32(i)` writes `i` as **eight** characters over the RFC 4648 alphabet
`ABCDEFGHIJKLMNOPQRSTUVWXYZ234567`, most significant character first, so index 0
is `AAAAAAAA` and index 1 is `AAAAAAAB`.
Roots:
| Leaves | Root |
| --- | --- |
| 1 | `0x7ca886812f35d90936862fa06e3c40ca0b2282f546ab55a41c8f508de16441a3` |
| 2 | `0xce83b5f5c0601e73c10a1c22cd2a1e1987b9b41b23ff41fb258ff8c4e884bf65` |
| 3 | `0x171120f724139e8128f19c056358868f5c4b20e33ae668b4508e51668b8f107d` |
| 4 | `0x3d22b549e701ab6a1ce7a997a626dfd90cb782a90f4dd88bca63231ed5fc5e86` |
| 5 | `0x59481c14af7499c1483c1870dfbc8ac195f6aef7ef0fcb87074eeedef0167a00` |
| 7 | `0xea0f7d5a28a14122746a9d94eabbd696ef90a2605e9e367003a72cef6658ee73` |
| 8 | `0x49a3b5302735f401cc26542068e9151783bab61e4f6deb16b42b546f4be257cd` |
| 100 | `0xf9bb4c893c4cccfc4682020e2224b1d9ac1555db9b7da776711ddd16b85b656d` |
| 1000 | `0x39c2380978c25fdab32f6ba977a569e5109f866cddc3fb618f4f1f63e9b8ae77` |
### 8.3 A three-leaf tree in full
The odd-node rule and the proof shape are both visible here.
```
[0] STAMP-2026-0725-AAAAAAAA
content 0x4e6d78893da6dcc1185aa31f08a154eb531360a5b9a99368651b543f559a569b
leaf 0x7ca886812f35d90936862fa06e3c40ca0b2282f546ab55a41c8f508de16441a3
[1] STAMP-2026-0725-AAAAAAAB
content 0x7cc9ffe6d7372a1021b9568609b781a5860a8cbb82863fc12842f35d02699c41
leaf 0x8d2fd3b45220509c70e7a115e7ddb72fb1a3a3dd47b0d4b13637822e3fa11b60
[2] STAMP-2026-0725-AAAAAAAC
content 0x22da59e2af0caef916ef7190b339ea7571e42b5f80e9d6a07673c24605ba3122
leaf 0x5013c320e431ebe4a2399b889a3102b83b225262f9e70e82d0c4815518f6376a
```
Level 1 pairs leaves 0 and 1 into
`0xce83b5f5c0601e73c10a1c22cd2a1e1987b9b41b23ff41fb258ff8c4e884bf65` and
promotes leaf 2 unchanged. Level 2 pairs those two into the root
`0x171120f724139e8128f19c056358868f5c4b20e33ae668b4508e51668b8f107d`.
Proofs:
| Leaf | Proof |
| --- | --- |
| 0 | `0x8d2fd3b4… right`, then `0x5013c320… right` |
| 1 | `0x7ca88681… left`, then `0x5013c320… right` |
| 2 | `0xce83b5f5… left` |
Leaf 2 has one step, not two, because it was promoted at level 1.
## 9. Checking the whole chain by hand
Everything below uses `curl`, `shasum` and `xxd`, which are on any macOS or
Linux machine. `sha256sum` works in place of `shasum -a 256`. Nothing here runs
Vitahash code.
**Step 1: the content hash.** The canonical form is the exact byte string the
hash was taken over. Download it and hash it yourself:
```bash
STAMP=STAMP-2026-0725-A1B2C3D4
curl -s "https://vitahash.org/api/verify/$STAMP/canonical" | shasum -a 256
```
That number is the content hash the verification page shows. If it differs, the
article has changed since it was stamped and nothing after this step matters.
**Step 2: the leaf.** Hash the stamp ID, then hash the prefix, that digest, and
the content hash together:
```bash
CONTENT=$(curl -s "https://vitahash.org/api/verify/$STAMP/canonical" | shasum -a 256 | cut -d' ' -f1)
STAMP_DIGEST=$(printf '%s' "$STAMP" | shasum -a 256 | cut -d' ' -f1)
printf '00%s%s' "$STAMP_DIGEST" "$CONTENT" | xxd -r -p | shasum -a 256
```
That is the leaf, and it is the value the verification page labels "leaf".
**Step 3: the walk.** Take the proof steps from the page in order. With
`CURRENT` holding the leaf and `SIBLING` the step's sibling, both without `0x`:
```bash
# sibling on the left
printf '01%s%s' "$SIBLING" "$CURRENT" | xxd -r -p | shasum -a 256
# sibling on the right
printf '01%s%s' "$CURRENT" "$SIBLING" | xxd -r -p | shasum -a 256
```
Feed each result back in as `CURRENT` and repeat. After the last step you have a
root. At 10,000 articles a day that is fourteen commands.
**Step 4: the anchored root.** Open the attestation on easscan using the UID the
page links to, or read `getAttestation(uid)` on the EAS contract at
`0x4200000000000000000000000000000000000021` through Basescan's contract reader.
The decoded `merkleRoot` field is the value Vitahash wrote to Base.
**Step 5: compare.** The root from step 3 and the root from step 4 were obtained
by two routes that share nothing. If they agree, this article was published on
that day with exactly the content you just hashed, and no edit since then can
hide.
## 10. Changing these rules
Do not. Every proof already handed to a reader was produced by these rules
against a root that is on Base permanently, and there is no way to rewrite it.
If a change genuinely cannot be avoided, it is a new version, not an edit:
1. Add `MERKLE_VERSION = 2` alongside version 1, with **different prefix bytes**
(§3). Both implementations stay in the codebase permanently.
2. Add new golden vectors beside the existing ones. **The version 1 vectors stay
and must keep passing.** If a version 1 vector fails, the change has
invalidated every proof already published; revert it rather than updating the
expectation.
3. Dispatch on `stamp_batches.merkle_version` when verifying. Existing batches
keep the version on their row and are never rebuilt.
4. Decide, and write down, what `/verify/{stampId}` shows for a proof produced
under a version this build cannot walk. It is a fourth state, not a mismatch,
for the same reason CANONICALIZATION.md §9 gives: calling it a mismatch would
accuse an untouched article of being altered.
A change to the EAS schema string is a separate and larger problem, covered in
`contracts/README.md`: the schema UID is derived from the string and a
registered schema is permanent.
## 11. Reimplementing this in another language
1. Implement §4's leaf rule. Check it against §8.1.
2. Implement §6's fold, with promotion rather than duplication. Check every root
in §8.2.
3. Implement §7's walk. Check that every leaf of the 3, 5 and 100 leaf trees
reaches the recorded root.
4. Only then compare anything against a live attestation.
The three details most likely to be got wrong, in the order they are usually got
wrong: forgetting the prefix bytes, duplicating the odd node instead of
promoting it, and hashing the content hash's hexadecimal text instead of its 32
raw bytes.