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

No one assigned

    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