Merkle Multi Proofs

Seun Lanlege — Mad scientist

Merkle Multi Proofs

Merkle multi proofs enable more efficient merkle proofs by re-using the intermediate nodes shared by the proof leaves during the recalculation of the root hash of the tree. In order to understand the benefits provided by merkle multi proofs, consider proving leaf L0 (position 8) individually — we need 3 proof nodes: its sibling (9), its uncle (5), and the right subtree root (3):

Single proof for L0

Now consider proving leaf L4 (position 12) individually — again 3 proof nodes: its sibling (13), its uncle (7), and the left subtree root (2):

Single proof for L4

Proving both leaves separately requires 3+3=63 + 3 = 6 proof nodes in total. But notice that node 33 (needed to prove L0) is the ancestor of L4, and node 22 (needed to prove L4) is the ancestor of L0. In a merkle multi proof, these intermediate nodes are computed during verification rather than supplied — so the combined proof needs only 44 proof nodes instead of 66:

Combined multi proof

This scheme brings significant space savings when proving the existence of multiple items in a merkle tree, and the savings grow as more leaves share intermediate ancestors. In this article, we introduce a custom proof format that gives us computational savings at the cost of additional space complexity for execution environments where the cost of execution may be too high.

Position-Based Indexing

We use a 1-based position numbering scheme where the root node is at position 11:

Position-based indexing

parent(i)=i/2left(i)=2iright(i)=2i+1sibling(i)=i1\text{parent}(i) = \lfloor i / 2 \rfloor \qquad \text{left}(i) = 2i \qquad \text{right}(i) = 2i + 1 \qquad \text{sibling}(i) = i \oplus 1

Even positions are always left children, odd positions are always right children. The sibling of any node is found by toggling the least significant bit.

Given nn leaves in the tree, the height is h=log2(n)h = \lceil \log_2(n) \rceil and the first leaf position is 2h2^h. A leaf at 0-based index kk maps to tree position 2h+k2^h + k.

Unbalanced Trees

In practice, the number of leaves in a merkle tree is rarely a power of 2. A tree with nn leaves has height h=log2(n)h = \lceil \log_2(n) \rceil, which means the leaf level has 2h2^h slots but only nn are occupied. The rightmost positions at each level may have no sibling:

Unbalanced tree

When walking up from a node whose sibling doesn’t exist (e.g. position 12 has sibling 121=1312 \oplus 1 = 13, but position 13 is beyond the tree), the node is promoted unchanged to the parent level — no hashing occurs. Position 12 promotes to become position 6, which itself has no sibling (position 7 doesn’t exist), so it promotes again to position 3.

To detect this, the verifier tracks the number of valid nodes at each level. Starting from nn at the leaf level and computing n/2\lceil n / 2 \rceil for each level above, the last valid position at any level is:

lastValid=2log2(pos)+nodesAtLevel1\text{lastValid} = 2^{\lfloor \log_2(\text{pos}) \rfloor} + \text{nodesAtLevel} - 1

A sibling at position pos1>lastValid\text{pos} \oplus 1 > \text{lastValid} does not exist in the tree.

Proof Schema

Each leaf carries its 0-based index and hash. The proof is a flat array of sibling hashes — no position metadata. The verifier derives positions internally from the leaf indices and leafCount.

Verification Algorithm

The algorithm converts leaf indices to 1-based tree positions (pos=2h+index\text{pos} = 2^h + \text{index}), then walks up the tree level by level. At each level, for each node it resolves the sibling:

  1. Sibling in the working set — the next node has position pos1\text{pos} \oplus 1, hash them together
  2. Sibling in the proof — consume the next proof element as the sibling
  3. Sibling doesn’t exist — unbalanced tree edge, promote unchanged (see above)

Algorithm 1 Merkle Multi Proof Verification

Require: proof hashes PP, leaves LL with 0-based indices, leaf count nn

Ensure: root hash of the merkle tree

1:hlog2(n)h \gets \lceil \log_2(n) \rceil

2:for each leaf lLl \in L do

3:l.pos2h+l.indexl.\text{pos} \gets 2^h + l.\text{index}

4:end for

5:nodesAtLeveln\textit{nodesAtLevel} \gets n

6:repeat

7:lastValid2log2(L[0].pos)+nodesAtLevel1\textit{lastValid} \gets 2^{\lfloor \log_2(L[0].\text{pos}) \rfloor} + \textit{nodesAtLevel} - 1

8:next\textit{next} \gets \emptyset

9:for each node vLv \in L do

10:if next node ww is sibling (v.pos1v.\text{pos} \oplus 1) then

11:parentHpair(v,w)\textit{parent} \gets H_{\text{pair}}(v, w)

12:skip ww

13:else if v.pos1lastValidv.\text{pos} \oplus 1 \leq \textit{lastValid} then // sibling exists in tree

14:parentHpair(v,P.next())\textit{parent} \gets H_{\text{pair}}(v, P.\text{next}())

15:else // unbalanced edge

16:parentv.hash\textit{parent} \gets v.\text{hash}

17:end if

18:nextnext{(v.pos/2, parent)}\textit{next} \gets \textit{next} \cup \{(\lfloor v.\text{pos} / 2 \rfloor,\ \textit{parent})\}

19:end for

20:LnextL \gets \textit{next}

21:nodesAtLevelnodesAtLevel/2\textit{nodesAtLevel} \gets \lceil \textit{nodesAtLevel} / 2 \rceil

22:until L[0].pos=1L[0].\text{pos} = 1

23:return L[0].hashL[0].\text{hash}

HpairH_{\text{pair}} orders the hash inputs by position — even positions are left children, odd are right:

Hpair(v,s)={H(v.hashs.hash)if v.pos is evenH(s.hashv.hash)if v.pos is oddH_{\text{pair}}(v, s) = \begin{cases} H(v.\text{hash} \| s.\text{hash}) & \text{if } v.\text{pos} \text{ is even} \\ H(s.\text{hash} \| v.\text{hash}) & \text{if } v.\text{pos} \text{ is odd} \end{cases}

The working set is written in-place, shrinking each iteration.

Reference Implementation

A production Solidity implementation of this algorithm is available at polytope-labs/solidity-merkle-trees.