Skip to content

bug: TrieImpl.insert() is not idempotent for duplicate key-value pairs #6608

@vividctrlalt

Description

@vividctrlalt

Summary

TrieImpl.insert() violates the Merkle Trie invariant: the same dataset should always produce the same root hash, regardless of insertion order. When a duplicate key-value pair is inserted (same key, same value), the node is unconditionally marked as dirty, which invalidates the internal hash cache and causes the final root hash to become insertion-order-dependent.

Root Cause

In TrieImpl.java, the kvNodeSetValueOrNode() method (L873-878) unconditionally sets dirty = true without checking whether the new value equals the existing value:

public Node kvNodeSetValueOrNode(Object valueOrNode) {
    parse();
    assert getType() != NodeType.BranchNode;
    children[1] = valueOrNode;    // overwrites even if same value
    dirty = true;                  // always marks dirty
    return this;
}

This is triggered from insert() (L188-189) when commonPrefix.equals(k):

} else if (commonPrefix.equals(k)) {
    return n.kvNodeSetValueOrNode(nodeOrValue);  // no same-value check
}

Reproduction

The existing TrieTest.testOrder() (currently @Ignore) demonstrates the issue:

  1. Insert keys 1-99, then re-insert key 10 with the same value
  2. Shuffle the insertion order and rebuild
  3. Root hashes differ ~3% of the time depending on shuffle order

Impact

Current: None. The allowAccountStateRoot chain parameter (proposal 25) has not been enabled on mainnet. AccountStateCallBack checks allowAccountStateRoot() before using TrieImpl, so this code path is never executed.

Future: Consensus-level risk. If proposal 25 is approved, AccountStateCallBack will use TrieImpl to compute per-block account state root hashes stored in block headers. Different nodes could compute different root hashes for the same block if duplicate trie.put(address, state) calls occur, leading to BadBlockException and potential chain forks.

Suggested Fix

Add a same-value check in kvNodeSetValueOrNode() to short-circuit when the value hasn't changed:

public Node kvNodeSetValueOrNode(Object valueOrNode) {
    parse();
    assert getType() != NodeType.BranchNode;
    if (valueOrNode instanceof byte[] && children[1] instanceof byte[]
        && Arrays.equals((byte[]) valueOrNode, (byte[]) children[1])) {
        return this;  // no change, skip dirty marking
    }
    children[1] = valueOrNode;
    dirty = true;
    return this;
}

Then remove the @Ignore on TrieTest.testOrder() to verify the fix.

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions