Skip to content

test(tests): add MPT structural test suite - #3600

Draft
CPerezz wants to merge 39 commits into
ethereum:forks/amsterdamfrom
CPerezz:mpt-structural-tests
Draft

CPerezz wants to merge 39 commits into
ethereum:forks/amsterdamfrom
CPerezz:mpt-structural-tests

Conversation

@CPerezz

@CPerezz CPerezz commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Description

Adds tests/mpt/: fixtures that exercise the Merkle Patricia Trie as a data structure.
Consumed exactly like any other blockchain_test fixture set.

Results of running within hive (NO NEED TO RUN THE FIXTURES THERE THOUGH)

Client (SHA) Runner Entries Reported
go-ethereum 1.15.11 evm blocktest / statetest (pathdb) 111 + 53 111/111, 53/53
erigon 14273f79a6 evm blocktest 111 111/111
Nethermind 53358159 nethtest --blockTest / state 111 + 53 111/111, 53/53
ethrex 89e1602 ef_tests-blockchain / ef_tests-state (LEVM) 111 + 53 61/61 files, 53/53
nimbus-eth1 157433b eest_blockchain_test (Aristo) 111 111/111
Besu 26.8.1 evmtool block-test / consume direct (needs #3610) 111 + 53 111/111, 53/53
reth d3d70d3fc3 ef-test-runner 111 61/61 files
evmone ee0bf39 evmone test (from-scratch rebuild) 111 + 53 96/96 files

Reth's offline ef-test-runner discards the trie updates it computes, so it rebuilds each block's root from the flat hashed state; the hive runs above cover reth's incremental paths instead. We need to PR or do something about this one.

Found along the way, filed separately: #3608, #3609, #3610.

Related Issues or PRs

#3608, #3609, #3610

Checklist

  • Ran fast static checks: ruff, ruff format, mypy, codespell clean on tests/mpt/
  • PR title has the form <type>(<area>): <title>

Cute Animal Picture

img

@CPerezz CPerezz added C-test Category: test A-tests Area: Consensus tests. labels Sep 16, 2026
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.01%. Comparing base (f7847b7) to head (9057067).
⚠️ Report is 6 commits behind head on forks/amsterdam.

Additional details and impacted files
@@                 Coverage Diff                 @@
##           forks/amsterdam    #3600      +/-   ##
===================================================
- Coverage            94.44%   94.01%   -0.43%     
===================================================
  Files                  624      624              
  Lines                36928    36969      +41     
  Branches              3326     3334       +8     
===================================================
- Hits                 34875    34757     -118     
- Misses                1450     1547      +97     
- Partials               603      665      +62     
Flag Coverage Δ
unittests 94.01% <ø> (-0.43%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Fixtures whose committed state root is the cross-client oracle for the
Merkle Patricia Trie itself: storage slots and addresses are mined so
their keccak256 hashes force specific node operations, and every test
asserts the node path it claims against the reference `patricialize`
at fill time. Valid from Osaka on so every client consumes one set.

Covers leaf and extension splits, branch collapses onto a leaf, an
extension and a branch, extension merges, 16-way branches, deletes of
missing keys, the 31/32/33-byte inlining boundary for storage and
withdrawal leaves, and account-leaf deletion through a same-transaction
CREATE2 + SELFDESTRUCT onto a pre-funded mined address (EIP-6780).
pre:  empty
post: ext(1) -> branch@1 -> {leaf(62), leaf(62)}
The shortest extension a leaf split can create, between the
four-nibble split and the prefix-free root branch.
pre:  branch@0 -> {leaf(63), leaf(63)}
post: branch@0 -> {ext(1) -> branch@2, leaf} or {branch@1, leaf}
The split result is re-parented into an existing branch slot instead of
becoming the root, a different frame in every node-walking client.
pre:  ext(4) -> branch@4 / ext(1) -> branch@1
post: branch@0 -> {ext(3) -> ..} / branch@0 -> {branch@1 -> ..}
A 4-nibble extension is shortened; a 1-nibble one vanishes without
leaving a zero-length extension behind.
pre:  branch@0 -> {branch@1 -> {leaf, leaf}, leaf}
post: branch@0 -> {leaf(63), leaf(63)}
The survivor gains the branch nibble and stays in its parent's slot;
nothing merges because the parent is a branch.
pre:  branch@0 -> {branch@1 -> {leaf, branch@2}, leaf}
post: branch@0 -> {ext(1) -> branch@2, leaf}
A fresh 1-nibble extension appears between two branches instead of
merging into an existing one.
pre:  branch@0 -> {branch@1 -> {leaf, leaf}, leaf}
post: ext(1) -> branch@1 -> {leaf, leaf}
The root changes kind from branch to extension.
pre:  branch@0 -> {branch@1 -> {leaf, ext(2) -> branch@4}, leaf}
post: branch@0 -> {ext(3) -> branch@4, leaf}
The extension absorbs the branch nibble; nothing above it merges.
pre:  ext(4) -> branch@4 -> {leaf, leaf, leaf}
post: ext(4) -> branch@4 -> {leaf, leaf}
The smallest non-collapsing delete; a client that reduces at two
remaining children passes 16 -> 15 and fails here.
pre:  leaf(64)
post: leaf(64)
Control: the walk for the absent key ends at a different leaf; the
root must equal the pre-state root.
pre:  ext(4) -> branch@4 -> {16 x leaf}
post: leaf(64)
One block diff takes the branch from 16 children to a collapsed and
merged leaf; the existing cases cover 16 -> 15 and 16 -> 0.
pre:  leaf(64)
post: ext(4) -> branch@4 -> {16 x leaf}
One split of a committed leaf followed by fourteen slot fills of the
branch it just created, all in one block.
pre:  leaf(64)
post: leaf(64), storage root byte-identical to genesis
Control: the transient write would have split the leaf; the block
diff must leave no residue of it.
pre:  ext(4) -> branch@4 -> {leaf, leaf}
post: the same shape, with the same or a new value
Control: a per-key delete marker must not shadow the later write.
pre:  ext(4) -> branch@4 -> {leaf = 1, leaf = 2}
post: leaf(64) = 7
The collapsed leaf must carry the survivor's new value, not its
committed one.
pre:  branch@0 -> {leaf a, leaf}
post: branch@0 -> {leaf b, leaf}
Child count unchanged while the slot's content is swapped; a
path-keyed cache must not serve the old leaf.
pre:  ext(2) -> branch@2 -> {l3, ext(2) -> branch@5}
post: ext(3) -> branch@3 -> {new, ext(1) -> branch@5}
A collapse at depth 2 and a split at depth 3 in one diff; neither the
pre shape nor either single-step result.
pre:  ext(4) -> branch@4 -> {c, ext(3) -> branch@8 -> {a, b}}, a and b
      31 B inline
post: a = leaf(59) 33 B hashed under branch@4, then inline again
The flip comes from the path growing on collapse, not from the value.
pre:  .. -> branch@4 -> {survivor, doomed}
post: the same shape; doomed is a balance-only leaf again
A per-block destruction record must not shadow the later write.
pre:  .. -> branch@4 -> {survivor contract, doomed}
post: .. -> leaf whose value carries the flipped storage root
Storage root and account re-pathing change in one block diff.
pre:  no withdrawals
post: one 30-byte root leaf, withdrawalsRoot = keccak256 of its RLP
The root is hashed even below the inlining threshold; only the
withdrawals trie can reach the case.
pre:  no withdrawals
post: branch@0 -> {8: leaf(1), 0: branch@1 -> {leaf(0) ..}}, 3 or 16
      entries, inline and hashed leaves in one branch
Empty-path leaves (prefix 0x20) cannot occur in a state trie.
pre:  one sender, one recipient
post: the transaction and receipt tries at 17, 129, 130 and 258
      transfers: nibble-1 slot, two-byte keys, a 1-nibble and a
      3-nibble extension
The only extension nodes an index trie can contain.
Slots: siblings of TWO_SLOTS_EXT4 at depths 2 and 3 (extension splits
under a branch parent), a pair sharing exactly 10 nibbles (32-byte leaf
from the value side), a triple where two keys share 9 nibbles and a
third shares 8 (inline-to-inline collapse), and sixteen slots with
distinct first nibbles (root-level 16-way branch). Addresses: siblings
of TWO_ADDRS_EXT4 at depths 1 and 3, salts sharing exactly 2 and 3
nibbles with it, and fifteen salts filling the SIXTEEN_ADDRS_BRANCH4
prefix, all for account-trie collapse cells with a controlled parent.
ROOT_PAIR is now spelled through SINGLE_SLOT, the identity five tests
rely on.
Every docstring now states the node path as `pre:`/`post:` lines in one
notation; client symbol citations are gone. Shape helpers move to
`trie_shape` (`storage_shape`, `account_shape`, `index_shape`,
`covering`, `leaf_rlp_size`). Delete tests are named `test_delete_*`,
genesis-only account tests `test_genesis_*`.

Account shapes are computed over the full genesis set (pre-alloc plus
the fork's system contracts, plus the coinbase after the block) and pin
the parent kind around the mined group with `covering`; the previous
`all(... if d == 4)` was vacuous when no node sat at depth 4. The
storage-root flip test now collapses onto the contract itself, created
through the factory with a salt whose address shares four nibbles with
the doomed one, so storage root and account re-pathing change in one
diff.

The three delete-missing tests merge into one parametrized control (the
write never reaches trie code), the trie-identical per-transaction
variant of the mass delete is dropped, insert-then-delete now covers a
leaf split, an extension split and a branch slot, and the index-trie
test adds counts 145, 257 and 273. Withdrawal values are encoded as a
plain RLP tuple instead of a fork-pinned class.
pre:  branch@0 -> {ext(3) -> branch@4 -> {p, q}, leaf(63)}
post: ext(4) -> branch@4 -> {p, q}
Closes the survivor-extension x parent-root cell; the root changes kind
from branch to extension. A client with a dedicated "extension becomes
root" path computes a different root here and nowhere else.
pre:  branch@0 -> {ext(3) -> branch@4 -> {p, q}, leaf(63)}
post: the extension split at offset 0, in the middle, at its last
      nibble, and the 1-nibble case where it vanishes
Every earlier extension split happened at the root; here the result is
re-parented into an existing branch slot, a different frame in every
node-walking client.
pre:  ext(2) -> branch@2 -> {l3, ext(2) -> branch@5 -> {l1, l2}}
post: ext(2) -> branch@2 -> {l3, leaf(61)}
The only extension-parent collapse whose merged leaf does not become
the root: the merge and the update of the parent branch's slot happen
in the same step.
pre:  ext(4) -> branch@4 / branch@0 / leaf
post: empty
Adds root branch -> empty and root leaf -> empty next to the existing
root extension case, so every root kind reaches the empty trie root.
pre:  ext(4) -> branch@4 -> {c, ext(3) -> branch@8 -> {a, b}} with a,
      b inline (31 B) or hashed (33 B) by value size, c always hashed;
      and ext(8) -> branch@8 -> {s, branch@9 -> {x, y}} all inline
post: deleted inline / hashed x survivor flips / stays hashed / stays
      inline, each restored in a second block
Six cells of the (deleted child, survivor encoding) matrix; the
previous single flip test covered one. Hash-keyed stores decode inline
children from the parent and must re-inline a rebuilt survivor.
pre:  empty
post: ext(10) -> branch@10 -> {leaf(53), leaf(53)}, 30 B with a 1-byte
      value and exactly 32 B with a 2-byte value
The existing 32-byte case comes from the key side (56 nibbles); a
client computing leaf lengths arithmetically has a separate branch for
a value byte >= 0x80, which only this cell reaches at the threshold.
pre:  ext(8) -> branch@8 -> {leaf(55) 31 B, leaf(55) 31 B}
post: the leaf holds a different 1-byte value, still 31 B inline
Next to the existing flip to 33 B: the inline case rewrites the leaf
inside its parent's RLP and never gives it a hash, so dirty tracking
keyed by child hash has nothing to key on.
pre:  empty; block 1 builds each geometry, block 2 deletes, block 3
      re-inserts
post: the geometry's shape after the round trip
Adds three-to-two, leaf/ext/branch survivors under a branch parent,
both root collapses and the nested-extension leaf to the three-block
test. Genesis lands in a client's base layer; block-1 nodes live in
its diff layer or dirty cache, which is where these cells were still
untested.
pre:  branch@0 -> {ext(3) -> branch@4 -> {p, q}, branch@1 -> {1, 14}};
      two contracts with ext(4)/ext(5) tries; two contracts holding the
      same single-leaf trie
post: both root children collapse, or one collapses while the other
      splits; two storage roots change in one diff; one contract's copy
      of the shared node is deleted, the other's grown next block
Touched-prefix sets with two entries under one root, parallel storage
roots, and hash-keyed stores that refcount or drop a node still used
by another trie.
pre:  ext(2) -> branch@2 -> {l3, ext(2) -> branch@5}; branch@0 with 16
      leaves; ext(4) -> branch@4 -> {p, q}
post: the inner extension replaced by a branch in one step; the root
      branch filled, reduced to 15 and to 1; q round-trips to its
      original value while p really changes
Extension-first-nibble splits so far happened at the root only; root
nodes take dedicated code in several clients; a touched-key applier
must neither skip the changed subtree nor rewrite the unchanged leaf.
pre:  .. -> branch@4 -> {survivor, doomed}
post: the survivor credited in the collapse block; the doomed leaf
      deleted, re-created by a transfer and deleted again across three
      blocks; re-created by a withdrawal, or not when it carries 0 Gwei
The collapse must read the survivor's updated balance; a resurrected
balance-only account is deployable again; withdrawals apply after the
transactions and a zero credit leaves no empty account.
pre:  mined siblings at depths 1 and 3 make the parent of the
      collapsing branch a controlled branch or extension; three and
      sixteen accounts under one branch
post: leaf, extension and branch survivors under a branch parent, a
      branch survivor under an extension, 3 -> 2, and 16 -> 1 via
      fifteen CREATE2 deletions in one block
Brings the account trie to parity with the storage cells; each cell's
parent kind is pinned over the full genesis set, so an uncontrolled
account sharing the prefix fails the fill instead of changing the cell.
`fund_address(addr, 0)` yields an empty account that the filler omits
from the genesis allocation, so EIP-161 clearing of a committed empty
leaf cannot be built through the public API. Recorded next to the other
inexpressible account cases.
The package docstring now carries the survivor x parent grid and the
enumerated classes (splits, root transitions, arity, encoding, one-block
sequences, index tries, account bookkeeping) so the "what is covered"
statement lives in the repository next to the tests.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-tests Area: Consensus tests. C-test Category: test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant