diff --git a/docs/ADR-001-merkle-structure.md b/docs/ADR-001-merkle-structure.md index a33b243e..bf73d3bb 100644 --- a/docs/ADR-001-merkle-structure.md +++ b/docs/ADR-001-merkle-structure.md @@ -1,35 +1,76 @@ -# ADR 001: Sorted-Pair Merkle Tree for Receipt Verification +# ADR 001: Sorted-Pair Merkle Tree Structure -## Context -When verifying that a given receipt belongs to an anchored batch, we need a cryptographic proof. A standard Merkle tree proof requires not only the sibling hashes but also the positional index (left or right) for each step in the path to ensure the hashes are concatenated in the correct order. +**Status:** Accepted +**Context:** `ReceiptAnchor` and off-chain SDK + +## Problem + +In a standard Merkle tree, validating a proof requires knowing whether each sibling hash in the proof path is a left child or a right child, so they can be concatenated in the correct order before hashing. This typically requires passing positional metadata (e.g., an array of booleans or a bitmap) alongside the hashes in the proof. + +Passing this metadata on-chain to Soroban smart contracts increases calldata size, parsing complexity, and WASM instruction counts, which in turn increases transaction fees. ## Decision -We chose a **sorted-pair Merkle tree** convention. When hashing two child nodes to compute their parent's hash, the two child hashes are first sorted lexicographically (e.g., as byte arrays) before being concatenated and hashed. + +We use a **sorted-pair SHA-256** strategy for Merkle tree construction and verification. +When computing the parent hash of two siblings `a` and `b`, we sort them lexicographically before hashing: + +```rust +if a < b { + hash(a, b) +} else { + hash(b, a) +} +``` + +This sorted-pair convention is used both in the off-chain TypeScript SDK (to build the tree and generate proofs) and in the on-chain `ReceiptAnchor` contract (to verify proofs). + +## Mechanics and Visuals + +By sorting the siblings before concatenation, the order of hashes is deterministic based purely on their values. The verification logic does not need to know whether a proof node is a left or right sibling in the original tree structure. It simply takes the current working hash and the next proof hash, sorts them, and hashes the result. This completely eliminates the need for positional flags in the proof array. + +### 4-Leaf Tree Example ```mermaid graph TD - Root["Root Hash
Hash(sort(H(A,B), H(C,D)))"] - Node1["Node 1
Hash(sort(A, B))"] - Node2["Node 2
Hash(sort(C, D))"] - LeafA["Leaf A"] - LeafB["Leaf B"] - LeafC["Leaf C"] - LeafD["Leaf D"] - - Root --- Node1 - Root --- Node2 - Node1 --- LeafA - Node1 --- LeafB - Node2 --- LeafC - Node2 --- LeafD + classDef default fill:#f9f9f9,stroke:#333,stroke-width:2px; + classDef sorted fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px; + + R["Root Hash
hash(min(H12, H34), max(H12, H34))"]:::sorted + + H12["Node 1-2
hash(min(H1, H2), max(H1, H2))"]:::sorted + H34["Node 3-4
hash(min(H3, H4), max(H3, H4))"]:::sorted + + H1["Leaf 1 (Hash)"] + H2["Leaf 2 (Hash)"] + H3["Leaf 3 (Hash)"] + H4["Leaf 4 (Hash)"] + + R --> H12 + R --> H34 + + H12 --> H1 + H12 --> H2 + + H34 --> H3 + H34 --> H4 ``` -## Consequences +## Trade-offs ### Benefits -1. **Reduced Proof Size:** The proof only needs to be an array of sibling hashes. We don't need to encode or transmit the left/right directional bits. -2. **Lower Gas Costs:** On Soroban, parsing smaller proof structures and simplifying the verification loop (just sorting two 32-byte arrays instead of conditional branching on a bitmask) saves CPU instructions and memory allocation, leading to lower execution fees. -3. **Simpler Verification Logic:** The on-chain loop simply takes the current hash and the next proof element, sorts them, and hashes them together. +- **Lower WASM Instruction Counts:** The on-chain verification loop is simpler. It doesn't need to parse metadata or branch on positional flags beyond the simple lexicographical comparison. +- **Smaller On-Chain Calldata Footprint:** The proof is merely an array of 32-byte hashes (`Vec>`). No extra booleans or bitmasks are passed, keeping the payload compact. +- **Cheaper Fees:** Reduced calldata size and fewer WASM instructions directly translate to lower execution and rent fees on Soroban. + +### Drawbacks +- **Non-Standard Tooling Compatibility:** Standard Merkle tree libraries (like OpenZeppelin's MerkleProof in Solidity or generic JS libraries) do not support sorted-pair hashing out of the box. Both our off-chain SDK and on-chain contracts must use our custom implementation, and third-party integrators cannot use off-the-shelf standard Merkle proof generators without modification. + +## Security Context + +The primary security concern with sorted-pair Merkle trees is the potential for **second-preimage attacks**, where an attacker might forge a proof by reordering nodes or treating inner nodes as leaves. + +However, this design choice remains safe against collision and second-preimage attacks due to our specific constraints: +1. **Uniform 32-Byte Leaves:** All leaves in our tree are strictly 32-byte hashes (the `payment_ref`). Because the leaves and the inner nodes both have the exact same fixed length (32 bytes), an attacker cannot exploit variable-length concatenation vulnerabilities. +2. **Domain Separation (Contextual):** While we do not use explicit domain separation byte prefixes (e.g., `0x00` for leaves, `0x01` for inner nodes) in this specific optimization, the uniform strictness of 32-byte elements ensures that an inner node (which is a hash of two 32-byte hashes) cannot be easily confused for a valid leaf (which is a hash of a receipt) in a way that allows a targeted second-preimage collision. The attacker would have to find a receipt whose hash exactly equals the hash of two other nodes, which reduces to breaking the collision resistance of SHA-256. -### Trade-offs -- **Loss of Positional Data:** We can prove a receipt is in the batch, but we cannot prove *where* it is in the batch (its exact index). For our use case (receipt inclusion verification), this index is irrelevant, making the trade-off highly favorable. +Therefore, for our specific 32-byte leaf constraints, sorting before hashing does not compromise collision resistance.