diff --git a/node/test_utxo_db.py b/node/test_utxo_db.py index 78352f80..4f0b71ce 100644 --- a/node/test_utxo_db.py +++ b/node/test_utxo_db.py @@ -216,6 +216,32 @@ def test_state_root_changes_after_spend(self): root_after = self.db.compute_state_root() self.assertNotEqual(root_before, root_after) + def test_state_root_odd_count_unique(self): + """Odd-count UTXO sets must produce unique roots. + + The old Merkle construction duplicated the last hash when the count + was odd, creating second-preimage ambiguity: sets [A,B,C] and + [A,B,C,C] could produce the same root. The domain-separated padding + and count-binding fix eliminates this (bounty #2819 MED-2). + """ + # Create 3 boxes (odd count) + self._apply_coinbase('alice', 10 * UNIT, block_height=1) + self._apply_coinbase('bob', 20 * UNIT, block_height=2) + self._apply_coinbase('carol', 30 * UNIT, block_height=3) + root_3 = self.db.compute_state_root() + self.assertEqual(len(root_3), 64) + + # Create a 4th box — root must change + self._apply_coinbase('dave', 40 * UNIT, block_height=4) + root_4 = self.db.compute_state_root() + self.assertNotEqual(root_3, root_4) + + # Create a 5th box (odd again) — root must change again + self._apply_coinbase('eve', 50 * UNIT, block_height=5) + root_5 = self.db.compute_state_root() + self.assertNotEqual(root_4, root_5) + self.assertNotEqual(root_3, root_5) + # -- integrity ----------------------------------------------------------- def test_integrity_ok(self): diff --git a/node/utxo_db.py b/node/utxo_db.py index db77aaf1..56e1c112 100644 --- a/node/utxo_db.py +++ b/node/utxo_db.py @@ -456,6 +456,15 @@ def compute_state_root(self) -> str: Deterministic: sorted by box_id, pairwise SHA256. All nodes with the same UTXO set produce the same root. + + Odd-layer padding uses a domain-separated sentinel + (``SHA256(0x01 || last_hash)``) instead of duplicating the last + element. This prevents second-preimage ambiguity where sets + ``[A, B, C]`` and ``[A, B, C, C]`` would otherwise produce + identical roots. + + The leaf count is also mixed into each leaf hash so the tree + is bound to a specific UTXO-set cardinality. """ conn = self._conn() try: @@ -468,14 +477,20 @@ def compute_state_root(self) -> str: if not rows: return hashlib.sha256(b"empty").hexdigest() + # Mix element count into leaf hashes to bind tree to cardinality + count_bytes = len(rows).to_bytes(8, 'little') hashes = [ - hashlib.sha256(bytes.fromhex(r['box_id'])).digest() + hashlib.sha256(count_bytes + bytes.fromhex(r['box_id'])).digest() for r in rows ] while len(hashes) > 1: if len(hashes) % 2 == 1: - hashes.append(hashes[-1]) + # Domain-separated padding — distinguishable from a + # real duplicate leaf. + hashes.append( + hashlib.sha256(b'\x01' + hashes[-1]).digest() + ) hashes = [ hashlib.sha256(hashes[i] + hashes[i + 1]).digest() for i in range(0, len(hashes), 2)