chore(python): make merkle tree balanced
What changed, and why it matters
This commit changes how Trezor's Python library builds Merkle trees so that the trees are balanced. Previously, leftover odd nodes were pushed upward, which could make some membership proofs much longer than others. The change is described as a routine improvement ('chore') with no security claim by the vendor. There is no direct evidence in the commit that this fixes an active vulnerability, but unbalanced Merkle trees can theoretically leak information about which item is being proved or create subtle correctness/performance issues in protocols that assume balanced trees.
Treat as a hardening/maintenance change rather than an urgent security fix. Review any consumers of `MerkleTree.get_proof()` that may have assumed or depended on the previous unbalanced proof lengths, and verify that the regenerated translation signatures are correctly signed and distributed. No immediate patching urgency is indicated by the commit itself.
Security signals we found
Merkle tree construction algorithm changed from unbalanced to balanced
Membership proof lengths now differ by at most one across all leaves
No vendor security advisory, CVE, or changelog security note present
Translation signature root hash regenerated, indicating downstream signature data depends on this code
Evidence from the diff
The patch replaces an iterative left-to-right pairing algorithm in python/src/trezorlib/merkle_tree.py with a balanced construction. The new _build_tree method computes depth = ceil(log2(n)), capacity = 2^depth, and split = 2n - capacity, then pairs the first split leaves one level deeper and keeps the remaining leaves shallow, producing a power-of-two upper level that is collapsed into a perfect tree. A test is added verifying that for leaf counts 1-33, proof lengths differ by at most one. The core/translations/signatures.json root hash is updated, consistent with regenerated translation signatures that use this Merkle-tree code.
Changed components
python/src/trezorlib/merkle_tree.pycore/translations/signatures.jsonpython/tests/test_merkle_tree.pyInspect captured patch +47 / −30
diff --git a/core/translations/signatures.json b/core/translations/signatures.json
index dcbdcb0f..359f9393 100644
--- a/core/translations/signatures.json
+++ b/core/translations/signatures.json
@@ -1,8 +1,8 @@
{
"current": {
- "merkle_root": "1ed3d1cf544c1172247be18991f0eb764241609e27089decbf375f12bf8befe9",
- "datetime": "2026-07-22T12:42:32.025263+00:00",
- "commit": "4bdca3d276ea349e448315634f177e1e9c56f8a0"
+ "merkle_root": "3b24a185c7289802d17ba03e35c097b81ef50a9f50b089beaf6c092269d9d5bf",
+ "datetime": "2026-07-23T14:12:43.060131+00:00",
+ "commit": "05344ecdd7938701c805cf9964566c3f5af959c4"
},
"history": [
{
diff --git a/python/.changelog.d/7359.changed b/python/.changelog.d/7359.changed
new file mode 100644
index 00000000..261fc8e9
--- /dev/null
+++ b/python/.changelog.d/7359.changed
@@ -0,0 +1 @@
+Generated Merkle trees are now balanced.
diff --git a/python/src/trezorlib/merkle_tree.py b/python/src/trezorlib/merkle_tree.py
index 8acdf8b5..2b12d96d 100755
--- a/python/src/trezorlib/merkle_tree.py
+++ b/python/src/trezorlib/merkle_tree.py
@@ -82,13 +82,9 @@ class Node:
class MerkleTree:
"""Merkle tree for a list of byte values.
- The tree is built up as follows:
-
- 1. Order the leaves by their hash.
- 2. Build up the next level up by pairing the leaves in the current level from left
- to right.
- 3. Any left-over odd node at the current level gets pushed to the next level.
- 4. Repeat until there is only one node left.
+ The leaves are ordered by their hash and the tree is built balanced: all leaves end
+ up within one level of each other, so any two membership proofs differ in length by
+ at most one. See `_build_tree` for details.
Values are not saved in the tree, only their hashes. This allows us to construct a
tree with very large values without having to keep them in memory.
@@ -128,31 +124,41 @@ class MerkleTree:
def __init__(self, values: t.Iterable[bytes]) -> None:
leaves = [Leaf(value) for value in values]
- leaves.sort(key=lambda leaf: leaf.tree_hash)
if not leaves:
raise ValueError("Merkle tree must have at least one value")
+ leaves.sort(key=lambda leaf: leaf.tree_hash)
self.entries = {leaf.tree_hash: leaf for leaf in leaves}
-
- # build the tree
- current_level = leaves
- while len(current_level) > 1:
- # build one level of the tree
- next_level = []
- while len(current_level) >= 2:
- left, right, *current_level = current_level
- next_level.append(Node(left, right))
-
- # add the remaining one or zero nodes to the next level
- next_level.extend(current_level)
-
- # switch levels and continue
- current_level = next_level
-
- assert len(current_level) == 1, "Tree must have exactly one root node"
- # save the root
- self.root = current_level[0]
+ self.root = self._build_tree(leaves)
+
+ @staticmethod
+ def _build_tree(leaves: t.Sequence[NodeType]) -> NodeType:
+ """Build a balanced tree from an ordered sequence of leaves.
+
+ The first leaves are pushed one level deeper so that the level above the bottom
+ becomes a power of two; from there the tree is perfect. The deep leaves go first,
+ so the last leaves get the shortest proofs.
+ """
+ if len(leaves) == 1:
+ return leaves[0]
+
+ # `depth` is ceil(log2(n)) and `capacity` the leaf count of a perfect tree of
+ # that depth (the smallest power of two >= n). The first `split` leaves are
+ # pushed one level deeper and paired up, so the level above the bottom becomes a
+ # perfect power of two (`capacity // 2` wide); the remaining `n - split` leaves
+ # stay shallow (shorter proofs).
+ depth = (len(leaves) - 1).bit_length()
+ capacity = 1 << depth
+ split = 2 * len(leaves) - capacity
+ level = [Node(leaves[i], leaves[i + 1]) for i in range(0, split, 2)] + list(
+ leaves[split:]
+ )
+
+ # `level` now has a power-of-two length: collapse it into a perfect tree.
+ while len(level) > 1:
+ level = [Node(level[i], level[i + 1]) for i in range(0, len(level), 2)]
+ return level[0]
def get_root_hash(self) -> bytes:
return self.root.tree_hash
diff --git a/python/tests/test_merkle_tree.py b/python/tests/test_merkle_tree.py
index a0dd8051..8a8aa1d0 100644
--- a/python/tests/test_merkle_tree.py
+++ b/python/tests/test_merkle_tree.py
@@ -105,3 +105,13 @@ def test_tree(
for value, proof in proofs.items():
assert mt.get_proof(value) == proof
assert evaluate_proof(value, proof) == root_hash
+
+
+@pytest.mark.parametrize("count", range(1, 34))
+def test_tree_is_balanced(count: int) -> None:
+ # All leaves end up within one level of each other, so any two proofs differ in
+ # length by at most one.
+ values = [f"value-{i}".encode() for i in range(count)]
+ mt = MerkleTree(values)
+ lengths = [len(mt.get_proof(v)) for v in values]
+ assert max(lengths) - min(lengths) <= 1
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.