Merkle specification

# 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.