Skip to content

Instantly share code, notes, and snippets.

@pirapira
Last active August 9, 2026 21:34
Show Gist options
  • Select an option

  • Save pirapira/80bc4747f560f3b9d9fa430d5db0ce20 to your computer and use it in GitHub Desktop.

Select an option

Save pirapira/80bc4747f560f3b9d9fa430d5db0ce20 to your computer and use it in GitHub Desktop.
evm-sail rejects 67 nibbles in mpt leaf path

Text generated by Claude Code. I tried the command in "Run" section and saw the output.

Does evm-sail accept the over-length MPT witness path from execution-specs PR #3193?

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.


1. Background

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.

2. Environment

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

3. Setup

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 pydantic

The extraction lives at evm-sail/extractions/python/src/evm/ and is checked into the repository, so no make extract-python step is needed.

4. The harness

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 fixture

The 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.

5. Run

pr3193/venv/bin/python pr3193/run_overlength_leaf.py

Expected 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).

6. Why it is rejected

Two independent mechanisms in the Sail source, both in evm-sail/sail/lib/mpt/:

Type-level boundprimitives.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 checkhex_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.

7. Caveat: does evm-sail reach the node?

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.

8. Files in this directory

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment