Text generated by Claude Code. I tried the command in "Run" section and saw the output.
Answer: no. evm-sail's trie-node decoder rejects a leaf whose remaining key
exceeds 64 nibbles with fatal_error(RlpDecode), both by a decode-time length
check and by the type of TriePath itself. Caveats about whether the decoder is
reached for that particular fixture are in §5.
Everything below is self-contained: starting from an empty directory, the steps reproduce the result end to end. No build of the Sail model is required — the repository ships a generated Python extraction of the same specification.
execution-specs PR #3193
("feat(tests): zkevm case for mpt witness with a leaf path of 67 nibbles",
targeting projects/zkevm) adds one test file,
tests/amsterdam/eip8025_optional_proofs/test_witness_state_overlength.py.
The test splices
MutableLeafNode(rest_of_key=AmsterdamBytes(b"\x00" * 67), value=b"", _dirty=True)into a previously-empty child slot of a branch node in the parent state trie, re-roots the parent header and the execution payload around the resulting malformed state root, and asserts:
Block(txs=[], stateless_input_bytes_modifier=overlength_secured_trie_path,
expected_stateless_validation_success=True)A secured-trie key is keccak256 output — 32 bytes, so 64 nibbles. A 67-nibble
remaining key is therefore longer than any reachable key. The PR body notes the
case is "currently accepted, but would be rejected on stateless guests with
length checks on mpt paths."
evm-sail is such a guest. This document verifies that claim against it.
Verified with:
| Component | Version |
|---|---|
| evm-sail | 45fc3fce902fe4853e9d53bfafe2446ed5ab51aa (https://github.com/frisitano/evm-sail.git) |
| Python | 3.14.6 (3.11+ should work) |
ethereum-types |
0.4.1 |
pydantic |
2.13.4 |
| Platform | macOS (darwin 25.5.0); nothing here is platform-specific |
mkdir evm-sail-test && cd evm-sail-test
# The specification under test.
git clone https://github.com/frisitano/evm-sail.git
git -C evm-sail checkout 45fc3fce902fe4853e9d53bfafe2446ed5ab51aa
# The PR diff, for reference.
mkdir -p pr3193
curl -sL https://github.com/ethereum/execution-specs/pull/3193.diff -o pr3193/pr3193.diff
# Dependencies of the generated Python extraction.
python3 -m venv pr3193/venv
pr3193/venv/bin/pip install ethereum-types pydanticThe extraction lives at evm-sail/extractions/python/src/evm/ and is checked
into the repository, so no make extract-python step is needed.
Save as pr3193/run_overlength_leaf.py:
"""Run evm-sail's extracted MPT node decoder on the PR-3193 fixture leaf.
execution-specs PR #3193 (tests/amsterdam/eip8025_optional_proofs/
test_witness_state_overlength.py) inserts a leaf whose `rest_of_key` is 67
nibbles into a secured state trie, and asserts the Python decoder accepts it.
This script builds the same leaf node (and a well-formed 64-nibble control),
feeds each to evm-sail's own `decode_input_trie_node`, and reports the result.
Usage:
python3 pr3193/run_overlength_leaf.py
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent / "evm-sail" / "extractions" / "python"
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT))
import evm.HostContract as host # noqa: E402
import evm.lib.mpt.codec as codec # noqa: E402
import evm.lib.mpt.primitives as mpt_primitives # noqa: E402
from evm._runtime import SailExit # noqa: E402
from evm.lib.mpt.codec import decode_input_trie_node # noqa: E402
from evm.primitives.bytes import stateless_input_slice # noqa: E402
# The extraction drops fatal_error's reason on the floor (it raises SailExit(None)).
# Recover it so the rejection can be attributed to a specific check.
_reason: list[object] = []
def _recording_fatal_error(reason):
_reason.append(reason)
raise SailExit(None)
for _module in (codec, mpt_primitives):
_module.fatal_error = _recording_fatal_error
def hex_prefix(nibbles: list[int], is_leaf: bool) -> bytes:
"""Compact (hex-prefix) encoding, YP Appendix C."""
flag = 2 if is_leaf else 0
odd = len(nibbles) % 2 == 1
if odd:
head = [(flag | 1) << 4 | nibbles[0]]
rest = nibbles[1:]
else:
head = [flag << 4]
rest = nibbles
return bytes(head + [rest[i] << 4 | rest[i + 1] for i in range(0, len(rest), 2)])
def rlp_bytes(b: bytes) -> bytes:
if len(b) == 1 and b[0] < 0x80:
return b
if len(b) < 56:
return bytes([0x80 + len(b)]) + b
length = len(b).to_bytes((len(b).bit_length() + 7) // 8, "big")
return bytes([0xB7 + len(length)]) + length + b
def rlp_list(items: list[bytes]) -> bytes:
payload = b"".join(items)
if len(payload) < 56:
return bytes([0xC0 + len(payload)]) + payload
length = len(payload).to_bytes((len(payload).bit_length() + 7) // 8, "big")
return bytes([0xF7 + len(length)]) + length + payload
def leaf_node(nibble_count: int) -> bytes:
"""The RLP of a leaf whose remaining key is `nibble_count` zero nibbles."""
path = hex_prefix([0] * nibble_count, is_leaf=True)
return rlp_list([rlp_bytes(path), rlp_bytes(b"")])
def decode(node_rlp: bytes):
host.reset()
host.get_state().stateless_input_bytes = node_rlp
return decode_input_trie_node(stateless_input_slice(0, len(node_rlp)))
def report(nibble_count: int) -> None:
node_rlp = leaf_node(nibble_count)
path_len = len(hex_prefix([0] * nibble_count, is_leaf=True))
print(f"--- leaf with {nibble_count}-nibble remaining key ---")
print(f" hex-prefix path bytes : {path_len} (HEX_PREFIX_MAX_LENGTH = 33)")
print(f" node RLP bytes : {len(node_rlp)}")
_reason.clear()
try:
node = decode(node_rlp)
print(f" decode_input_trie_node: ACCEPTED -> {type(node).__name__}")
except BaseException as exc: # noqa: BLE001 - fatal_error raises its own type
cause = _reason[0] if _reason else type(exc).__name__
print(f" decode_input_trie_node: REJECTED -> fatal_error({cause})")
if __name__ == "__main__":
report(64) # control: the longest key a secured trie can hold
report(67) # the PR-3193 fixtureThe script constructs the same node the fixture does — a leaf with an
all-zero remaining key and an empty value — at 67 nibbles, plus a 64-nibble
control to show the boundary is not simply "rejects everything". It hands each
node to decode_input_trie_node, the entry point evm-sail uses for every
witness trie node it resolves.
pr3193/venv/bin/python pr3193/run_overlength_leaf.pyExpected output:
--- leaf with 64-nibble remaining key ---
hex-prefix path bytes : 33 (HEX_PREFIX_MAX_LENGTH = 33)
node RLP bytes : 36
decode_input_trie_node: ACCEPTED -> InputLeafNode
--- leaf with 67-nibble remaining key ---
hex-prefix path bytes : 34 (HEX_PREFIX_MAX_LENGTH = 33)
node RLP bytes : 37
decode_input_trie_node: REJECTED -> fatal_error(2)
FatalError code 2 is RlpDecode
(evm-sail/extractions/python/src/evm/exceptions.py).
Two independent mechanisms in the Sail source, both in
evm-sail/sail/lib/mpt/:
Type-level bound — primitives.sail:11-23:
type trie_path_len = range(0, 64)
struct TriePath = { data : b256, len : trie_path_len }The path payload is a single 256-bit word and the length is statically bounded to 64. A 67-nibble path is not representable in the model at all; Sail checks this bound at definition time.
Decode-time check — hex_prefix_decode_ref, primitives.sail:248:
let maximum_length = HEX_PREFIX_MAX_LENGTH; /* = 33, primitives.sail:25 */
if maximum_length < n then {
fatal_error(RlpDecode)
}67 nibbles is odd, so its hex-prefix (compact) encoding occupies
1 + (67 - 1) / 2 = 34 bytes — one over the bound. The 64-nibble control
encodes to exactly 33 and passes. (There is a second guard for the odd case,
paired_nibbles < 64, raising WitnessDeficient; the length check fires
first here.)
The same pair of checks appears in scratch_hex_prefix_decode_ref, the
counterpart used when canonicalization reopens a node evm-sail just encoded.
Witness ingestion is lazy. index_witness_nodes_cursor
(evm-sail/sail/lib/ssz/stateless_input.sail:594) walks the SSZ witness list
and only keccak-hashes each node, recording its (offset, length) span via
nodedb_insert. Nothing is decoded up front:
let (node, next) = ssz_list_pop(cursor);
let node_hash = keccak256(node);
nodedb_insert(node_hash, node.bytes, node.len);Decoding happens on demand during the trie walk (sail/lib/mpt/trie.sail).
The fixture's malformed leaf encodes to 37 bytes — at or above the 32-byte threshold — so its parent branch references it by hash rather than inlining it, and it occupies a slot that was previously empty. evm-sail therefore decodes it, and rejects, only if some key lookup or state update descends that particular nibble. If no key routes there, the node is inert and evm-sail accepts the block exactly as the Python spec does.
So the precise claim verified here is: evm-sail rejects the over-length path
whenever it decodes the node. Determining whether this specific fixture's
empty block routes a state access into that slot requires generating the
fixture from the PR branch and running the C build end to end — see
evm-sail/Makefile targets c-spec and eest-smoke.
| File | Contents |
|---|---|
REPRODUCTION.md |
This document |
NOTES.md |
Shorter write-up of the same finding |
pr3193.diff |
The PR diff as fetched |
run_overlength_leaf.py |
The harness from §4 |
venv/ |
Virtualenv from §3 |