diff --git a/.github/actions/build-evm-client/geth/action.yaml b/.github/actions/build-evm-client/geth/action.yaml index 7770f0192c1..0f8d832a631 100644 --- a/.github/actions/build-evm-client/geth/action.yaml +++ b/.github/actions/build-evm-client/geth/action.yaml @@ -6,7 +6,7 @@ inputs: required: true default: 'ethereum/go-ethereum' ref: - description: 'Reference to branch, commit, or tag to use to build the EVM binary' + description: 'Branch or full commit SHA to use to build the EVM binary' required: true default: 'master' golang: @@ -16,13 +16,16 @@ inputs: runs: using: "composite" steps: - - name: Get latest geth commit + - name: Resolve geth commit id: geth-sha shell: bash + env: + GETH_REPOSITORY: ${{ inputs.repo }} + GETH_REF: ${{ inputs.ref }} run: | - SHA=$(git ls-remote https://github.com/${{ inputs.repo }}.git refs/heads/${{ inputs.ref }} | cut -f1) - echo "sha=$SHA" >> $GITHUB_OUTPUT - echo "Latest geth commit: $SHA" + SHA=$(python3 .github/scripts/resolve_git_ref.py "$GETH_REPOSITORY" "$GETH_REF") + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Resolved geth commit: $SHA" - name: Prepare cache target dir shell: bash run: mkdir -p "$GITHUB_WORKSPACE/go-ethereum/cmd/evm" diff --git a/.github/actions/build-fixtures/action.yaml b/.github/actions/build-fixtures/action.yaml index 61d255c22ab..9b31aeade5a 100644 --- a/.github/actions/build-fixtures/action.yaml +++ b/.github/actions/build-fixtures/action.yaml @@ -76,13 +76,19 @@ runs: if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 5 ]; then exit "$EXIT_CODE" fi + - name: Validate zkEVM Benchmark Fixtures + if: inputs.split_label == '' && inputs.release_name == 'zkevm-benchmark' + shell: bash + run: | + uv run -q .github/scripts/validate_zkevm_benchmark_fixtures.py \ + fixtures_${{ inputs.release_name }}.tar.gz - name: Generate Benchmark Genesis Files - if: inputs.split_label == '' && contains(inputs.release_name, 'benchmark') + if: inputs.split_label == '' && inputs.release_name != 'zkevm-benchmark' && contains(inputs.release_name, 'benchmark') uses: ./.github/actions/build-benchmark-genesis with: fixtures_path: fixtures_${{ inputs.release_name }}.tar.gz - name: Upload Benchmark Genesis Artifact - if: inputs.split_label == '' && contains(inputs.release_name, 'benchmark') + if: inputs.split_label == '' && inputs.release_name != 'zkevm-benchmark' && contains(inputs.release_name, 'benchmark') uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: benchmark_genesis_${{ inputs.release_name }} diff --git a/.github/configs/evm.yaml b/.github/configs/evm.yaml index 981615796f2..51afcb8e654 100644 --- a/.github/configs/evm.yaml +++ b/.github/configs/evm.yaml @@ -4,6 +4,12 @@ benchmark: ref: glamsterdam-devnet-7 evm-bin: evm xdist: auto +zkevm-benchmark: + impl: geth + repo: jsign/go-ethereum + ref: df17b59f9dfd731f0b7da0a85c30548f3db45b0b + evm-bin: evm + xdist: auto eels: impl: eels repo: null diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 735fffa0cd9..78f71254908 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -22,7 +22,17 @@ benchmark_fast: fill-params: --fork=Amsterdam --generate-all-formats --gas-benchmark-values 100 ./tests/benchmark/compute feature_only: true +zkevm-benchmark: + evm-type: zkevm-benchmark + fill-params: --fork=Amsterdam --gas-benchmark-values=10,30,60 -m blockchain_test ./tests/benchmark/compute --maxprocesses=30 --dist=worksteal + feature_only: true + # Shared entry for all `-devnet` releases; matched by `-devnet` suffix. devnet: evm-type: eels fill-params: --until=Amsterdam --generate-all-formats + +zkevm: + evm-type: eels + fill-params: -m "blockchain_test or blockchain_test_engine" --fork=Amsterdam ./tests/ + feature_only: true diff --git a/.github/scripts/check_zkevm_benchmark_release.py b/.github/scripts/check_zkevm_benchmark_release.py new file mode 100644 index 00000000000..2ed272e8624 --- /dev/null +++ b/.github/scripts/check_zkevm_benchmark_release.py @@ -0,0 +1,98 @@ +#!/usr/bin/env -S uv run --script +"""Check a zkEVM benchmark release request against GitHub state.""" + +import json +import os +import subprocess +import sys +from typing import Any, NoReturn + + +def fail(message: str) -> NoReturn: + """Print an error and stop the request.""" + print(f"Error: {message}", file=sys.stderr) + sys.exit(1) + + +def github_api(path: str) -> Any: + """Return all pages from a GitHub API request.""" + result = subprocess.run( + ["gh", "api", "--paginate", "--slurp", path], + capture_output=True, + text=True, + ) + if result.returncode != 0: + fail(f"gh api failed for {path}: {result.stderr.strip()}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + fail(f"gh api returned invalid JSON for {path}: {error}") + + +def flatten_pages(response: Any) -> list[dict[str, Any]]: + """Flatten the page list returned by ``gh api --slurp``.""" + if not isinstance(response, list): + fail("gh api returned an invalid paginated response") + items: list[dict[str, Any]] = [] + for page in response: + if not isinstance(page, list): + fail("gh api returned an invalid page") + for item in page: + if not isinstance(item, dict): + fail("gh api returned an invalid item") + items.append(item) + return items + + +def matching_refs(repository: str, tag: str) -> list[dict[str, Any]]: + """Return refs that match *tag*.""" + path = f"repos/{repository}/git/matching-refs/tags/{tag}" + return flatten_pages(github_api(path)) + + +def check_release(version: str, source_ref: str) -> None: + """Check the source input and destination release state.""" + repository = os.environ.get("GITHUB_REPOSITORY", "") + if not repository: + fail("GITHUB_REPOSITORY is empty") + + source_tag = f"tests-zkevm@{version}" + destination_tag = f"tests-zkevm-benchmark@{version}" + if source_ref != source_tag: + fail( + f"source ref must be '{source_tag}' for version '{version}', " + f"got '{source_ref}'" + ) + + destination_refs = matching_refs(repository, destination_tag) + if any( + ref.get("ref") == f"refs/tags/{destination_tag}" + for ref in destination_refs + ): + fail(f"destination tag '{destination_tag}' already exists") + + releases = flatten_pages( + github_api(f"repos/{repository}/releases?per_page=100") + ) + if any(release.get("tag_name") == destination_tag for release in releases): + fail( + f"destination release or draft '{destination_tag}' already exists" + ) + + print(f"Source tag: {source_tag}") + print(f"Destination release: {destination_tag}") + + +def main() -> None: + """Check the command-line release request.""" + if len(sys.argv) != 3: + print( + "Usage: check_zkevm_benchmark_release.py ", + file=sys.stderr, + ) + sys.exit(1) + check_release(sys.argv[1], sys.argv[2]) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/resolve_git_ref.py b/.github/scripts/resolve_git_ref.py new file mode 100644 index 00000000000..04d0f7e851f --- /dev/null +++ b/.github/scripts/resolve_git_ref.py @@ -0,0 +1,68 @@ +#!/usr/bin/env -S uv run --script +"""Resolve a GitHub repository branch or full commit SHA.""" + +import re +import subprocess +import sys + +FULL_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + +def resolve_git_ref(repository: str, ref: str) -> str: + """Return the commit SHA for a branch name or full commit SHA.""" + if not repository: + raise ValueError("repository is empty") + if not ref: + raise ValueError("ref is empty") + if FULL_COMMIT_RE.fullmatch(ref): + return ref.lower() + + branch_ref = ref if ref.startswith("refs/heads/") else f"refs/heads/{ref}" + result = subprocess.run( + [ + "git", + "ls-remote", + "--exit-code", + f"https://github.com/{repository}.git", + branch_ref, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise ValueError( + f"could not resolve branch '{ref}' in repository '{repository}'" + ) + + lines = [line for line in result.stdout.splitlines() if line.strip()] + if len(lines) != 1: + raise ValueError( + f"branch '{ref}' in repository '{repository}' resolved to " + f"{len(lines)} commits" + ) + sha = lines[0].split(maxsplit=1)[0] + if not FULL_COMMIT_RE.fullmatch(sha): + raise ValueError( + f"branch '{ref}' in repository '{repository}' returned an " + "invalid commit SHA" + ) + return sha.lower() + + +def main() -> None: + """Resolve the command-line repository and ref.""" + if len(sys.argv) != 3: + print( + "Usage: resolve_git_ref.py ", + file=sys.stderr, + ) + sys.exit(1) + try: + print(resolve_git_ref(sys.argv[1], sys.argv[2])) + except ValueError as error: + print(f"Error: {error}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/tests/test_release_scripts.py b/.github/scripts/tests/test_release_scripts.py index 44e6dbcab77..fe625729314 100644 --- a/.github/scripts/tests/test_release_scripts.py +++ b/.github/scripts/tests/test_release_scripts.py @@ -5,6 +5,7 @@ interface, matching how GitHub Actions calls them. """ +import io import json import os import subprocess @@ -20,15 +21,23 @@ MERGE_INDEX_SCRIPT = SCRIPTS_DIR / "merge_index_files.py" CHECK_COMMITS_SCRIPT = SCRIPTS_DIR / "check_new_commits.py" RESOLVE_CACHED_SCRIPT = SCRIPTS_DIR / "resolve_cached_release.py" +CHECK_ZKEVM_RELEASE_SCRIPT = SCRIPTS_DIR / "check_zkevm_benchmark_release.py" +RESOLVE_GIT_REF_SCRIPT = SCRIPTS_DIR / "resolve_git_ref.py" +VALIDATE_ZKEVM_FIXTURES_SCRIPT = ( + SCRIPTS_DIR / "validate_zkevm_benchmark_fixtures.py" +) -def run_script(script: Path, *args: str) -> subprocess.CompletedProcess: +def run_script( + script: Path, *args: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess: """Run a uv inline-deps script and return the result.""" return subprocess.run( ["uv", "run", "-q", str(script), *args], capture_output=True, text=True, cwd=REPO_ROOT, + env=env, ) @@ -72,6 +81,25 @@ def test_unsplit_feature_produces_single_entry(self): assert matrix[0]["from_fork"] == "" assert matrix[0]["until_fork"] == "" + def test_zkevm_benchmark_produces_single_entry(self): + """Verify the zkEVM benchmark feature is an unsplit build.""" + result = run_script( + BUILD_MATRIX_SCRIPT, + "zkevm-benchmark", + "v0.9.0", + ) + assert result.returncode == 0 + out = parse_matrix_output(result.stdout) + matrix = json.loads(out["build_matrix"]) + assert matrix == [ + { + "feature": "zkevm-benchmark", + "label": "", + "from_fork": "", + "until_fork": "", + } + ] + def test_devnet_name_resolves_to_shared_feature(self): """Verify a -devnet name resolves to the devnet feature.""" result = run_script( @@ -200,6 +228,9 @@ def test_known_evm_passes(self): path="${@: -1}" case "$path" in *actions/workflows*) response="$FAKE_GH_RUNS" ;; + *matching-refs/tags/tests-zkevm-benchmark*) + response="${FAKE_GH_DESTINATION_TAGS:-$FAKE_GH_TAGS}" + ;; */artifacts) run_id="${path##*/runs/}" run_id="${run_id%%/*}" @@ -207,6 +238,7 @@ def test_known_evm_passes(self): response="${!var:-$FAKE_GH_ARTIFACTS}" ;; *matching-refs*) response="$FAKE_GH_TAGS" ;; + *releases*) response="$FAKE_GH_RELEASES" ;; *compare/tests@*) response="$FAKE_GH_COMPARE_TAG" ;; *compare*) response="$FAKE_GH_COMPARE" ;; *) response="" ;; @@ -224,6 +256,270 @@ def test_known_evm_passes(self): NO_ARTIFACTS = '{"artifacts": []}' +class TestResolveGitRef: + """Test resolve_git_ref.py.""" + + def run_with_fake_git( + self, tmp_path: Path, ref: str, output: str, exit_code: int + ) -> subprocess.CompletedProcess: + """Run the resolver with a fake git command.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_git = bin_dir / "git" + fake_git.write_text( + f"#!/usr/bin/env bash\nprintf '%s' '{output}'\nexit {exit_code}\n" + ) + fake_git.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{bin_dir}:{env['PATH']}" + return run_script( + RESOLVE_GIT_REF_SCRIPT, + "example/geth", + ref, + env=env, + ) + + def test_full_commit_does_not_query_remote(self): + """Verify a full commit SHA resolves without a git command.""" + commit = "a" * 40 + result = run_script(RESOLVE_GIT_REF_SCRIPT, "example/geth", commit) + assert result.returncode == 0 + assert result.stdout.strip() == commit + + def test_branch_resolves_to_full_commit(self, tmp_path): + """Verify a branch uses the commit returned by git ls-remote.""" + commit = "b" * 40 + result = self.run_with_fake_git( + tmp_path, + "devnet", + f"{commit}\trefs/heads/devnet\n", + 0, + ) + assert result.returncode == 0 + assert result.stdout.strip() == commit + + def test_unresolved_branch_fails(self, tmp_path): + """Verify an unresolved branch stops the build.""" + result = self.run_with_fake_git(tmp_path, "missing", "", 2) + assert result.returncode == 1 + assert "could not resolve branch 'missing'" in result.stderr + + def test_empty_ref_fails(self): + """Verify an empty ref stops the build.""" + result = run_script(RESOLVE_GIT_REF_SCRIPT, "example/geth", "") + assert result.returncode == 1 + assert "ref is empty" in result.stderr + + +class TestCheckZkevmBenchmarkRelease: + """Test check_zkevm_benchmark_release.py.""" + + @staticmethod + def run_check( + tmp_path: Path, + destination_tags: str = "[[]]", + releases: str = "[[]]", + version: str = "v0.9.0", + source_ref: str = "tests-zkevm@v0.9.0", + ) -> subprocess.CompletedProcess: + """Run the release check with canned GitHub API responses.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_gh = bin_dir / "gh" + fake_gh.write_text(FAKE_GH) + fake_gh.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{bin_dir}:{env['PATH']}" + env["GITHUB_REPOSITORY"] = "ethereum/execution-specs" + env["FAKE_GH_DESTINATION_TAGS"] = destination_tags + env["FAKE_GH_RELEASES"] = releases + return run_script( + CHECK_ZKEVM_RELEASE_SCRIPT, + version, + source_ref, + env=env, + ) + + def test_new_release_request_passes(self, tmp_path): + """Verify a matching source input and unused destination pass.""" + result = self.run_check(tmp_path) + assert result.returncode == 0 + assert "Destination release: tests-zkevm-benchmark@v0.9.0" in ( + result.stdout + ) + + def test_existing_destination_tag_fails(self, tmp_path): + """Verify an existing destination tag stops the request.""" + destination_tags = json.dumps( + [[{"ref": "refs/tags/tests-zkevm-benchmark@v0.9.0"}]] + ) + result = self.run_check(tmp_path, destination_tags=destination_tags) + assert result.returncode == 1 + assert "destination tag" in result.stderr + + def test_existing_destination_draft_fails(self, tmp_path): + """Verify an existing destination draft stops the request.""" + releases = json.dumps( + [[{"tag_name": "tests-zkevm-benchmark@v0.9.0", "draft": True}]] + ) + result = self.run_check(tmp_path, releases=releases) + assert result.returncode == 1 + assert "release or draft" in result.stderr + + def test_mismatched_source_ref_fails_without_api_call(self, tmp_path): + """Verify the source ref and release version must match.""" + result = self.run_check( + tmp_path, + source_ref="tests-zkevm@v0.8.0", + ) + assert result.returncode == 1 + assert "source ref must be 'tests-zkevm@v0.9.0'" in result.stderr + + +class TestValidateZkevmBenchmarkFixtures: + """Test validate_zkevm_benchmark_fixtures.py.""" + + fixture_path = ( + "fixtures/blockchain_tests/for_amsterdam_at_0060M/" + "benchmark/compute/test_example.json" + ) + + @staticmethod + def valid_fixture() -> dict: + """Return one valid zkEVM benchmark fixture file.""" + return { + "test_example": { + "network": "Amsterdam", + "blocks": [ + { + "statelessInputBytes": "0x1234", + "statelessOutputBytes": "0xabcd", + } + ], + "_info": { + "metadata": { + "opcode_count_per_block": [{"ADD": 1}], + } + }, + } + } + + @staticmethod + def make_archive(tmp_path: Path, files: dict[str, dict]) -> Path: + """Create a fixture archive from JSON file mappings.""" + archive_path = tmp_path / "fixtures_zkevm-benchmark.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + for name, contents in files.items(): + data = json.dumps(contents).encode() + member = tarfile.TarInfo(name) + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + return archive_path + + def run_validator( + self, tmp_path: Path, files: dict[str, dict] + ) -> subprocess.CompletedProcess: + """Create and validate one fixture archive.""" + archive_path = self.make_archive(tmp_path, files) + return run_script(VALIDATE_ZKEVM_FIXTURES_SCRIPT, str(archive_path)) + + def test_valid_archive_passes(self, tmp_path): + """Verify an archive with all configured gas limits passes.""" + files = { + self.fixture_path.replace("0060M", gas_limit): self.valid_fixture() + for gas_limit in ("0010M", "0030M", "0060M") + } + result = self.run_validator(tmp_path, files) + assert result.returncode == 0 + assert "Validated 3 fixture cases in 3 fixture files" in result.stdout + + def test_empty_archive_fails(self, tmp_path): + """Verify an empty archive fails.""" + result = self.run_validator(tmp_path, {}) + assert result.returncode == 1 + assert "contains no zkEVM benchmark fixtures" in result.stderr + + def test_wrong_target_directory_fails(self, tmp_path): + """Verify fixtures must use a configured gas limit.""" + path = self.fixture_path.replace("0060M", "0050M") + result = self.run_validator(tmp_path, {path: self.valid_fixture()}) + assert result.returncode == 1 + assert "target must be one of" in result.stderr + + def test_wrong_fork_directory_fails(self, tmp_path): + """Verify fixtures cannot target another fork.""" + path = self.fixture_path.replace("amsterdam", "prague") + result = self.run_validator(tmp_path, {path: self.valid_fixture()}) + assert result.returncode == 1 + assert "target must be one of" in result.stderr + + def test_extra_fixture_format_fails(self, tmp_path): + """Verify the archive cannot contain another fixture format.""" + engine_path = ( + "fixtures/blockchain_tests_engine/for_amsterdam_at_0060M/" + "benchmark/compute/test_example.json" + ) + result = self.run_validator( + tmp_path, + { + self.fixture_path: self.valid_fixture(), + engine_path: self.valid_fixture(), + }, + ) + assert result.returncode == 1 + assert "unexpected fixture formats" in result.stderr + + def test_missing_stateless_input_fails(self, tmp_path): + """Verify the final block must contain stateless input bytes.""" + fixture = self.valid_fixture() + del fixture["test_example"]["blocks"][-1]["statelessInputBytes"] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "statelessInputBytes must be" in result.stderr + + def test_malformed_stateless_output_fails(self, tmp_path): + """Verify the final block output must contain complete bytes.""" + fixture = self.valid_fixture() + fixture["test_example"]["blocks"][-1]["statelessOutputBytes"] = "0x1" + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "statelessOutputBytes must be" in result.stderr + + def test_empty_blocks_fails(self, tmp_path): + """Verify each fixture case must contain a block.""" + fixture = self.valid_fixture() + fixture["test_example"]["blocks"] = [] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "blocks must be a non-empty list" in result.stderr + + def test_opcode_count_length_must_match_blocks(self, tmp_path): + """Verify opcode count metadata has one entry for each block.""" + fixture = self.valid_fixture() + metadata = fixture["test_example"]["_info"]["metadata"] + metadata["opcode_count_per_block"] = [] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "has 0 entries for 1 blocks" in result.stderr + + def test_opcode_count_metadata_is_required(self, tmp_path): + """Verify each fixture case contains opcode count metadata.""" + fixture = self.valid_fixture() + del fixture["test_example"]["_info"]["metadata"] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "opcode_count_per_block must be a list" in result.stderr + + def test_final_opcode_count_must_not_be_empty(self, tmp_path): + """Verify the final opcode count contains at least one opcode.""" + fixture = self.valid_fixture() + metadata = fixture["test_example"]["_info"]["metadata"] + metadata["opcode_count_per_block"] = [{}] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "final opcode count must be a non-empty object" in result.stderr + + class TestCheckNewCommits: """Test check_new_commits.py.""" diff --git a/.github/scripts/validate_zkevm_benchmark_fixtures.py b/.github/scripts/validate_zkevm_benchmark_fixtures.py new file mode 100644 index 00000000000..2ecdaba7c96 --- /dev/null +++ b/.github/scripts/validate_zkevm_benchmark_fixtures.py @@ -0,0 +1,151 @@ +#!/usr/bin/env -S uv run --script +"""Validate a zkEVM benchmark fixture archive before release.""" + +import json +import re +import sys +import tarfile +from pathlib import Path, PurePosixPath +from typing import Any, NoReturn + +EXPECTED_FORMAT = "blockchain_tests" +EXPECTED_TARGETS = { + "for_amsterdam_at_0010M", + "for_amsterdam_at_0030M", + "for_amsterdam_at_0060M", +} +FIXTURE_FORMATS = { + "state_tests", + "blockchain_tests", + "blockchain_tests_engine", + "blockchain_tests_engine_x", + "blockchain_tests_sync", +} +HEX_BYTES_RE = re.compile(r"^0x(?:[0-9a-fA-F]{2})+$") + + +def fail(message: str) -> NoReturn: + """Raise a release validation error.""" + raise ValueError(message) + + +def validate_hex_bytes(value: Any, field: str) -> None: + """Validate one non-empty hexadecimal byte string.""" + if not isinstance(value, str) or not HEX_BYTES_RE.fullmatch(value): + fail(f"{field} must be a non-empty, even-length 0x byte string") + + +def validate_case(case_name: str, fixture: Any) -> None: + """Validate one fixture case.""" + if not isinstance(fixture, dict): + fail(f"{case_name}: fixture must be an object") + if fixture.get("network") != "Amsterdam": + fail(f"{case_name}: network must be Amsterdam") + + blocks = fixture.get("blocks") + if not isinstance(blocks, list) or not blocks: + fail(f"{case_name}: blocks must be a non-empty list") + final_block = blocks[-1] + if not isinstance(final_block, dict): + fail(f"{case_name}: final block must be an object") + validate_hex_bytes( + final_block.get("statelessInputBytes"), + f"{case_name}: final block statelessInputBytes", + ) + validate_hex_bytes( + final_block.get("statelessOutputBytes"), + f"{case_name}: final block statelessOutputBytes", + ) + + info = fixture.get("_info") + metadata = info.get("metadata") if isinstance(info, dict) else None + opcode_counts = ( + metadata.get("opcode_count_per_block") + if isinstance(metadata, dict) + else None + ) + if not isinstance(opcode_counts, list): + fail(f"{case_name}: opcode_count_per_block must be a list") + if len(opcode_counts) != len(blocks): + fail( + f"{case_name}: opcode_count_per_block has {len(opcode_counts)} " + f"entries for {len(blocks)} blocks" + ) + if not isinstance(opcode_counts[-1], dict) or not opcode_counts[-1]: + fail(f"{case_name}: final opcode count must be a non-empty object") + + +def validate_archive(archive_path: Path) -> tuple[int, int]: + """Validate *archive_path* and return its file and fixture counts.""" + fixture_files = 0 + fixture_cases = 0 + seen_formats: set[str] = set() + + with tarfile.open(archive_path, mode="r:gz") as archive: + for member in archive: + path = PurePosixPath(member.name) + member_formats = FIXTURE_FORMATS.intersection(path.parts) + seen_formats.update(member_formats) + if not member.isfile() or path.suffix != ".json": + continue + if EXPECTED_FORMAT not in path.parts or ".meta" in path.parts: + continue + + format_index = path.parts.index(EXPECTED_FORMAT) + if len(path.parts) <= format_index + 1: + fail(f"{member.name}: missing benchmark target directory") + target = path.parts[format_index + 1] + if target not in EXPECTED_TARGETS: + fail( + f"{member.name}: target must be one of " + f"{', '.join(sorted(EXPECTED_TARGETS))}. Got {target}" + ) + + extracted = archive.extractfile(member) + if extracted is None: + fail(f"{member.name}: could not read fixture file") + try: + contents = json.load(extracted) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + fail(f"{member.name}: invalid JSON: {error}") + if not isinstance(contents, dict) or not contents: + fail(f"{member.name}: fixture file must be a non-empty object") + + fixture_files += 1 + for case_name, fixture in contents.items(): + validate_case(f"{member.name}:{case_name}", fixture) + fixture_cases += 1 + + unexpected_formats = seen_formats - {EXPECTED_FORMAT} + if unexpected_formats: + fail( + "archive contains unexpected fixture formats: " + + ", ".join(sorted(unexpected_formats)) + ) + if fixture_files == 0 or fixture_cases == 0: + fail("archive contains no zkEVM benchmark fixtures") + return fixture_files, fixture_cases + + +def main() -> None: + """Validate the command-line fixture archive.""" + if len(sys.argv) != 2: + print( + "Usage: validate_zkevm_benchmark_fixtures.py ", + file=sys.stderr, + ) + sys.exit(1) + archive_path = Path(sys.argv[1]) + try: + fixture_files, fixture_cases = validate_archive(archive_path) + except (OSError, tarfile.TarError, ValueError) as error: + print(f"Error: {error}", file=sys.stderr) + sys.exit(1) + print( + f"Validated {fixture_cases} fixture cases in " + f"{fixture_files} fixture files." + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/benchmark-fill-parity.yaml b/.github/workflows/benchmark-fill-parity.yaml new file mode 100644 index 00000000000..afab41d6ec6 --- /dev/null +++ b/.github/workflows/benchmark-fill-parity.yaml @@ -0,0 +1,134 @@ +name: Benchmark Fill Parity + +on: + pull_request: + workflow_dispatch: + inputs: + geth_repo: + description: "Geth repository to build" + required: false + type: string + default: "jsign/go-ethereum" + geth_branch: + description: "Geth branch to build" + required: false + type: string + default: "glamsterdam-devnet-8-t8n-zkevm" + benchmark_file: + description: "Benchmark test file to fill" + required: false + type: string + default: "tests/benchmark/compute/instruction/test_memory.py" + fill_fork: + description: "Fork to fill" + required: false + type: string + default: "Amsterdam" + xdist_workers: + description: "Number of fill workers" + required: false + type: string + default: "auto" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.ref_name != github.event.repository.default_branch }} + +env: + GETH_REPO: ${{ inputs.geth_repo || 'jsign/go-ethereum' }} + GETH_BRANCH: ${{ inputs.geth_branch || 'glamsterdam-devnet-8-t8n-zkevm' }} + BENCHMARK_FILE: ${{ inputs.benchmark_file || 'tests/benchmark/compute/instruction/test_memory.py' }} + FILL_FORK: ${{ inputs.fill_fork || 'Amsterdam' }} + XDIST_WORKERS: ${{ inputs.xdist_workers || 'auto' }} + +jobs: + compare-fills: + name: Compare Python and Geth fills + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + + - name: Setup uv + uses: ./.github/actions/setup-uv + with: + enable-cache: "false" + + - name: Install dependencies + run: uv sync --no-progress + + - name: Build Geth evm + uses: ./.github/actions/build-evm-client/geth + with: + repo: ${{ env.GETH_REPO }} + ref: ${{ env.GETH_BRANCH }} + + - name: Validate benchmark file input + shell: bash + run: | + case "$BENCHMARK_FILE" in + ./*) + echo "benchmark_file must be relative to the repository root without a leading ./" + exit 1 + ;; + tests/benchmark/*.py) + ;; + *) + echo "benchmark_file must be a Python file under tests/benchmark/" + exit 1 + ;; + esac + + if [ ! -f "$BENCHMARK_FILE" ]; then + echo "benchmark_file does not exist: $BENCHMARK_FILE" + exit 1 + fi + + - name: Prepare fill directories + run: mkdir -p fill_tmp/python fill_tmp/geth fill_logs/python fill_logs/geth + + - name: Fill benchmark with Python spec + run: | + uv run fill \ + --clean \ + --gas-benchmark-values 1 \ + --fork "$FILL_FORK" \ + -m blockchain_test \ + -n "$XDIST_WORKERS" \ + --output=fixtures_python \ + --no-html \ + --basetemp=fill_tmp/python \ + --log-to fill_logs/python \ + "$BENCHMARK_FILE" + + - name: Fill benchmark with Geth + run: | + uv run fill \ + --clean \ + --gas-benchmark-values 1 \ + --fork "$FILL_FORK" \ + --evm-bin=evm \ + -m blockchain_test \ + -n "$XDIST_WORKERS" \ + --output=fixtures_geth \ + --no-html \ + --basetemp=fill_tmp/geth \ + --log-to fill_logs/geth \ + "$BENCHMARK_FILE" + + - name: Compare fixture hashes + run: uv run hasher compare fixtures_python fixtures_geth + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: benchmark-fill-parity-debug + path: | + fixtures_python/ + fixtures_geth/ + fill_logs/ + if-no-files-found: ignore diff --git a/.github/workflows/compare-fixtures.yaml b/.github/workflows/compare-fixtures.yaml new file mode 100644 index 00000000000..065b726fdf3 --- /dev/null +++ b/.github/workflows/compare-fixtures.yaml @@ -0,0 +1,93 @@ +name: Compare Fixtures + +on: + pull_request: + paths-ignore: + - "**.md" + - "LICENSE*" + - ".gitignore" + - ".vscode/**" + - "whitelist.txt" + - "docs/**" + - "mkdocs.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fill-base: + name: Fill Fixtures (base) + runs-on: [self-hosted-ghr, size-xl-x64] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ github.event.pull_request.base.sha }} + submodules: true + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 + with: + enable-cache: false + version: ${{ vars.UV_VERSION }} + - name: Install dependencies + run: uv sync --no-progress + - name: Fill fixtures + run: uv run fill --clean -m "blockchain_test" --fork Amsterdam ./tests -n auto --output=fixtures_base --no-html + - name: Upload fixtures + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fixtures-base + path: fixtures_base/ + + fill-head: + name: Fill Fixtures (head) + runs-on: [self-hosted-ghr, size-xl-x64] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + submodules: true + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 + with: + enable-cache: false + version: ${{ vars.UV_VERSION }} + - name: Install dependencies + run: uv sync --no-progress + - name: Fill fixtures + run: uv run fill --clean -m "blockchain_test" --fork Amsterdam ./tests -n auto --output=fixtures_head --no-html + - name: Upload fixtures + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fixtures-head + path: fixtures_head/ + + compare: + name: Compare Fixture Hashes + runs-on: ubuntu-latest + needs: [fill-base, fill-head] + if: always() && needs.fill-base.result == 'success' && needs.fill-head.result == 'success' + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + submodules: true + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + version: ${{ vars.UV_VERSION }} + - name: Install dependencies + run: uv sync --no-progress + - name: Download base fixtures + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e + with: + name: fixtures-base + path: fixtures_base/ + - name: Download head fixtures + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e + with: + name: fixtures-head + path: fixtures_head/ + - name: Compare fixture hashes + run: uv run hasher compare fixtures_base fixtures_head diff --git a/.github/workflows/release_fixtures.yaml b/.github/workflows/release_fixtures.yaml index d56a9dae918..0607d27e4cb 100644 --- a/.github/workflows/release_fixtures.yaml +++ b/.github/workflows/release_fixtures.yaml @@ -27,7 +27,7 @@ on: required: true type: string branch: - description: "Branch to release from, e.g. devnets/bal/7 (required for *-devnet features)" + description: "Branch or source tag to release from, e.g. devnets/bal/7" required: false type: string evm: @@ -95,6 +95,16 @@ jobs: echo "short_sha=${sha:0:7}" >> "$GITHUB_OUTPUT" - uses: ./.github/actions/setup-uv + - name: Check zkEVM benchmark release request + if: inputs.feature == 'zkevm-benchmark' + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_SOURCE_REF: ${{ inputs.branch }} + run: | + uv run -q .github/scripts/check_zkevm_benchmark_release.py \ + "$INPUT_VERSION" "$INPUT_SOURCE_REF" + - name: Check for new commits (scheduled runs) id: check if: github.event_name == 'schedule' diff --git a/docs/dev/releasing_tests.md b/docs/dev/releasing_tests.md index ffbfe1cf504..c1972d71c18 100644 --- a/docs/dev/releasing_tests.md +++ b/docs/dev/releasing_tests.md @@ -12,9 +12,9 @@ gh workflow run release_fixtures.yaml -f feature= -f version=vX.Y.Z [-f | Input | Required | Description | | ---------- | ----------------- | ---------------------------------------------------------------------------------------------------- | -| `feature` | yes | Feature name, e.g. `tests`, `benchmark`, or a `-devnet` name. | +| `feature` | yes | Feature name, for example `tests`, `benchmark`, `zkevm-benchmark`, or a `-devnet` name. | | `version` | yes | Release version `vX.Y.Z` (validated against `^v[0-9]+\.[0-9]+\.[0-9]+$`). Tagged as `tests-@` (the `tests` feature tags as `tests@`). | -| `branch` | devnet only | Branch to build and release from. Optional for non-devnet features; **required** for devnet releases. | +| `branch` | feature-dependent | Branch or source tag to release from. Devnet and zkEVM benchmark releases require this input. | | `evm` | no | Override the evm impl (e.g. `geth`, `evmone`). Defaults to the feature's `evm-type` in `feature.yaml`. | | `evm_repo` | no | Override the t8n tool repo (e.g. `ethereum/go-ethereum`). | | `evm_ref` | no | Override the t8n tool branch / tag / commit. | @@ -39,6 +39,24 @@ Devnet releases must use a `-devnet` feature name (e.g. `feature=bal-devne gh workflow run release_fixtures.yaml -f feature=bal-devnet -f version=v7.0.0 -f branch=devnets/bal/7 ``` +## zkEVM benchmark releases + +Publish the source `tests-zkevm@vX.Y.Z` release before you make its benchmark release. The benchmark version must match the source version. + +```bash +gh workflow run release_fixtures.yaml \ + --ref 'tests-zkevm@vX.Y.Z' \ + -f feature=zkevm-benchmark \ + -f version=vX.Y.Z \ + -f branch='tests-zkevm@vX.Y.Z' +``` + +The workflow uses the Geth repository and commit in `evm.yaml` by default. A releaser can use the existing `evm`, `evm_repo`, and `evm_ref` inputs to override that configuration. + +The workflow fills Amsterdam compute benchmarks at 10M, 30M, and 60M gas. It produces only `blockchain_test` fixtures. + +Before upload, the workflow checks the stateless data in each fixture. It also checks the source version and the destination release. + ## What the workflow produces On success the workflow: @@ -51,6 +69,7 @@ On success the workflow: | ---------------- | ------- | ------------- | -------- | | `feature=tests version=v24.0.0` | `tests@v24.0.0` | `tests@v24.0.0` | `fixtures.tar.gz` | | `feature=bal-devnet version=v7.0.0 branch=devnets/bal/7` | `tests-bal-devnet@v7.0.0` | `tests-bal-devnet@v7.0.0` | `fixtures_bal-devnet.tar.gz` | +| `feature=zkevm-benchmark version=v0.9.0 branch=tests-zkevm@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `fixtures_zkevm-benchmark.tar.gz` | The release is created as a draft; review and publish it from the GitHub releases page. diff --git a/docs/running_tests/releases.md b/docs/running_tests/releases.md index 9adb54ee2a0..23ea42ed30b 100644 --- a/docs/running_tests/releases.md +++ b/docs/running_tests/releases.md @@ -2,7 +2,8 @@ Test fixtures are published as feature-scoped releases on the [`ethereum/execution-specs`](https://github.com/ethereum/execution-specs/releases) -repository: `tests@vX.Y.Z`, `-devnet@vX.Y.Z`, and `benchmark@vX.Y.Z`. Each release is +repository: `tests@vX.Y.Z`, `-devnet@vX.Y.Z`, `benchmark@vX.Y.Z`, and +`zkevm-benchmark@vX.Y.Z`. Each release is a self-contained `.tar.gz` of JSON fixtures that execution clients consume in CI. This page describes the release types, their versioning, the fixture formats they contain, @@ -28,6 +29,7 @@ and cadence. | Tests | `tests@vX.Y.Z` | `fixtures.tar.gz` | All forks, all tests (eventually including `ethereum/tests` state tests) | latest `forks/*` branch | | Devnet | `-devnet@vX.Y.Z` | `fixtures_-devnet.tar.gz` | All forks, all tests, for an upcoming-fork feature under active devnet testing | the devnet branch | | Benchmark | `benchmark@vX.Y.Z` | `fixtures_benchmark.tar.gz` | EVM benchmarking tests | latest `forks/*` branch | +| zkEVM benchmark | `zkevm-benchmark@vX.Y.Z` | `fixtures_zkevm-benchmark.tar.gz` | Amsterdam compute benchmarks with stateless input and output bytes | matching `tests-zkevm@vX.Y.Z` tag | - "Tests" releases track clients' production branches and are tagged frequently (roughly once or twice a week). They are the "must pass" release for mainnet CI, and supersede the @@ -35,8 +37,8 @@ and cadence. - "Devnet" releases target a specific feature under active development (e.g. `bal-devnet`). They are advisory/non-blocking and may not yet cover every EIP; see the corresponding release notes for the coverage provided. -- "Benchmark" (and, in future, zkEVM) releases are produced separately for their - specialized consumers. +- "Benchmark" releases are produced separately for their specialized consumers. +- "zkEVM benchmark" releases contain 10M, 30M, and 60M `blockchain_test` fixtures. Their versions match their source `tests-zkevm` releases. ## Versioning Scheme @@ -64,6 +66,8 @@ spec change in your target, so read the release notes before adopting it. This also lets two devnets of the same feature be maintained in parallel (e.g. `v3.0.1` alongside `v7.0.0`) without ambiguity, the same way `2.x` and `3.x` coexist under semver. +A `zkevm-benchmark` version must match its source `tests-zkevm` version. A benchmark-only correction requires a new `tests-zkevm` source release. + ## Fixture Formats Fixture releases contain JSON test fixtures in various formats. Note that transaction type @@ -171,6 +175,7 @@ to release URLs and downloads them. For example: uv run consume cache --input=latest # shorthand for tests@latest uv run consume cache --input=tests@latest uv run consume cache --input=bal-devnet@v7.0.0 +uv run consume cache --input=zkevm-benchmark@v0.9.0 ``` Raw tarballs can also be fetched directly with the GitHub CLI: diff --git a/docs/running_tests/test_formats/blockchain_test_engine.md b/docs/running_tests/test_formats/blockchain_test_engine.md index 6a525a9eba3..ae4cf361485 100644 --- a/docs/running_tests/test_formats/blockchain_test_engine.md +++ b/docs/running_tests/test_formats/blockchain_test_engine.md @@ -104,6 +104,17 @@ They can mismatch the hashes of the versioned blobs in the execution payload, fo Hash of the parent beacon block root. +#### - `executionWitness`: Optional execution witness object + +Optional fixture metadata for stateless validation. When present, contains +`state`, `codes`, and `headers` byte lists associated with this payload. + +#### - `executionWitnessMutated`: [`Optional`](./common_types.md#optional)`[bool]` + +Optional fixture metadata. When `true`, the payload's `executionWitness` was +deliberately modified by the filler for stateless validation negative testing +and is not expected to be generated by execution clients. + #### - `validationError`: [`TransactionException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.TransactionException)` | `[`BlockException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.BlockException) Validation error expected when executing the payload. diff --git a/docs/running_tests/test_formats/blockchain_test_engine_x.md b/docs/running_tests/test_formats/blockchain_test_engine_x.md index 4302b903789..cbf6d9bb08f 100644 --- a/docs/running_tests/test_formats/blockchain_test_engine_x.md +++ b/docs/running_tests/test_formats/blockchain_test_engine_x.md @@ -132,7 +132,10 @@ Optional; present from Cancun on. Maps forks to their blob schedule configuratio ### `FixtureEngineNewPayload` -Engine API payload structure identical to the one defined in [Blockchain Engine Tests](./blockchain_test_engine.md#fixtureenginenewpayload). Includes execution payload, versioned hashes, parent beacon block root, validation errors, version, and error codes. +Engine API payload structure identical to the one defined in [Blockchain Engine +Tests](./blockchain_test_engine.md#fixtureenginenewpayload). Includes +execution payload, optional execution witness metadata, versioned hashes, +parent beacon block root, validation errors, version, and error codes. ## Usage Notes diff --git a/docs/running_tests/test_formats/blockchain_test_sync.md b/docs/running_tests/test_formats/blockchain_test_sync.md index 5afbaa0a6d2..c9b332a618e 100644 --- a/docs/running_tests/test_formats/blockchain_test_sync.md +++ b/docs/running_tests/test_formats/blockchain_test_sync.md @@ -127,6 +127,17 @@ List of hashes of the versioned blobs that are part of the execution payload. Hash of the parent beacon block root. +#### - `executionWitness`: Optional execution witness object + +Optional fixture metadata for stateless validation. When present, contains +`state`, `codes`, and `headers` byte lists associated with this payload. + +#### - `executionWitnessMutated`: [`Optional`](./common_types.md#optional)`[bool]` + +Optional fixture metadata. When `true`, the payload's `executionWitness` was +deliberately modified by the filler for stateless validation negative testing +and is not expected to be generated by execution clients. + #### - `validationError`: [`Optional`](./common_types.md#optional)`[`[`TransactionException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.TransactionException)` | `[`BlockException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.BlockException)`]` For sync tests, this field should not be present as sync tests only work with valid chains. Invalid blocks cannot be synced. diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index 0b47af7a9be..4ae3bc90363 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -70,6 +70,9 @@ ConsolidationRequest, DepositRequest, Environment, + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, FeeSystemContractRequest, NetworkWrappedTransaction, Removable, @@ -170,6 +173,9 @@ "CodeGasMeasure", "Conditional", "ConsolidationRequest", + "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", "ExtCallGenerator", "DeploymentTestType", "DepositRequest", diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/consume.py b/packages/testing/src/execution_testing/cli/pytest_commands/consume.py index 3059f463c05..4b14110c6ab 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/consume.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/consume.py @@ -51,6 +51,13 @@ def get_command_logic_test_paths(command_name: str) -> List[Path]: / "simulator_logic" / f"test_via_{test_command}.py" ] + elif command_name == "engine_witness": + command_logic_test_paths = [ + base_path + / "simulators" + / "simulator_logic" + / "test_via_engine_witness.py" + ] elif command_name == "sync": command_logic_test_paths = [ base_path / "simulators" / "simulator_logic" / "test_via_sync.py" @@ -79,9 +86,10 @@ def decorator(func: Callable[..., Any]) -> click.Command: command_name = func.__name__ command_help = func.__doc__ command_logic_test_paths = get_command_logic_test_paths(command_name) + cli_name = command_name.replace("_", "-") @consume.command( - name=command_name, + name=cli_name, help=command_help, context_settings={"ignore_unknown_options": True}, ) @@ -120,6 +128,18 @@ def engine() -> None: pass +@consume_command(is_hive=True) +def engine_witness() -> None: + """ + Verify client-emitted execution witnesses against the fixture. + + Default transport: JSON-RPC engine_newPayloadWithWitnessVX with RLP + witness. + Pass --ssz to use the REST POST /new-payload-with-witness endpoint. + """ + pass + + @consume_command(is_hive=True) def enginex() -> None: """Consume via Engine API with pre-alloc optimization.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py index 23d6fbd5ad0..a746732ca1c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py @@ -66,6 +66,7 @@ def check_live_port(test_suite_name: str) -> Literal[8545, 8551]: "eels/consume-engine", "eels/consume-enginex", "eels/consume-sync", + "eels/consume-engine-witness", "eels/build-block", }: return 8551 diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/__init__.py new file mode 100644 index 00000000000..095c8b113fa --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/__init__.py @@ -0,0 +1 @@ +"""Engine witness simulator package.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/conftest.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/conftest.py new file mode 100644 index 00000000000..c2d65cec1ca --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/conftest.py @@ -0,0 +1,104 @@ +""" +Pytest fixtures for the `consume engine-witness` simulator. + +Drives the Hive back-end and EL clients through a witness-emitting +payload execution path, using JSON-RPC+RLP by default or REST+SSZ when +`--ssz` is enabled, then asserts the client-generated execution witness +matches the fixture. +""" + +import io +from typing import Mapping + +import pytest +from hive.client import Client + +from execution_testing.exceptions import ExceptionMapper +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.rpc import EngineSSZRPC + +pytest_plugins = ( + "execution_testing.cli.pytest_commands.plugins.pytest_hive.pytest_hive", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.base", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.single_test_client", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.test_case_description", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.timing_data", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.exceptions", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine_api", +) + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Register the `--ssz` transport flag for the engine-witness simulator.""" + parser.addoption( + "--ssz", + action="store_true", + default=False, + help=( + "Use the REST POST /new-payload-with-witness endpoint with " + "SSZ-encoded response instead of the default JSON-RPC " + "engine_newPayloadWithWitnessVX with RLP-encoded witness " + ), + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Set the supported fixture formats for the engine-witness simulator.""" + config.supported_fixture_formats = [BlockchainEngineFixture] # type: ignore[attr-defined] + + +@pytest.fixture(scope="session") +def use_ssz_transport(request: pytest.FixtureRequest) -> bool: + """Return True when `--ssz` was passed on the CLI.""" + return bool(request.config.getoption("--ssz")) + + +@pytest.fixture(scope="module") +def test_suite_name() -> str: + """The name of the hive test suite used in this simulator.""" + return "eels/consume-engine-witness" + + +@pytest.fixture(scope="module") +def test_suite_description() -> str: + """The description of the hive test suite used in this simulator.""" + return ( + "Execute blockchain-engine fixtures via the witness-emitting Engine " + "API path, using JSON-RPC engine_newPayloadWithWitnessVX with " + "RLP-encoded witness by default or REST POST " + "/new-payload-with-witness with SSZ response when --ssz is enabled, " + "verifying the client-generated execution witness against the fixture " + "witness." + ) + + +@pytest.fixture(scope="function") +def client_files( + buffered_genesis: io.BufferedReader, +) -> Mapping[str, io.BufferedReader]: + """Define the files that hive will start the client with.""" + files = {} + files["/genesis.json"] = buffered_genesis + return files + + +@pytest.fixture(scope="function") +def genesis_header(fixture: BlockchainEngineFixture) -> "FixtureHeader": + """Provide the genesis header from the fixture.""" + return fixture.genesis + + +@pytest.fixture(scope="function") +def engine_ssz_rpc( + client: Client, client_exception_mapper: ExceptionMapper | None +) -> EngineSSZRPC: + """Provide the REST client used by the `--ssz` witness transport.""" + if client_exception_mapper: + return EngineSSZRPC( + f"http://{client.ip}:8551", + response_validation_context={ + "exception_mapper": client_exception_mapper, + }, + ) + return EngineSSZRPC(f"http://{client.ip}:8551") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/__init__.py new file mode 100644 index 00000000000..f2c1ab2abce --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for consume simulator helpers.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py new file mode 100644 index 00000000000..1f759c27714 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py @@ -0,0 +1,40 @@ +"""Tests for engine-witness simulator skip handling.""" + +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from execution_testing.cli.pytest_commands.plugins.consume.simulators.simulator_logic.test_via_engine_witness import ( # noqa: E501 + test_blockchain_via_engine_witness as run_engine_witness, +) + + +def test_mutated_execution_witness_fixture_is_skipped() -> None: + """Fixtures with deliberately mutated witnesses are not consumable.""" + fixture = cast( + Any, + SimpleNamespace( + payloads=[ + SimpleNamespace( + execution_witness=object(), + execution_witness_mutated=True, + ) + ] + ), + ) + unused_dependency = cast(Any, None) + + with pytest.raises( + pytest.skip.Exception, + match="fixture contains a deliberately mutated executionWitness", + ): + run_engine_witness( + timing_data=unused_dependency, + eth_rpc=unused_dependency, + engine_rpc=unused_dependency, + engine_ssz_rpc=unused_dependency, + fixture=fixture, + genesis_header=unused_dependency, + use_ssz_transport=False, + ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_witness_diff.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_witness_diff.py new file mode 100644 index 00000000000..c067cb9af89 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_witness_diff.py @@ -0,0 +1,74 @@ +"""Tests for strict witness comparison helper.""" + +import pytest + +from execution_testing.base_types import Bytes +from execution_testing.cli.pytest_commands.plugins.consume.simulators.helpers.witness_diff import ( # noqa: E501 + WitnessMismatchError, + assert_witness_matches, +) +from execution_testing.test_types.execution_witness import ExecutionWitness + + +def _w( + state: list[bytes] | None = None, + codes: list[bytes] | None = None, + headers: list[bytes] | None = None, +) -> ExecutionWitness: + return ExecutionWitness( + state=[Bytes(b) for b in state or []], + codes=[Bytes(b) for b in codes or []], + headers=[Bytes(b) for b in headers or []], + ) + + +def test_matching_witnesses_pass() -> None: + """Separate byte-equal witnesses match.""" + # Create separate objects so equality is not just object identity. + expected = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60"], headers=[b"\xf9"]) + actual = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60"], headers=[b"\xf9"]) + assert_witness_matches(expected=expected, actual=actual) + + +def test_reordered_state_fails() -> None: + """State item comparison is order-sensitive.""" + expected = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60", b"\x70"]) + actual = _w(state=[b"\xbb", b"\xaa"], codes=[b"\x60", b"\x70"]) + with pytest.raises(WitnessMismatchError, match="state: ordered mismatch"): + assert_witness_matches(expected=expected, actual=actual) + + +def test_duplicates_are_significant() -> None: + """Duplicate items make the witness differ.""" + expected = _w(state=[b"\xaa"]) + actual = _w(state=[b"\xaa", b"\xaa"]) + with pytest.raises(WitnessMismatchError, match="state: 1 extra"): + assert_witness_matches(expected=expected, actual=actual) + + +def test_missing_state_node_fails() -> None: + """Client missing a state node gives a missing diff line.""" + expected = _w(state=[b"\xaa", b"\xbb"]) + actual = _w(state=[b"\xaa"]) + with pytest.raises(WitnessMismatchError, match="state: 1 missing"): + assert_witness_matches(expected=expected, actual=actual) + + +def test_extra_code_fails() -> None: + """Client over-collecting a code gives an extra diff line.""" + expected = _w(codes=[b"\x60"]) + actual = _w(codes=[b"\x60", b"\x70"]) + with pytest.raises(WitnessMismatchError, match=r"codes: 1 extra"): + assert_witness_matches(expected=expected, actual=actual) + + +def test_multi_field_mismatch_reports_all() -> None: + """All mismatching fields are reported in one exception.""" + expected = _w(state=[b"\xaa"], codes=[b"\x60"], headers=[b"\xf9"]) + actual = _w(state=[b"\xbb"], codes=[b"\x61"], headers=[]) + with pytest.raises(WitnessMismatchError) as excinfo: + assert_witness_matches(expected=expected, actual=actual) + msg = str(excinfo.value) + assert "state:" in msg + assert "codes:" in msg + assert "headers:" in msg diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/witness_diff.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/witness_diff.py new file mode 100644 index 00000000000..d5be3558168 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/witness_diff.py @@ -0,0 +1,87 @@ +"""Strict witness comparison helper for the engine-witness simulator.""" + +from typing import Iterable + +from execution_testing.base_types import Bytes +from execution_testing.test_types.execution_witness import ExecutionWitness + + +def _item_preview(value: bytes) -> str: + """Return a short hex preview for one witness item.""" + return "0x" + value.hex()[:16] + + +def _items_preview(items: list[bytes]) -> str: + """Return previews for the first few witness items.""" + return ", ".join(_item_preview(item) for item in items[:5]) + + +def _ordered_field_mismatch( + field: str, expected: Iterable[Bytes], actual: Iterable[Bytes] +) -> str | None: + """Return a human-readable mismatch line for one ordered field.""" + exp = [bytes(x) for x in expected] + act = [bytes(x) for x in actual] + + if exp == act: + return None + + common_len = min(len(exp), len(act)) + if exp[:common_len] == act[:common_len]: + if len(exp) > len(act): + missing = exp[common_len:] + return ( + f"{field}: {len(missing)} missing " + f"(not emitted by client): {_items_preview(missing)}" + ) + extra = act[common_len:] + return ( + f"{field}: {len(extra)} extra " + f"(over-collected by client): {_items_preview(extra)}" + ) + + first_mismatch = next( + i + for i, (expected_item, actual_item) in enumerate( + zip(exp, act, strict=False) + ) + if expected_item != actual_item + ) + + return ( + f"{field}: ordered mismatch " + f"(expected {len(exp)} items, got {len(act)}); " + f"first mismatch at index {first_mismatch}: " + f"expected {_item_preview(exp[first_mismatch])}, " + f"got {_item_preview(act[first_mismatch])}" + ) + + +class WitnessMismatchError(AssertionError): + """Raised when a client-emitted witness does not match the fixture's.""" + + +def assert_witness_matches( + expected: ExecutionWitness, actual: ExecutionWitness +) -> None: + """ + Assert the client-emitted `actual` witness matches the fixture `expected`. + + Each of `state`, `codes`, and `headers` must match exactly. Ordering and + duplicate entries are significant. + """ + messages: list[str] = [] + for field, expected_items, actual_items in ( + ("state", expected.state, actual.state), + ("codes", expected.codes, actual.codes), + ("headers", expected.headers, actual.headers), + ): + mismatch = _ordered_field_mismatch(field, expected_items, actual_items) + if mismatch is not None: + messages.append(mismatch) + + if messages: + raise WitnessMismatchError( + "client witness does not match fixture witness:\n " + + "\n ".join(messages) + ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_engine_witness.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_engine_witness.py new file mode 100644 index 00000000000..071f6047ae0 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_engine_witness.py @@ -0,0 +1,278 @@ +"""Hive simulator for witness-emitting payload execution.""" + +import pytest + +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import ( + FixtureEngineNewPayload, + FixtureHeader, +) +from execution_testing.logging import get_logger +from execution_testing.rpc import ( + EngineRPC, + EngineSSZRPC, + EngineWitnessEndpointNotImplementedError, + EthRPC, + ForkchoiceUpdateTimeoutError, +) +from execution_testing.rpc.rpc_types import ( + ForkchoiceState, + JSONRPCError, + NewPayloadWithWitnessResponse, + PayloadStatusEnum, +) + +from ..helpers.exceptions import ( + GenesisBlockMismatchExceptionError, + LoggedError, +) +from ..helpers.timing import TimingData +from ..helpers.witness_diff import ( + WitnessMismatchError, + assert_witness_matches, +) + +logger = get_logger(__name__) + +_JSONRPC_METHOD_NOT_FOUND = -32601 + + +def _witness_endpoint_label( + payload: FixtureEngineNewPayload, + *, + use_ssz_transport: bool, +) -> str: + """Return the timing label for the selected witness endpoint.""" + if use_ssz_transport: + return "POST /new-payload-with-witness" + return f"engine_newPayloadWithWitnessV{payload.new_payload_version}" + + +def _send_payload_with_witness( + *, + use_ssz_transport: bool, + engine_rpc: EngineRPC, + engine_ssz_rpc: EngineSSZRPC, + payload: FixtureEngineNewPayload, +) -> NewPayloadWithWitnessResponse | JSONRPCError: + """ + Execute one payload through the configured witness endpoint. + + Return the response, or the caught Engine API error for the assertion to + validate against the fixture's expected ``error_code``. + """ + try: + if use_ssz_transport: + return engine_ssz_rpc.new_payload_with_witness(*payload.params) + return engine_rpc.new_payload_with_witness( + *payload.params, + version=payload.new_payload_version, + ) + except EngineWitnessEndpointNotImplementedError as e: + pytest.skip(str(e)) + except JSONRPCError as e: + # An unimplemented endpoint is a transport skip, but only when no + # error was expected; otherwise the error is a result to assert. + if payload.error_code is None and e.code == _JSONRPC_METHOD_NOT_FOUND: + pytest.skip( + "client does not support " + f"engine_newPayloadWithWitnessV" + f"{payload.new_payload_version}: {e.message}" + ) + return e + + +def _assert_witness_response( + *, + payload: FixtureEngineNewPayload, + payload_number: int, + result: NewPayloadWithWitnessResponse | JSONRPCError, + payload_timing: TimingData, + use_ssz_transport: bool, +) -> None: + """Assert one witness result (response or error) matches the fixture.""" + if isinstance(result, JSONRPCError): + # The client raised an Engine API error; a negative test expects it. + if payload.error_code is None: + raise LoggedError( + f"Payload {payload_number}: unexpected error: " + f"{result.code} - {result.message}" + ) + if result.code != payload.error_code: + raise LoggedError( + f"Payload {payload_number}: unexpected error code: " + f"got {result.code}, expected {payload.error_code}" + ) + return + + if payload.error_code is not None: + # Negative test expected an Engine API error, but got a response. + raise LoggedError( + f"Payload {payload_number}: client did not raise the expected " + f"Engine API error code {payload.error_code}" + ) + + response = result + expected_status = ( + PayloadStatusEnum.VALID + if payload.valid() + else PayloadStatusEnum.INVALID + ) + if response.status != expected_status: + raise LoggedError( + f"unexpected status: want {expected_status}, got {response.status}" + ) + + if response.status != PayloadStatusEnum.VALID: + if use_ssz_transport and response.witness is not None: + raise LoggedError( + f"Payload {payload_number}: {response.status} status but " + "client returned a non-empty witness; the REST+SSZ endpoint " + "requires an empty witness when not VALID" + ) + return + + expected_witness = payload.execution_witness + if expected_witness is None: + logger.warning( + f"Payload {payload_number}: fixture has no executionWitness; " + "skipping witness diff" + ) + return + + actual_witness = response.witness + if actual_witness is None: + raise LoggedError( + f"Payload {payload_number}: VALID status but client returned " + "no witness" + ) + + with payload_timing.time("Witness diff"): + try: + assert_witness_matches( + expected=expected_witness, + actual=actual_witness, + ) + except WitnessMismatchError as e: + raise LoggedError(str(e)) from e + + +def _advance_forkchoice_to_payload( + *, + engine_rpc: EngineRPC, + payload: FixtureEngineNewPayload, +) -> None: + """Advance the client forkchoice to one valid payload.""" + response = engine_rpc.forkchoice_updated( + forkchoice_state=ForkchoiceState( + head_block_hash=payload.params[0].block_hash, + ), + payload_attributes=None, + version=payload.forkchoice_updated_version, + ) + status = response.payload_status.status + if status != PayloadStatusEnum.VALID: + raise LoggedError( + f"unexpected forkchoice status: want {PayloadStatusEnum.VALID}, " + f"got {status}" + ) + + +def test_blockchain_via_engine_witness( + timing_data: TimingData, + eth_rpc: EthRPC, + engine_rpc: EngineRPC, + engine_ssz_rpc: EngineSSZRPC, + fixture: BlockchainEngineFixture, + genesis_header: FixtureHeader, + use_ssz_transport: bool, +) -> None: + """Execute blockchain-engine fixtures through a witness endpoint.""" + if any(p.execution_witness_mutated for p in fixture.payloads): + pytest.skip("fixture contains a deliberately mutated executionWitness") + + if not any(p.execution_witness is not None for p in fixture.payloads): + pytest.skip("fixture has no executionWitness on any payload") + + transport_label = "REST+SSZ" if use_ssz_transport else "JSON-RPC+RLP" + logger.info(f"Using {transport_label} witness transport") + + with timing_data.time("Initial forkchoice update"): + logger.info("Sending initial forkchoice update to genesis block...") + try: + forkchoice_response = engine_rpc.forkchoice_updated_with_retry( + forkchoice_state=ForkchoiceState( + head_block_hash=fixture.genesis.block_hash, + ), + forkchoice_version=( + fixture.payloads[0].forkchoice_updated_version + ), + max_attempts=30, + wait_fixed=1.0, + ) + except ForkchoiceUpdateTimeoutError as e: + raise LoggedError( + f"Timed out waiting for forkchoice update to genesis: {e}" + ) from None + + status = forkchoice_response.payload_status.status + if status != PayloadStatusEnum.VALID: + raise LoggedError( + f"Unexpected status on forkchoice updated to genesis: {status}" + ) + + with timing_data.time("Get genesis block"): + genesis_block = eth_rpc.get_block_by_number(0) + assert genesis_block is not None, "genesis_block is None" + if genesis_block["hash"] != str(genesis_header.block_hash): + raise GenesisBlockMismatchExceptionError( + expected_header=genesis_header, + got_genesis_block=genesis_block, + ) + + with timing_data.time("Payloads execution") as total_payload_timing: + payload_count = len(fixture.payloads) + logger.info(f"Starting execution of {payload_count} payloads...") + for payload_number, payload in enumerate(fixture.payloads, start=1): + logger.info( + f"Processing payload {payload_number}/{payload_count}..." + ) + with total_payload_timing.time( + f"Payload {payload_number}" + ) as payload_timing: + with payload_timing.time( + _witness_endpoint_label( + payload, + use_ssz_transport=use_ssz_transport, + ) + ): + witness_result = _send_payload_with_witness( + use_ssz_transport=use_ssz_transport, + engine_rpc=engine_rpc, + engine_ssz_rpc=engine_ssz_rpc, + payload=payload, + ) + + _assert_witness_response( + payload=payload, + payload_number=payload_number, + result=witness_result, + payload_timing=payload_timing, + use_ssz_transport=use_ssz_transport, + ) + + # A raised error means the block was rejected, so there is no + # canonical block to advance the forkchoice to. + if ( + not isinstance(witness_result, JSONRPCError) + and payload.valid() + ): + with payload_timing.time( + f"engine_forkchoiceUpdatedV" + f"{payload.forkchoice_updated_version}" + ): + _advance_forkchoice_to_payload( + engine_rpc=engine_rpc, + payload=payload, + ) + logger.info("All payloads processed successfully.") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py index bdfd69c4855..fc5b89c33a5 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py @@ -1595,6 +1595,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs["is_tx_gas_heavy_test"] = is_tx_gas_heavy_test kwargs["is_exception_test"] = is_exception_test kwargs["is_inclusion_test"] = is_inclusion_test + if ( + "skip_stateless_validation" in cls.model_fields + and "skip_stateless_validation" not in kwargs + and request.node.get_closest_marker( + "skip_stateless_validation" + ) + ): + kwargs["skip_stateless_validation"] = True if ( op_mode == OpMode.OPTIMIZE_GAS or op_mode == OpMode.OPTIMIZE_GAS_POST_PROCESSING diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 29f8d06f939..ca8e379d4d5 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -15,11 +15,17 @@ import pytest -from execution_testing.test_types import Environment from execution_testing.client_clis import ( ExecutionSpecsTransitionTool, TransitionTool, ) +from execution_testing.fixtures import ( + BlockchainEngineFixture, + BlockchainFixture, +) +from execution_testing.fixtures.file import Fixtures +from execution_testing.test_types import Environment + from ..filler import default_output_directory # Path to the real benchmark conftest.py that we copy into testdirs. @@ -826,6 +832,676 @@ def test_fixture_output_based_on_command_line_args( assert properties["build"] == build_name +test_module_execution_witness = textwrap.dedent( + """\ + import pytest + + from execution_testing import Account, Environment, Op, Transaction + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness(state_test, pre) -> None: + contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) + state_test(env=Environment(), + pre=pre, post={contract: Account(storage={0: 1})}, + tx=Transaction(to=contract, sender=pre.fund_eoa())) + """ +) + +test_module_execution_witness_skip_stateless = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, + ) + + @pytest.mark.valid_at("Amsterdam") + @pytest.mark.skip_stateless_validation + def test_skip_stateless_validation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) + sender = pre.fund_eoa() + tx = Transaction(to=contract, sender=sender) + + blockchain_test( + pre=pre, + post={ + contract: Account(storage={0: 1}), + sender: Account(nonce=1), + }, + blocks=[Block(txs=[tx])], + ) + """ +) + +test_module_execution_witness_soundness = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, + ) + from execution_testing.test_types.execution_witness.modifiers import ( + remove_header_at, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_soundness( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(-1)) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + """ +) + +test_module_execution_witness_expected_true = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_expected_true( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + expected_stateless_validation_success=True, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + """ +) + +test_module_execution_witness_missing_expected = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, + ) + from execution_testing.test_types.execution_witness.modifiers import ( + remove_header_at, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_missing_expected( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(-1)) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + """ +) + + +test_module_execution_witness_rlp_modifier = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Header, + Op, + Transaction, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_rlp_modifier( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) + sender = pre.fund_eoa() + tx = Transaction(to=contract, sender=sender) + + blockchain_test( + pre=pre, + post={ + contract: Account(storage={0: 1}), + sender: Account(nonce=1), + }, + blocks=[ + Block( + txs=[tx], + rlp_modifier=Header(extra_data=b"mutated"), + ) + ], + ) + """ +) + + +test_module_execution_witness_rlp_modifier_stateless_intent = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Alloc, + Block, + BlockchainTestFiller, + Header, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_rlp_modifier_stateless_intent( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + blockchain_test( + pre=pre, + post={}, + blocks=[ + Block( + rlp_modifier=Header(extra_data=b"mutated"), + expected_stateless_validation_success=True, + ) + ], + ) + """ +) + + +def test_execution_witness_in_blockchain_fixture( + testdir: pytest.Testdir, +) -> None: + """ + Fill a minimal Amsterdam state_test that calls a pre-deployed contract, + then verify the resulting blockchain and engine fixtures contain + execution witness data. + """ + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join("test_module_execution_witness.py") + test_module.write(test_module_execution_witness) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + result.assert_outcomes( + passed=3, + failed=0, + skipped=0, + errors=0, + ) + + output_dir = Path(default_output_directory()).absolute() + assert output_dir.exists() + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness/execution_witness.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + fixture_data = Fixtures.model_validate_json(fixture_path.read_text()) + + assert len(fixture_data) == 1, "Expected exactly one fixture" + fixture = next(iter(fixture_data.values())) + assert isinstance(fixture, BlockchainFixture) + block = fixture.blocks[0] + + # executionWitness exists with non-empty state, codes, and headers + witness = block.execution_witness + assert witness is not None + assert len(witness.state) > 0, "executionWitness.state is empty" + assert len(witness.codes) > 0, "executionWitness.codes is empty" + assert len(witness.headers) > 0, "executionWitness.headers is empty" + + # statelessInputBytes is schema-prefixed guest input bytes. + sib = block.stateless_input_bytes + assert sib is not None and len(sib) > 0 + from ethereum.forks.amsterdam.stateless import ( + STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_BYTES, + ) + + assert bytes(sib).startswith(STATELESS_INPUT_SCHEMA_ID_BYTES) + + sob = block.stateless_output_bytes + assert sob is not None and len(sob) > 0 + + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as EthereumBytes + from ethereum_types.numeric import U16, U64 + + stateless_output = deserialize_stateless_output(EthereumBytes(bytes(sob))) + assert stateless_output.successful_validation is True + assert stateless_output.chain_id == U64(1) + assert stateless_output.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + + engine_fixture_path = Path( + "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" + "module_execution_witness/execution_witness.json" + ) + assert engine_fixture_path.exists(), ( + f"{engine_fixture_path} does not exist" + ) + + engine_fixture_data = Fixtures.model_validate_json( + engine_fixture_path.read_text() + ) + + assert len(engine_fixture_data) == 1, "Expected exactly one engine fixture" + engine_fixture = next(iter(engine_fixture_data.values())) + assert isinstance(engine_fixture, BlockchainEngineFixture) + engine_payload = engine_fixture.payloads[0] + + engine_witness = engine_payload.execution_witness + assert engine_witness is not None + assert len(engine_witness.state) > 0, ( + "engine executionWitness.state is empty" + ) + assert len(engine_witness.codes) > 0, ( + "engine executionWitness.codes is empty" + ) + assert len(engine_witness.headers) > 0, ( + "engine executionWitness.headers is empty" + ) + assert engine_witness == witness + + +def test_execution_witness_skip_stateless_validation( + testdir: pytest.Testdir, +) -> None: + """The skip marker omits stateless witness and byte outputs.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_skip_stateless_validation.py" + ) + test_module.write(test_module_execution_witness_skip_stateless) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = [ + "-c", + "pytest-fill.ini", + "-v", + "--until=Amsterdam", + "-m", + "blockchain_test", + "--no-html", + ] + result = testdir.runpytest(*args) + result.assert_outcomes( + passed=1, + failed=0, + skipped=0, + errors=0, + ) + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_skip_stateless_validation/skip_stateless_validation.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + assert len(fixture_data) == 1, "Expected exactly one fixture" + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][0] + + assert "executionWitness" not in block + assert "statelessInputBytes" not in block + assert "statelessOutputBytes" not in block + + +def test_execution_witness_rlp_modifier_omits_stateless_artifacts( + testdir: pytest.Testdir, +) -> None: + """RLP-mutated blocks should not expose canonical stateless artifacts.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_rlp_modifier.py" + ) + test_module.write(test_module_execution_witness_rlp_modifier) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = [ + "-c", + "pytest-fill.ini", + "-v", + "--until=Amsterdam", + "-m", + "blockchain_test", + "--no-html", + ] + result = testdir.runpytest(*args) + result.assert_outcomes( + passed=1, + failed=0, + skipped=0, + errors=0, + ) + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness_rlp_modifier/" + "execution_witness_rlp_modifier.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + assert len(fixture_data) == 1, "Expected exactly one fixture" + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][0] + + assert "executionWitness" not in block + assert "statelessInputBytes" not in block + assert "statelessOutputBytes" not in block + + +def test_execution_witness_rlp_modifier_rejects_stateless_intent( + testdir: pytest.Testdir, +) -> None: + """RLP modifiers cannot be combined with explicit stateless assertions.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_rlp_modifier_stateless_intent.py" + ) + test_module.write( + test_module_execution_witness_rlp_modifier_stateless_intent + ) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret != 0 + result.stdout.fnmatch_lines( + [ + "*Blocks with rlp_modifier omit stateless artifacts because " + "they are generated before the RLP mutation*" + ] + ) + + +def test_execution_witness_expected_true_reuses_canonical_stateless_result( + testdir: pytest.Testdir, +) -> None: + """Explicit True expectation should preserve the canonical success path.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_expected_true.py" + ) + test_module.write(test_module_execution_witness_expected_true) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret == 0 + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness_expected_true/" + "execution_witness_expected_true.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][-1] + + from ethereum.crypto.hash import Hash32 + from ethereum.forks.amsterdam.stateless import ( + compute_new_payload_request_root, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum.forks.amsterdam.stateless import ( + STATELESS_INPUT_SCHEMA_ID, + ) + from ethereum_types.bytes import Bytes as EthereumBytes + from ethereum_types.numeric import U16 + + stateless_input = deserialize_stateless_input( + EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) + ) + stateless_output = deserialize_stateless_output( + EthereumBytes(bytes.fromhex(block["statelessOutputBytes"][2:])) + ) + + assert stateless_output.successful_validation is True + assert stateless_output.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) + assert ( + stateless_output.new_payload_request_root + == compute_new_payload_request_root(stateless_input) + ) + + +def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( + testdir: pytest.Testdir, +) -> None: + """Mutated witness fixtures should carry mutated stateless bytes.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_soundness.py" + ) + test_module.write(test_module_execution_witness_soundness) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret == 0 + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness_soundness/" + "execution_witness_soundness.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][-1] + + assert len(block["executionWitness"]["headers"]) == 1 + + from ethereum.crypto.hash import Hash32 + from ethereum.forks.amsterdam.stateless import ( + compute_new_payload_request_root, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum.forks.amsterdam.stateless import ( + STATELESS_INPUT_SCHEMA_ID, + ) + from ethereum_types.bytes import Bytes as EthereumBytes + from ethereum_types.numeric import U16 + + stateless_input = deserialize_stateless_input( + EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) + ) + stateless_output = deserialize_stateless_output( + EthereumBytes(bytes.fromhex(block["statelessOutputBytes"][2:])) + ) + + assert stateless_output.successful_validation is False + assert stateless_output.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) + assert ( + stateless_output.new_payload_request_root + == compute_new_payload_request_root(stateless_input) + ) + assert len(stateless_input.witness.headers) == 1 + assert [ + "0x" + bytes(header).hex() + for header in stateless_input.witness.headers + ] == block["executionWitness"]["headers"] + + engine_fixture_path = Path( + "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" + "module_execution_witness_soundness/" + "execution_witness_soundness.json" + ) + assert engine_fixture_path.exists(), ( + f"{engine_fixture_path} does not exist" + ) + + with open(engine_fixture_path, "r") as f: + engine_fixture_data = json.load(f) + + engine_fixture = next(iter(engine_fixture_data.values())) + engine_payload = engine_fixture["engineNewPayloads"][-1] + assert engine_payload["executionWitnessMutated"] is True + assert engine_payload["executionWitness"] == block["executionWitness"] + + +def test_execution_witness_modifier_requires_explicit_guest_expectation( + testdir: pytest.Testdir, +) -> None: + """Mutated witness tests should declare the expected guest result.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_missing_expected.py" + ) + test_module.write(test_module_execution_witness_missing_expected) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret != 0 + result.stdout.fnmatch_lines( + [ + "*Mutated execution witness tests must set " + "expected_stateless_validation_success explicitly*" + ] + ) + + test_module_environment_variables = textwrap.dedent( """\ import pytest diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py index 89e24f1f5e9..abb38999ec6 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py @@ -365,6 +365,17 @@ def hive_test( ): test_passed = True test_result_details = "Test passed.\n\n" + captured_output + elif ( + hasattr(request.node, "result_call") + and request.node.result_call.skipped + ): + test_passed = True + test_result_details = ( + "Test skipped.\n\n" + + request.node.result_call.longreprtext + + "\n" + + captured_output + ) elif ( hasattr(request.node, "result_call") and not request.node.result_call.passed diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py index cdf137c6998..f718a1024d2 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py @@ -121,6 +121,7 @@ def process_args(self, args: List[str]) -> List[str]: simulator_commands = { "engine", + "engine_witness", "enginex", "sync", "rlp", diff --git a/packages/testing/src/execution_testing/client_clis/cli_types.py b/packages/testing/src/execution_testing/client_clis/cli_types.py index fbb47bfaf9b..d1c4f5eeb54 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -46,6 +46,7 @@ from execution_testing.test_types import ( Alloc, Environment, + ExecutionWitness, Transaction, TransactionReceipt, ) @@ -403,6 +404,9 @@ class Result(CamelModel): requests: List[Bytes] | None = None block_access_list: Bytes | None = None block_access_list_hash: Hash | None = None + execution_witness: ExecutionWitness | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None block_exception: Annotated[ BlockExceptionWithMessage | UndefinedException | None, ExceptionMapperValidator, diff --git a/packages/testing/src/execution_testing/client_clis/transition_tool.py b/packages/testing/src/execution_testing/client_clis/transition_tool.py index 2a425e926dd..626d2a508e0 100644 --- a/packages/testing/src/execution_testing/client_clis/transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/transition_tool.py @@ -330,6 +330,7 @@ class TransitionToolData: reward: int blob_schedule: BlobSchedule | None state_test: bool = False + skip_stateless_validation: bool = False @property def fork_name(self) -> str: diff --git a/packages/testing/src/execution_testing/evm_tools/t8n/__init__.py b/packages/testing/src/execution_testing/evm_tools/t8n/__init__.py index 850fed9d05b..5f406928af7 100644 --- a/packages/testing/src/execution_testing/evm_tools/t8n/__init__.py +++ b/packages/testing/src/execution_testing/evm_tools/t8n/__init__.py @@ -123,8 +123,9 @@ class T8N(Load): ``T8N`` is JSON-free: callers hand in a testing ``TransitionTool.TransitionToolData`` (alloc / env / txs / - blob_schedule / fork / chain_id / reward / state_test) plus any - pre-PoS ommer data, and ``run()`` returns a + blob_schedule / fork / chain_id / reward / state_test / + skip_stateless_validation) plus any pre-PoS ommer data, and ``run()`` + returns a :class:`~execution_testing.client_clis.cli_types.TransitionToolOutput`. See :mod:`.cli` for the JSON wrapper used by the ``ethereum-spec-evm t8n`` entry point. @@ -139,6 +140,7 @@ class T8N(Load): body: Bytes state_test: bool state_reward: int + skip_stateless_validation: bool exception_mapper: Optional["ExceptionMapper"] _block_exception: Optional[str] @@ -212,6 +214,7 @@ def __init__( self.chain_id = U64(t8n_data.chain_id) self.state_test = t8n_data.state_test self.state_reward = t8n_data.reward + self.skip_stateless_validation = t8n_data.skip_stateless_validation self.exception_mapper = exception_mapper from execution_testing.client_clis.cli_types import LazyAlloc @@ -376,6 +379,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: target_address=self.fork.HISTORY_STORAGE_ADDRESS, data=block_env.block_hashes[-1], # The parent hash ) + if self.fork.has_track_ancestor_access: + self.fork.track_ancestor_access( + block_env.state, + Uint(1), + ) if self.fork.has_beacon_roots_address: self.fork.process_unchecked_system_transaction( @@ -422,7 +430,6 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: block_output.block_access_list = self.fork.build_block_access_list( block_env.block_access_list_builder, block_env.state ) - # Validate block access list gas limit constraint (EIP-7928) self.fork.validate_block_access_list_gas_limit( block_access_list=block_output.block_access_list, diff --git a/packages/testing/src/execution_testing/evm_tools/t8n/cli.py b/packages/testing/src/execution_testing/evm_tools/t8n/cli.py index 60bcaeee82c..0d9cd5d5747 100644 --- a/packages/testing/src/execution_testing/evm_tools/t8n/cli.py +++ b/packages/testing/src/execution_testing/evm_tools/t8n/cli.py @@ -88,6 +88,15 @@ def t8n_arguments(subparsers: argparse._SubParsersAction) -> None: t8n_parser.add_argument("--opcode.count", dest="opcode_count", type=str) t8n_parser.add_argument("--state-test", action="store_true") + t8n_parser.add_argument( + "--no-stateless", + dest="no_stateless", + action="store_true", + help=( + "Skip stateless witness generation, input serialization, " + "and guest validation." + ), + ) def _read_json_input( @@ -367,6 +376,7 @@ def build_t8n_from_cli_options( reward=_resolve_state_reward(options.state_reward, fork_module), blob_schedule=blob_schedule, state_test=options.state_test, + skip_stateless_validation=options.no_stateless, ) # ``Ommer.address`` is parsed via the per-fork ``hex_to_address`` diff --git a/packages/testing/src/execution_testing/evm_tools/t8n/result.py b/packages/testing/src/execution_testing/evm_tools/t8n/result.py index 0b07a60a20e..61a8bdab084 100644 --- a/packages/testing/src/execution_testing/evm_tools/t8n/result.py +++ b/packages/testing/src/execution_testing/evm_tools/t8n/result.py @@ -8,9 +8,12 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ethereum.crypto.hash import keccak256 +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import InvalidBlock from ethereum.merkle_patricia_trie import root, trie_get from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8 +from ethereum_types.numeric import U64, U256, Uint if TYPE_CHECKING: from execution_testing.client_clis.cli_types import ( @@ -66,6 +69,170 @@ def get_receipts_from_output(t8n: "T8N", block_output: Any) -> List[Any]: return receipts +def _ordered_block_headers(t8n: "T8N") -> List[Bytes]: + """ + Return the preceding block headers in increasing block-number order. + + Once header data is provided, require a contiguous sequence covering + the available 256-block history, matching the legacy T8N behavior. + """ + if not t8n.fork.has_track_ancestor_access or not t8n.env.block_headers: + return [] + + headers_by_number = { + int(number): Bytes(bytes(header)) + for number, header in t8n.env.block_headers.items() + } + block_number = int(t8n.env.number) + max_count = min(256, block_number) + headers: List[Bytes] = [] + for number in range(block_number - max_count, block_number): + try: + headers.append(headers_by_number[number]) + except KeyError: + raise ValueError( + f"missing block header for block {number}" + ) from None + return headers + + +def _build_execution_witness( + t8n: "T8N", + block_env: Any, + state_root: Hash32, +) -> Any: + """Build an execution witness against the still-unmodified pre-state.""" + # ``Alloc`` is the live PreState used during execution. Materialize an + # independent MPT mirror here because the Amsterdam witness builder needs + # the flat pre-state tries. ``T8N.run`` applies the block diff only after + # ``build_result`` returns, so this is still the original pre-state. + pre_state = t8n.alloc._materialize_state() + return t8n.fork.build_execution_witness( + block_env.state, + expected_post_state_root=state_root, + pre_state_accounts_data=pre_state._main_trie, + pre_state_storages_data=pre_state._storage_tries, + blockchain_headers=_ordered_block_headers(t8n), + ) + + +def _convert_withdrawals(t8n: "T8N") -> tuple[Any, ...]: + """Convert testing withdrawals into the active fork's withdrawal type.""" + return tuple( + t8n.fork.Withdrawal( + U64(int(withdrawal.index)), + U64(int(withdrawal.validator_index)), + t8n.fork.hex_to_address(withdrawal.address.hex()), + U256(int(withdrawal.amount)), + ) + for withdrawal in (t8n.env.withdrawals or []) + ) + + +def _payload_transactions(t8n: "T8N", block_output: Any) -> tuple[Any, ...]: + """Return the transactions committed to the block's transaction trie.""" + transactions: List[Any] = [] + for tx_index in range(len(t8n.txs)): + key = rlp.encode(Uint(tx_index)) + tx = trie_get(block_output.transactions_trie, key) + if tx is not None: + transactions.append(tx) + return tuple(transactions) + + +def _build_stateless_artifacts( + t8n: "T8N", + block_env: Any, + block_output: Any, + block_exception: Optional[str], + result_arguments: Dict[str, Any], + execution_witness: Any, +) -> Optional[tuple[bytes, bytes]]: + """Build and execute the stateless guest input for a blockchain test.""" + block_hashes = block_env.block_hashes + assert block_hashes and block_hashes[-1] is not None + + header = t8n.fork.Header( + parent_hash=Hash32(bytes(block_hashes[-1])), + ommers_hash=keccak256(rlp.encode([])), + coinbase=block_env.coinbase, + state_root=result_arguments["state_root"], + transactions_root=result_arguments["transactions_trie"], + receipt_root=result_arguments["receipts_root"], + bloom=result_arguments["logs_bloom"], + difficulty=Uint(0), + number=block_env.number, + gas_limit=block_env.block_gas_limit, + gas_used=Uint(result_arguments["gas_used"]), + timestamp=block_env.time, + extra_data=Bytes( + t8n.env.extra_data + if "extra_data" in t8n.env.model_fields_set + else b"" + ), + prev_randao=block_env.prev_randao, + nonce=Bytes8(b"\x00" * 8), + base_fee_per_gas=block_env.base_fee_per_gas, + withdrawals_root=result_arguments["withdrawals_root"], + blob_gas_used=block_output.blob_gas_used, + excess_blob_gas=block_env.excess_blob_gas, + parent_beacon_block_root=block_env.parent_beacon_block_root, + requests_hash=result_arguments["requests_hash"], + block_access_list_hash=result_arguments["block_access_list_hash"], + slot_number=block_env.slot_number, + ) + block = t8n.fork.Block( + header=header, + transactions=_payload_transactions(t8n, block_output), + ommers=(), + withdrawals=_convert_withdrawals(t8n), + ) + + try: + typed_requests = t8n.fork.decode_execution_requests( + tuple(block_output.requests) + ) + except InvalidBlock: + # Mocked system contracts can emit non-canonical request bytes. + # They cannot be represented in the typed stateless input. + return None + + stateless_input = t8n.fork.build_stateless_input( + block, + execution_witness=execution_witness, + execution_requests=typed_requests, + block_access_list=block_output.block_access_list, + chain_id=block_env.chain_id, + ) + stateless_input_bytes = t8n.fork.serialize_stateless_input(stateless_input) + stateless_output_bytes = t8n.fork.run_stateless_guest( + stateless_input_bytes + ) + stateless_output = t8n.fork.deserialize_stateless_output( + stateless_output_bytes + ) + + # The transition phase executes the block body before the finalized block + # exists, so block-level RLP validation is first observable here. + block_rlp_size_limit = t8n.fork.block_rlp_size_limit + block_rlp_limit_exceeded = ( + block_rlp_size_limit is not None + and len(rlp.encode(block)) > block_rlp_size_limit + ) + if ( + t8n.rejected_transactions + or block_exception is not None + or block_rlp_limit_exceeded + ): + assert not stateless_output.successful_validation + else: + assert stateless_output.successful_validation, ( + "Stateless validation failed" + ) + + return bytes(stateless_input_bytes), bytes(stateless_output_bytes) + + def build_result( t8n: "T8N", block_env: Any, @@ -117,6 +284,31 @@ def build_result( block_output.block_access_list ) + if t8n.fork.has_execution_witness and not t8n.skip_stateless_validation: + execution_witness = _build_execution_witness( + t8n, block_env, state_root + ) + arguments["execution_witness"] = { + "state": [bytes(node) for node in execution_witness.state], + "codes": [bytes(code) for code in execution_witness.codes], + "headers": [bytes(header) for header in execution_witness.headers], + } + + if not t8n.state_test: + stateless_artifacts = _build_stateless_artifacts( + t8n, + block_env, + block_output, + block_exception, + arguments, + execution_witness, + ) + if stateless_artifacts is not None: + ( + arguments["stateless_input_bytes"], + arguments["stateless_output_bytes"], + ) = stateless_artifacts + context: Optional[Dict[str, Any]] = None if t8n.exception_mapper is not None: context = {"exception_mapper": t8n.exception_mapper} diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 7e2b9b953a9..6a7b19d6b99 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -75,6 +75,7 @@ AllocGroupHash, BlockAccessList, Environment, + ExecutionWitness, Removable, Requests, TestPhase, @@ -609,6 +610,8 @@ class FixtureEngineNewPayload(CamelModel): params: EngineNewPayloadParameters new_payload_version: Number forkchoice_updated_version: Number + execution_witness: ExecutionWitness | None = None + execution_witness_mutated: bool | None = None validation_error: ExceptionInstanceOrList | None = None error_code: ( Annotated[ @@ -705,6 +708,8 @@ def from_fixture_header( withdrawals: List[Withdrawal] | None, requests: List[Bytes] | None, block_access_list: Bytes | None = None, + execution_witness: ExecutionWitness | None = None, + execution_witness_mutated: bool | None = None, execution_payload_modifier: ( "FixtureExecutionPayloadModifier | None" ) = None, @@ -776,6 +781,8 @@ def from_fixture_header( params=payload_params, new_payload_version=new_payload_version, forkchoice_updated_version=forkchoice_updated_version, + execution_witness=execution_witness, + execution_witness_mutated=execution_witness_mutated, **kwargs, ) @@ -840,6 +847,9 @@ def strip_block_number_computed_field(cls, data: Any) -> Any: ) withdrawals: List[FixtureWithdrawal] | None = None receipts: List[FixtureTransactionReceipt] | None = None + execution_witness: ExecutionWitness | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None block_access_list: BlockAccessList | None = Field( None, description="EIP-7928 Block Access List" ) @@ -878,7 +888,14 @@ class FixtureBlock(FixtureBlockBase): def without_rlp(self) -> FixtureBlockBase: """Return FixtureBlockBase without the RLP bytes set.""" return FixtureBlockBase( - **self.model_dump(exclude={"rlp"}), + **self.model_dump( + exclude={ + "rlp", + "execution_witness", + "stateless_input_bytes", + "stateless_output_bytes", + }, + ), ) @@ -898,6 +915,9 @@ class InvalidFixtureBlock(CamelModel): rlp: Bytes expect_exception: ExceptionInstanceOrList rlp_decoded: FixtureBlockBase | None = Field(None, alias="rlp_decoded") + execution_witness: ExecutionWitness | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None @post_state_validator() diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py index 827a430751a..134285f126a 100644 --- a/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py @@ -29,6 +29,7 @@ AuthorizationTuple, ConsolidationRequest, DepositRequest, + ExecutionWitness, Requests, Transaction, Withdrawal, @@ -752,6 +753,12 @@ target_pubkey=BLSPublicKey(2), ), ).requests_list, + execution_witness=ExecutionWitness( + state=[Bytes(b"state")], + codes=[Bytes(b"code")], + headers=[Bytes(b"header")], + ), + execution_witness_mutated=True, validation_error=[ BlockException.INCORRECT_BLOCK_FORMAT, TransactionException.INTRINSIC_GAS_TOO_LOW, @@ -837,6 +844,12 @@ ).requests_list ], ], + "executionWitness": { + "state": [Bytes(b"state").hex()], + "codes": [Bytes(b"code").hex()], + "headers": [Bytes(b"header").hex()], + }, + "executionWitnessMutated": True, "forkchoiceUpdatedVersion": "3", "newPayloadVersion": "4", "validationError": "BlockException.INCORRECT_BLOCK_FORMAT" diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 30fe2fee5b3..938809b171d 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -1085,6 +1085,17 @@ def system_contracts(cls) -> List[Address]: """Return list of system contracts supported by the fork.""" pass + @classmethod + def execution_witness_implicit_code_addresses( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> List[Address]: + """ + Return addresses whose pre-state bytecodes are implicitly expected in + execution witnesses for block execution at this fork. + """ + del block_number, timestamp + return [] + @classmethod @abstractmethod def deterministic_factory_predeploy_address(cls) -> Address | None: diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 72bb2b3c4b8..920b574f21e 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1634,6 +1634,39 @@ class Amsterdam( # related Amsterdam specs change over time, and before Amsterdam is # live on mainnet. + @classmethod + def execution_witness_implicit_code_addresses( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> List[Address]: + """Include tracked block-level system code.""" + del block_number, timestamp + return [ + Address( + 0x0000BFF46984E3725691FA540A8C7589300D8282, + label="BUILDER_DEPOSIT_CONTRACT_ADDRESS", + ), + Address( + 0x000064D678505AD48F8CCB093BC65613800E8282, + label="BUILDER_EXIT_CONTRACT_ADDRESS", + ), + Address( + 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, + label="BEACON_ROOTS_ADDRESS", + ), + Address( + 0x00000961EF480EB55E80D19AD83579A64C007002, + label="WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS", + ), + Address( + 0x0000BBDDC7CE488642FB579F8B00F3A590007251, + label="CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS", + ), + Address( + 0x0000F90827F1C53A10CB7A02335B175320002935, + label="HISTORY_STORAGE_ADDRESS", + ), + ] + @classmethod def engine_payload_attribute_target_gas_limit(cls) -> bool: """ diff --git a/packages/testing/src/execution_testing/forks/tests/test_forks.py b/packages/testing/src/execution_testing/forks/tests/test_forks.py index 98bf05b2ee4..b70c99ecab3 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/forks/tests/test_forks.py @@ -229,6 +229,24 @@ def test_forks() -> None: ) +def test_amsterdam_execution_witness_implicit_code_addresses() -> None: + """Amsterdam exposes exactly the tracked ambient witness code addresses.""" + addresses = [ + address.hex() + for address in Amsterdam.execution_witness_implicit_code_addresses() + ] + + assert addresses == [ + "0x0000bff46984e3725691fa540a8c7589300d8282", + "0x000064d678505ad48f8ccb093bc65613800e8282", + "0x000f3df6d732807ef1319fb7b8bb8522d0beac02", + "0x00000961ef480eb55e80d19ad83579a64c007002", + "0x0000bbddc7ce488642fb579f8b00f3a590007251", + "0x0000f90827f1c53a10cb7a02335b175320002935", + ] + assert "0x00000000219ab540356cbb839cbe05303d7705fa" not in addresses + + class ForkInPydanticModel(BaseModel): """Fork in pydantic model.""" diff --git a/packages/testing/src/execution_testing/rpc/__init__.py b/packages/testing/src/execution_testing/rpc/__init__.py index 1812a5fa98a..950f6598696 100644 --- a/packages/testing/src/execution_testing/rpc/__init__.py +++ b/packages/testing/src/execution_testing/rpc/__init__.py @@ -9,6 +9,8 @@ BlockNumberType, DebugRPC, EngineRPC, + EngineSSZRPC, + EngineWitnessEndpointNotImplementedError, EthRPC, ForkchoiceUpdateTimeoutError, NetRPC, @@ -28,6 +30,7 @@ ForkConfigBlobSchedule, JSONRPCRequest, JSONRPCResponse, + NewPayloadWithWitnessResponse, RPCCall, TransactionProtocol, ) @@ -42,6 +45,8 @@ "DebugRPC", "DEFAULT_REQUEST_TIMEOUT", "EngineRPC", + "EngineSSZRPC", + "EngineWitnessEndpointNotImplementedError", "EthConfigResponse", "EthRPC", "ForkConfig", @@ -50,6 +55,7 @@ "JSONRPCRequest", "JSONRPCResponse", "NetRPC", + "NewPayloadWithWitnessResponse", "NewPayloadTimeoutError", "RPCCall", "PeerConnectionTimeoutError", diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 58cfb14035d..ad49b056200 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -56,6 +56,7 @@ JSONRPCError, JSONRPCRequest, JSONRPCResponse, + NewPayloadWithWitnessResponse, PayloadAttributes, PayloadStatus, PayloadStatusEnum, @@ -159,6 +160,21 @@ def __init__( super().__init__(msg) +class EngineWitnessEndpointNotImplementedError(Exception): + """ + Raised when the client does not implement the REST + ``/new-payload-with-witness`` endpoint (HTTP 404 or 405). + """ + + def __init__(self, url: str, http_status: int): + """Initialize with endpoint URL and HTTP status.""" + self.url = url + self.http_status = http_status + super().__init__( + f"REST endpoint not implemented at {url} (HTTP {http_status})" + ) + + class NewPayloadTimeoutError(Exception): """Raised when ``engine_newPayload`` stays SYNCING past the retry limit.""" @@ -1440,6 +1456,24 @@ def new_payload(self, *params: Any, version: int) -> PayloadStatus: context=self.response_validation_context, ) + def new_payload_with_witness( + self, + *params: Any, + version: int, + ) -> NewPayloadWithWitnessResponse: + """ + `engine_newPayloadWithWitnessVX`: execute the payload and decode the + payload status plus hex-encoded RLP execution witness. + """ + method = f"newPayloadWithWitnessV{version}" + params_list = [to_json(param) for param in params] + + result = self.post_request( + request=RPCCall(method=method, params=params_list) + ).result_or_raise() + + return NewPayloadWithWitnessResponse.from_json_rpc_result(result) + def new_payload_with_retry( self, *params: Any, @@ -1652,6 +1686,72 @@ def _do_forkchoice_update() -> ForkchoiceUpdateResponse: return _do_forkchoice_update() +class EngineSSZRPC(BaseJwtRPC): + """ + REST client for `POST /new-payload-with-witness`. + + The endpoint uses a JSON request body and an SSZ response body; JWT auth + is inherited from `BaseJwtRPC`. + """ + + path: ClassVar[str] = "/new-payload-with-witness" + default_timeout: ClassVar[int] = 8 + + def new_payload_with_witness( + self, + *params: Any, + timeout: int | None = None, + ) -> NewPayloadWithWitnessResponse: + """ + `POST /new-payload-with-witness`: submit a payload and receive the + validation result together with the client-generated execution + witness. + + Raise `EngineWitnessEndpointNotImplementedError` on HTTP 404 or 405 + so the caller can skip clients without REST support. + """ + if timeout is None: + timeout = self.default_timeout + + url = self.url.rstrip("/") + self.path + body = [to_json(param) for param in params] + headers = { + "Content-Type": "application/json", + } | self.namespace_extra_headers() + + logger.debug(f"POST {url}, timeout={timeout}") + response = self.session.post( + url, json=body, headers=headers, timeout=timeout + ) + + if response.status_code in (404, 405): + raise EngineWitnessEndpointNotImplementedError( + url, response.status_code + ) + + # Engine API errors arrive as an HTTP error + JSON {code, message}. + if not response.ok: + try: + error = response.json() + except ValueError: + error = None + if isinstance(error, dict) and "code" in error: + raise JSONRPCError( + code=error["code"], + message=error.get("message", ""), + ) + response.raise_for_status() + + content_type = response.headers.get("Content-Type", "") + if "application/octet-stream" not in content_type: + raise ValueError( + f"Unexpected Content-Type from {url}: {content_type!r} " + f"(expected application/octet-stream)" + ) + + return NewPayloadWithWitnessResponse.from_ssz_bytes(response.content) + + class NetRPC(BaseRPC): """Represents a net RPC class for network-related RPC calls.""" diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index 271a268c04f..af86fe58cc2 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -2,11 +2,18 @@ import json from binascii import crc32 +from dataclasses import dataclass from enum import Enum from hashlib import sha256 from typing import Annotated, Any, Dict, List, Protocol, Self +import ethereum_rlp as eth_rlp from pydantic import AliasChoices, BaseModel, Field, model_validator +from remerkleable.basic import uint8 +from remerkleable.byte_arrays import ByteList, ByteVector +from remerkleable.complex import Container +from remerkleable.complex import List as SSZList +from remerkleable.union import Union as SSZUnion from execution_testing.base_types import ( Address, @@ -30,6 +37,7 @@ ) from execution_testing.forks import Fork from execution_testing.test_types import EOA, Transaction, Withdrawal +from execution_testing.test_types.execution_witness import ExecutionWitness class JSONRPCError(Exception): @@ -420,6 +428,218 @@ class EthConfigResponse(CamelModel): last: ForkConfig | None = None +# SSZ schema for the REST POST /new-payload-with-witness response. + +VALIDATION_ERROR_MAX = 8192 +MAX_WITNESS_BYTES = 2**30 # 1 GiB +MAX_WITNESS_ITEMS = 2**20 +MAX_WITNESS_ITEM_BYTES = 2**20 + + +class _SSZExecutionWitness(Container): + state: SSZList[ByteList[MAX_WITNESS_ITEM_BYTES], MAX_WITNESS_ITEMS] + codes: SSZList[ByteList[MAX_WITNESS_ITEM_BYTES], MAX_WITNESS_ITEMS] + headers: SSZList[ByteList[MAX_WITNESS_ITEM_BYTES], MAX_WITNESS_ITEMS] + + +class _SSZNewPayloadWithWitnessResponse(Container): + status: uint8 + latest_valid_hash: SSZUnion[None, ByteVector[32]] + validation_error: SSZUnion[None, ByteList[VALIDATION_ERROR_MAX]] + witness: ByteList[MAX_WITNESS_BYTES] + + +class _NewPayloadWithWitnessJSONRPCResult(CamelModel): + """JSON-RPC result for `engine_newPayloadWithWitnessVX`.""" + + model_config = CamelModel.model_config | {"extra": "ignore"} + + status: PayloadStatusEnum + latest_valid_hash: Hash | None = None + validation_error: str | None = None + witness: str | None = None + + +_SSZ_STATUS_TO_ENUM: Dict[int, PayloadStatusEnum] = { + 0: PayloadStatusEnum.VALID, + 1: PayloadStatusEnum.INVALID, + 2: PayloadStatusEnum.SYNCING, + 3: PayloadStatusEnum.ACCEPTED, + 4: PayloadStatusEnum.INVALID_BLOCK_HASH, +} + + +def _decode_0x_hex(value: str, field_name: str) -> bytes: + """Decode a strict JSON-RPC hex string.""" + if not value.startswith("0x"): + raise ValueError(f"{field_name} must be a 0x-prefixed hex string") + hex_value = value[2:] + if len(hex_value) % 2 != 0: + raise ValueError( + f"{field_name} must have an even number of hex digits" + ) + try: + return bytes.fromhex(hex_value) + except ValueError as e: + raise ValueError(f"{field_name} must be valid hex") from e + + +def _is_rlp_value(value: Any) -> bool: + """Return True when value can be re-encoded as an RLP value.""" + if isinstance(value, bytes): + return True + if isinstance(value, list): + return all(_is_rlp_value(item) for item in value) + return False + + +def _ensure_rlp_list(value: Any, field_name: str) -> List[Any]: + """Return an RLP list or raise a contextual error.""" + if not isinstance(value, list): + raise ValueError( + f"execution witness {field_name} must be an RLP list, " + f"got {type(value).__name__}" + ) + return value + + +def _bytes_list_from_rlp(value: Any, field_name: str) -> List[Bytes]: + """Convert an RLP list of byte strings to `Bytes` values.""" + values = _ensure_rlp_list(value, field_name) + result: List[Bytes] = [] + for index, item in enumerate(values): + if not isinstance(item, bytes): + raise ValueError( + f"execution witness {field_name}[{index}] must be bytes, " + f"got {type(item).__name__}" + ) + result.append(Bytes(item)) + return result + + +def _headers_from_rlp(value: Any) -> List[Bytes]: + """Convert RLP header objects to encoded header bytes.""" + headers = _ensure_rlp_list(value, "headers") + result: List[Bytes] = [] + for index, header in enumerate(headers): + if not _is_rlp_value(header): + raise ValueError( + f"execution witness headers[{index}] must be an RLP value, " + f"got {type(header).__name__}" + ) + result.append(Bytes(eth_rlp.encode(header))) + return result + + +def _execution_witness_from_json_rpc_rlp( + witness_bytes: bytes, +) -> ExecutionWitness: + """Decode a JSON-RPC RLP execution witness.""" + parsed = eth_rlp.decode(witness_bytes) + if not isinstance(parsed, list): + raise ValueError( + "Unexpected execution witness RLP structure: " + f"{type(parsed).__name__}" + ) + # Some clients append a legacy, non-spec `keys` field. Accept it + # temporarily, but ignore it below and only build from the spec fields. + if len(parsed) not in (3, 4): + raise ValueError( + "Unexpected execution witness RLP structure: " + f"list of length {len(parsed)}" + ) + + headers_raw, codes_raw, state_raw = parsed[0:3] + return ExecutionWitness( + state=_bytes_list_from_rlp(state_raw, "state"), + codes=_bytes_list_from_rlp(codes_raw, "codes"), + headers=_headers_from_rlp(headers_raw), + ) + + +@dataclass(frozen=True, slots=True) +class NewPayloadWithWitnessResponse: + """ + Decoded response of POST /new-payload-with-witness. + + The witness field is ``None`` whenever status is not ``VALID`` (the spec + mandates an empty SSZ witness in that case). + """ + + status: PayloadStatusEnum + latest_valid_hash: Hash | None + validation_error: str | None + witness: ExecutionWitness | None = None + + @classmethod + def from_ssz_bytes(cls, data: bytes) -> Self: + """Decode an SSZ-encoded NewPayloadWithWitnessResponseV1 body.""" + resp = _SSZNewPayloadWithWitnessResponse.decode_bytes(data) + + status_int = int(resp.status) + try: + status = _SSZ_STATUS_TO_ENUM[status_int] + except KeyError as e: + raise ValueError(f"Unknown SSZ status byte: {status_int}") from e + + latest_valid_hash: Hash | None = None + if resp.latest_valid_hash.selector() == 1: + latest_valid_hash = Hash(bytes(resp.latest_valid_hash.value())) + + validation_error: str | None = None + if resp.validation_error.selector() == 1: + raw = bytes(resp.validation_error.value()) + validation_error = raw.decode("utf-8", errors="replace") + + witness: ExecutionWitness | None = None + witness_bytes = bytes(resp.witness) + if witness_bytes: + if status != PayloadStatusEnum.VALID: + raise ValueError( + f"{status.value} SSZ response must not contain a witness" + ) + inner = _SSZExecutionWitness.decode_bytes(witness_bytes) + witness = ExecutionWitness( + state=[Bytes(bytes(x)) for x in inner.state], + codes=[Bytes(bytes(x)) for x in inner.codes], + headers=[Bytes(bytes(x)) for x in inner.headers], + ) + + return cls( + status=status, + latest_valid_hash=latest_valid_hash, + validation_error=validation_error, + witness=witness, + ) + + @classmethod + def from_json_rpc_result(cls, data: Dict[str, Any]) -> Self: + """ + Decode a JSON-RPC `engine_newPayloadWithWitnessVX` response. + + The `witness` field is a hex-encoded RLP list + `[Headers, Codes, State]` where Headers are RLP-encoded header + structures. Some clients append a legacy `Keys` element; it is ignored + because it is not part of the current spec. Re-encode each header to + RLP bytes so the resulting ExecutionWitness has the same + `headers: List[Bytes]` shape as the fixture. + """ + result = _NewPayloadWithWitnessJSONRPCResult.model_validate(data) + + witness: ExecutionWitness | None = None + if result.witness is not None: + witness_bytes = _decode_0x_hex(result.witness, "witness") + if witness_bytes: + witness = _execution_witness_from_json_rpc_rlp(witness_bytes) + + return cls( + status=result.status, + latest_valid_hash=result.latest_valid_hash, + validation_error=result.validation_error, + witness=witness, + ) + + class TransactionProtocol(Protocol): """Protocol for a transaction that can be sent to the client.""" diff --git a/packages/testing/src/execution_testing/rpc/tests/test_new_payload_with_witness.py b/packages/testing/src/execution_testing/rpc/tests/test_new_payload_with_witness.py new file mode 100644 index 00000000000..c9cfbacf897 --- /dev/null +++ b/packages/testing/src/execution_testing/rpc/tests/test_new_payload_with_witness.py @@ -0,0 +1,312 @@ +"""Tests for `NewPayloadWithWitnessResponse` SSZ and RLP decoding.""" + +import ethereum_rlp as eth_rlp +import pytest +from ethereum_rlp.rlp import Extended +from remerkleable.basic import uint8 +from remerkleable.byte_arrays import ByteList, ByteVector + +from execution_testing.rpc.rpc_types import ( + MAX_WITNESS_BYTES, + MAX_WITNESS_ITEM_BYTES, + VALIDATION_ERROR_MAX, + NewPayloadWithWitnessResponse, + PayloadStatusEnum, + _SSZExecutionWitness, + _SSZNewPayloadWithWitnessResponse, +) + + +def _build_inner_witness( + state: list[bytes], codes: list[bytes], headers: list[bytes] +) -> bytes: + inner = _SSZExecutionWitness( + state=[ByteList[MAX_WITNESS_ITEM_BYTES](b) for b in state], + codes=[ByteList[MAX_WITNESS_ITEM_BYTES](b) for b in codes], + headers=[ByteList[MAX_WITNESS_ITEM_BYTES](b) for b in headers], + ) + return inner.encode_bytes() + + +def _build_response( + status: int, + latest_valid_hash: bytes | None, + validation_error: str | None, + witness_bytes: bytes, +) -> bytes: + fields = _SSZNewPayloadWithWitnessResponse.fields() + lvh_type = fields["latest_valid_hash"] + ve_type = fields["validation_error"] + + if latest_valid_hash is None: + lvh = lvh_type(selector=0, value=None) + else: + lvh = lvh_type(selector=1, value=ByteVector[32](latest_valid_hash)) + + if validation_error is None: + ve = ve_type(selector=0, value=None) + else: + ve = ve_type( + selector=1, + value=ByteList[VALIDATION_ERROR_MAX]( + validation_error.encode("utf-8") + ), + ) + + resp = _SSZNewPayloadWithWitnessResponse( + status=uint8(status), + latest_valid_hash=lvh, + validation_error=ve, + witness=ByteList[MAX_WITNESS_BYTES](witness_bytes), + ) + return resp.encode_bytes() + + +def test_decode_valid_with_witness() -> None: + """A VALID response carries latestValidHash and a non-empty witness.""" + witness_bytes = _build_inner_witness( + state=[b"\xaa\xaa", b"\xbb\xbb\xbb"], + codes=[b"\x60\x01"], + headers=[b"\xf9\x02"], + ) + raw = _build_response( + status=0, + latest_valid_hash=b"\x11" * 32, + validation_error=None, + witness_bytes=witness_bytes, + ) + + decoded = NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + assert decoded.status == PayloadStatusEnum.VALID + assert decoded.latest_valid_hash is not None + assert bytes(decoded.latest_valid_hash) == b"\x11" * 32 + assert decoded.validation_error is None + assert decoded.witness is not None + assert [bytes(x) for x in decoded.witness.state] == [ + b"\xaa\xaa", + b"\xbb\xbb\xbb", + ] + assert [bytes(x) for x in decoded.witness.codes] == [b"\x60\x01"] + assert [bytes(x) for x in decoded.witness.headers] == [b"\xf9\x02"] + + +def test_decode_invalid_with_validation_error() -> None: + """An INVALID response carries a validation_error string and no witness.""" + raw = _build_response( + status=1, + latest_valid_hash=None, + validation_error="invalid state root", + witness_bytes=b"", + ) + + decoded = NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + assert decoded.status == PayloadStatusEnum.INVALID + assert decoded.latest_valid_hash is None + assert decoded.validation_error == "invalid state root" + assert decoded.witness is None + + +def test_decode_syncing_empty_witness() -> None: + """A SYNCING response has no witness.""" + raw = _build_response( + status=2, + latest_valid_hash=None, + validation_error=None, + witness_bytes=b"", + ) + + decoded = NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + assert decoded.status == PayloadStatusEnum.SYNCING + assert decoded.latest_valid_hash is None + assert decoded.validation_error is None + assert decoded.witness is None + + +def test_decode_unknown_status_byte_raises() -> None: + """An unknown status uint8 raises a descriptive error.""" + raw = _build_response( + status=99, + latest_valid_hash=None, + validation_error=None, + witness_bytes=b"", + ) + + with pytest.raises(ValueError, match="Unknown SSZ status byte: 99"): + NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + +def test_decode_invalid_with_witness_raises() -> None: + """A non-VALID SSZ response must not carry witness bytes.""" + raw = _build_response( + status=1, + latest_valid_hash=None, + validation_error="invalid state root", + witness_bytes=_build_inner_witness( + state=[b"\xaa"], + codes=[], + headers=[], + ), + ) + + with pytest.raises( + ValueError, match="INVALID SSZ response must not contain a witness" + ): + NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + +# --- JSON-RPC (RLP witness) decode --- + + +def _json_rpc_witness_rlp( + headers: list[Extended], + codes: list[bytes], + state: list[bytes], + *, + legacy_keys: list[bytes] | None = None, +) -> bytes: + """Build an RLP witness payload returned by the JSON-RPC endpoint.""" + fields: list[Extended] = [headers, codes, state] + if legacy_keys is not None: + fields.append(legacy_keys) + return eth_rlp.encode(fields) + + +def test_decode_json_rpc_valid() -> None: + """Round-trip a VALID JSON-RPC response with RLP witness.""" + # A minimal "header" RLP list with two short fields. + header_list = [b"\x01" * 4, b"\x02" * 4] + witness_hex = ( + "0x" + + _json_rpc_witness_rlp( + headers=[header_list], + codes=[b"\x60\x01"], + state=[b"\xaa\xaa", b"\xbb"], + ).hex() + ) + + response_json = { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": witness_hex, + } + + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) + + assert decoded.status == PayloadStatusEnum.VALID + assert decoded.latest_valid_hash is not None + assert bytes(decoded.latest_valid_hash) == b"\x11" * 32 + assert decoded.validation_error is None + assert decoded.witness is not None + assert [bytes(c) for c in decoded.witness.codes] == [b"\x60\x01"] + assert sorted(bytes(s) for s in decoded.witness.state) == sorted( + [b"\xaa\xaa", b"\xbb"] + ) + # Headers must come back as re-encoded RLP bytes (matching the fixture + # format), so encoding the decoded header recovers the original list. + assert len(decoded.witness.headers) == 1 + assert eth_rlp.decode(bytes(decoded.witness.headers[0])) == header_list + + +def test_decode_json_rpc_ignores_legacy_keys() -> None: + """A legacy fourth `keys` RLP field is ignored.""" + header_list = [b"\x01" * 4, b"\x02" * 4] + witness_hex = ( + "0x" + + _json_rpc_witness_rlp( + headers=[header_list], + codes=[b"\x60\x01"], + state=[b"\xaa"], + legacy_keys=[b"legacy-key"], + ).hex() + ) + + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result( + { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": witness_hex, + } + ) + + assert decoded.witness is not None + assert [bytes(c) for c in decoded.witness.codes] == [b"\x60\x01"] + assert [bytes(s) for s in decoded.witness.state] == [b"\xaa"] + assert eth_rlp.decode(bytes(decoded.witness.headers[0])) == header_list + + +def test_decode_json_rpc_invalid_no_witness() -> None: + """An INVALID JSON-RPC response has no witness payload.""" + response_json = { + "status": "INVALID", + "latestValidHash": None, + "validationError": "block root mismatch", + # The witness field may be omitted on INVALID. + } + + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) + + assert decoded.status == PayloadStatusEnum.INVALID + assert decoded.latest_valid_hash is None + assert decoded.validation_error == "block root mismatch" + assert decoded.witness is None + + +def test_decode_json_rpc_empty_witness_hex() -> None: + """Parse an empty non-VALID witness as no witness.""" + response_json = { + "status": "SYNCING", + "latestValidHash": None, + "validationError": None, + "witness": "0x", + } + + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) + + assert decoded.status == PayloadStatusEnum.SYNCING + assert decoded.witness is None + + +def test_decode_json_rpc_witness_must_be_0x_prefixed() -> None: + """Witness hex must use the JSON-RPC 0x prefix.""" + response_json = { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": _json_rpc_witness_rlp( + headers=[], + codes=[], + state=[], + ).hex(), + } + + with pytest.raises(ValueError, match="0x-prefixed"): + NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) + + +def test_decode_json_rpc_rejects_non_list_witness_field() -> None: + """Codes and state must be RLP lists, not bare byte strings.""" + header_list = [b"\x01" * 4, b"\x02" * 4] + witness_hex = ( + "0x" + + eth_rlp.encode( + [ + [header_list], + b"\x60\x01", + [b"\xaa"], + ] + ).hex() + ) + response_json = { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": witness_hex, + } + + with pytest.raises(ValueError, match="codes must be an RLP list"): + NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index f2d26f8ec40..28dc1425252 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -35,6 +35,7 @@ from execution_testing.client_clis import ( BlockExceptionWithMessage, ClientBackend, + ExecutionSpecsTransitionTool, FillerBackend, LazyAlloc, Result, @@ -68,6 +69,7 @@ LabeledFixtureFormat, ) from execution_testing.fixtures.blockchain import ( + ExecutionWitness, FixtureBlock, FixtureBlockBase, FixtureConfig, @@ -98,8 +100,22 @@ BlockAccessListExpectation, ) from execution_testing.test_types.chain_config_types import ChainConfigDefaults +from execution_testing.test_types.execution_witness import ( + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, +) +from execution_testing.test_types.execution_witness.modifiers import ( + PublicKeyModifier, +) from .base import BaseTest, FillResult, OpMode, verify_result +from .blockchain_stateless import ( + apply_execution_witness_expectations, + finalize_stateless_artifacts, + stateless_artifacts_from_t8n, + stateless_options_for_block, +) from .debugging import print_traces from .helpers import verify_block, verify_transactions @@ -116,6 +132,7 @@ def environment_from_parent_header(parent: "FixtureHeader") -> "Environment": parent_gas_limit=parent.gas_limit, parent_ommers_hash=parent.ommers_hash, block_hashes={parent.number: parent.block_hash}, + block_headers={parent.number: parent.rlp}, ) @@ -135,7 +152,10 @@ def apply_new_parent( updated["parent_slot_number"] = new_parent.slot_number block_hashes = env.block_hashes.copy() block_hashes[new_parent.number] = new_parent.block_hash + block_headers = env.block_headers.copy() + block_headers[new_parent.number] = new_parent.rlp updated["block_hashes"] = block_hashes + updated["block_headers"] = block_headers return env.copy(**updated) @@ -317,6 +337,48 @@ class Block(Header): If set, the block access list will be verified and potentially corrupted for invalid tests. """ + expected_execution_witness_codes: ( + ExecutionWitnessCodesExpectation | None + ) = None + """ + If set, the execution witness codes will be verified and potentially + modified for invalid tests. + """ + expected_execution_witness_state: ( + ExecutionWitnessStateExpectation | None + ) = None + """ + If set, the execution witness state will be verified and potentially + modified for invalid tests. + """ + expected_execution_witness_headers: ( + ExecutionWitnessHeadersExpectation | None + ) = None + """ + If set, the execution witness headers will be verified and potentially + modified for invalid tests. + """ + stateless_input_public_keys_modifier: PublicKeyModifier | None = Field( + default=None, + exclude=True, + ) + """ + If set, mutate the stateless input transaction public keys before rerunning + the guest for invalid tests. + """ + stateless_input_bytes_modifier: Callable[[Bytes], Bytes] | None = Field( + default=None, + exclude=True, + ) + """ + If set, mutate the serialized stateless input bytes before rerunning the + guest for invalid tests. + """ + expected_stateless_validation_success: bool | None = None + """ + If set, assert the stateless guest result matches this expectation. This + must be set explicitly for tests that mutate stateless validation input. + """ exception: BLOCK_EXCEPTION_TYPE = None # If set, the block is expected to be rejected by the client. skip_exception_verification: bool = False @@ -416,6 +478,9 @@ def set_environment(self, env: Environment) -> Environment: new_env_values["gas_limit"] = ( self.gas_limit or env.parent_gas_limit or Environment().gas_limit ) + new_env_values["extra_data"] = ( + self.extra_data if self.extra_data is not None else Bytes(b"") + ) if not isinstance(self.base_fee_per_gas, Removable): new_env_values["base_fee_per_gas"] = self.base_fee_per_gas new_env_values["withdrawals"] = self.withdrawals @@ -485,6 +550,10 @@ class BuiltBlock(CamelModel): rlp_modifier: Header | None = None fork: Fork block_access_list: BlockAccessList | None + execution_witness: ExecutionWitness | None = None + execution_witness_mutated: bool = False + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None engine_new_payload_block_access_list: Bytes | None = None engine_new_payload_slot_number: HexNumber | None = None @@ -536,6 +605,15 @@ def get_fixture_block( block_access_list=self.block_access_list if self.block_access_list else None, + execution_witness=self.execution_witness + if self.execution_witness + else None, + stateless_input_bytes=self.stateless_input_bytes + if self.stateless_input_bytes is not None + else None, + stateless_output_bytes=self.stateless_output_bytes + if self.stateless_output_bytes is not None + else None, fork=self.fork, ).with_rlp(txs=self.txs) @@ -549,6 +627,9 @@ def get_fixture_block( in self.expected_exception else fixture_block.without_rlp() ), + execution_witness=self.execution_witness, + stateless_input_bytes=self.stateless_input_bytes, + stateless_output_bytes=self.stateless_output_bytes, ) return fixture_block @@ -608,6 +689,10 @@ def get_fixture_engine_new_payload(self) -> FixtureEngineNewPayload: block_access_list=self.block_access_list.rlp if self.block_access_list else None, + execution_witness=self.execution_witness, + execution_witness_mutated=( + True if self.execution_witness_mutated else None + ), execution_payload_modifier=self.engine_payload_modifier(), validation_error=self.expected_exception, error_code=self.engine_api_error_code, @@ -761,6 +846,11 @@ class BlockchainTest(BaseTest): """ Include transaction receipts in the fixture output. """ + skip_stateless_validation: bool = False + """ + Skip stateless witness generation, input serialization, and guest + validation for this test. + """ supported_fixture_formats: ClassVar[ Sequence[FixtureFormat | LabeledFixtureFormat] @@ -784,6 +874,10 @@ class BlockchainTest(BaseTest): "Only generate a blockchain test engine fixture" ), "blockchain_test_only": "Only generate a blockchain test fixture", + "skip_stateless_validation": ( + "Skip stateless witness generation, input serialization, and " + "guest validation." + ), } @classmethod @@ -969,6 +1063,11 @@ def generate_block_data( "exception must be the last transaction in the block" ) + stateless_options = stateless_options_for_block( + block=block, + skip_stateless_validation=self.skip_stateless_validation, + ) + transition_tool_output = t8n.evaluate( transition_tool_data=TransitionTool.TransitionToolData( alloc=previous_alloc, @@ -978,6 +1077,7 @@ def generate_block_data( chain_id=self.chain_id, reward=fork.get_reward(), blob_schedule=fork.blob_schedule(), + skip_stateless_validation=stateless_options.skip_validation, ), slow_request=self.is_tx_gas_heavy_test, ) @@ -1012,9 +1112,7 @@ def generate_block_data( ), blob_gas_used=blob_gas_used, transactions_trie=Transaction.list_root(txs), - extra_data=( - block.extra_data if block.extra_data is not None else b"" - ), + extra_data=env.extra_data, slot_number=slot_number_value, fork=fork, ) @@ -1145,6 +1243,64 @@ def generate_block_data( "arbitrary bytes." ) + t8n_witness = transition_tool_output.result.execution_witness + stateless_artifacts = apply_execution_witness_expectations( + block=block, + fork=fork, + previous_alloc=previous_alloc, + block_number=int(env.number), + timestamp=int(env.timestamp), + parent_hash=header.parent_hash, + execution_witness=t8n_witness, + ) + missing_stateless_artifacts = ( + not stateless_options.skip_validation + and t8n_witness is not None + and bal is not None + and ( + transition_tool_output.result.stateless_input_bytes is None + or transition_tool_output.result.stateless_output_bytes is None + ) + ) + if missing_stateless_artifacts: + # Temporary trust path for external benchmark filling until Geth + # emits both stateless byte fields. + assert not isinstance(t8n, ExecutionSpecsTransitionTool), ( + "EELS must provide stateless input and output bytes" + ) + assert self.operation_mode == OpMode.BENCHMARKING, ( + "Missing stateless artifacts are only supported for external " + "benchmark fills" + ) + assert block.exception is None, ( + "Missing stateless artifacts require a valid benchmark block" + ) + stateless_artifacts = stateless_artifacts_from_t8n( + options=stateless_options, + artifacts=stateless_artifacts, + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + header=header, + previous_env=previous_env, + txs=txs, + result=transition_tool_output.result, + withdrawals=env.withdrawals, + requests_list=requests_list, + execution_witness=t8n_witness, + block_access_list=bal, + chain_id=self.chain_id, + ) + stateless_artifacts = finalize_stateless_artifacts( + options=stateless_options, + artifacts=stateless_artifacts, + block=block, + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + chain_id=self.chain_id, + ) + built_block_kwargs: Dict[str, Any] = dict( header=header, alloc=transition_tool_output.alloc, @@ -1160,6 +1316,12 @@ def generate_block_data( rlp_modifier=block.rlp_modifier, fork=fork, block_access_list=bal, + execution_witness=stateless_artifacts.execution_witness, + execution_witness_mutated=( + stateless_artifacts.execution_witness_mutated + ), + stateless_input_bytes=stateless_artifacts.stateless_input_bytes, + stateless_output_bytes=stateless_artifacts.stateless_output_bytes, engine_new_payload_block_access_list=( block.engine_new_payload_block_access_list if block.engine_new_payload_block_access_list is not None @@ -1193,6 +1355,7 @@ def generate_block_data( block.expected_block_access_list is not None and block.expected_block_access_list.has_modifier ) + and not stateless_artifacts.execution_witness_mutated ): # Only verify block level exception if: - No transaction # exception was raised, because these are not reported as block @@ -1204,7 +1367,9 @@ def generate_block_data( # the engine payload after the transition tool has run. - No # BAL modifier was specified, because a rewritten BAL, whether # in contents or in encoding, is applied after the transition - # tool has run and is what produces the block exception. + # tool has run and is what produces the block exception. - No + # witness modifier was specified, because witness soundness is + # verified separately via the guest rerun. built_block.verify_block_exception( transition_tool_exceptions_reliable=t8n.exception_mapper.reliable, ) diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py new file mode 100644 index 00000000000..8746f38909b --- /dev/null +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -0,0 +1,1140 @@ +"""Stateless helpers for blockchain test generation.""" + +from dataclasses import dataclass, replace +from typing import Any, Callable, List, Protocol, Tuple + +from execution_testing.base_types import ( + Bytes, + Hash, + ZeroPaddedHexNumber, +) +from execution_testing.client_clis import LazyAlloc, Result +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.forks import Fork +from execution_testing.test_types import ( + Alloc, + Environment, + ExecutionWitness, + Transaction, + Withdrawal, +) +from execution_testing.test_types.block_access_list import BlockAccessList +from execution_testing.test_types.execution_witness import ( + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, +) +from execution_testing.test_types.execution_witness.modifiers import ( + PublicKeyModifier, +) + + +class StatelessBlockProtocol(Protocol): + """Block fields needed by stateless validation orchestration.""" + + @property + def rlp_modifier(self) -> object | None: + """RLP modifier configured for the block.""" + ... + + @property + def expected_execution_witness_codes( + self, + ) -> ExecutionWitnessCodesExpectation | None: + """Expected execution witness codes.""" + ... + + @property + def expected_execution_witness_state( + self, + ) -> ExecutionWitnessStateExpectation | None: + """Expected execution witness state.""" + ... + + @property + def expected_execution_witness_headers( + self, + ) -> ExecutionWitnessHeadersExpectation | None: + """Expected execution witness headers.""" + ... + + @property + def stateless_input_public_keys_modifier( + self, + ) -> PublicKeyModifier | None: + """Public-key modifier for stateless input reruns.""" + ... + + @property + def stateless_input_bytes_modifier( + self, + ) -> Callable[[Bytes], Bytes] | None: + """Serialized stateless input modifier for raw-input reruns.""" + ... + + @property + def expected_stateless_validation_success(self) -> bool | None: + """Expected stateless guest validation result.""" + ... + + @property + def exception(self) -> object | None: + """Block exception expectation.""" + ... + + +@dataclass(frozen=True) +class StatelessBlockOptions: + """Stateless options derived before transition-tool execution.""" + + skip_validation: bool + public_keys_modifier: PublicKeyModifier | None + stateless_input_bytes_modifier: Callable[[Bytes], Bytes] | None + expected_validation_success: bool | None + + @property + def has_public_keys_modifier(self) -> bool: + """Whether stateless input public keys should be mutated.""" + return self.public_keys_modifier is not None + + @property + def has_stateless_input_bytes_modifier(self) -> bool: + """Whether raw stateless input bytes should be mutated.""" + return self.stateless_input_bytes_modifier is not None + + +@dataclass(frozen=True) +class StatelessValidationArtifacts: + """Stateless artifacts passed between blockchain generation phases.""" + + execution_witness: ExecutionWitness | None + execution_witness_mutated: bool + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None + + +def stateless_options_for_block( + *, + block: StatelessBlockProtocol, + skip_stateless_validation: bool, +) -> StatelessBlockOptions: + """Derive stateless options and reject incompatible block settings.""" + has_witness_expectation = ( + block.expected_execution_witness_state is not None + or block.expected_execution_witness_codes is not None + or block.expected_execution_witness_headers is not None + ) + public_keys_modifier = block.stateless_input_public_keys_modifier + has_public_keys_modifier = public_keys_modifier is not None + stateless_input_bytes_modifier = block.stateless_input_bytes_modifier + has_stateless_input_bytes_modifier = ( + stateless_input_bytes_modifier is not None + ) + expected_success = block.expected_stateless_validation_success + omit_stateless_artifacts = block.rlp_modifier is not None + + if omit_stateless_artifacts and ( + has_witness_expectation + or has_public_keys_modifier + or has_stateless_input_bytes_modifier + or expected_success is not None + ): + raise AssertionError( + "Blocks with rlp_modifier omit stateless artifacts because " + "they are generated before the RLP mutation. SSZ/stateless " + "mutation tests require a separate explicit mechanism." + ) + if skip_stateless_validation and ( + has_witness_expectation + or has_public_keys_modifier + or has_stateless_input_bytes_modifier + or expected_success is not None + ): + raise AssertionError( + "skip_stateless_validation cannot be combined with " + "execution witness expectations, stateless input public-key " + "modifiers, stateless input byte modifiers, or " + "expected_stateless_validation_success" + ) + + return StatelessBlockOptions( + skip_validation=skip_stateless_validation or omit_stateless_artifacts, + public_keys_modifier=public_keys_modifier, + stateless_input_bytes_modifier=stateless_input_bytes_modifier, + expected_validation_success=expected_success, + ) + + +def apply_execution_witness_expectations( + *, + block: StatelessBlockProtocol, + fork: Fork, + previous_alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, + parent_hash: Hash, + execution_witness: ExecutionWitness | None, +) -> StatelessValidationArtifacts: + """Verify and apply execution witness expectations for a block.""" + adjusted_witness = execution_witness + state_expectation = block.expected_execution_witness_state + if state_expectation is not None and adjusted_witness is not None: + state_expectation.verify_against(adjusted_witness) + adjusted_witness = state_expectation.modify_if_invalid_test( + adjusted_witness + ) + + codes_expectation = block.expected_execution_witness_codes + if codes_expectation is not None and adjusted_witness is not None: + effective_codes_expectation = with_execution_witness_implicit_codes( + expectation=codes_expectation, + fork=fork, + alloc=previous_alloc, + block_number=block_number, + timestamp=timestamp, + ) + effective_codes_expectation.verify_against(adjusted_witness) + adjusted_witness = codes_expectation.modify_if_invalid_test( + adjusted_witness + ) + + headers_expectation = block.expected_execution_witness_headers + if headers_expectation is not None and adjusted_witness is not None: + headers_expectation.verify_against( + adjusted_witness, + parent_hash=parent_hash, + fork=fork, + ) + adjusted_witness = headers_expectation.modify_if_invalid_test( + adjusted_witness + ) + + return StatelessValidationArtifacts( + execution_witness=adjusted_witness, + execution_witness_mutated=_has_execution_witness_modifier(block), + ) + + +def stateless_artifacts_from_t8n( + *, + options: StatelessBlockOptions, + artifacts: StatelessValidationArtifacts, + fork: Fork, + block_number: int, + timestamp: int, + header: FixtureHeader, + previous_env: Environment, + txs: List[Transaction], + result: Result, + withdrawals: List[Withdrawal] | None, + requests_list: List[Bytes] | None, + execution_witness: ExecutionWitness | None, + block_access_list: BlockAccessList | None, + chain_id: int, +) -> StatelessValidationArtifacts: + """Collect or derive serialized stateless artifacts from t8n output.""" + stateless_input_bytes = result.stateless_input_bytes + stateless_output_bytes = result.stateless_output_bytes + if ( + not options.skip_validation + and execution_witness is not None + and block_access_list is not None + and (stateless_input_bytes is None or stateless_output_bytes is None) + ): + built_artifacts = build_amsterdam_stateless_artifacts_from_t8n( + fork=fork, + block_number=block_number, + timestamp=timestamp, + header=header, + previous_env=previous_env, + txs=txs, + result=result, + withdrawals=withdrawals, + requests_list=requests_list, + execution_witness=execution_witness, + block_access_list=block_access_list, + chain_id=chain_id, + ) + if built_artifacts is not None: + stateless_input_bytes, stateless_output_bytes = built_artifacts + + return replace( + artifacts, + stateless_input_bytes=stateless_input_bytes, + stateless_output_bytes=stateless_output_bytes, + ) + + +def finalize_stateless_artifacts( + *, + options: StatelessBlockOptions, + artifacts: StatelessValidationArtifacts, + block: StatelessBlockProtocol, + fork: Fork, + block_number: int, + timestamp: int, + chain_id: int, +) -> StatelessValidationArtifacts: + """Verify, mutate, and rerun stateless guest artifacts as needed.""" + stateless_input_bytes = artifacts.stateless_input_bytes + stateless_output_bytes = artifacts.stateless_output_bytes + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_output_bytes=stateless_output_bytes, + ) + + has_witness_modifier = artifacts.execution_witness_mutated + if has_witness_modifier and options.expected_validation_success is None: + raise AssertionError( + "Mutated execution witness tests must set " + "expected_stateless_validation_success explicitly" + ) + if ( + options.has_public_keys_modifier + and options.expected_validation_success is None + ): + raise AssertionError( + "Mutated stateless input public-key tests must set " + "expected_stateless_validation_success explicitly" + ) + if ( + options.has_stateless_input_bytes_modifier + and options.expected_validation_success is None + ): + raise AssertionError( + "Mutated stateless input byte tests must set " + "expected_stateless_validation_success explicitly" + ) + + public_keys: Tuple[Bytes, ...] | None = None + should_verify_stateless_input_public_keys = ( + stateless_input_bytes is not None + # The block could be invalid because of invalid txs, thus + # the public keys might not be properly constructed given they + # can't be decoded and thus provided in the execution witness. + and block.exception is None + ) + if stateless_input_bytes is not None and ( + should_verify_stateless_input_public_keys + or options.has_public_keys_modifier + ): + payload_transactions: Tuple[Bytes, ...] + public_keys, payload_transactions = ( + get_amsterdam_stateless_input_public_key_data( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_input_bytes=stateless_input_bytes, + ) + ) + if should_verify_stateless_input_public_keys: + verify_stateless_input_public_keys( + public_keys, + payload_transactions, + chain_id, + ) + elif options.has_public_keys_modifier: + raise Exception( + "Stateless input public-key mutation requires stateless " + "input bytes" + ) + + canonical_successful_validation: bool | None = None + if ( + has_witness_modifier + or options.has_public_keys_modifier + or options.expected_validation_success is not None + ): + if stateless_output_bytes is None: + raise Exception( + "Stateless guest verification requires stateless output bytes" + ) + if stateless_output is None: + raise Exception( + "Stateless output decoding is only supported for Amsterdam" + ) + canonical_successful_validation = ( + stateless_output.successful_validation + ) + + has_structured_stateless_overrides = ( + has_witness_modifier or options.has_public_keys_modifier + ) + final_successful_validation = canonical_successful_validation + if has_structured_stateless_overrides: + if stateless_input_bytes is None: + raise Exception( + "Stateless guest rerun requires stateless input bytes" + ) + if has_witness_modifier and artifacts.execution_witness is None: + raise Exception( + "Stateless guest witness mutation rerun requires " + "execution witness" + ) + modified_public_keys: Tuple[Bytes, ...] | None = None + if options.public_keys_modifier is not None: + if public_keys is None: + raise Exception("Stateless guest rerun requires public keys") + modified_public_keys = options.public_keys_modifier(public_keys) + stateless_input_bytes = ( + rebuild_amsterdam_stateless_input_with_overrides( + fork=fork, + block_number=block_number, + timestamp=timestamp, + original_stateless_input_bytes=stateless_input_bytes, + execution_witness=( + artifacts.execution_witness + if has_witness_modifier + else None + ), + public_keys=modified_public_keys, + ) + ) + + should_rerun_stateless_guest = ( + has_structured_stateless_overrides + or options.has_stateless_input_bytes_modifier + ) + if options.has_stateless_input_bytes_modifier: + if stateless_input_bytes is None: + raise Exception( + "Stateless guest raw input rerun requires stateless " + "input bytes" + ) + stateless_input_bytes_modifier = options.stateless_input_bytes_modifier + if stateless_input_bytes_modifier is None: + raise Exception("Stateless input bytes modifier is required") + stateless_input_bytes = stateless_input_bytes_modifier( + stateless_input_bytes + ) + + if should_rerun_stateless_guest: + if stateless_input_bytes is None: + raise Exception( + "Stateless guest rerun requires stateless input bytes" + ) + ( + stateless_input_bytes, + stateless_output_bytes, + successful_validation, + ) = rerun_amsterdam_stateless_guest_with_input_bytes( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_input_bytes=stateless_input_bytes, + ) + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_output_bytes=stateless_output_bytes, + ) + final_successful_validation = successful_validation + + if ( + options.expected_validation_success is not None + and final_successful_validation != options.expected_validation_success + ): + raise AssertionError( + "Stateless guest validation result mismatch: " + f"got {final_successful_validation}, " + f"want {options.expected_validation_success}" + ) + + if stateless_output is not None: + if stateless_input_bytes is None: + raise Exception( + "Stateless output verification requires stateless input bytes" + ) + verify_amsterdam_stateless_output( + block_number=block_number, + chain_id=chain_id, + stateless_input_bytes=stateless_input_bytes, + stateless_output=stateless_output, + input_bytes_modified=options.has_stateless_input_bytes_modifier, + ) + + return replace( + artifacts, + stateless_input_bytes=stateless_input_bytes, + stateless_output_bytes=stateless_output_bytes, + ) + + +def execution_witness_implicit_codes_for_block( + *, + fork: Fork, + alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, +) -> List[Bytes]: + """ + Return ambient witness bytecodes implied by block-level execution. + + These codes are resolved from the effective pre-state for the block, not + from raw fork defaults, so test `pre` overrides are respected. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + addresses = active_fork.execution_witness_implicit_code_addresses( + block_number=block_number, + timestamp=timestamp, + ) + if not addresses: + return [] + + effective_alloc = ( + alloc.materialize() if isinstance(alloc, LazyAlloc) else alloc + ) + + codes: List[Bytes] = [] + seen: set[Bytes] = set() + for address in addresses: + if address not in effective_alloc: + continue + account = effective_alloc[address] + if account is None or len(account.code) == 0: + continue + code = Bytes(account.code) + if code in seen: + continue + codes.append(code) + seen.add(code) + return codes + + +def with_execution_witness_implicit_codes( + *, + expectation: ExecutionWitnessCodesExpectation, + fork: Fork, + alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, +) -> ExecutionWitnessCodesExpectation: + """Return expectation copy with ambient block-level codes added.""" + codes_present = list(expectation.codes_present) + seen = set(codes_present) + + for code in execution_witness_implicit_codes_for_block( + fork=fork, + alloc=alloc, + block_number=block_number, + timestamp=timestamp, + ): + if code in seen: + continue + codes_present.append(code) + seen.add(code) + + return expectation.model_copy(update={"codes_present": codes_present}) + + +def rebuild_amsterdam_stateless_input_with_overrides( + *, + fork: Fork, + block_number: int, + timestamp: int, + original_stateless_input_bytes: Bytes, + execution_witness: ExecutionWitness | None = None, + public_keys: Tuple[Bytes, ...] | None = None, +) -> Bytes: + """ + Rebuild the stateless input bytes with test overrides. + + Amsterdam is currently the only fork with stateless guest support in this + repository, so the rebuild path is kept Amsterdam-specific. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Execution witness input rebuild is only supported for Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless import ( + ExecutionWitness as AmsterdamExecutionWitness, + ) + from ethereum.forks.amsterdam.stateless import ( + StatelessInput as AmsterdamStatelessInput, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + serialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + original_input = deserialize_stateless_input( + AmsterdamBytes(bytes(original_stateless_input_bytes)) + ) + rebuilt_witness = original_input.witness + if execution_witness is not None: + rebuilt_witness = AmsterdamExecutionWitness( + state=tuple( + AmsterdamBytes(bytes(node)) for node in execution_witness.state + ), + codes=tuple( + AmsterdamBytes(bytes(code)) for code in execution_witness.codes + ), + headers=tuple( + AmsterdamBytes(bytes(header)) + for header in execution_witness.headers + ), + ) + rebuilt_input = AmsterdamStatelessInput( + new_payload_request=original_input.new_payload_request, + witness=rebuilt_witness, + chain_id=original_input.chain_id, + public_keys=( + tuple(AmsterdamBytes(bytes(key)) for key in public_keys) + if public_keys is not None + else original_input.public_keys + ), + ) + rebuilt_input_bytes = serialize_stateless_input(rebuilt_input) + return Bytes(bytes(rebuilt_input_bytes)) + + +def rerun_amsterdam_stateless_guest_with_input_bytes( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes, +) -> tuple[Bytes, Bytes, bool]: + """ + Rerun the Amsterdam stateless guest with raw stateless input bytes. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Stateless guest raw input rerun is only supported for Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless_guest import run_stateless_guest + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + guest_input_bytes = AmsterdamBytes(bytes(stateless_input_bytes)) + stateless_output_bytes = run_stateless_guest(guest_input_bytes) + stateless_output = deserialize_stateless_output(stateless_output_bytes) + + return ( + Bytes(bytes(guest_input_bytes)), + Bytes(bytes(stateless_output_bytes)), + stateless_output.successful_validation, + ) + + +def get_amsterdam_stateless_input_public_key_data( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes, +) -> tuple[Tuple[Bytes, ...], Tuple[Bytes, ...]]: + """ + Decode Amsterdam stateless input public keys and payload transactions. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Stateless input public-key decoding is only supported for " + "Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(stateless_input_bytes)) + ) + public_keys = tuple( + Bytes(bytes(public_key)) for public_key in stateless_input.public_keys + ) + payload_transactions = tuple( + Bytes(bytes(transaction)) + for transaction in ( + stateless_input.new_payload_request.execution_payload.transactions + ) + ) + return public_keys, payload_transactions + + +def verify_stateless_input_public_keys( + public_keys: Tuple[Bytes, ...], + payload_transactions: Tuple[Bytes, ...], + chain_id: int, +) -> None: + """ + Verify that every payload transaction has its recovered public key. + """ + payload_transaction_count = len(payload_transactions) + if len(public_keys) != payload_transaction_count: + raise AssertionError( + "Stateless input public key count does not match payload " + f"transactions: got {len(public_keys)} public keys for " + f"{payload_transaction_count} transactions" + ) + + from ethereum.forks.amsterdam.transactions import ( + decode_transaction, + recover_transaction_public_key, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U64 + + for index, (public_key, payload_transaction) in enumerate( + zip(public_keys, payload_transactions, strict=True) + ): + transaction = decode_transaction( + AmsterdamBytes(bytes(payload_transaction)) + ) + expected_public_key = recover_transaction_public_key( + U64(chain_id), + transaction, + ) + if bytes(public_key) != bytes(expected_public_key): + raise AssertionError( + "Stateless input public key " + f"{index} does not match recovered transaction public key" + ) + + +def _decode_amsterdam_header_bytes(header_rlp: Bytes) -> Any | None: + """ + Decode an Amsterdam or immediate pre-Amsterdam RLP header. + """ + from ethereum.forks.amsterdam.stateless import _decode_header + from ethereum_types.bytes import Bytes as AmsterdamBytes + + try: + return _decode_header(AmsterdamBytes(bytes(header_rlp))) + except Exception: + return None + + +def _convert_amsterdam_execution_witness( + execution_witness: ExecutionWitness, +) -> Any: + """ + Convert fixture execution witness data to Amsterdam fork types. + """ + from ethereum.forks.amsterdam.stateless import ( + ExecutionWitness as AmsterdamExecutionWitness, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + return AmsterdamExecutionWitness( + state=tuple( + AmsterdamBytes(bytes(node)) for node in execution_witness.state + ), + codes=tuple( + AmsterdamBytes(bytes(code)) for code in execution_witness.codes + ), + headers=tuple( + AmsterdamBytes(bytes(header)) + for header in execution_witness.headers + ), + ) + + +def _convert_amsterdam_withdrawals( + withdrawals: List[Withdrawal] | None, +) -> Any: + """ + Convert fixture withdrawals to Amsterdam fork withdrawals. + """ + from ethereum.forks.amsterdam.blocks import ( + Withdrawal as AmsterdamWithdrawal, + ) + from ethereum.state import Address as AmsterdamAddress + from ethereum_types.numeric import U64 + + if withdrawals is None: + return () + return tuple( + AmsterdamWithdrawal( + index=U64(int(withdrawal.index)), + validator_index=U64(int(withdrawal.validator_index)), + address=AmsterdamAddress(bytes(withdrawal.address)), + amount=U64(int(withdrawal.amount)), + ) + for withdrawal in withdrawals + ) + + +def _convert_amsterdam_block_access_list( + block_access_list: BlockAccessList, +) -> Any: + """ + Convert fixture BAL data to Amsterdam fork BAL data. + """ + import importlib + + block_access_lists = importlib.import_module( + "ethereum.forks.amsterdam.block_access_lists" + ) + + def bal_type(name: str) -> Any: + return getattr(block_access_lists, name) + + account_changes = bal_type("AccountChanges") + balance_change = bal_type("BalanceChange") + code_change = bal_type("CodeChange") + nonce_change = bal_type("NonceChange") + slot_changes = bal_type("SlotChanges") + storage_change = bal_type("StorageChange") + + from ethereum.state import Address as AmsterdamAddress + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U32, U64, U256 + + return [ + account_changes( + address=AmsterdamAddress(bytes(account.address)), + storage_changes=tuple( + slot_changes( + slot=U256(int(slot.slot)), + changes=tuple( + storage_change( + block_access_index=U32( + int(change.block_access_index) + ), + new_value=U256(int(change.post_value)), + ) + for change in slot.slot_changes + ), + ) + for slot in account.storage_changes + ), + storage_reads=tuple( + U256(int(slot)) for slot in account.storage_reads + ), + balance_changes=tuple( + balance_change( + block_access_index=U32(int(change.block_access_index)), + post_balance=U256(int(change.post_balance)), + ) + for change in account.balance_changes + ), + nonce_changes=tuple( + nonce_change( + block_access_index=U32(int(change.block_access_index)), + new_nonce=U64(int(change.post_nonce)), + ) + for change in account.nonce_changes + ), + code_changes=tuple( + code_change( + block_access_index=U32(int(change.block_access_index)), + new_code=AmsterdamBytes(bytes(change.new_code)), + ) + for change in account.code_changes + ), + ) + for account in block_access_list.root + ] + + +def build_amsterdam_stateless_artifacts_from_t8n( + *, + fork: Fork, + block_number: int, + timestamp: int, + header: FixtureHeader, + previous_env: Environment, + txs: List[Transaction], + result: Result, + withdrawals: List[Withdrawal] | None, + requests_list: List[Bytes] | None, + execution_witness: ExecutionWitness, + block_access_list: BlockAccessList, + chain_id: int, +) -> tuple[Bytes, Bytes] | None: + """ + Build Amsterdam stateless input/output bytes from t8n witness artifacts. + + Returns ``None`` when the finalized request list cannot be decoded into + the Amsterdam request container, matching the existing EELS t8n behavior. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam" or block_number == 0: + return None + + from ethereum.forks.amsterdam.blocks import ( + Block as AmsterdamBlock, + ) + from ethereum.forks.amsterdam.blocks import ( + Header as AmsterdamHeader, + ) + from ethereum.forks.amsterdam.execution_engine.requests import ( + decode_execution_requests, + ) + from ethereum.forks.amsterdam.stateless import ( + STATELESS_INPUT_SCHEMA_ID, + StatelessValidationResult, + compute_new_payload_request_root, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + serialize_stateless_output, + ) + from ethereum.forks.amsterdam.stateless_host import ( + build_stateless_input, + serialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U16, U64 + + parent_number = ZeroPaddedHexNumber(block_number - 1) + parent_header_rlp = previous_env.block_headers.get(parent_number) + if parent_header_rlp is None: + return None + parent_header = _decode_amsterdam_header_bytes(parent_header_rlp) + if parent_header is None: + return None + if Hash(parent_header_rlp.keccak256()) != header.parent_hash: + return None + + current_header = _decode_amsterdam_header_bytes(header.rlp) + if not isinstance(current_header, AmsterdamHeader): + return None + + try: + execution_requests = decode_execution_requests( + tuple( + AmsterdamBytes(bytes(request)) + for request in requests_list or [] + ) + ) + except Exception: + return None + + rejected_indices = { + int(rejected.index) for rejected in result.rejected_transactions + } + accepted_txs = tuple( + AmsterdamBytes(bytes(tx.rlp())) + for index, tx in enumerate(txs) + if index not in rejected_indices + ) + block = AmsterdamBlock( + header=current_header, + transactions=accepted_txs, + ommers=(), + withdrawals=_convert_amsterdam_withdrawals(withdrawals), + ) + stateless_input = build_stateless_input( + block, + execution_witness=_convert_amsterdam_execution_witness( + execution_witness + ), + execution_requests=execution_requests, + block_access_list=_convert_amsterdam_block_access_list( + block_access_list + ), + chain_id=U64(chain_id), + ) + stateless_input_bytes = serialize_stateless_input(stateless_input) + # Temporary trust path for external benchmark filling until Geth emits + # both stateless byte fields. + stateless_output = StatelessValidationResult( + new_payload_request_root=compute_new_payload_request_root( + stateless_input + ), + successful_validation=True, + chain_id=U64(chain_id), + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), + ) + stateless_output_bytes = serialize_stateless_output(stateless_output) + return ( + Bytes(bytes(stateless_input_bytes)), + Bytes(bytes(stateless_output_bytes)), + ) + + +def decode_amsterdam_stateless_output( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_output_bytes: Bytes | None, +) -> Any | None: + """ + Decode Amsterdam stateless output, if available for the active fork. + + Amsterdam is currently the only fork with stateless guest support in this + repository, so the decode path is kept Amsterdam-specific. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam" or stateless_output_bytes is None: + return None + + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + return deserialize_stateless_output( + AmsterdamBytes(bytes(stateless_output_bytes)) + ) + + +def assert_amsterdam_stateless_output_chain_id( + *, + block_number: int, + chain_id: int, + stateless_output: Any | None, + expected_chain_id: Any | None = None, +) -> None: + """ + Assert the stateless output reports the expected chain identifier. + """ + if stateless_output is None: + return + + if expected_chain_id is None: + from ethereum_types.numeric import U64 + + expected_chain_id = U64(chain_id) + + if stateless_output.chain_id != expected_chain_id: + raise AssertionError( + "Stateless output chain_id mismatch for block " + f"{block_number}: got {stateless_output.chain_id}, " + f"want {expected_chain_id}" + ) + + +def is_invalid_input_stateless_output(stateless_output: Any) -> bool: + """ + Return whether output is the invalid stateless input sentinel. + """ + from ethereum_types.numeric import U16, U64 + + return ( + not stateless_output.successful_validation + and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 + and stateless_output.chain_id == U64(0) + and stateless_output.schema_id == U16(0) + ) + + +def assert_amsterdam_stateless_output_request_root( + *, + block_number: int, + stateless_input: Any, + stateless_output: Any, +) -> None: + """ + Assert the output commits to the decoded Amsterdam payload request. + """ + from ethereum.forks.amsterdam.stateless import ( + compute_new_payload_request_root, + ) + + expected_root = compute_new_payload_request_root(stateless_input) + actual_root = stateless_output.new_payload_request_root + if actual_root != expected_root: + raise AssertionError( + "Stateless output new_payload_request_root mismatch for block " + f"{block_number}: got 0x{bytes(actual_root).hex()}, " + f"want 0x{bytes(expected_root).hex()}" + ) + + +def assert_amsterdam_stateless_output_schema_id( + *, + block_number: int, + stateless_output: Any, +) -> None: + """ + Assert the output identifies the input schema executed by the guest. + """ + from ethereum.forks.amsterdam.stateless import ( + STATELESS_INPUT_SCHEMA_ID, + ) + from ethereum_types.numeric import U16 + + expected_schema_id = U16(STATELESS_INPUT_SCHEMA_ID) + + if stateless_output.schema_id != expected_schema_id: + raise AssertionError( + "Stateless output schema_id mismatch for block " + f"{block_number}: got {stateless_output.schema_id}, " + f"want {expected_schema_id}" + ) + + +def verify_amsterdam_stateless_output( + *, + block_number: int, + chain_id: int, + stateless_input_bytes: Bytes, + stateless_output: Any, + input_bytes_modified: bool, +) -> None: + """ + Verify the public values returned by the Amsterdam stateless guest. + """ + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + try: + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(stateless_input_bytes)) + ) + except Exception as exc: + if input_bytes_modified and is_invalid_input_stateless_output( + stateless_output + ): + return + raise AssertionError( + "Stateless input decoding failed for block " + f"{block_number}, but its output is not the invalid-input sentinel" + ) from exc + + assert_amsterdam_stateless_output_request_root( + block_number=block_number, + stateless_input=stateless_input, + stateless_output=stateless_output, + ) + assert_amsterdam_stateless_output_schema_id( + block_number=block_number, + stateless_output=stateless_output, + ) + assert_amsterdam_stateless_output_chain_id( + block_number=block_number, + chain_id=chain_id, + stateless_output=stateless_output, + expected_chain_id=( + stateless_input.chain_id if input_bytes_modified else None + ), + ) + + +def _has_execution_witness_modifier( + block: StatelessBlockProtocol, +) -> bool: + """Return whether any execution witness expectation mutates the witness.""" + return ( + ( + block.expected_execution_witness_state is not None + and block.expected_execution_witness_state._modifier is not None + ) + or ( + block.expected_execution_witness_codes is not None + and block.expected_execution_witness_codes._modifier is not None + ) + or ( + block.expected_execution_witness_headers is not None + and block.expected_execution_witness_headers._modifier is not None + ) + ) diff --git a/packages/testing/src/execution_testing/specs/state.py b/packages/testing/src/execution_testing/specs/state.py index 16b88605210..b3a3f8ad661 100644 --- a/packages/testing/src/execution_testing/specs/state.py +++ b/packages/testing/src/execution_testing/specs/state.py @@ -54,6 +54,9 @@ Alloc, BlockAccessListExpectation, Environment, + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, Transaction, ) @@ -84,6 +87,15 @@ class StateTest(BaseTest): blockchain_test_header_verify: Optional[Header] = None blockchain_test_rlp_modifier: Optional[Header] = None expected_block_access_list: Optional[BlockAccessListExpectation] = None + expected_execution_witness_codes: Optional[ + ExecutionWitnessCodesExpectation + ] = None + expected_execution_witness_state: Optional[ + ExecutionWitnessStateExpectation + ] = None + expected_execution_witness_headers: Optional[ + ExecutionWitnessHeadersExpectation + ] = None chain_id: int = 1 supported_fixture_formats: ClassVar[ @@ -311,6 +323,15 @@ def _generate_blockchain_blocks(self) -> List[Block]: "header_verify": self.blockchain_test_header_verify, "rlp_modifier": self.blockchain_test_rlp_modifier, "expected_block_access_list": self.expected_block_access_list, + "expected_execution_witness_codes": ( + self.expected_execution_witness_codes + ), + "expected_execution_witness_state": ( + self.expected_execution_witness_state + ), + "expected_execution_witness_headers": ( + self.expected_execution_witness_headers + ), } if not fork.header_prev_randao_required(): kwargs["difficulty"] = self.env.difficulty diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index 315f5543b10..23d648976e2 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -20,6 +20,13 @@ Withdrawal, ) from .chain_config_types import ChainConfig, ChainConfigDefaults +from .execution_witness import ( + ExecutionWitness, + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, + ExecutionWitnessValidationError, +) from .helpers import ( DETERMINISTIC_FACTORY_ADDRESS, DETERMINISTIC_FACTORY_BYTECODE, @@ -84,9 +91,14 @@ "ChainConfigDefaults", "ConsolidationRequest", "DepositRequest", + "EOA", "Environment", "EnvironmentDefaults", - "EOA", + "ExecutionWitness", + "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", + "ExecutionWitnessValidationError", "FeeSystemContractRequest", "NetworkWrappedTransaction", "Removable", diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index 98ddb6ce161..0bf7b66b5bf 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -164,6 +164,9 @@ def strip_computed_fields(cls, data: Any) -> Any: parent_beacon_block_root: Hash | None = Field(None) block_hashes: Dict[ZeroPaddedHexNumber, Hash] = Field(default_factory=dict) + block_headers: Dict[ZeroPaddedHexNumber, Bytes] = Field( + default_factory=dict + ) ommers: List[Hash] = Field(default_factory=list) withdrawals: List[Withdrawal] | None = Field(None) extra_data: Bytes = Field(Bytes(b"\x00"), exclude=True) @@ -234,7 +237,7 @@ def set_fork_requirements(self, fork: Fork) -> "Environment": else 0 ) - return self.copy(**updated_values) + return self.copy(extra_data=self.extra_data, **updated_values) @classmethod def for_fork(cls, fork: Fork, **kwargs: Any) -> "Environment": diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py new file mode 100644 index 00000000000..38546ad0050 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py @@ -0,0 +1,22 @@ +""" +Execution witness models for stateless validation. + +This package provides types for execution witness data and +expectation-based assertions for test writing. +""" + +from .exceptions import ExecutionWitnessValidationError +from .expectations import ( + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, +) +from .types import ExecutionWitness + +__all__ = [ + "ExecutionWitness", + "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", + "ExecutionWitnessValidationError", +] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/exceptions.py b/packages/testing/src/execution_testing/test_types/execution_witness/exceptions.py new file mode 100644 index 00000000000..86dd5f59daf --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions related to execution witness validation.""" + + +class ExecutionWitnessValidationError(Exception): + """Custom exception for execution witness validation errors.""" + + pass diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py new file mode 100644 index 00000000000..d40ff3f18e0 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -0,0 +1,446 @@ +""" +Execution witness expectation classes for test validation. + +This module contains classes for defining and validating expected +execution witness state, codes, and headers in tests. +""" + +from __future__ import annotations + +from typing import Callable, List + +import ethereum_rlp as eth_rlp +from pydantic import Field, PrivateAttr + +from execution_testing.base_types import Bytes, CamelModel, Hash +from execution_testing.forks import Fork, Prague + +from .exceptions import ExecutionWitnessValidationError +from .types import ExecutionWitness + + +class ExecutionWitnessCodesExpectation(CamelModel): + """ + Execution witness codes expectation model for test writing. + + Define which bytecodes should or should not appear in + executionWitness.codes. + + Ambient block-level codes (system contracts called every block) + are automatically added to codes_present by the framework before + verification. Tests only need to declare scenario-specific codes. + + Example: + expected_execution_witness_codes = ExecutionWitnessCodesExpectation( + codes_present=[Bytes(runtime_code)], + codes_absent=[Bytes(created_code)], + ) + + """ + + codes_present: List[Bytes] = Field( + default_factory=list, + description="Bytecodes that must be present in witness codes", + ) + codes_absent: List[Bytes] = Field( + default_factory=list, + description=("Bytecodes that must NOT be present in witness codes"), + ) + + _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( + PrivateAttr(default=None) + ) + + def modify( + self, + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], + ) -> "ExecutionWitnessCodesExpectation": + """ + Create a new expectation with a modifier for invalid test cases. + + Args: + modifiers: One or more functions that take and return + an ExecutionWitness + + Returns: + A new ExecutionWitnessCodesExpectation with modifiers applied + + """ + new_instance = self.model_copy(deep=True) + new_instance._modifier = _compose(*modifiers) + return new_instance + + def modify_if_invalid_test( + self, t8n_witness: "ExecutionWitness" + ) -> "ExecutionWitness": + """ + Apply the modifier to the given witness if this is an invalid test. + + Args: + t8n_witness: The ExecutionWitness from the t8n tool + + Returns: + The potentially transformed ExecutionWitness for the fixture + + """ + if self._modifier: + return self._modifier(t8n_witness) + return t8n_witness + + def verify_against(self, actual_witness: "ExecutionWitness") -> None: + """ + Verify that the actual witness codes match this expectation. + + Validation steps: + 1. Structural invariants: no duplicates and lexicographic + ascending order + 2. Presence checks: codes_present entries exist + 3. Absence checks: codes_absent entries do not exist + 4. Exhaustiveness: no extra codes are allowed + + Args: + actual_witness: The ExecutionWitness from the t8n tool + + Raises: + ExecutionWitnessValidationError: If verification fails + + """ + actual_codes = actual_witness.codes + + # 1. Structural invariants (always checked) + if len(actual_codes) != len(set(actual_codes)): + seen: set[Bytes] = set() + dupes: list[Bytes] = [] + for code in actual_codes: + if code in seen: + dupes.append(code) + seen.add(code) + raise ExecutionWitnessValidationError( + f"Witness codes contain duplicates: {[c.hex() for c in dupes]}" + ) + + if actual_codes != sorted(actual_codes): + raise ExecutionWitnessValidationError( + "Witness codes are not sorted in lexicographic ascending order" + ) + + actual_set = set(actual_codes) + + # 2. Presence checks + for code in self.codes_present: + if code not in actual_set: + raise ExecutionWitnessValidationError( + f"Expected bytecode {code.hex()} not found " + f"in witness codes" + ) + + # 3. Absence checks + for code in self.codes_absent: + if code in actual_set: + raise ExecutionWitnessValidationError( + f"Bytecode {code.hex()} should not be in " + f"witness codes but was found" + ) + + # 4. Exhaustiveness check + expected_set = set(self.codes_present) + unexpected = actual_set - expected_set + if unexpected: + raise ExecutionWitnessValidationError( + f"Unexpected bytecodes in witness codes: " + f"{[c.hex() for c in unexpected]}" + ) + + +class ExecutionWitnessStateExpectation(CamelModel): + """ + Execution witness state expectation model for test writing. + + Define which encoded trie nodes should or should not appear in + executionWitness.state. + + Example: + expected_execution_witness_state = ExecutionWitnessStateExpectation( + nodes_present=[Bytes(derived_node_rlp)], + ) + + """ + + nodes_present: List[Bytes] = Field( + default_factory=list, + description="Encoded trie nodes that must be present in witness state", + ) + nodes_absent: List[Bytes] = Field( + default_factory=list, + description=( + "Encoded trie nodes that must NOT be present in witness state" + ), + ) + + _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( + PrivateAttr(default=None) + ) + + def modify( + self, + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], + ) -> "ExecutionWitnessStateExpectation": + """ + Create a new expectation with a modifier for invalid test cases. + + Args: + modifiers: One or more functions that take and return + an ExecutionWitness + + Returns: + A new ExecutionWitnessStateExpectation with modifiers applied + + """ + new_instance = self.model_copy(deep=True) + new_instance._modifier = _compose(*modifiers) + return new_instance + + def modify_if_invalid_test( + self, t8n_witness: "ExecutionWitness" + ) -> "ExecutionWitness": + """ + Apply the modifier to the given witness if this is an invalid test. + + Args: + t8n_witness: The ExecutionWitness from the t8n tool + + Returns: + The potentially transformed ExecutionWitness for the fixture + + """ + if self._modifier: + return self._modifier(t8n_witness) + return t8n_witness + + def verify_against(self, actual_witness: "ExecutionWitness") -> None: + """ + Verify that the actual witness state matches this expectation. + + Validation steps: + 1. Structural invariants: no duplicates and lexicographic + ascending order + 2. Presence checks: nodes_present entries exist + 3. Absence checks: nodes_absent entries do not exist + + Args: + actual_witness: The ExecutionWitness from the t8n tool + + Raises: + ExecutionWitnessValidationError: If verification fails + + """ + actual_nodes = actual_witness.state + + if len(actual_nodes) != len(set(actual_nodes)): + seen: set[Bytes] = set() + dupes: list[Bytes] = [] + for node in actual_nodes: + if node in seen: + dupes.append(node) + seen.add(node) + raise ExecutionWitnessValidationError( + "Witness state contains duplicates: " + f"{[n.hex() for n in dupes]}" + ) + + if actual_nodes != sorted(actual_nodes): + raise ExecutionWitnessValidationError( + "Witness state is not sorted in lexicographic ascending order" + ) + + actual_set = set(actual_nodes) + + for node in self.nodes_present: + if node not in actual_set: + raise ExecutionWitnessValidationError( + f"Expected trie node {node.hex()} not found " + f"in witness state" + ) + + for node in self.nodes_absent: + if node in actual_set: + raise ExecutionWitnessValidationError( + f"Trie node {node.hex()} should not be in " + f"witness state but was found" + ) + + +class ExecutionWitnessHeadersExpectation(CamelModel): + """ + Execution witness headers expectation model for test writing. + + Define expected properties of executionWitness.headers. + + Example: + expected_execution_witness_headers = ( + ExecutionWitnessHeadersExpectation( + expected_count=5, + ) + ) + + """ + + expected_count: int = Field( + description="Exact number of RLP-encoded headers expected", + ) + + _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( + PrivateAttr(default=None) + ) + + def modify( + self, + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], + ) -> "ExecutionWitnessHeadersExpectation": + """ + Create a new expectation with a modifier for invalid test cases. + + Args: + modifiers: One or more functions that take and return + an ExecutionWitness + + Returns: + A new ExecutionWitnessHeadersExpectation with modifiers + applied + + """ + new_instance = self.model_copy(deep=True) + new_instance._modifier = _compose(*modifiers) + return new_instance + + def modify_if_invalid_test( + self, t8n_witness: "ExecutionWitness" + ) -> "ExecutionWitness": + """ + Apply the modifier to the given witness if this is an invalid test. + + Args: + t8n_witness: The ExecutionWitness from the t8n tool + + Returns: + The potentially transformed ExecutionWitness for the fixture + + """ + if self._modifier: + return self._modifier(t8n_witness) + return t8n_witness + + def verify_against( + self, + actual_witness: ExecutionWitness, + parent_hash: Hash, + fork: Fork, + ) -> None: + """ + Verify header count and structural invariants. + + Validation steps: + 1. Count matches expected_count + 2. No more than 256 headers + 3. Sorted ascending by block number (Prague+) + 4. Contiguous: keccak256(headers[i]) == parent_hash of + headers[i+1] (Prague+) + 5. Last header is the current block's parent: + keccak256(headers[-1]) == parent_hash (Prague+) + + Steps 3-5 require RLP decoding and are only performed for + Prague and newer forks where EIP-2935 guarantees at least + one ancestor header (the parent) is always tracked. + + Args: + actual_witness: The ExecutionWitness from the t8n tool + parent_hash: The parent hash of the current block + fork: The fork under test + + Raises: + ExecutionWitnessValidationError: If verification fails + + """ + actual_headers = actual_witness.headers + + # 1. Count check + if len(actual_headers) != self.expected_count: + raise ExecutionWitnessValidationError( + f"Expected {self.expected_count} witness headers, " + f"got {len(actual_headers)}" + ) + + # 2. Max 256 headers + if len(actual_headers) > 256: + raise ExecutionWitnessValidationError( + f"Witness headers exceed maximum of 256: " + f"got {len(actual_headers)}" + ) + + # Since Prague we have EIP-2935 which requires the parent + # block's header to be included. + if len(actual_headers) == 0 or fork < Prague: + return + + # Decode all headers to extract block numbers and parent + # hashes for structural checks. + decoded = [] + for rlp_header in actual_headers: + fields: list[bytes] = eth_rlp.decode(rlp_header) # type: ignore[assignment] + header_parent_hash = Hash(fields[0]) + block_number = int.from_bytes(fields[8], "big") + header_hash = rlp_header.keccak256() + decoded.append((block_number, header_parent_hash, header_hash)) + + # 3. Sorted ascending by block number + block_numbers = [d[0] for d in decoded] + if block_numbers != sorted(block_numbers): + raise ExecutionWitnessValidationError( + "Witness headers are not sorted in ascending " + f"block number order: {block_numbers}" + ) + + # 4. Contiguous: keccak256(headers[i]) == parent_hash of + # headers[i+1] + for i in range(len(decoded) - 1): + _, _, current_hash = decoded[i] + _, next_parent_hash, _ = decoded[i + 1] + if current_hash != next_parent_hash: + raise ExecutionWitnessValidationError( + f"Witness headers not contiguous at index " + f"{i}: hash {current_hash.hex()} != " + f"parent_hash {next_parent_hash.hex()} of " + f"header {i + 1}" + ) + + # 5. Last header is the current block's parent + _, _, last_hash = decoded[-1] + if last_hash != parent_hash: + raise ExecutionWitnessValidationError( + f"Last witness header hash " + f"{last_hash.hex()} != current block " + f"parent_hash {parent_hash.hex()}" + ) + + +def _compose( + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], +) -> Callable[["ExecutionWitness"], "ExecutionWitness"]: + """Compose multiple modifiers into a single modifier.""" + + def composed( + witness: ExecutionWitness, + ) -> ExecutionWitness: + result = witness + for modifier in modifiers: + result = modifier(result) + return result + + return composed + + +__all__ = [ + "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", +] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py new file mode 100644 index 00000000000..cf412cd0793 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -0,0 +1,247 @@ +""" +Execution witness modifier functions for invalid test cases. + +This module provides modifier functions that can be used to modify +execution witnesses for testing invalid block scenarios. +""" + +from typing import Callable, Tuple + +from execution_testing.base_types import Bytes + +from .types import ExecutionWitness + +PublicKeyModifier = Callable[[Tuple[Bytes, ...]], Tuple[Bytes, ...]] + + +def add_code( + code: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Add a bytecode entry to the witness codes list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_codes = list(witness.codes) + new_codes.append(code) + new_codes.sort() + return witness.model_copy(update={"codes": new_codes}) + + return transform + + +def add_state_node( + node: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Add an encoded trie node entry to the witness state list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_state = list(witness.state) + new_state.append(node) + new_state.sort() + return witness.model_copy(update={"state": new_state}) + + return transform + + +def remove_state_node( + node: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove an encoded trie node entry from the witness state list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_state = [entry for entry in witness.state if entry != node] + if len(new_state) == len(witness.state): + raise ValueError( + f"Trie node {node.hex()} not found in witness state to remove" + ) + return witness.model_copy(update={"state": new_state}) + + return transform + + +def remove_code( + code: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove a bytecode entry from the witness codes list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_codes = [c for c in witness.codes if c != code] + if len(new_codes) == len(witness.codes): + raise ValueError( + f"Bytecode {code.hex()} not found in witness codes to remove" + ) + return witness.model_copy(update={"codes": new_codes}) + + return transform + + +def remove_code_at( + index: int, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove the bytecode entry at `index` from the witness codes list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_codes = list(witness.codes) + try: + new_codes.pop(index) + except IndexError as exc: + raise IndexError( + f"Code index {index} out of range for witness codes" + ) from exc + return witness.model_copy(update={"codes": new_codes}) + + return transform + + +def reverse_codes() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Reverse the order of witness codes.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy( + update={"codes": list(reversed(witness.codes))} + ) + + return transform + + +def reverse_state_nodes() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Reverse the order of witness state nodes.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy( + update={"state": list(reversed(witness.state))} + ) + + return transform + + +def clear_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove all header entries from the witness.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy(update={"headers": []}) + + return transform + + +def remove_header_at( + index: int, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove the header entry at `index` from the witness.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_headers = list(witness.headers) + try: + new_headers.pop(index) + except IndexError as exc: + raise IndexError( + f"Header index {index} out of range for witness headers" + ) from exc + return witness.model_copy(update={"headers": new_headers}) + + return transform + + +def prepend_header( + header: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Prepend a header entry to the witness.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy( + update={"headers": [header, *witness.headers]} + ) + + return transform + + +def reverse_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Reverse the order of witness headers.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy( + update={"headers": list(reversed(witness.headers))} + ) + + return transform + + +def replace_header_at( + index: int, + header: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Replace the header entry at `index` with `header`.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_headers = list(witness.headers) + try: + new_headers[index] = header + except IndexError as exc: + raise IndexError( + f"Header index {index} out of range for witness headers" + ) from exc + return witness.model_copy(update={"headers": new_headers}) + + return transform + + +def replace_public_key_at( + index: int, + public_key: Bytes, +) -> PublicKeyModifier: + """Replace the transaction public key at `index`.""" + + def transform( + public_keys: Tuple[Bytes, ...], + ) -> Tuple[Bytes, ...]: + new_public_keys = list(public_keys) + try: + new_public_keys[index] = public_key + except IndexError as exc: + raise IndexError( + f"Public key index {index} out of range for stateless input" + ) from exc + return tuple(new_public_keys) + + return transform + + +__all__ = [ + "add_state_node", + "add_code", + "clear_headers", + "PublicKeyModifier", + "remove_state_node", + "remove_code", + "remove_code_at", + "reverse_codes", + "reverse_state_nodes", + "prepend_header", + "remove_header_at", + "reverse_headers", + "replace_header_at", + "replace_public_key_at", +] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/types.py b/packages/testing/src/execution_testing/test_types/execution_witness/types.py new file mode 100644 index 00000000000..4a0a698ba89 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/types.py @@ -0,0 +1,18 @@ +"""Execution witness types.""" + +from typing import List + +from pydantic import Field + +from execution_testing.base_types import ( + Bytes, + CamelModel, +) + + +class ExecutionWitness(CamelModel): + """Execution witness for stateless validation.""" + + state: List[Bytes] = Field(default_factory=list) + codes: List[Bytes] = Field(default_factory=list) + headers: List[Bytes] = Field(default_factory=list) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py new file mode 100644 index 00000000000..9df9226705a --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -0,0 +1,136 @@ +"""Tests for execution witness expectations.""" + +import pytest + +from execution_testing.base_types import Bytes +from execution_testing.test_types.execution_witness import ( + ExecutionWitness, + ExecutionWitnessStateExpectation, + ExecutionWitnessValidationError, +) +from execution_testing.test_types.execution_witness.modifiers import ( + add_code, + add_state_node, + clear_headers, + prepend_header, + remove_code, + remove_code_at, + remove_header_at, + remove_state_node, + replace_header_at, + reverse_codes, + reverse_headers, + reverse_state_nodes, +) + + +def test_execution_witness_state_expectation_accepts_sorted_subset() -> None: + """State expectations should accept sorted witnesses with extras.""" + witness = ExecutionWitness( + state=[Bytes(b"aa"), Bytes(b"bb")], + codes=[], + headers=[], + ) + + ExecutionWitnessStateExpectation( + nodes_present=[Bytes(b"aa")] + ).verify_against(witness) + + +def test_execution_witness_state_expectation_rejects_duplicates() -> None: + """Duplicate state entries should fail structural validation.""" + witness = ExecutionWitness( + state=[Bytes(b"aa"), Bytes(b"aa")], + codes=[], + headers=[], + ) + + with pytest.raises( + ExecutionWitnessValidationError, match="contains duplicates" + ): + ExecutionWitnessStateExpectation().verify_against(witness) + + +def test_execution_witness_state_expectation_rejects_unsorted_entries() -> ( + None +): + """Unsorted state entries should fail structural validation.""" + witness = ExecutionWitness( + state=[Bytes(b"bb"), Bytes(b"aa")], + codes=[], + headers=[], + ) + + with pytest.raises(ExecutionWitnessValidationError, match="not sorted"): + ExecutionWitnessStateExpectation().verify_against(witness) + + +def test_execution_witness_state_expectation_rejects_missing_node() -> None: + """Missing required nodes should fail validation.""" + witness = ExecutionWitness(state=[Bytes(b"aa")], codes=[], headers=[]) + + with pytest.raises( + ExecutionWitnessValidationError, match="not found in witness state" + ): + ExecutionWitnessStateExpectation( + nodes_present=[Bytes(b"bb")] + ).verify_against(witness) + + +def test_execution_witness_state_modifiers_add_and_remove() -> None: + """State modifiers should update the witness state list.""" + witness = ExecutionWitness(state=[Bytes(b"aa")], codes=[], headers=[]) + + modified = add_state_node(Bytes(b"bb"))(witness) + assert modified.state == [Bytes(b"aa"), Bytes(b"bb")] + + restored = remove_state_node(Bytes(b"bb"))(modified) + assert restored.state == [Bytes(b"aa")] + + reversed_state = reverse_state_nodes()(modified) + assert reversed_state.state == [Bytes(b"bb"), Bytes(b"aa")] + + +def test_execution_witness_code_modifiers() -> None: + """Code modifiers should update witness codes predictably.""" + witness = ExecutionWitness( + state=[], + codes=[Bytes(b"aa"), Bytes(b"bb")], + headers=[], + ) + + added = add_code(Bytes(b"cc"))(witness) + assert added.codes == [Bytes(b"aa"), Bytes(b"bb"), Bytes(b"cc")] + + removed = remove_code(Bytes(b"bb"))(added) + assert removed.codes == [Bytes(b"aa"), Bytes(b"cc")] + + removed_by_index = remove_code_at(0)(added) + assert removed_by_index.codes == [Bytes(b"bb"), Bytes(b"cc")] + + reversed_codes = reverse_codes()(witness) + assert reversed_codes.codes == [Bytes(b"bb"), Bytes(b"aa")] + + +def test_execution_witness_header_modifiers() -> None: + """Header modifiers should update witness headers predictably.""" + witness = ExecutionWitness( + state=[], + codes=[], + headers=[Bytes(b"aa"), Bytes(b"bb")], + ) + + removed = remove_header_at(-1)(witness) + assert removed.headers == [Bytes(b"aa")] + + replaced = replace_header_at(0, Bytes(b"cc"))(witness) + assert replaced.headers == [Bytes(b"cc"), Bytes(b"bb")] + + prepended = prepend_header(Bytes(b"00"))(witness) + assert prepended.headers == [Bytes(b"00"), Bytes(b"aa"), Bytes(b"bb")] + + reversed_headers = reverse_headers()(witness) + assert reversed_headers.headers == [Bytes(b"bb"), Bytes(b"aa")] + + cleared = clear_headers()(witness) + assert cleared.headers == [] diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness_expectation.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness_expectation.py new file mode 100644 index 00000000000..6ac801fe0fa --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness_expectation.py @@ -0,0 +1,27 @@ +"""Unit tests for execution witness code expectations.""" + +import pytest + +from execution_testing.base_types import Bytes +from execution_testing.test_types import ( + ExecutionWitness, + ExecutionWitnessCodesExpectation, + ExecutionWitnessValidationError, +) + + +def test_codes_expectation_rejects_unexpected_codes() -> None: + """Witness code expectations are always exhaustive.""" + expected_code = Bytes(b"\x60\x00") + unexpected_code = Bytes(b"\x60\x01") + + expectation = ExecutionWitnessCodesExpectation( + codes_present=[expected_code] + ) + actual_witness = ExecutionWitness(codes=[expected_code, unexpected_code]) + + with pytest.raises( + ExecutionWitnessValidationError, + match="Unexpected bytecodes in witness codes", + ): + expectation.verify_against(actual_witness) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_types.py b/packages/testing/src/execution_testing/test_types/tests/test_types.py index 3abe5dce247..fbb276a85b9 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_types.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_types.py @@ -15,6 +15,7 @@ to_json, ) from execution_testing.base_types.pydantic import CopyValidateModel +from execution_testing.forks import Amsterdam from ..account_types import EOA, Alloc from ..block_types import ( @@ -501,6 +502,7 @@ def test_account_merge( "currentNumber": "0x01", "currentTimestamp": "0x03e8", "blockHashes": {}, + "blockHeaders": {}, "ommers": [], "parentUncleHash": ( "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" @@ -566,6 +568,7 @@ def test_account_merge( "0x01": "0x0000000000000000000000000000000000000000000000000000000000000002", # noqa: E501 "0x03": "0x0000000000000000000000000000000000000000000000000000000000000004", # noqa: E501 }, + "blockHeaders": {}, "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000004", # noqa: E501 "ommers": [], }, @@ -951,6 +954,13 @@ def test_model_copy(model: CopyValidateModel) -> None: assert model.copy().model_fields_set == model.model_fields_set +def test_environment_fork_requirements_preserve_extra_data() -> None: + """Preserve extra data while applying fork requirements.""" + env = Environment(extra_data=b"current block") + + assert env.set_fork_requirements(Amsterdam).extra_data == env.extra_data + + @pytest.mark.parametrize( "value, expected", [ diff --git a/packages/testing/src/execution_testing/tools/utility/generators.py b/packages/testing/src/execution_testing/tools/utility/generators.py index ee7b526d81f..569780022f0 100644 --- a/packages/testing/src/execution_testing/tools/utility/generators.py +++ b/packages/testing/src/execution_testing/tools/utility/generators.py @@ -42,13 +42,16 @@ class SystemContractTestType(StrEnum): def param(self) -> Any: """Return the parameter for the test.""" - return pytest.param( - self, - id=self.value, - marks=pytest.mark.exception_test - if self != SystemContractTestType.GAS_LIMIT - else [], - ) + marks: List[Any] = [] + if self != SystemContractTestType.GAS_LIMIT: + marks.append(pytest.mark.exception_test) + if self in ( + SystemContractTestType.GAS_LIMIT, + SystemContractTestType.OUT_OF_GAS_ERROR, + ): + marks.append(pytest.mark.skip_stateless_validation) + + return pytest.param(self, id=self.value, marks=marks) class ContractAddressHasBalance(StrEnum): diff --git a/pyproject.toml b/pyproject.toml index 89dd04d9d85..a22c216679b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "py-ecc>=8.0.0b2,<9", "ethereum-types>=0.4.1,<0.5", "ethereum-rlp>=0.1.6,<0.2", + "eth-remerkleable>=0.1.29,<0.2", "cryptography>=45.0.1,<46", "platformdirs>=4.2,<5", "libcst>=1.8,<2", @@ -190,10 +191,7 @@ packages = [ "ethereum_spec_tools" = ["py.typed"] [project.optional-dependencies] -optimized = [ - "rust-pyspec-glue>=0.0.9,<0.1.0", - "ethash>=1.1.0,<2", -] +optimized = ["rust-pyspec-glue>=0.0.9,<0.1.0", "ethash>=1.1.0,<2"] [dependency-groups] test = [ @@ -219,11 +217,7 @@ actionlint = [ "pyflakes>=3.0", "shellcheck-py>=0.10", ] -doc = [ - "docc>=0.6.1,<0.7.0", - "fladrif>=0.2.0,<0.3.0", - "mistletoe>=1.5.0,<2", -] +doc = ["docc>=0.6.1,<0.7.0", "fladrif>=0.2.0,<0.3.0", "mistletoe>=1.5.0,<2"] mkdocs = [ "cairosvg>=2.7.0,<3", "codespell>=2.4.1,<3", @@ -255,9 +249,7 @@ dev = [ # Opt-in (not part of dev): native-accelerated state and ethash for the # sync tool. ethash has no CPython 3.14 wheels, so installing this group # requires a C toolchain on 3.14. -optimized = [ - "ethereum-execution[optimized]", -] +optimized = ["ethereum-execution[optimized]"] [tool.setuptools.dynamic] version = { attr = "ethereum.__version__" } @@ -348,27 +340,18 @@ transform = [ [tool.docc.plugins."docc.python.transform"] excluded_references = [ - "ethereum_spec_tools.lint.lints", # This is a namespace package. + "ethereum_spec_tools.lint.lints", # This is a namespace package. ] [tool.docc.plugins."ethereum_spec_tools.docc.python"] -paths = [ - "src", -] -excluded_paths = [ - "src/ethereum_optimized", - "src/ethereum_spec_tools", -] +paths = ["src"] +excluded_paths = ["src/ethereum_optimized", "src/ethereum_spec_tools"] [tool.docc.plugins."docc.html.context"] -extra_css = [ - "static/custom.css", -] +extra_css = ["static/custom.css"] [tool.docc.plugins."docc.files.discover"] -files = [ - "static/custom.css", -] +files = ["static/custom.css"] [tool.docc.output] path = ".just/docs-spec" @@ -384,15 +367,15 @@ line-length = 79 [tool.ruff.lint] exclude = ["tests/fixtures"] select = [ - "E", # pycodestyle errors - "F", # Pyflakes - "B", # flake8-bugbear - "W", # pycodestyle warnings - "I", # isort - "A", # flake8-builtins - "N", # pep8-naming - "D", # pydocstyle - "C4", # flake8-comprehensions + "E", # pycodestyle errors + "F", # Pyflakes + "B", # flake8-bugbear + "W", # pycodestyle warnings + "I", # isort + "A", # flake8-builtins + "N", # pep8-naming + "D", # pydocstyle + "C4", # flake8-comprehensions "ARG", # flake8-unused-arguments ] fixable = [ @@ -404,38 +387,38 @@ fixable = [ "D", # pydocstyle ] ignore = [ -# Common to STEEL - "C401", # Unnecessary generator set - "C408", # Unnecessary collection call - "D107", # Missing docstring in __init__ - "D200", # One-line docstring should fit on one line with quotes - "D203", # 1 blank line required before class docstring - "D205", # Missing blank line after summary - "D212", # Multi-line docstring summary should start at the first line - "D401", # First line should be in imperative mood ("Do", not "Does") + # Common to STEEL + "C401", # Unnecessary generator set + "C408", # Unnecessary collection call + "D107", # Missing docstring in __init__ + "D200", # One-line docstring should fit on one line with quotes + "D203", # 1 blank line required before class docstring + "D205", # Missing blank line after summary + "D212", # Multi-line docstring summary should start at the first line + "D401", # First line should be in imperative mood ("Do", not "Does") ] [tool.ruff.lint.per-file-ignores] "src/ethereum_spec_tools/loaders/fork_loader.py" = [ - "N802" # Property names do not need to be lowercase + "N802", # Property names do not need to be lowercase ] "src/ethereum_spec_tools/lint/*" = [ - "N802" # Special linting code absolved of function naming reqs + "N802", # Special linting code absolved of function naming reqs ] "src/ethereum/crypto/*" = [ - "N806", # Special crypto code absolved of variable naming reqs - "N802" # Special crypto code absolved of function naming reqs + "N806", # Special crypto code absolved of variable naming reqs + "N802", # Special crypto code absolved of function naming reqs ] "packages/testing/src/execution_testing/evm_tools/t8n/evm_trace/eip3155.py" = [ - "N815" # The traces must use camel case in JSON property names + "N815", # The traces must use camel case in JSON property names ] "tests/*" = ["ARG001"] "packages/testing/src/execution_testing/evm_tools/tests/*" = ["ARG001"] "vulture_whitelist.py" = [ - "B018", # Useless expression (intentional for Vulture whitelisting) - "E402", # Module-level imports throughout file (needed for whitelisting) - "F403", # Star imports needed for whitelisting - "F405", # Undefined names from star imports + "B018", # Useless expression (intentional for Vulture whitelisting) + "E402", # Module-level imports throughout file (needed for whitelisting) + "F403", # Star imports needed for whitelisting + "F405", # Undefined names from star imports ] [tool.ruff.lint.mccabe] @@ -478,9 +461,9 @@ skip = [ "*.coverage*", "uv.lock", ] -ignore-words = "whitelist.txt" # Custom whitelist file -count = true # Display counts of errors -check-hidden = false # Don't check hidden files (starting with .) +ignore-words = "whitelist.txt" # Custom whitelist file +count = true # Display counts of errors +check-hidden = false # Don't check hidden files (starting with .) [tool.mypy] namespace_packages = true @@ -503,12 +486,12 @@ enable_error_code = [ "exhaustive-match", "deprecated", - #"mutable-override", - #"truthy-bool", - #"explicit-override", - #"ignore-without-code", - #"possibly-undefined", - #"redundant-expr", + #"mutable-override", + #"truthy-bool", + #"explicit-override", + #"ignore-without-code", + #"possibly-undefined", + #"redundant-expr", ] exclude = [ "^\\.cache/", diff --git a/src/ethereum/forks/amsterdam/block_access_lists.py b/src/ethereum/forks/amsterdam/block_access_lists.py index 55bec6463cc..18386fe6ce2 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists.py +++ b/src/ethereum/forks/amsterdam/block_access_lists.py @@ -656,7 +656,11 @@ def update_builder_from_tx( post_account.code_hash if post_account else EMPTY_CODE_HASH ) if pre_code_hash != post_code_hash: - post_code = get_code(tx_state, post_code_hash) + post_code = get_code( + tx_state, + post_code_hash, + address, + ) add_code_change(builder, address, idx, post_code) # Compare storage writes against block cumulative state diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index 17fb23253a8..d27bf3f302c 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -19,6 +19,7 @@ from ethereum.crypto.hash import Hash32 from ethereum.state import Address, Root +from ethereum.utils.ssz import SszContainer from .fork_types import Bloom from .transactions import ( @@ -34,7 +35,7 @@ @final @slotted_freezable @dataclass -class Withdrawal: +class Withdrawal(SszContainer): """ Withdrawals represent a transfer of ETH from the consensus layer (beacon chain) to the execution layer, as validated by the consensus layer. Each diff --git a/src/ethereum/forks/amsterdam/execution_engine/__init__.py b/src/ethereum/forks/amsterdam/execution_engine/__init__.py new file mode 100644 index 00000000000..c37f9365aa0 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/__init__.py @@ -0,0 +1,52 @@ +""" +Execution Engine. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +The execution engine is the interface defined by the consensus layer +in the consensus-specs for calling the execution layer. +It provides methods for the consensus-specs to verify and apply +new payloads to the execution layer state. + +These methods correspond to the ``ExecutionEngine`` abstraction in the +consensus-specs and can change over forks. +""" + +from .forkchoice_update import notify_forkchoice_updated +from .get_payload import get_payload +from .new_payload import ( + is_valid_block_hash, + is_valid_versioned_hashes, + verify_and_notify_new_payload, +) +from .types import ( + BlobsBundle, + ExecutionEngine, + ExecutionPayload, + ExecutionRequests, + GetPayloadResponse, + NewPayloadRequest, + PayloadAttributes, + PayloadId, +) + +__all__ = [ + "BlobsBundle", + "ExecutionEngine", + "ExecutionPayload", + "ExecutionRequests", + "GetPayloadResponse", + "NewPayloadRequest", + "PayloadAttributes", + "PayloadId", + "get_payload", + "is_valid_block_hash", + "is_valid_versioned_hashes", + "notify_forkchoice_updated", + "verify_and_notify_new_payload", +] diff --git a/src/ethereum/forks/amsterdam/execution_engine/forkchoice_update.py b/src/ethereum/forks/amsterdam/execution_engine/forkchoice_update.py new file mode 100644 index 00000000000..2f96bf4e9a3 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/forkchoice_update.py @@ -0,0 +1,26 @@ +""" +Forkchoice update and payload build signal. +""" + +from typing import Optional + +from ethereum.crypto.hash import Hash32 + +from .types import ( + ExecutionEngine, + PayloadAttributes, + PayloadId, +) + + +def notify_forkchoice_updated( + _chain: ExecutionEngine, + _head_block_hash: Hash32, + _safe_block_hash: Hash32, + _finalized_block_hash: Hash32, + _payload_attributes: Optional[PayloadAttributes], +) -> Optional[PayloadId]: + """ + Notify the execution engine about the latest fork-choice state. + """ + raise NotImplementedError diff --git a/src/ethereum/forks/amsterdam/execution_engine/get_payload.py b/src/ethereum/forks/amsterdam/execution_engine/get_payload.py new file mode 100644 index 00000000000..832d5643d82 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/get_payload.py @@ -0,0 +1,16 @@ +""" +Payload build storage and retrieval helpers. +""" + +from .types import ( + GetPayloadResponse, + PayloadId, +) + + +def get_payload(_payload_id: PayloadId) -> GetPayloadResponse: + """ + Return a prepared payload response for a previously returned + ``PayloadId``. + """ + raise NotImplementedError diff --git a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py new file mode 100644 index 00000000000..6e2143c65c8 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py @@ -0,0 +1,169 @@ +""" +Payload verification. +""" + +from typing import Optional, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes + +from ethereum.crypto.hash import keccak256 +from ethereum.exceptions import InvalidBlock +from ethereum.state import ( + BlockDiff, + PreState, + Root, +) +from ethereum.state_mpt import apply_changes_to_state + +from ..blocks import Block +from ..fork import ChainContext, execute_block, get_last_256_block_hashes +from ..fork_types import VersionedHash +from ..transactions import BlobTransaction, decode_transaction +from .requests import ExecutionRequests +from .types import ExecutionEngine, ExecutionPayload, NewPayloadRequest +from .validation_helpers import _payload_block, _payload_header + + +def is_valid_block_hash( + execution_payload: ExecutionPayload, + parent_beacon_block_root: Root, + execution_requests: ExecutionRequests, +) -> bool: + """ + Return ``True`` if and only if ``execution_payload.block_hash`` is + computed correctly. + """ + try: + header = _payload_header( + execution_payload, + parent_beacon_block_root, + execution_requests, + ) + except Exception: + # Any decoding or conversion failure means the payload + # cannot produce a valid header. + return False + return keccak256(rlp.encode(header)) == execution_payload.block_hash + + +def is_valid_versioned_hashes( + new_payload_request: NewPayloadRequest, +) -> bool: + """ + Return ``True`` if and only if the versioned hashes computed by blob + transactions in ``new_payload_request.execution_payload`` match + ``new_payload_request.versioned_hashes``. + """ + computed_versioned_hashes: list[VersionedHash] = [] + + try: + for encoded_tx in new_payload_request.execution_payload.transactions: + tx = decode_transaction(encoded_tx) + if isinstance(tx, BlobTransaction): + computed_versioned_hashes.extend(tx.blob_versioned_hashes) + except Exception: + # Any decoding failure means versioned hashes cannot be + # verified. + return False + + return tuple(computed_versioned_hashes) == ( + new_payload_request.versioned_hashes + ) + + +def execute_new_payload_request( + new_payload_request: NewPayloadRequest, + pre_state: PreState, + chain_context: ChainContext, + transaction_public_keys: Optional[Tuple[Bytes, ...]] = None, +) -> Tuple[BlockDiff, Block]: + """ + Validate and execute a payload against ``pre_state``. + + Note: This is conceptually similar to notify_new_payload. + We however do not return a boolean because we want the caller + to apply the diff and handle the case where they may need to + rollback state on an error. + + Parameters + ---------- + new_payload_request : + The payload request to validate and execute. + pre_state : + Pre-execution state provider. + chain_context : + Chain context needed for block execution. + transaction_public_keys : + Optional transaction public keys in payload order. + + Returns + ------- + block_diff : `BlockDiff` + Account, storage, and code changes produced by execution. + block : `Block` + The block derived from the payload. + + """ + payload = new_payload_request.execution_payload + parent_beacon_block_root = new_payload_request.parent_beacon_block_root + execution_requests = new_payload_request.execution_requests + + if b"" in payload.transactions: + raise InvalidBlock("Empty transaction in payload") + + if not is_valid_block_hash( + payload, + parent_beacon_block_root, + execution_requests, + ): + raise InvalidBlock("Invalid block hash") + + if not is_valid_versioned_hashes(new_payload_request): + raise InvalidBlock("Invalid versioned hashes") + + block = _payload_block( + payload, + parent_beacon_block_root, + execution_requests, + ) + block_diff = execute_block( + block, + pre_state, + chain_context, + transaction_public_keys=transaction_public_keys, + ) + return block_diff, block + + +def verify_and_notify_new_payload( + chain: ExecutionEngine, + new_payload_request: NewPayloadRequest, +) -> bool: + """ + Validate the payload and, if valid, apply it to the chain. + """ + chain_context = ChainContext( + chain_id=chain.chain_id, + block_hashes=get_last_256_block_hashes(chain), + parent_header=chain.blocks[-1].header, + ) + + try: + # TODO: This returning a block is a bit weird + # We could not return the block and then convert + # the payload into a block below + block_diff, block = execute_new_payload_request( + new_payload_request, + chain.state, + chain_context, + ) + except InvalidBlock: + return False + + apply_changes_to_state(chain.state, block_diff) + chain.blocks.append(block) + if len(chain.blocks) > 255: + chain.blocks = chain.blocks[-255:] + + return True diff --git a/src/ethereum/forks/amsterdam/execution_engine/requests.py b/src/ethereum/forks/amsterdam/execution_engine/requests.py new file mode 100644 index 00000000000..77beaf66226 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/requests.py @@ -0,0 +1,325 @@ +""" +Typed execution-layer requests and engine-API wire-form codecs. + +The consensus layer defines ``ExecutionRequests`` as a typed Container +holding deposit, withdrawal, consolidation, builder deposit, and builder exit +lists. +""" + +from dataclasses import dataclass +from typing import Annotated, Sequence, Tuple, final + +from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64 + +from ethereum.exceptions import InvalidBlock +from ethereum.state import Address +from ethereum.utils.ssz import ( + ProgressiveSszContainer, + SszContainer, + progressive_list, +) + +from ..requests import ( + BUILDER_DEPOSIT_REQUEST_TYPE, + BUILDER_EXIT_REQUEST_TYPE, + CONSOLIDATION_REQUEST_TYPE, + DEPOSIT_REQUEST_TYPE, + WITHDRAWAL_REQUEST_TYPE, +) + +DEPOSIT_REQUEST_SIZE = 48 + 32 + 8 + 96 + 8 +WITHDRAWAL_REQUEST_SIZE = 20 + 48 + 8 +CONSOLIDATION_REQUEST_SIZE = 20 + 48 + 48 +BUILDER_DEPOSIT_REQUEST_SIZE = 184 +BUILDER_EXIT_REQUEST_SIZE = 68 + + +@final +@slotted_freezable +@dataclass +class DepositRequest(SszContainer): + """A single EIP-6110 deposit request.""" + + pubkey: Bytes48 + withdrawal_credentials: Bytes32 + amount: U64 + signature: Bytes96 + index: U64 + + +@final +@slotted_freezable +@dataclass +class WithdrawalRequest(SszContainer): + """A single EIP-7002 withdrawal request.""" + + source_address: Address + validator_pubkey: Bytes48 + amount: U64 + + +@final +@slotted_freezable +@dataclass +class ConsolidationRequest(SszContainer): + """A single EIP-7251 consolidation request.""" + + source_address: Address + source_pubkey: Bytes48 + target_pubkey: Bytes48 + + +@final +@slotted_freezable +@dataclass +class BuilderDepositRequest(SszContainer): + """A single EIP-8282 builder deposit request.""" + + pubkey: Bytes48 + withdrawal_credentials: Bytes32 + amount: U64 + signature: Bytes96 + + +@final +@slotted_freezable +@dataclass +class BuilderExitRequest(SszContainer): + """A single EIP-8282 builder exit request.""" + + source_address: Address + pubkey: Bytes48 + + +@final +@slotted_freezable +@dataclass +class ExecutionRequests(ProgressiveSszContainer): + """ + Typed engine-API container of execution-layer triggered requests. + + Mirrors the consensus-layer ``ExecutionRequests`` Container. + """ + + deposits: Annotated[Tuple[DepositRequest, ...], progressive_list()] + withdrawals: Annotated[Tuple[WithdrawalRequest, ...], progressive_list()] + consolidations: Annotated[ + Tuple[ConsolidationRequest, ...], progressive_list() + ] + builder_deposits: Annotated[ + Tuple[BuilderDepositRequest, ...], progressive_list() + ] + builder_exits: Annotated[ + Tuple[BuilderExitRequest, ...], progressive_list() + ] + + +def _encode_deposit(d: DepositRequest) -> Bytes: + return Bytes( + bytes(d.pubkey) + + bytes(d.withdrawal_credentials) + + bytes(d.amount.to_le_bytes8()) + + bytes(d.signature) + + bytes(d.index.to_le_bytes8()) + ) + + +def _encode_withdrawal(w: WithdrawalRequest) -> Bytes: + return Bytes( + bytes(w.source_address) + + bytes(w.validator_pubkey) + + bytes(w.amount.to_le_bytes8()) + ) + + +def _encode_consolidation(c: ConsolidationRequest) -> Bytes: + return Bytes( + bytes(c.source_address) + + bytes(c.source_pubkey) + + bytes(c.target_pubkey) + ) + + +def _encode_builder_deposit(b: BuilderDepositRequest) -> Bytes: + return Bytes( + bytes(b.pubkey) + + bytes(b.withdrawal_credentials) + + bytes(b.amount.to_le_bytes8()) + + bytes(b.signature) + ) + + +def _encode_builder_exit(b: BuilderExitRequest) -> Bytes: + return Bytes(bytes(b.source_address) + bytes(b.pubkey)) + + +def _decode_deposit(payload: Bytes) -> DepositRequest: + return DepositRequest( + pubkey=Bytes48(payload[0:48]), + withdrawal_credentials=Bytes32(payload[48:80]), + amount=U64.from_le_bytes(payload[80:88]), + signature=Bytes96(payload[88:184]), + index=U64.from_le_bytes(payload[184:192]), + ) + + +def _decode_withdrawal(payload: Bytes) -> WithdrawalRequest: + return WithdrawalRequest( + source_address=Address(payload[0:20]), + validator_pubkey=Bytes48(payload[20:68]), + amount=U64.from_le_bytes(payload[68:76]), + ) + + +def _decode_consolidation(payload: Bytes) -> ConsolidationRequest: + return ConsolidationRequest( + source_address=Address(payload[0:20]), + source_pubkey=Bytes48(payload[20:68]), + target_pubkey=Bytes48(payload[68:116]), + ) + + +def _decode_builder_deposit(payload: Bytes) -> BuilderDepositRequest: + return BuilderDepositRequest( + pubkey=Bytes48(payload[0:48]), + withdrawal_credentials=Bytes32(payload[48:80]), + amount=U64.from_le_bytes(payload[80:88]), + signature=Bytes96(payload[88:184]), + ) + + +def _decode_builder_exit(payload: Bytes) -> BuilderExitRequest: + return BuilderExitRequest( + source_address=Address(payload[0:20]), + pubkey=Bytes48(payload[20:68]), + ) + + +def encode_execution_requests( + requests: ExecutionRequests, +) -> Tuple[Bytes, ...]: + """ + Flatten a typed ``ExecutionRequests`` into the engine-API wire form. + + Each non-empty list is emitted as a single blob + ``TYPE_BYTE || concat(serialize(item) for item)``, in ascending + type order. Empty lists are omitted. Mirrors CL's + ``get_execution_requests_list()``. + """ + output: list[Bytes] = [] + if requests.deposits: + body = b"".join(_encode_deposit(d) for d in requests.deposits) + output.append(Bytes(DEPOSIT_REQUEST_TYPE + body)) + if requests.withdrawals: + body = b"".join(_encode_withdrawal(w) for w in requests.withdrawals) + output.append(Bytes(WITHDRAWAL_REQUEST_TYPE + body)) + if requests.consolidations: + body = b"".join( + _encode_consolidation(c) for c in requests.consolidations + ) + output.append(Bytes(CONSOLIDATION_REQUEST_TYPE + body)) + if requests.builder_deposits: + body = b"".join( + _encode_builder_deposit(b) for b in requests.builder_deposits + ) + output.append(Bytes(BUILDER_DEPOSIT_REQUEST_TYPE + body)) + if requests.builder_exits: + body = b"".join( + _encode_builder_exit(b) for b in requests.builder_exits + ) + output.append(Bytes(BUILDER_EXIT_REQUEST_TYPE + body)) + return tuple(output) + + +def decode_execution_requests( + wire: Sequence[Bytes], +) -> ExecutionRequests: + """ + Parse the engine-API wire form into a typed ``ExecutionRequests``. + + Validates strict ascending type order, no duplicate type bytes, no + unknown type bytes, and that each payload's length is a multiple of + the per-type item size. + """ + deposits: Tuple[DepositRequest, ...] = () + withdrawals: Tuple[WithdrawalRequest, ...] = () + consolidations: Tuple[ConsolidationRequest, ...] = () + builder_deposits: Tuple[BuilderDepositRequest, ...] = () + builder_exits: Tuple[BuilderExitRequest, ...] = () + + last_type = -1 + for blob in wire: + if len(blob) < 1: + raise InvalidBlock("Empty execution request blob") + type_byte = bytes(blob[0:1]) + body = bytes(blob[1:]) + type_int = type_byte[0] + if type_int <= last_type: + raise InvalidBlock( + "Execution requests must be in strict ascending type order" + ) + last_type = type_int + + if type_byte == DEPOSIT_REQUEST_TYPE: + if len(body) % DEPOSIT_REQUEST_SIZE != 0: + raise InvalidBlock("Invalid deposit request payload length") + deposits = tuple( + _decode_deposit(Bytes(body[i : i + DEPOSIT_REQUEST_SIZE])) + for i in range(0, len(body), DEPOSIT_REQUEST_SIZE) + ) + elif type_byte == WITHDRAWAL_REQUEST_TYPE: + if len(body) % WITHDRAWAL_REQUEST_SIZE != 0: + raise InvalidBlock("Invalid withdrawal request payload length") + withdrawals = tuple( + _decode_withdrawal( + Bytes(body[i : i + WITHDRAWAL_REQUEST_SIZE]) + ) + for i in range(0, len(body), WITHDRAWAL_REQUEST_SIZE) + ) + elif type_byte == CONSOLIDATION_REQUEST_TYPE: + if len(body) % CONSOLIDATION_REQUEST_SIZE != 0: + raise InvalidBlock( + "Invalid consolidation request payload length" + ) + consolidations = tuple( + _decode_consolidation( + Bytes(body[i : i + CONSOLIDATION_REQUEST_SIZE]) + ) + for i in range(0, len(body), CONSOLIDATION_REQUEST_SIZE) + ) + elif type_byte == BUILDER_DEPOSIT_REQUEST_TYPE: + if len(body) % BUILDER_DEPOSIT_REQUEST_SIZE != 0: + raise InvalidBlock( + "Invalid builder deposit request payload length" + ) + builder_deposits = tuple( + _decode_builder_deposit( + Bytes(body[i : i + BUILDER_DEPOSIT_REQUEST_SIZE]) + ) + for i in range(0, len(body), BUILDER_DEPOSIT_REQUEST_SIZE) + ) + elif type_byte == BUILDER_EXIT_REQUEST_TYPE: + if len(body) % BUILDER_EXIT_REQUEST_SIZE != 0: + raise InvalidBlock( + "Invalid builder exit request payload length" + ) + builder_exits = tuple( + _decode_builder_exit( + Bytes(body[i : i + BUILDER_EXIT_REQUEST_SIZE]) + ) + for i in range(0, len(body), BUILDER_EXIT_REQUEST_SIZE) + ) + else: + raise InvalidBlock( + f"Unknown execution request type byte {type_byte!r}" + ) + + return ExecutionRequests( + deposits=deposits, + withdrawals=withdrawals, + consolidations=consolidations, + builder_deposits=builder_deposits, + builder_exits=builder_exits, + ) diff --git a/src/ethereum/forks/amsterdam/execution_engine/types.py b/src/ethereum/forks/amsterdam/execution_engine/types.py new file mode 100644 index 00000000000..02c96772f21 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/types.py @@ -0,0 +1,139 @@ +""" +Execution engine data structures and aliases. +""" + +from dataclasses import dataclass +from typing import Annotated, Tuple, final + +from ethereum_types.bytes import Bytes, Bytes8, Bytes32 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32 +from ethereum.state import Address, Root +from ethereum.utils.ssz import ( + ProgressiveSszContainer, + SszContainer, + byte_list, + progressive_byte_list, + progressive_list, + uint, +) + +from ..blocks import Withdrawal +from ..fork import BlockChain +from ..fork_types import Bloom, VersionedHash +from .requests import ExecutionRequests + +# In this module, the execution engine is the chain/state container used by +# the fork's transition functions. +ExecutionEngine = BlockChain +PayloadId = Bytes8 +_ZERO_HASH32 = Hash32(b"\x00" * 32) +MAX_EXTRA_DATA_BYTES = 32 + + +@final +@slotted_freezable +@dataclass +class ExecutionPayload(ProgressiveSszContainer): + """ + Represent a new block to be processed by the execution layer. + + The consensus layer constructs this from a beacon block body and + passes it to the execution engine for validation. + + Note: execution_request_hash is not a direct field in ExecutionPayload + but it is indirectly committed to via `block_hash` since `request_hash` + is in the EL-block header. + """ + + parent_hash: Hash32 + fee_recipient: Address + state_root: Root + receipts_root: Root + logs_bloom: Bloom + prev_randao: Bytes32 + block_number: Annotated[Uint, uint(64)] + gas_limit: Annotated[Uint, uint(64)] + gas_used: Annotated[Uint, uint(64)] + timestamp: Annotated[U256, uint(64)] + extra_data: Annotated[Bytes, byte_list(MAX_EXTRA_DATA_BYTES)] + base_fee_per_gas: Annotated[Uint, uint(256)] + block_hash: Hash32 + transactions: Annotated[ + Tuple[Annotated[Bytes, progressive_byte_list()], ...], + progressive_list(), + ] + withdrawals: Annotated[Tuple[Withdrawal, ...], progressive_list()] + blob_gas_used: U64 + excess_blob_gas: U64 + block_access_list: Annotated[Bytes, progressive_byte_list()] + slot_number: U64 + + +@final +@slotted_freezable +@dataclass +class NewPayloadRequest(SszContainer): + """ + Contains an execution payload along with versioned hashes, the + parent beacon block root, and execution requests for the + ``verify_and_notify_new_payload`` entry point. + + This corresponds to the consensus-layer `NewPayloadRequest` + container used for Engine API calls. + + [Bellatrix `NewPayloadRequest`]: + https://ethereum.github.io/consensus-specs/specs/bellatrix/beacon-chain/#newpayloadrequest + [Electra modified `NewPayloadRequest`]: + https://ethereum.github.io/consensus-specs/specs/electra/beacon-chain/#modified-newpayloadrequest + """ + + execution_payload: ExecutionPayload + versioned_hashes: Annotated[Tuple[VersionedHash, ...], progressive_list()] + parent_beacon_block_root: Root + execution_requests: ExecutionRequests + + +@final +@slotted_freezable +@dataclass +class PayloadAttributes: + """ + Carry the parameters that the consensus layer supplies when it + requests the execution layer to build a new block. + """ + + timestamp: U256 + prev_randao: Bytes32 + suggested_fee_recipient: Address + withdrawals: Tuple[Withdrawal, ...] + parent_beacon_block_root: Root + + +@final +@slotted_freezable +@dataclass +class BlobsBundle: + """ + Bundle of blobs data associated with a built payload. + """ + + commitments: Tuple[Bytes, ...] + proofs: Tuple[Bytes, ...] + blobs: Tuple[Bytes, ...] + + +@final +@slotted_freezable +@dataclass +class GetPayloadResponse: + """ + Response returned by ``get_payload`` for a prepared payload build. + """ + + execution_payload: ExecutionPayload + block_value: U256 + blobs_bundle: BlobsBundle + execution_requests: ExecutionRequests diff --git a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py new file mode 100644 index 00000000000..f76af3d1ffa --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py @@ -0,0 +1,122 @@ +""" +Shared execution-engine conversion helpers. +""" + +from typing import Optional + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8 +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.merkle_patricia_trie import Trie, root, trie_set +from ethereum.state import Root + +from ..blocks import Block, Header +from ..fork import EMPTY_OMMER_HASH +from ..requests import compute_requests_hash +from ..transactions import LegacyTransaction, decode_transaction +from .requests import ExecutionRequests, encode_execution_requests +from .types import ExecutionPayload + + +def _payload_header( + execution_payload: ExecutionPayload, + parent_beacon_block_root: Root, + execution_requests: ExecutionRequests, +) -> Header: + """ + Build the execution header implied by a payload request. + """ + transactions_trie: Trie[Bytes, Optional[Bytes]] = Trie( + secured=False, default=None + ) + for i, encoded_tx in enumerate(execution_payload.transactions): + trie_set( + transactions_trie, + rlp.encode(Uint(i)), + encoded_tx, + ) + transactions_root = root(transactions_trie) + + withdrawals_trie: Trie[Bytes, Optional[Bytes]] = Trie( + secured=False, default=None + ) + for i, withdrawal in enumerate(execution_payload.withdrawals): + trie_set( + withdrawals_trie, + rlp.encode(Uint(i)), + rlp.encode(withdrawal), + ) + withdrawals_root = root(withdrawals_trie) + + requests_hash = Hash32( + compute_requests_hash( + list(encode_execution_requests(execution_requests)) + ) + ) + + return Header( + parent_hash=execution_payload.parent_hash, + ommers_hash=EMPTY_OMMER_HASH, + coinbase=execution_payload.fee_recipient, + state_root=execution_payload.state_root, + transactions_root=transactions_root, + receipt_root=execution_payload.receipts_root, + bloom=execution_payload.logs_bloom, + difficulty=Uint(0), + number=execution_payload.block_number, + gas_limit=execution_payload.gas_limit, + gas_used=execution_payload.gas_used, + timestamp=execution_payload.timestamp, + extra_data=execution_payload.extra_data, + prev_randao=execution_payload.prev_randao, + nonce=Bytes8(b"\x00\x00\x00\x00\x00\x00\x00\x00"), + base_fee_per_gas=execution_payload.base_fee_per_gas, + withdrawals_root=withdrawals_root, + blob_gas_used=execution_payload.blob_gas_used, + excess_blob_gas=execution_payload.excess_blob_gas, + parent_beacon_block_root=parent_beacon_block_root, + requests_hash=requests_hash, + block_access_list_hash=Hash32( + keccak256(execution_payload.block_access_list) + ), + slot_number=execution_payload.slot_number, + ) + + +def _payload_transaction_to_block_transaction( + encoded_transaction: Bytes, +) -> LegacyTransaction | Bytes: + """Return the canonical block representation of a payload transaction.""" + if not encoded_transaction or encoded_transaction[0] < 0xC0: + return encoded_transaction + + transaction = decode_transaction(encoded_transaction) + assert isinstance(transaction, LegacyTransaction) + return transaction + + +def _payload_block( + execution_payload: ExecutionPayload, + parent_beacon_block_root: Root, + execution_requests: ExecutionRequests, +) -> Block: + """ + Convert an execution payload request into an execution-layer block. + """ + header = _payload_header( + execution_payload, + parent_beacon_block_root, + execution_requests, + ) + + return Block( + header=header, + transactions=tuple( + _payload_transaction_to_block_transaction(encoded_transaction) + for encoded_transaction in execution_payload.transactions + ), + ommers=(), + withdrawals=execution_payload.withdrawals, + ) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 5c4a10809cc..c21dc80c17e 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -26,9 +26,14 @@ InvalidBlock, InvalidSenderError, ) -from ethereum.forks.bpo5.blocks import Header as PreviousHeader +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.merkle_patricia_trie import root, trie_set -from ethereum.state import EMPTY_CODE_HASH, Address, BlockDiff +from ethereum.state import ( + EMPTY_CODE_HASH, + Address, + BlockDiff, + PreState, +) from ethereum.state_mpt import State, apply_changes_to_state from . import vm @@ -67,6 +72,7 @@ incorporate_tx_into_block, increment_nonce, set_account_balance, + track_ancestor_access, ) from .transactions import ( BlobTransaction, @@ -82,6 +88,7 @@ get_transaction_hash, has_access_list, recover_sender, + recover_sender_from_public_key, validate_transaction, ) from .utils.address import compute_contract_address @@ -153,7 +160,7 @@ class ChainContext: block_hashes: List[Hash32] """Recent ancestor hashes (up to 256) for the ``BLOCKHASH`` opcode.""" - parent_header: Header | PreviousHeader + parent_header: Header | PreviousForkHeader """Parent header used for header validation and system contracts.""" @@ -273,8 +280,9 @@ def state_transition(chain: BlockChain, block: Block) -> None: def execute_block( block: Block, - pre_state: State, + pre_state: PreState, chain_context: ChainContext, + transaction_public_keys: Optional[Tuple[Bytes, ...]] = None, ) -> BlockDiff: """ Execute a block and validate the resulting roots against the header. @@ -289,6 +297,8 @@ def execute_block( Pre-execution state provider. chain_context : Chain context that the block may need during execution. + transaction_public_keys : + Optional transaction public keys in block order. Returns ------- @@ -299,6 +309,13 @@ def execute_block( if len(rlp.encode(block)) > MAX_RLP_BLOCK_SIZE: raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE") + if transaction_public_keys is not None and len( + transaction_public_keys + ) != len(block.transactions): + raise InvalidBlock( + "Transaction public key count does not match block transactions" + ) + parent_header = chain_context.parent_header validate_header(parent_header, block.header) @@ -321,6 +338,7 @@ def execute_block( parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), slot_number=block.header.slot_number, + transaction_public_keys=transaction_public_keys, ) block_output = apply_body( @@ -429,7 +447,7 @@ def calculate_base_fee_per_gas( def validate_header( - parent_header: Header | PreviousHeader, header: Header + parent_header: Header | PreviousForkHeader, header: Header ) -> None: """ Verify a block header against its parent. @@ -540,7 +558,19 @@ def check_transaction( limit. """ - sender = recover_sender(tx) + sender_public_key = None + if block_env.transaction_public_keys is not None: + sender_public_key = block_env.transaction_public_keys[int(index)] + + if sender_public_key is None: + sender = recover_sender(tx) + else: + sender = recover_sender_from_public_key( + block_env.chain_id, + tx, + sender_public_key, + ) + intrinsic = validate_transaction(tx, sender) tx_state = TransactionState(parent=block_env.state) @@ -573,7 +603,11 @@ def check_transaction( if Uint(sender_account.balance) < max_gas_fee + Uint(tx.value): raise InsufficientBalanceError("insufficient sender balance") - sender_code = get_code(tx_state, sender_account.code_hash) + sender_code = get_code( + tx_state, + sender_account.code_hash, + sender, + ) if sender_account.code_hash != EMPTY_CODE_HASH and not is_valid_delegation( sender_code ): @@ -703,6 +737,7 @@ def process_checked_system_transaction( system_contract_code = get_code( untracked_state, get_account(untracked_state, target_address).code_hash, + target_address, ) if len(system_contract_code) == 0: @@ -828,6 +863,10 @@ def apply_body( target_address=HISTORY_STORAGE_ADDRESS, data=block_env.block_hashes[-1], # The parent hash ) + track_ancestor_access( + block_env.state, + Uint(1), + ) for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py new file mode 100644 index 00000000000..269cd0e67ed --- /dev/null +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -0,0 +1,1040 @@ +""" +Incremental Merkle Patricia Trie. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Provide a mutable MPT that supports incremental updates and +witness tracking. The tree structure is updated in-place rather +than rebuilt from scratch on each root calculation. +""" + +from dataclasses import dataclass, field +from typing import ( + Callable, + Dict, + Generic, + List, + Mapping, + MutableMapping, + Optional, + Tuple, + Union, + final, +) + +from ethereum_rlp import Extended, rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint, ulen + +from ethereum.crypto.hash import keccak256 +from ethereum.merkle_patricia_trie import ( + EMPTY_TRIE_ROOT, + K, + V, + _prepare_data, + bytes_to_nibble_list, + common_prefix_length, + encode_node, + nibble_list_to_compact, +) +from ethereum.state import Account, Address, Root + + +@final +@dataclass +class MutableLeafNode: + """Mutable leaf node in the Merkle Trie for in-place updates.""" + + rest_of_key: Bytes + value: Bytes + _hash: Optional[Bytes] = None + _rlp: Optional[Bytes] = None + _dirty: bool = False + + +@final +@dataclass +class MutableExtensionNode: + """Mutable extension node in the Merkle Trie for in-place updates.""" + + key_segment: Bytes + child: "MutableNode" + _hash: Optional[Bytes] = None + _rlp: Optional[Bytes] = None + _dirty: bool = False + + +@final +@dataclass +class MutableBranchNode: + """Mutable branch node in the Merkle Trie for in-place updates.""" + + children: List[Optional["MutableNode"]] + value: Bytes + _hash: Optional[Bytes] = None + _rlp: Optional[Bytes] = None + _dirty: bool = False + + +@final +@dataclass +class HashedNode: + """Placeholder for a trie subtree known only by its hash.""" + + _hash: Bytes + + +MutableNode = Union[ + MutableLeafNode, + MutableExtensionNode, + MutableBranchNode, + HashedNode, + None, +] + + +@final +@dataclass +class Witness: + """Track nodes accessed during trie operations for witness generation.""" + + accessed_nodes: Dict[Bytes, Bytes] = field(default_factory=dict) + + +@final +@dataclass +class IncrementalMPT(Generic[K, V]): + """ + An MPT that supports incremental updates and witness tracking. + + Maintain an actual tree structure that can be updated in-place, + rather than rebuilding the entire tree on each root calculation. + """ + + secured: bool + default: V + root_node: MutableNode = None + witness: Witness = field(default_factory=Witness) + _data: Dict[K, V] = field(default_factory=dict) + + +def _build_mutable_tree( + obj: Mapping[Bytes, Bytes], level: Uint +) -> MutableNode: + """ + Build a mutable tree structure from a prepared key-value mapping. + + Similar to ``patricialize()`` but create mutable nodes for + in-place updates. + + Parameters + ---------- + obj : + Underlying trie key-value pairs, with keys in + nibble-list format. + level : + Current trie level. + + Returns + ------- + node : `MutableNode` + Root node of the mutable tree. + + """ + if len(obj) == 0: + return None + + arbitrary_key = next(iter(obj)) + + if len(obj) == 1: + return MutableLeafNode( + rest_of_key=arbitrary_key[level:], + value=obj[arbitrary_key], + ) + + substring = arbitrary_key[level:] + prefix_length = len(substring) + for key in obj: + prefix_length = min( + prefix_length, + common_prefix_length(substring, key[level:]), + ) + if prefix_length == 0: + break + + if prefix_length > 0: + prefix = arbitrary_key[int(level) : int(level) + prefix_length] + child = _build_mutable_tree(obj, level + Uint(prefix_length)) + return MutableExtensionNode(key_segment=prefix, child=child) + + branches: List[MutableMapping[Bytes, Bytes]] = [{} for _ in range(16)] + value = b"" + + for key in obj: + if len(key) == level: + value = obj[key] + else: + branches[key[level]][key] = obj[key] + + children: List[Optional[MutableNode]] = [ + _build_mutable_tree(branches[k], level + Uint(1)) for k in range(16) + ] + + return MutableBranchNode(children=children, value=value) + + +def build_mpt( + data: Mapping[K, V], + secured: bool, + default: V, + get_storage_root: Optional[Callable[[Address], Root]] = None, +) -> IncrementalMPT[K, V]: + """ + Build an IncrementalMPT from key-value data. + + Call with the pre-execution state to create a mutable tree + structure that can be updated in-place during execution. + + Parameters + ---------- + data : + The source key-value data to build from. + secured : + Whether to hash keys before insertion. + default : + Default value for missing keys. + get_storage_root : + Function to get the storage root of an account. + + Returns + ------- + mpt : `IncrementalMPT[K, V]` + An incremental MPT with the same data. + + """ + prepared = _prepare_data(data, secured, get_storage_root) + root_node = _build_mutable_tree(prepared, Uint(0)) + + return IncrementalMPT( + secured=secured, + default=default, + root_node=root_node, + _data=dict(data), + ) + + +def _invalidate_hash(node: MutableNode) -> None: + """Invalidate the cached hash of a node.""" + assert not isinstance(node, HashedNode), "HashedNode cannot be invalidated" + if node is None: + return + node._hash = None + node._rlp = None + + +def _record_witness( + witness: Witness, + node: MutableNode, +) -> None: + """Record a node access in the witness.""" + assert not isinstance(node, HashedNode), "HashedNode cannot be witnessed" + if node is None: + return + + if node._dirty: + return + + node_hash, node_rlp = _compute_node_hash_and_rlp(node) + if node_hash is not None and node_hash not in witness.accessed_nodes: + witness.accessed_nodes[node_hash] = node_rlp + + +def _encode_mutable_node(node: MutableNode) -> Extended: + """ + Encode a mutable node to its RLP form. + + Similar to ``encode_internal_node`` but for mutable nodes. + """ + if node is None: + return b"" + elif isinstance(node, HashedNode): + raise AssertionError("HashedNode cannot be inline-encoded") + elif isinstance(node, MutableLeafNode): + return ( + nibble_list_to_compact(node.rest_of_key, True), + node.value, + ) + elif isinstance(node, MutableExtensionNode): + child_encoded = _encode_mutable_node_to_extended(node.child) + return ( + nibble_list_to_compact(node.key_segment, False), + child_encoded, + ) + elif isinstance(node, MutableBranchNode): + children_encoded = [ + _encode_mutable_node_to_extended(child) for child in node.children + ] + return children_encoded + [node.value] + else: + raise AssertionError(f"Invalid mutable node type {type(node)}!") + + +def _encode_mutable_node_to_extended( + node: MutableNode, +) -> Extended: + """ + Encode a mutable node for embedding in parent. + + Return the hash if RLP >= 32 bytes, otherwise return + unencoded form. + """ + if node is None: + return b"" + + if isinstance(node, HashedNode): + return node._hash + + if not node._dirty and node._hash is not None: + return node._hash + + unencoded = _encode_mutable_node(node) + encoded = rlp.encode(unencoded) + node._rlp = encoded + + if len(encoded) < 32: + return unencoded + else: + node._hash = keccak256(encoded) + return node._hash + + +def _compute_node_hash_and_rlp( + node: MutableNode, +) -> Tuple[Optional[Bytes], Bytes]: + """ + Compute the hash and RLP encoding of a node. + + Return (hash, rlp) where hash may be None for small nodes. + """ + if node is None: + return None, b"" + + assert not isinstance(node, HashedNode), ( + "HashedNode cannot appear in _compute_node_hash_and_rlp" + ) + + if node._rlp is not None: + if node._hash is not None: + return node._hash, node._rlp + elif len(node._rlp) >= 32: + return keccak256(node._rlp), node._rlp + + unencoded = _encode_mutable_node(node) + encoded = rlp.encode(unencoded) + + node._rlp = encoded + + if len(encoded) >= 32: + node._hash = keccak256(encoded) + return node._hash, encoded + else: + return None, encoded + + +def mpt_get(mpt: IncrementalMPT[K, V], key: K) -> V: + """ + Get a value from the incremental MPT. + + Traverse the tree and record accessed nodes in the witness + for execution witness generation. + + Parameters + ---------- + mpt : + The incremental MPT to get from. + key : + Key to lookup. + + Returns + ------- + value : `V` + Value at the key, or the default value if not found. + + """ + value = mpt._data.get(key, mpt.default) + + if mpt.secured: + nibble_key = bytes_to_nibble_list(keccak256(key)) + else: + nibble_key = bytes_to_nibble_list(key) + + _mpt_traverse_for_witness(mpt, mpt.root_node, nibble_key, Uint(0)) + + return value + + +def _mpt_traverse_for_witness( + mpt: IncrementalMPT, + node: MutableNode, + key: Bytes, + level: Uint, +) -> None: + """Traverse the tree recording nodes in the witness.""" + if node is None: + return + + _record_witness(mpt.witness, node) + + if isinstance(node, MutableLeafNode): + pass + elif isinstance(node, MutableExtensionNode): + segment_len = len(node.key_segment) + lvl = int(level) + if key[lvl : lvl + segment_len] == node.key_segment: + _mpt_traverse_for_witness( + mpt, + node.child, + key, + Uint(lvl + segment_len), + ) + elif isinstance(node, MutableBranchNode): + lvl = int(level) + if lvl < len(key): + child_idx = key[lvl] + _mpt_traverse_for_witness( + mpt, + node.children[child_idx], + key, + Uint(lvl + 1), + ) + + +def mpt_set( + mpt: IncrementalMPT[K, V], + key: K, + value: V, + get_storage_root: Optional[Callable[[Address], Root]] = None, +) -> None: + """ + Set a value in the incremental MPT. + + Update the tree in-place and invalidate cached hashes along + the path. + + Parameters + ---------- + mpt : + The incremental MPT to update. + key : + Key to set. + value : + Value to set at the key. + get_storage_root : + Function to get storage root (for Account values). + + """ + if value == mpt.default: + if key in mpt._data: + del mpt._data[key] + else: + mpt._data[key] = value + + if mpt.secured: + nibble_key = bytes_to_nibble_list(keccak256(key)) + else: + nibble_key = bytes_to_nibble_list(key) + + if value == mpt.default: + encoded_value = b"" + elif isinstance(value, Account): + assert get_storage_root is not None + address = Address(key) + encoded_value = encode_node(value, get_storage_root(address)) + elif value is None: + raise AssertionError("cannot encode `None`") + else: + encoded_value = encode_node(value) + + if encoded_value == b"": + mpt.root_node = _mpt_delete_node( + mpt, mpt.root_node, nibble_key, Uint(0) + ) + else: + mpt.root_node = _mpt_insert_node( + mpt, mpt.root_node, nibble_key, encoded_value, Uint(0) + ) + + +def _mpt_insert_node( + mpt: IncrementalMPT, + node: MutableNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """ + Insert or update a value in the mutable tree. + + Return the new/updated node for this position. + """ + if node is None: + return MutableLeafNode( + rest_of_key=key[level:], value=value, _dirty=True + ) + + _invalidate_hash(node) + + if isinstance(node, MutableLeafNode): + return _insert_into_leaf(mpt, node, key, value, level) + elif isinstance(node, MutableExtensionNode): + return _insert_into_extension(mpt, node, key, value, level) + elif isinstance(node, MutableBranchNode): + return _insert_into_branch(mpt, node, key, value, level) + else: + raise AssertionError(f"Invalid node type {type(node)}") + + +def _insert_into_leaf( + _mpt: IncrementalMPT, + node: MutableLeafNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """Handle insertion when current node is a leaf.""" + existing_key = node.rest_of_key + remaining_key = key[level:] + + if existing_key == remaining_key: + node.value = value + node._dirty = True + return node + + prefix_len = common_prefix_length(existing_key, remaining_key) + + if prefix_len > 0: + branch = _create_branch_from_two_leaves( + existing_key[prefix_len:], + node.value, + remaining_key[prefix_len:], + value, + ) + return MutableExtensionNode( + key_segment=existing_key[:prefix_len], + child=branch, + _dirty=True, + ) + else: + return _create_branch_from_two_leaves( + existing_key, node.value, remaining_key, value + ) + + +def _create_branch_from_two_leaves( + key1: Bytes, + value1: Bytes, + key2: Bytes, + value2: Bytes, +) -> MutableBranchNode: + """Create a branch node from two key-value pairs.""" + children: List[Optional[MutableNode]] = [None] * 16 + branch_value = b"" + + if len(key1) == 0: + branch_value = value1 + else: + idx1 = key1[0] + children[idx1] = MutableLeafNode( + rest_of_key=key1[1:], + value=value1, + _dirty=True, + ) + + if len(key2) == 0: + branch_value = value2 + else: + idx2 = key2[0] + children[idx2] = MutableLeafNode( + rest_of_key=key2[1:], + value=value2, + _dirty=True, + ) + + return MutableBranchNode( + children=children, + value=branch_value, + _dirty=True, + ) + + +def _insert_into_extension( + mpt: IncrementalMPT, + node: MutableExtensionNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """Handle insertion when current node is an extension.""" + remaining_key = key[level:] + segment = node.key_segment + prefix_len = common_prefix_length(segment, remaining_key) + + if prefix_len == len(segment): + node.child = _mpt_insert_node( + mpt, + node.child, + key, + value, + level + Uint(prefix_len), + ) + node._dirty = True + return node + + if prefix_len > 0: + new_child = _split_extension(node, remaining_key, value, prefix_len) + return MutableExtensionNode( + key_segment=segment[:prefix_len], + child=new_child, + _dirty=True, + ) + else: + return _split_extension(node, remaining_key, value, 0) + + +def _split_extension( + node: MutableExtensionNode, + remaining_key: Bytes, + value: Bytes, + prefix_len: int, +) -> MutableNode: + """Split an extension node when keys diverge.""" + segment = node.key_segment + children: List[Optional[MutableNode]] = [None] * 16 + branch_value = b"" + + segment_after_prefix = segment[prefix_len:] + if len(segment_after_prefix) == 1: + idx = segment_after_prefix[0] + children[idx] = node.child + elif len(segment_after_prefix) > 1: + idx = segment_after_prefix[0] + children[idx] = MutableExtensionNode( + key_segment=segment_after_prefix[1:], + child=node.child, + _dirty=True, + ) + + key_after_prefix = remaining_key[prefix_len:] + if len(key_after_prefix) == 0: + branch_value = value + else: + idx = key_after_prefix[0] + if children[idx] is None: + children[idx] = MutableLeafNode( + rest_of_key=key_after_prefix[1:], + value=value, + _dirty=True, + ) + else: + raise AssertionError("Unexpected collision during split") + + return MutableBranchNode( + children=children, + value=branch_value, + _dirty=True, + ) + + +def _insert_into_branch( + mpt: IncrementalMPT, + node: MutableBranchNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """Handle insertion when current node is a branch.""" + remaining_key = key[level:] + + if len(remaining_key) == 0: + node.value = value + node._dirty = True + return node + + child_idx = remaining_key[0] + node.children[child_idx] = _mpt_insert_node( + mpt, + node.children[child_idx], + key, + value, + level + Uint(1), + ) + node._dirty = True + return node + + +def _mpt_delete_node( + mpt: IncrementalMPT, + node: MutableNode, + key: Bytes, + level: Uint, +) -> MutableNode: + """ + Delete a key from the mutable tree. + + Return the updated node (may be different type or None). + """ + if node is None: + return None + + _invalidate_hash(node) + + if isinstance(node, MutableLeafNode): + if node.rest_of_key == key[level:]: + return None + return node + elif isinstance(node, MutableExtensionNode): + return _delete_from_extension(mpt, node, key, level) + elif isinstance(node, MutableBranchNode): + return _delete_from_branch(mpt, node, key, level) + else: + raise AssertionError(f"Invalid node type {type(node)}") + + +def _delete_from_extension( + mpt: IncrementalMPT, + node: MutableExtensionNode, + key: Bytes, + level: Uint, +) -> MutableNode: + """Handle deletion when current node is an extension.""" + segment = node.key_segment + remaining_key = key[level:] + prefix_len = common_prefix_length(segment, remaining_key) + + if prefix_len < len(segment): + return node + + old_child = node.child + new_child = _mpt_delete_node(mpt, old_child, key, level + ulen(segment)) + + if new_child is None: + return None + + if isinstance(new_child, MutableExtensionNode): + return MutableExtensionNode( + key_segment=segment + new_child.key_segment, + child=new_child.child, + _dirty=True, + ) + elif isinstance(new_child, MutableLeafNode): + return MutableLeafNode( + rest_of_key=segment + new_child.rest_of_key, + value=new_child.value, + _dirty=True, + ) + + assert not isinstance(new_child, HashedNode) + child_changed = new_child is not old_child or ( + new_child is not None and new_child._dirty + ) + if not child_changed: + return node + + node.child = new_child + node._dirty = True + return node + + +def _delete_from_branch( + mpt: IncrementalMPT, + node: MutableBranchNode, + key: Bytes, + level: Uint, +) -> MutableNode: + """Handle deletion when current node is a branch.""" + remaining_key = key[level:] + + if len(remaining_key) == 0: + if node.value == b"": + return node + node.value = b"" + else: + child_idx = remaining_key[0] + old_child = node.children[child_idx] + new_child = _mpt_delete_node( + mpt, + old_child, + key, + level + Uint(1), + ) + assert not isinstance(new_child, HashedNode) + child_changed = new_child is not old_child or ( + new_child is not None and new_child._dirty + ) + if not child_changed: + return node + node.children[child_idx] = new_child + + node._dirty = True + return _collapse_branch(mpt, node) + + +def _collapse_branch( + mpt: IncrementalMPT, node: MutableBranchNode +) -> MutableNode: + """Collapse a branch node if it has only one child.""" + non_empty = [(i, c) for i, c in enumerate(node.children) if c is not None] + + assert len(non_empty) > 0 or node.value != b"" + + if len(non_empty) == 1 and node.value == b"": + idx, child = non_empty[0] + _record_witness(mpt.witness, child) + nibble = Bytes([idx]) + + if isinstance(child, MutableLeafNode): + return MutableLeafNode( + rest_of_key=nibble + child.rest_of_key, + value=child.value, + _dirty=True, + ) + elif isinstance(child, MutableExtensionNode): + return MutableExtensionNode( + key_segment=nibble + child.key_segment, + child=child.child, + _dirty=True, + ) + elif isinstance(child, MutableBranchNode): + return MutableExtensionNode( + key_segment=nibble, + child=child, + _dirty=True, + ) + else: + raise AssertionError(f"Unexpected node type {type(child)}") + + if len(non_empty) == 0 and node.value != b"": + return MutableLeafNode( + rest_of_key=b"", + value=node.value, + _dirty=True, + ) + + return node + + +def mpt_root(mpt: IncrementalMPT) -> Root: + """ + Compute the root hash of the incremental MPT. + + Use cached hashes where available for efficiency. + + Parameters + ---------- + mpt : + The incremental MPT. + + Returns + ------- + root : `Root` + The MPT root hash. + + """ + if mpt.root_node is None: + return EMPTY_TRIE_ROOT + + root_encoded = _encode_mutable_node_to_extended(mpt.root_node) + + if isinstance(root_encoded, Bytes): + return Root(root_encoded) + else: + return keccak256(rlp.encode(root_encoded)) + + +def compact_to_nibbles(compact: Bytes) -> Tuple[Bytes, bool]: + """ + Decode hex-prefix (compact) encoding into nibbles and leaf flag. + + Inverse of ``nibble_list_to_compact``. + + Parameters + ---------- + compact : + Compact-encoded key bytes. + + Returns + ------- + nibbles : + The decoded nibble sequence. + is_leaf : + ``True`` if the compact encoding indicates a leaf node. + + """ + first_nibble = compact[0] >> 4 + is_leaf = (first_nibble & 0x02) != 0 + odd = (first_nibble & 0x01) != 0 + + nibbles = bytearray() + if odd: + nibbles.append(compact[0] & 0x0F) + for byte in compact[1:]: + nibbles.append(byte >> 4) + nibbles.append(byte & 0x0F) + + return Bytes(nibbles), is_leaf + + +def _resolve_child_ref( + node_db: Dict[Bytes, Bytes], + child_ref: Extended, +) -> MutableNode: + """ + Resolve a child reference from an RLP-decoded trie node. + + Handle three cases: empty string (no child), 32-byte hash + (look up in node_db or create a ``HashedNode``), and inline RLP list + (decode directly). + """ + if isinstance(child_ref, (bytes, bytearray)): + ref_bytes = Bytes(child_ref) + if len(ref_bytes) == 0: + return None + assert len(ref_bytes) == 32, ( + f"Unexpected child ref length: {len(ref_bytes)}" + ) + if ref_bytes in node_db: + return _decode_witness_node(node_db, node_db[ref_bytes]) + return HashedNode(_hash=ref_bytes) + else: + return _decode_witness_node(node_db, rlp.encode(child_ref)) + + +def _decode_witness_node( + node_db: Dict[Bytes, Bytes], + rlp_bytes: Bytes, +) -> MutableNode: + """ + Decode an RLP-encoded trie node into a ``MutableNode``. + + Parameters + ---------- + node_db : + Mapping from node hash to RLP-encoded node data. + rlp_bytes : + The RLP-encoded node to decode. + + """ + node_hash: Optional[Bytes] = None + if len(rlp_bytes) >= 32: + node_hash = keccak256(rlp_bytes) + + decoded = rlp.decode(rlp_bytes) + + if isinstance(decoded, (bytes, bytearray)): + assert len(decoded) == 0, "Expected empty node" + return None + + assert isinstance(decoded, list) + + if len(decoded) == 2: + path_bytes = decoded[0] + assert isinstance(path_bytes, (bytes, bytearray)) + nibbles, is_leaf = compact_to_nibbles(Bytes(path_bytes)) + + if is_leaf: + value = decoded[1] + assert isinstance(value, (bytes, bytearray)) + return MutableLeafNode( + rest_of_key=nibbles, + value=Bytes(value), + _hash=node_hash, + _rlp=rlp_bytes, + ) + else: + assert len(nibbles) > 0, "ExtensionNode must have a non-empty path" + child = _resolve_child_ref(node_db, decoded[1]) + assert isinstance(child, (MutableBranchNode, HashedNode)), ( + "ExtensionNode child must be a BranchNode" + ) + return MutableExtensionNode( + key_segment=nibbles, + child=child, + _hash=node_hash, + _rlp=rlp_bytes, + ) + + elif len(decoded) == 17: + children: List[Optional[MutableNode]] = [] + for i in range(16): + children.append(_resolve_child_ref(node_db, decoded[i])) + value_raw = decoded[16] + if isinstance(value_raw, (bytes, bytearray)): + value = Bytes(value_raw) + else: + value = b"" + occupied = 16 - children.count(None) + (value != b"") + assert occupied >= 2, ( + "BranchNode must have at least 2 occupied entries" + ) + return MutableBranchNode( + children=children, + value=value, + _hash=node_hash, + _rlp=rlp_bytes, + ) + else: + raise AssertionError(f"Invalid RLP node length: {len(decoded)}") + + +def decode_witness_to_mpt( + node_db: Dict[Bytes, Bytes], + root_hash: Root, + secured: bool, + default: V, +) -> IncrementalMPT[K, V]: + """ + Build an ``IncrementalMPT`` from a witness node database. + + Decode the trie starting at ``root_hash``, resolving child + references from ``node_db``. Unknown children become + ``HashedNode`` placeholders. + + Parameters + ---------- + node_db : + Mapping from node hash to RLP-encoded node data. + root_hash : + Root hash of the trie to decode. + secured : + Whether keys are hashed before insertion. + default : + Default value for missing keys. + + Returns + ------- + mpt : `IncrementalMPT[K, V]` + The decoded incremental MPT. + + """ + if root_hash == EMPTY_TRIE_ROOT: + return IncrementalMPT( + secured=secured, + default=default, + root_node=None, + _data={}, + ) + + root_rlp = node_db[root_hash] + root_node = _decode_witness_node(node_db, root_rlp) + + return IncrementalMPT( + secured=secured, + default=default, + root_node=root_node, + _data={}, + ) diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 472f9917ae1..e1b556f638c 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -19,7 +19,16 @@ """ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple, final +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + List, + Optional, + Set, + Tuple, + final, +) from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify @@ -39,6 +48,10 @@ from .block_access_lists import BlockAccessListBuilder +CodeRead = Tuple[Address, Hash32] +"""Code read keyed by account address and code hash.""" + + @final @dataclass class BlockState: @@ -47,8 +60,9 @@ class BlockState: Read chain: block writes -> pre_state. - ``account_reads`` and ``storage_reads`` accumulate across all - transactions for BAL generation. + ``account_reads`` and ``storage_reads`` accumulate across all transactions + for BAL generation. ``code_reads`` accumulates code accesses used for + execution witness generation. """ pre_state: PreState @@ -60,7 +74,9 @@ class BlockState: storage_writes: Dict[Address, Dict[Bytes32, U256]] = field( default_factory=dict ) + code_reads: Set[CodeRead] = field(default_factory=set) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + oldest_ancestor_offset: Optional[Uint] = None @final @@ -71,9 +87,9 @@ class TransactionState: Read chain: tx writes -> block writes -> pre_state. - ``storage_reads`` and ``account_reads`` are shared references - that survive rollback (reads from failed calls still appear in the - Block Access List). + ``storage_reads``, ``account_reads``, and ``code_reads`` are shared + references that survive rollback (reads from failed calls still + appear in the Block Access List). """ parent: BlockState @@ -85,6 +101,7 @@ class TransactionState: storage_writes: Dict[Address, Dict[Bytes32, U256]] = field( default_factory=dict ) + code_reads: Set[CodeRead] = field(default_factory=set) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( @@ -213,18 +230,29 @@ def get_account(tx_state: TransactionState, address: Address) -> Account: return account -def get_code(tx_state: TransactionState, code_hash: Hash32) -> Bytes: +def get_code( + tx_state: TransactionState, + code_hash: Hash32, + address: Address, +) -> Bytes: """ Get the bytecode for a given code hash. Read chain: tx code_writes -> block code_writes -> pre_state. + Only record a ``code_reads`` entry when the bytecode is actually + fetched from ``pre_state``. Reads satisfied by ``code_writes`` + (same-tx or earlier-tx CREATEs) are already available to a + stateless verifier and do not need to appear in the witness. + Parameters ---------- tx_state : The transaction state. code_hash : Hash of the code to look up. + address : + Address whose code is being accessed. Returns ------- @@ -238,6 +266,7 @@ def get_code(tx_state: TransactionState, code_hash: Hash32) -> Bytes: return tx_state.code_writes[code_hash] if code_hash in tx_state.parent.code_writes: return tx_state.parent.code_writes[code_hash] + tx_state.code_reads.add((address, code_hash)) return tx_state.parent.pre_state.get_code(code_hash) @@ -720,8 +749,8 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: Create a snapshot of the transaction state for rollback. Deep-copy writes and transient storage. The parent reference, - ``created_accounts``, ``storage_reads``, and ``account_reads`` - are shared (not rolled back). + ``created_accounts``, ``storage_reads``, ``account_reads``, and + ``code_reads`` are shared (not rolled back). Parameters ---------- @@ -741,6 +770,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: addr: dict(slots) for addr, slots in tx_state.storage_writes.items() }, + code_reads=tx_state.code_reads, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, transient_storage=dict(tx_state.transient_storage), @@ -801,6 +831,7 @@ def incorporate_tx_into_block( # Merge reads and touches into block-level sets block.storage_reads.update(tx_state.storage_reads) block.account_reads.update(tx_state.account_reads) + block.code_reads.update(tx_state.code_reads) # Merge cumulative writes for address, account in tx_state.account_writes.items(): @@ -820,6 +851,7 @@ def incorporate_tx_into_block( tx_state.transient_storage.clear() tx_state.storage_reads = set() tx_state.account_reads = set() + tx_state.code_reads = set() def extract_block_diff(block_state: BlockState) -> BlockDiff: @@ -842,3 +874,49 @@ def extract_block_diff(block_state: BlockState) -> BlockDiff: storage_changes=block_state.storage_writes, code_changes=block_state.code_writes, ) + + +def get_witness_ancestors( + block_headers: List[Bytes], + oldest_ancestor_offset: Optional[Uint], +) -> List[Bytes]: + """ + Collect RLP-encoded ancestor headers from ``oldest_ancestor_offset`` + blocks back onward. + + Parameters + ---------- + block_headers : + RLP-encoded headers. + oldest_ancestor_offset : + Offset from the current block to the oldest ancestor accessed + during execution, or ``None`` if no ancestor was accessed. + + """ + if oldest_ancestor_offset is None: + return [] + return list(block_headers[-int(oldest_ancestor_offset) :]) + + +def track_ancestor_access(block_state: BlockState, offset: Uint) -> None: + """ + Record that an ancestor block was accessed. + + Update ``oldest_ancestor_offset`` if ``offset`` is further back (larger) + than the current value. Called by the BLOCKHASH opcode (when + returning a valid hash) and the EIP-2935 system contract call. + + Parameters + ---------- + block_state : + The block state. + offset : + Offset from the current block to the ancestor that was + accessed. + + """ + if ( + block_state.oldest_ancestor_offset is None + or offset > block_state.oldest_ancestor_offset + ): + block_state.oldest_ancestor_offset = offset diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py new file mode 100644 index 00000000000..64d00d9dfe2 --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -0,0 +1,311 @@ +""" +Stateless validation interfaces. +""" + +from dataclasses import dataclass +from enum import IntEnum +from typing import Annotated, List, Sequence, Tuple, final + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U16, U64 + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader +from ethereum.state import Root +from ethereum.utils.ssz import ( + SszContainer, + byte_list, + byte_vector, + progressive_list, + ssz_list, +) + +from .blocks import Header +from .execution_engine.new_payload import execute_new_payload_request +from .execution_engine.requests import ExecutionRequests +from .execution_engine.types import NewPayloadRequest +from .fork import ChainContext +from .fork_types import VersionedHash +from .witness_state import WitnessState, build_code_db, build_node_db + +MAX_WITNESS_HEADERS = 256 +MAX_BYTES_PER_CODE = 2**16 +MAX_BYTES_PER_HEADER = 2**10 +MAX_BYTES_PER_WITNESS_NODE = 2**10 +PUBLIC_KEY_BYTES = 65 + + +@final +@slotted_freezable +@dataclass +class ExecutionWitness(SszContainer): + """ + Execution witness data for stateless validation. + """ + + state: Annotated[ + Tuple[Annotated[Bytes, byte_list(MAX_BYTES_PER_WITNESS_NODE)], ...], + progressive_list(), + ] + """ + Hashed trie-node preimages needed during execution and state-root + recomputation. + """ + + codes: Annotated[ + Tuple[Annotated[Bytes, byte_list(MAX_BYTES_PER_CODE)], ...], + progressive_list(), + ] + """ + Contract-code preimages (created or accessed) needed during execution. + """ + + headers: Annotated[ + Tuple[Annotated[Bytes, byte_list(MAX_BYTES_PER_HEADER)], ...], + ssz_list(MAX_WITNESS_HEADERS), + ] + """ + RLP-encoded block headers used for pre-state and ``BLOCKHASH`` correctness + proofs. This may trend toward empty EIP-7709. + """ + + +@final +@slotted_freezable +@dataclass +class ExecutionPayloadHeader: + """ + Execution payload header for stateless input scaffolding. + + TODO: Replace with the fork-specific execution payload header container. + """ + + +@final +@slotted_freezable +@dataclass +class NewPayloadRequestHeader: + """ + Header-only form of ``NewPayloadRequest`` for stateless flows. + + We expect ``hash_tree_root(execution_payload_header)`` equals + ``hash_tree_root(execution_payload)``. + """ + + execution_payload_header: ExecutionPayloadHeader + versioned_hashes: Sequence[VersionedHash] + parent_beacon_block_root: Root + execution_requests: ExecutionRequests + + +class ProtocolFork(IntEnum): + """ + Stable execution-layer fork identifiers used by stateless schemas. + """ + + Frontier = 0x01 + Homestead = 0x02 + DAOFork = 0x03 + TangerineWhistle = 0x04 + SpuriousDragon = 0x05 + Byzantium = 0x06 + StPetersburg = 0x07 + Istanbul = 0x08 + MuirGlacier = 0x09 + Berlin = 0x0A + London = 0x0B + ArrowGlacier = 0x0C + GrayGlacier = 0x0D + Paris = 0x0E + Shanghai = 0x0F + Cancun = 0x10 + Prague = 0x11 + Osaka = 0x12 + BPO1 = 0x13 + BPO2 = 0x14 + Amsterdam = 0x15 + + +STATELESS_INPUT_SCHEMA_FORK_INDEX = ProtocolFork.Amsterdam +STATELESS_INPUT_SCHEMA_REVISION = 0x01 +STATELESS_INPUT_SCHEMA_ID = ( + STATELESS_INPUT_SCHEMA_FORK_INDEX << 8 +) | STATELESS_INPUT_SCHEMA_REVISION +STATELESS_INPUT_SCHEMA_ID_SIZE = 2 +STATELESS_INPUT_SCHEMA_ID_BYTES = STATELESS_INPUT_SCHEMA_ID.to_bytes( + STATELESS_INPUT_SCHEMA_ID_SIZE, + "big", +) + + +@final +@slotted_freezable +@dataclass +class StatelessInput(SszContainer): + """ + Input to stateless validation. + """ + + new_payload_request: NewPayloadRequest + """ + Consensus-layer payload request to validate statelessly. See + ``execution_engine.NewPayloadRequest`` for structure and links to + consensus-specs. + """ + + witness: ExecutionWitness + """ + Execution witness material required to re-execute the core + state transition function statelessly. + """ + + chain_id: U64 + """ + Chain identifier used during payload validation and execution. + """ + + public_keys: Annotated[ + Tuple[Annotated[Bytes, byte_vector(PUBLIC_KEY_BYTES)], ...], + progressive_list(), + ] + """ + 65-byte uncompressed transaction public keys, in payload order. + """ + + +@final +@slotted_freezable +@dataclass +class StatelessValidationResult(SszContainer): + """ + Result returned by stateless validation. + + Note: We use return values to denote "public inputs". + + If ``schema_id`` is zero, the guest could not decode the input or + produce a validation result, and the remaining fields have sentinel + defaults. Otherwise, the fields identify the decoded input, including + when execution validation fails. + + """ + + new_payload_request_root: Hash32 + """ + SSZ root of the decoded ``NewPayloadRequest``. This is zero in the + sentinel result. + """ + + successful_validation: bool + """ + Whether the decoded stateless input validated successfully. ``False`` + means validation failed or the guest could not produce a result. + """ + + chain_id: U64 + """ + Chain identifier decoded from the input. This is zero in the sentinel + result. + """ + + schema_id: U16 + """ + Exact input schema decoded and executed by the guest. This uses the + zero sentinel when the guest cannot decode the input or produce a + validation result. + """ + + +def compute_new_payload_request_root( + stateless_input: StatelessInput, +) -> Hash32: + """ + Compute the request root for a stateless input via SSZ hash tree root. + """ + return Hash32(stateless_input.new_payload_request.hash_tree_root()) + + +def _decode_header(header_bytes: Bytes) -> Header | PreviousForkHeader: + """ + Decode an RLP-encoded header, trying the current fork first and + falling back to the previous fork for transition-period headers. + """ + try: + return rlp.decode_to(Header, header_bytes) + except rlp.DecodingError: + return rlp.decode_to(PreviousForkHeader, header_bytes) + + +def validate_headers( + encoded_headers: Tuple[Bytes, ...], +) -> Tuple[List[Header | PreviousForkHeader], List[Hash32]]: + """ + Validate that a sequence of encoded headers forms a contiguous chain. + + Each header's ``parent_hash`` must match the hash of the preceding + header. Return the decoded headers and block hashes. Headers may + come from different forks during fork transitions. + """ + assert len(encoded_headers) <= 256, "Too many headers in witness" + headers = [ + _decode_header(header_bytes) for header_bytes in encoded_headers + ] + block_hashes: List[Hash32] = [ + keccak256(header_bytes) for header_bytes in encoded_headers + ] + for i in range(1, len(headers)): + if headers[i].parent_hash != block_hashes[i - 1]: + raise Exception("Witness headers are not contiguous") + return headers, block_hashes + + +def verify_stateless_new_payload( + stateless_input: StatelessInput, +) -> StatelessValidationResult: + """ + Statelessly validate the execution payload. + """ + new_payload_request_root = compute_new_payload_request_root( + stateless_input + ) + witness = stateless_input.witness + + try: + # EEST has one implementation per fork, so it does not need to check + # the execution payload timestamp against the current fork activation + # information. A real implementation MUST do these checks! + + # Validate the headers are contiguous and compute their + # blockhashes. + decoded_headers, block_hashes = validate_headers(witness.headers) + parent_header = decoded_headers[-1] + + chain_context = ChainContext( + chain_id=stateless_input.chain_id, + block_hashes=block_hashes, + parent_header=parent_header, + ) + + pre_state = WitnessState( + _node_db=build_node_db(witness.state), + _state_root=parent_header.state_root, + _code_db=build_code_db(witness.codes), + ) + + execute_new_payload_request( + stateless_input.new_payload_request, + pre_state, + chain_context, + transaction_public_keys=stateless_input.public_keys, + ) + successful_validation = True + except Exception: + successful_validation = False + + return StatelessValidationResult( + new_payload_request_root=new_payload_request_root, + successful_validation=successful_validation, + chain_id=stateless_input.chain_id, + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), + ) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py new file mode 100644 index 00000000000..dbb9481116e --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -0,0 +1,64 @@ +""" +Stateless guest interfaces. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U16, U64 + +from ethereum.crypto.hash import Hash32 + +from .stateless import ( + STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_SIZE, + StatelessInput, + StatelessValidationResult, + verify_stateless_new_payload, +) + + +def serialize_stateless_output( + output: StatelessValidationResult, +) -> Bytes: + """Serialize a StatelessValidationResult to SSZ bytes.""" + return Bytes(output.encode_bytes()) + + +def deserialize_stateless_input(data: Bytes) -> StatelessInput: + """Deserialize a StatelessInput from schema-prefixed SSZ bytes.""" + if len(data) < STATELESS_INPUT_SCHEMA_ID_SIZE: + raise ValueError("Stateless input is missing schema id") + schema_id = int.from_bytes( + data[:STATELESS_INPUT_SCHEMA_ID_SIZE], + "big", + ) + if schema_id != STATELESS_INPUT_SCHEMA_ID: + raise ValueError( + f"Unsupported stateless input schema id: 0x{schema_id:04x}" + ) + return StatelessInput.decode_bytes(data[STATELESS_INPUT_SCHEMA_ID_SIZE:]) + + +def _default_failed_stateless_output() -> StatelessValidationResult: + """ + Return the sentinel when the guest cannot produce a validation result. + """ + return StatelessValidationResult( + new_payload_request_root=Hash32(b"\0" * 32), + successful_validation=False, + chain_id=U64(0), + schema_id=U16(0), + ) + + +def run_stateless_guest(input_bytes: Bytes) -> Bytes: + """ + Run the stateless guest with serialized input, return serialized output. + """ + try: + stateless_input = deserialize_stateless_input(input_bytes) + stateless_output = verify_stateless_new_payload(stateless_input) + except Exception: + stateless_output = _default_failed_stateless_output() + + output_bytes = serialize_stateless_output(stateless_output) + return output_bytes diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py new file mode 100644 index 00000000000..ee25ec0a644 --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -0,0 +1,133 @@ +""" +Host-side assembly of stateless input from block execution data. +""" + +from typing import List + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U64 + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import InvalidSignatureError + +from .block_access_lists import BlockAccessList +from .blocks import Block +from .execution_engine.requests import ExecutionRequests +from .execution_engine.types import ExecutionPayload, NewPayloadRequest +from .fork_types import VersionedHash +from .stateless import ( + STATELESS_INPUT_SCHEMA_ID_BYTES, + ExecutionWitness, + StatelessInput, + StatelessValidationResult, +) +from .transactions import ( + BlobTransaction, + LegacyTransaction, + Transaction, + decode_transaction, + recover_transaction_public_key, +) + + +def serialize_stateless_input( + stateless_input: StatelessInput, +) -> Bytes: + """Serialize a StatelessInput to schema-prefixed SSZ bytes.""" + return Bytes( + STATELESS_INPUT_SCHEMA_ID_BYTES + stateless_input.encode_bytes() + ) + + +def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: + """Deserialize a StatelessValidationResult from SSZ bytes.""" + return StatelessValidationResult.decode_bytes(data) + + +def build_stateless_input( + block: Block, + *, + execution_witness: ExecutionWitness, + execution_requests: ExecutionRequests, + block_access_list: BlockAccessList, + chain_id: U64, +) -> StatelessInput: + """ + Build a StatelessInput from a completed block. + + Extract the header, transactions, and withdrawals from the block, + compute the block hash, collect versioned hashes, and package + everything into a StatelessInput ready for stateless guest execution. + """ + header = block.header + block_hash = Hash32(keccak256(rlp.encode(header))) + + # Encode transactions to bytes, recover public keys, and collect + # versioned hashes. + tx_bytes_list: List[Bytes] = [] + public_keys: List[Bytes] = [] + versioned_hashes: List[VersionedHash] = [] + for tx in block.transactions: + tx_obj: Transaction + if isinstance(tx, LegacyTransaction): + tx_bytes_list.append(Bytes(rlp.encode(tx))) + tx_obj = tx + else: + tx_bytes_list.append(Bytes(tx)) + # A typed tx may be malformed (pre-execution-rejected by + # t8n but still committed to by the block's transactions + # trie). + try: + tx_obj = decode_transaction(tx) + except Exception: + continue + try: + public_keys.append( + recover_transaction_public_key(chain_id, tx_obj) + ) + except InvalidSignatureError: + # Rejected transactions remain in invalid payloads passed to the + # guest, but cannot provide a recoverable public key. + continue + if isinstance(tx_obj, BlobTransaction): + versioned_hashes.extend(tx_obj.blob_versioned_hashes) + + # Block access list as RLP bytes. + bal_bytes = Bytes(rlp.encode(block_access_list)) + + payload = ExecutionPayload( + parent_hash=header.parent_hash, + fee_recipient=header.coinbase, + state_root=header.state_root, + receipts_root=header.receipt_root, + logs_bloom=header.bloom, + prev_randao=header.prev_randao, + block_number=header.number, + gas_limit=header.gas_limit, + gas_used=header.gas_used, + timestamp=header.timestamp, + extra_data=header.extra_data, + base_fee_per_gas=header.base_fee_per_gas, + block_hash=block_hash, + transactions=tuple(tx_bytes_list), + withdrawals=block.withdrawals, + blob_gas_used=header.blob_gas_used, + excess_blob_gas=header.excess_blob_gas, + block_access_list=bal_bytes, + slot_number=header.slot_number, + ) + + new_payload = NewPayloadRequest( + execution_payload=payload, + versioned_hashes=tuple(versioned_hashes), + parent_beacon_block_root=header.parent_beacon_block_root, + execution_requests=execution_requests, + ) + + return StatelessInput( + new_payload_request=new_payload, + witness=execution_witness, + chain_id=chain_id, + public_keys=tuple(public_keys), + ) diff --git a/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py b/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py new file mode 100644 index 00000000000..8059c643464 --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py @@ -0,0 +1,290 @@ +""" +Stateless validation types. +""" + +from typing import Dict, List, Optional, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U256, Uint + +from ethereum.crypto.hash import Hash32 +from ethereum.merkle_patricia_trie import EMPTY_TRIE_ROOT, Trie +from ethereum.state import Account, Address, PreState, Root + +from .incremental_mpt import ( + IncrementalMPT, + build_mpt, + mpt_get, + mpt_root, + mpt_set, +) +from .state_tracker import BlockState +from .stateless import ExecutionWitness + + +def _build_pre_state_storage_mpts( + pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]], +) -> Dict[Address, IncrementalMPT[Bytes32, U256]]: + """Build incremental storage MPTs from pre-state flat storage data.""" + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]] = {} + for address, data in pre_state_storages_data.items(): + incr_storage_mpts[address] = build_mpt( + data._data, secured=True, default=U256(0) + ) + return incr_storage_mpts + + +def _build_pre_state_account_mpt( + pre_state_accounts_data: Trie[Address, Optional[Account]], + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], +) -> IncrementalMPT[Address, Optional[Account]]: + """Build the incremental account MPT from pre-state flat account data.""" + + def get_pre_storage_root(address: Address) -> Root: + if address in incr_storage_mpts: + return mpt_root(incr_storage_mpts[address]) + return EMPTY_TRIE_ROOT + + return build_mpt( + pre_state_accounts_data._data, + secured=True, + default=None, + get_storage_root=get_pre_storage_root, + ) + + +def _collect_storage_accesses( + block_state: BlockState, +) -> Dict[Address, Set[Bytes32]]: + """ + Collect all storage keys that must be traversed on pre-state tries. + + Includes both reads and writes so all needed pre-state nodes are captured + before in-place mutation. + """ + all_storage_accesses: Dict[Address, Set[Bytes32]] = {} + for address, key in block_state.storage_reads: + all_storage_accesses.setdefault(address, set()).add(key) + for address, slots in block_state.storage_writes.items(): + all_storage_accesses.setdefault(address, set()).update(slots) + return all_storage_accesses + + +def _capture_pre_state_storage_nodes( + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], + all_storage_accesses: Dict[Address, Set[Bytes32]], +) -> None: + """Traverse pre-state storage tries to record witness nodes.""" + for address, keys in all_storage_accesses.items(): + if address not in incr_storage_mpts: + continue + for key in keys: + mpt_get(incr_storage_mpts[address], key) + + +def _apply_storage_writes( + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], + storage_writes: Dict[Address, Dict[Bytes32, U256]], +) -> None: + """Apply block storage writes to incremental storage MPTs.""" + for address, dirty_keys in storage_writes.items(): + if address not in incr_storage_mpts: + # New storage created during block. + incr_storage_mpts[address] = build_mpt( + {}, secured=True, default=U256(0) + ) + + # Two passes: insert/update first, deletions second. + for key, value in dirty_keys.items(): + if value != 0: + mpt_set(incr_storage_mpts[address], key, value) + for key, value in dirty_keys.items(): + if value == 0: + mpt_set(incr_storage_mpts[address], key, value) + + +def _get_all_dirty_accounts(block_state: BlockState) -> Set[Address]: + """Return addresses whose account leaf may change in the state trie.""" + return set(block_state.account_writes.keys()) | set( + block_state.storage_writes.keys() + ) + + +def _capture_pre_state_account_nodes( + incr_account_mpt: IncrementalMPT[Address, Optional[Account]], + account_reads: Set[Address], + all_dirty_accounts: Set[Address], +) -> None: + """Traverse pre-state account trie to record witness nodes.""" + for address in account_reads | all_dirty_accounts: + mpt_get(incr_account_mpt, address) + + +def _apply_account_writes( + incr_account_mpt: IncrementalMPT[Address, Optional[Account]], + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], + block_state: BlockState, + all_dirty_accounts: Set[Address], +) -> None: + """Apply final account values and post-storage roots to account trie.""" + for address in all_dirty_accounts: + if address in block_state.account_writes: + account = block_state.account_writes[address] + else: + account = block_state.pre_state.get_account_optional(address) + + if address in incr_storage_mpts: + addr_storage_root = mpt_root(incr_storage_mpts[address]) + else: + addr_storage_root = EMPTY_TRIE_ROOT + + def get_storage_root_fn( + _: Address, sr: Root = addr_storage_root + ) -> Root: + return sr + + mpt_set( + incr_account_mpt, + address, + account, + get_storage_root=get_storage_root_fn, + ) + + +def _collect_accessed_nodes( + incr_account_mpt: IncrementalMPT[Address, Optional[Account]], + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], +) -> Dict[Bytes, Bytes]: + """Merge accessed trie nodes from account and storage witnesses.""" + accessed_nodes = dict(incr_account_mpt.witness.accessed_nodes) + for mpt in incr_storage_mpts.values(): + accessed_nodes.update(mpt.witness.accessed_nodes) + return accessed_nodes + + +def build_execution_witness( + block_state: BlockState, + expected_post_state_root: Root, + pre_state_accounts_data: Trie[Address, Optional[Account]], + pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]], + blockchain_headers: Optional[List[Bytes]] = None, +) -> ExecutionWitness: + """ + Build the execution witness from block state and pre-state trie data. + + Sort state and codes in lexicographic ascending order, headers by + block number ascending. + """ + ancestor_headers = get_witness_ancestors( + blockchain_headers if blockchain_headers is not None else [], + block_state.oldest_ancestor_offset, + ) + codes = get_witness_codes(block_state.code_reads, block_state.pre_state) + + # Build account and storage IncrementalMPTs from pre-state flat data. + incr_storage_mpts = _build_pre_state_storage_mpts(pre_state_storages_data) + incr_account_mpt = _build_pre_state_account_mpt( + pre_state_accounts_data, incr_storage_mpts + ) + + # 1. Traverse all accessed and dirty storage keys on the pre-state + # MPTs to capture pre-state trie nodes in the witness. This must + # happen before any writes since writes mutate the tree in-place. + all_storage_accesses = _collect_storage_accesses(block_state) + _capture_pre_state_storage_nodes(incr_storage_mpts, all_storage_accesses) + + # 2. Apply dirty storage to storages (writes) + _apply_storage_writes(incr_storage_mpts, block_state.storage_writes) + + # Accounts are "dirty" if: + # - Account fields changed (nonce/balance/code) - tracked in dirty_accounts + # - Storage changed (storage root changed) - tracked in dirty_storage + all_dirty_accounts = _get_all_dirty_accounts(block_state) + + # 3. Traverse all accessed and dirty accounts on the pre-state MPT + # to capture pre-state trie nodes before writes mutate the tree. + _capture_pre_state_account_nodes( + incr_account_mpt, + block_state.account_reads, + all_dirty_accounts, + ) + + # 4. Apply dirty accounts + _apply_account_writes( + incr_account_mpt, + incr_storage_mpts, + block_state, + all_dirty_accounts, + ) + + # Safety check: the post-state root implied by the witness construction + # must match the canonical state-root calculation. + assert mpt_root(incr_account_mpt) == expected_post_state_root + + # Collect witness from all MPTs + accessed_nodes = _collect_accessed_nodes( + incr_account_mpt, incr_storage_mpts + ) + + return ExecutionWitness( + state=tuple(sorted(accessed_nodes.values())), + codes=tuple(codes), + headers=tuple(ancestor_headers), + ) + + +def get_witness_codes( + code_reads: Set[Tuple[Address, Hash32]], + pre_state: PreState, +) -> List[Bytes]: + """ + Collect bytecodes from the pre-state for all code reads during execution. + + Include a code hash only when the same address already had that code in the + pre-state. This avoids accidentally including bytecode created during the + current block when the same hash already exists elsewhere. + + Parameters + ---------- + code_reads : + Code reads as ``(address, code_hash)`` during block execution. + pre_state : + The pre-execution state. + + """ + witness_code_hashes: Set[Hash32] = set() + for address, code_hash in code_reads: + pre_account = pre_state.get_account_optional(address) + if pre_account is None or pre_account.code_hash != code_hash: + continue + witness_code_hashes.add(code_hash) + + codes: List[Bytes] = [] + for code_hash in witness_code_hashes: + try: + codes.append(pre_state.get_code(code_hash)) + except KeyError: + pass + return sorted(codes) + + +def get_witness_ancestors( + block_headers: List[Bytes], + oldest_ancestor_offset: Optional[Uint], +) -> List[Bytes]: + """ + Collect RLP-encoded ancestor headers from ``oldest_ancestor_offset`` + blocks back onward. + + Parameters + ---------- + block_headers : + RLP-encoded headers. + oldest_ancestor_offset : + Offset from the current block to the oldest ancestor accessed + during execution, or ``None`` if no ancestor was accessed. + + """ + if oldest_ancestor_offset is None: + return [] + return list(block_headers[-int(oldest_ancestor_offset) :]) diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions.py index 693064688da..ae0e5ebd0c7 100644 --- a/src/ethereum/forks/amsterdam/transactions.py +++ b/src/ethereum/forks/amsterdam/transactions.py @@ -12,7 +12,10 @@ from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import U64, U256, Uint, ulen -from ethereum.crypto.elliptic_curve import SECP256K1N, secp256k1_recover +from ethereum.crypto.elliptic_curve import ( + SECP256K1N, + secp256k1_recover, +) from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import ( InsufficientTransactionGasError, @@ -54,6 +57,7 @@ class IntrinsicGasCost: TX_MAX_GAS_LIMIT = Uint(16_777_216) +SECP256K1_UNCOMPRESSED_PUBLIC_KEY_PREFIX = b"\x04" BLOB_COUNT_LIMIT = 6 """ @@ -865,9 +869,62 @@ def recover_sender(tx: Transaction) -> Address: signing hash of the transaction. The sender's public key can be obtained with these two values and therefore the sender address can be retrieved. - This function takes chain_id and a transaction as parameters and returns - the address of the sender of the transaction. It raises an - `InvalidSignatureError` if the signature values (r, s, v) are invalid. + This function takes a transaction as a parameter and returns the address + of the sender of the transaction. It raises an `InvalidSignatureError` if + the signature values (r, s, v) are invalid. + """ + tx_chain_id = chain_id(tx) + recovery_chain_id = U64(0) if tx_chain_id is None else tx_chain_id + public_key = recover_transaction_public_key(recovery_chain_id, tx) + return _sender_address_from_public_key(public_key) + + +def recover_transaction_public_key(chain_id: U64, tx: Transaction) -> Bytes: + """ + Recover the canonical uncompressed SEC1 public key for a transaction. + """ + r, s, recovery_id, signing_hash = _signature_recovery_parameters( + chain_id, tx + ) + return Bytes( + SECP256K1_UNCOMPRESSED_PUBLIC_KEY_PREFIX + + secp256k1_recover(r, s, recovery_id, signing_hash) + ) + + +def recover_sender_from_public_key( + chain_id: U64, tx: Transaction, public_key: Bytes +) -> Address: + """ + Verify that ``public_key`` is the transaction sender's canonical + uncompressed SEC1 public key. + + This reference implementation verifies the supplied key by recovering the + canonical public key from the transaction signature and comparing the two. + Optimized implementations may avoid full public-key recovery, but must + still verify that the supplied key validates the signature and is + consistent with the transaction's recovery id / y-parity bit. Otherwise, + another valid recovery candidate could derive a different sender address. + + Returns the sender address derived from the verified public key. + """ + if public_key != recover_transaction_public_key(chain_id, tx): + raise InvalidSignatureError + return _sender_address_from_public_key(public_key) + + +def _sender_address_from_public_key(public_key: Bytes) -> Address: + """ + Derive the sender address from an uncompressed SEC1 public key. + """ + return Address(keccak256(bytes(public_key)[1:])[12:32]) + + +def _signature_recovery_parameters( + chain_id: U64, tx: Transaction +) -> Tuple[U256, U256, U256, Hash32]: + """ + Validate the transaction signature fields and return recovery inputs. """ r, s = tx.r, tx.s if U256(0) >= r or r >= SECP256K1N: @@ -878,45 +935,60 @@ def recover_sender(tx: Transaction) -> Address: if isinstance(tx, LegacyTransaction): v = tx.v if v == 27 or v == 28: - public_key = secp256k1_recover( - r, s, v - U256(27), signing_hash_pre155(tx) + return ( + r, + s, + v - U256(27), + signing_hash_pre155(tx), ) else: - assert v >= U256(35), "call chain_id before recover_sender" - tx_chain_id = U64((v - U256(35)) >> U256(1)) - v = (v - U256(35)) & U256(1) - public_key = secp256k1_recover( + chain_id_x2 = U256(chain_id) * U256(2) + if v != U256(35) + chain_id_x2 and v != U256(36) + chain_id_x2: + raise InvalidSignatureError("bad v") + return ( r, s, - v, - signing_hash_155(tx, tx_chain_id), + v - U256(35) - chain_id_x2, + signing_hash_155(tx, chain_id), ) elif isinstance(tx, AccessListTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_2930(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_2930(tx), ) elif isinstance(tx, FeeMarketTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_1559(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_1559(tx), ) elif isinstance(tx, BlobTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_4844(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_4844(tx), ) elif isinstance(tx, SetCodeTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_7702(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_7702(tx), ) - - return Address(keccak256(public_key)[12:32]) + else: + raise InvalidSignatureError("unsupported transaction type") def signing_hash_pre155(tx: LegacyTransaction) -> Hash32: diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 3125187719d..4331ae0dbe2 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -64,6 +64,7 @@ class BlockEnvironment: parent_beacon_block_root: Hash32 block_access_list_builder: BlockAccessListBuilder slot_number: U64 + transaction_public_keys: Optional[Tuple[Bytes, ...]] = None @final diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 8663ca85cb7..1b41a2afbec 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -95,7 +95,11 @@ def resolve_delegated_code_address( it with precompiles disabled; otherwise return `target_address` unchanged. """ - code = get_code(state, get_account(state, target_address).code_hash) + code = get_code( + state, + get_account(state, target_address).code_hash, + target_address, + ) delegated_address = get_delegated_code_address(code) if delegated_address is None: return target_address, False @@ -173,7 +177,11 @@ def calculate_delegation_cost( """ tx_state = evm.tx_env.state - code = get_code(tx_state, get_account(tx_state, address).code_hash) + code = get_code( + tx_state, + get_account(tx_state, address).code_hash, + address, + ) if not is_valid_delegation(code): return False, address, GasCosts.ZERO @@ -216,7 +224,11 @@ def validate_authorization( accessed_authorities.add(authority) authority_account = get_account(tx_state, authority) - authority_code = get_code(tx_state, authority_account.code_hash) + authority_code = get_code( + tx_state, + authority_account.code_hash, + authority, + ) if authority_code and not is_valid_delegation(authority_code): return None @@ -303,7 +315,9 @@ def set_delegation( tx_state, authority ) pre_state_authority_code = get_code( - tx_state, pre_state_authority_account.code_hash + tx_state, + pre_state_authority_account.code_hash, + authority, ) delegated_before_tx = is_valid_delegation(pre_state_authority_code) diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index d599a1b654e..d8dd518bff8 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -17,7 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint, ulen from ethereum.exceptions import GasUsedExceedsLimitError -from ethereum.forks.bpo5.blocks import Header as PreviousHeader +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.trace import GasAndRefund, StateGasAndRefund, evm_trace from ethereum.utils.numeric import ceil32, taylor_exponential @@ -260,6 +260,9 @@ class GasCosts: OPCODE_SELFDESTRUCT_BASE: Final[ExecutionGas] = ExecutionGas(Uint(5000)) +BLOB_SCHEDULE_TARGET = GasCosts.BLOB_SCHEDULE_TARGET +BLOB_SCHEDULE_MAX = GasCosts.BLOB_SCHEDULE_MAX +BLOB_BASE_FEE_UPDATE_FRACTION = GasCosts.BLOB_BASE_FEE_UPDATE_FRACTION MAX_BLOB_GAS_PER_BLOCK: Final[U64] = ( GasCosts.BLOB_SCHEDULE_MAX * GasCosts.PER_BLOB ) @@ -861,7 +864,7 @@ def init_code_cost(init_code_length: Uint) -> ExecutionGas: def calculate_excess_blob_gas( - parent_header: Header | PreviousHeader, + parent_header: Header | PreviousForkHeader, ) -> U64: """ Calculates the excess blob gas for the current block based @@ -883,7 +886,7 @@ def calculate_excess_blob_gas( blob_gas_used = U64(0) base_fee_per_gas = Uint(0) - if isinstance(parent_header, (Header, PreviousHeader)): + if isinstance(parent_header, (Header, PreviousForkHeader)): # Read them from any parent that carries the fields, so # accumulated excess blob gas survives a fork transition. excess_blob_gas = parent_header.excess_blob_gas diff --git a/src/ethereum/forks/amsterdam/vm/instructions/block.py b/src/ethereum/forks/amsterdam/vm/instructions/block.py index fa286c439cc..608ff9da682 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/block.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/block.py @@ -13,6 +13,7 @@ from ethereum_types.numeric import U256, Uint +from ...state_tracker import track_ancestor_access from .. import Evm from ..gas import GasCosts, charge_gas from ..stack import pop, push @@ -57,6 +58,10 @@ def block_hash(evm: Evm) -> None: current_block_hash = evm.block_env.block_hashes[ -(current_block_number - block_number) ] + track_ancestor_access( + evm.block_env.state, + current_block_number - block_number, + ) push(evm.stack, U256.from_be_bytes(current_block_hash)) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 582c36c1c58..c923e855c37 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -352,7 +352,7 @@ def extcodesize(evm: Evm) -> None: # OPERATION tx_state = evm.tx_env.state code_hash = get_account(tx_state, address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -399,7 +399,7 @@ def extcodecopy(evm: Evm) -> None: evm.memory += b"\x00" * extend_memory.expand_by tx_state = evm.tx_env.state code_hash = get_account(tx_state, address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 47d2adc6f2f..1323caf7b6e 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -538,7 +538,7 @@ def call(evm: Evm) -> None: evm.accessed_addresses.add(code_address) code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) charge_gas(evm, extra_gas + extend_memory.cost) @@ -664,7 +664,7 @@ def callcode(evm: Evm) -> None: evm.accessed_addresses.add(code_address) code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) # CHILD GRANT # Charge the call's cost and withhold the child's execution gas @@ -845,7 +845,7 @@ def delegatecall(evm: Evm) -> None: tx_state = evm.tx_env.state code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) # CHILD GRANT # Charge the call's cost and withhold the child's execution gas @@ -948,7 +948,7 @@ def staticcall(evm: Evm) -> None: tx_state = evm.tx_env.state code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) # CHILD GRANT # Charge the call's cost and withhold the child's execution gas diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 656cbc6b323..4ccc21e0f04 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -198,6 +198,7 @@ def create_evm( code = get_code( tx_env.state, get_account(tx_env.state, code_address).code_hash, + code_address, ) ## Build the frame diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py new file mode 100644 index 00000000000..8246fc0031b --- /dev/null +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -0,0 +1,311 @@ +""" +Witness-backed PreState. + +Implement the ``PreState`` protocol using execution witness data +""" + +from dataclasses import dataclass, field +from typing import AbstractSet, Dict, List, Optional, Tuple, final + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.merkle_patricia_trie import EMPTY_TRIE_ROOT, InternalNode +from ethereum.state import ( + EMPTY_CODE_HASH, + Account, + Address, + BlockDiff, + Root, +) + +from .incremental_mpt import ( + HashedNode, + IncrementalMPT, + MutableBranchNode, + MutableExtensionNode, + MutableLeafNode, + MutableNode, + decode_witness_to_mpt, + mpt_root, + mpt_set, +) + + +def build_node_db(state_entries: Tuple[Bytes, ...]) -> Dict[Bytes, Bytes]: + """Build hash -> RLP mapping from witness state preimages.""" + db: Dict[Bytes, Bytes] = {} + for entry in state_entries: + db[keccak256(entry)] = entry + return db + + +def build_code_db(code_entries: Tuple[Bytes, ...]) -> Dict[Hash32, Bytes]: + """Build code_hash -> bytecode mapping from witness codes.""" + db: Dict[Hash32, Bytes] = {} + for code in code_entries: + db[keccak256(code)] = code + return db + + +def _trie_lookup( + root_node: MutableNode, + key_hash: Hash32, +) -> Optional[Bytes]: + """ + Walk a decoded MPT from root following nibblized key_hash. + + Return leaf value or ``None`` if not found. + + """ + nibbles = bytearray() + for byte in key_hash: + nibbles.append(byte >> 4) + nibbles.append(byte & 0x0F) + + node = root_node + pos = 0 + + while node is not None: + if isinstance(node, HashedNode): + raise AssertionError( + "Encountered unresolved HashedNode during witness lookup" + ) + + if isinstance(node, MutableLeafNode): + if bytes(nibbles[pos:]) == node.rest_of_key: + return node.value + return None + + if isinstance(node, MutableExtensionNode): + segment = node.key_segment + if bytes(nibbles[pos : pos + len(segment)]) != segment: + return None + pos += len(segment) + node = node.child + continue + + assert isinstance(node, MutableBranchNode), ( + f"Unexpected node type {type(node)}" + ) + + if pos == len(nibbles): + return node.value or None + idx = nibbles[pos] + pos += 1 + node = node.children[idx] + + return None + + +def _decode_account_from_leaf( + leaf_value: Bytes, +) -> Tuple[Account, Root]: + """ + Decode (nonce, balance, storage_root, code_hash) from trie leaf. + + Return the ``Account`` and the ``storage_root`` separately + (storage_root is not stored on ``Account``). + """ + decoded = rlp.decode(leaf_value) + assert isinstance(decoded, list) and len(decoded) == 4 + + nonce = Uint(int.from_bytes(decoded[0], "big")) if decoded[0] else Uint(0) + balance = ( + U256(int.from_bytes(decoded[1], "big")) if decoded[1] else U256(0) + ) + storage_root = Root(decoded[2]) if decoded[2] else EMPTY_TRIE_ROOT + code_hash = Hash32(decoded[3]) if decoded[3] else EMPTY_CODE_HASH + + account = Account( + nonce=nonce, + balance=balance, + code_hash=code_hash, + ) + return account, storage_root + + +@final +@dataclass +class WitnessState: + """ + ``PreState`` backed by execution witness data. + + Serve account, storage, and code reads from trie-node + preimages and bytecodes provided in the execution witness. + """ + + _node_db: Dict[Bytes, Bytes] + _state_root: Root + _code_db: Dict[Hash32, Bytes] + _storage_root_cache: Dict[Address, Root] = field(default_factory=dict) + _decoded_secure_roots: Dict[Root, MutableNode] = field( + default_factory=dict + ) + + def _get_decoded_secure_root(self, root_hash: Root) -> MutableNode: + """Decode and cache a secured trie root for read-only lookups.""" + if root_hash == EMPTY_TRIE_ROOT: + return None + if root_hash not in self._decoded_secure_roots: + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + self._node_db, + root_hash, + secured=True, + default=b"", + ) + self._decoded_secure_roots[root_hash] = decoded_mpt.root_node + return self._decoded_secure_roots[root_hash] + + def get_account_optional(self, address: Address) -> Optional[Account]: + """ + Get the account at an address. + + Return ``None`` if there is no account at the address. + """ + key_hash = keccak256(address) + leaf = _trie_lookup( + self._get_decoded_secure_root(self._state_root), key_hash + ) + if leaf is None: + self._storage_root_cache[address] = EMPTY_TRIE_ROOT + return None + account, storage_root = _decode_account_from_leaf(leaf) + self._storage_root_cache[address] = storage_root + return account + + def get_storage(self, address: Address, key: Bytes32) -> U256: + """ + Get a storage value. + + Return ``U256(0)`` if the key has not been set. + """ + if address not in self._storage_root_cache: + self.get_account_optional(address) + storage_root = self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) + if storage_root == EMPTY_TRIE_ROOT: + return U256(0) + + key_hash = keccak256(key) + leaf = _trie_lookup( + self._get_decoded_secure_root(storage_root), key_hash + ) + if leaf is None: + return U256(0) + + decoded = rlp.decode(leaf) + if isinstance(decoded, (bytes, bytearray)): + if len(decoded) == 0: + return U256(0) + return U256(int.from_bytes(decoded, "big")) + return U256(0) + + def get_code(self, code_hash: Hash32) -> Bytes: + """ + Get the bytecode for a given code hash. + + Return ``b""`` for ``EMPTY_CODE_HASH``. + """ + if code_hash == EMPTY_CODE_HASH: + return b"" + return self._code_db[code_hash] + + def compute_state_root(self, block_diff: BlockDiff) -> Root: + """ + Compute the state root after applying ``block_diff``. + + Conform to the implementation-agnostic ``PreState`` protocol while + reusing the witness-backed incremental MPT calculation. + """ + state_root, _ = self.compute_state_root_and_trie_changes( + block_diff.account_changes, + block_diff.storage_changes, + block_diff.storage_clears, + ) + return state_root + + def compute_state_root_and_trie_changes( + self, + account_changes: Dict[Address, Optional[Account]], + storage_changes: Dict[Address, Dict[Bytes32, U256]], + storage_clears: AbstractSet[Address] = frozenset(), + ) -> Tuple[Root, List[InternalNode]]: + """ + Compute the state root after applying changes. + + Build partial ``IncrementalMPT`` tries from the witness, + apply diffs, and compute the new root. + """ + new_storage_roots: Dict[Address, Root] = {} + + for address, slots in storage_changes.items(): + if ( + address not in storage_clears + and address not in self._storage_root_cache + ): + self.get_account_optional(address) + old_root = ( + EMPTY_TRIE_ROOT + if address in storage_clears + else self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) + ) + storage_mpt: IncrementalMPT[Bytes32, U256] = decode_witness_to_mpt( + self._node_db, + old_root, + secured=True, + default=U256(0), + ) + # We must do insertions+updates before deletions to minimize branch + # compressions. + for key, value in slots.items(): + if value != 0: + mpt_set(storage_mpt, key, value) + for key, value in slots.items(): + if value == 0: + mpt_set(storage_mpt, key, value) + new_storage_roots[address] = mpt_root(storage_mpt) + + state_mpt: IncrementalMPT[Address, Optional[Account]] = ( + decode_witness_to_mpt( + self._node_db, + self._state_root, + secured=True, + default=None, + ) + ) + + storage_touched = set(storage_changes) | set(storage_clears) + for address in storage_touched: + if address not in account_changes: + account = self.get_account_optional(address) + if account is not None: + sr = new_storage_roots.get(address, EMPTY_TRIE_ROOT) + + def _sr_fn(_a: Address, _sr: Root = sr) -> Root: + return _sr + + mpt_set( + state_mpt, + address, + account, + get_storage_root=_sr_fn, + ) + + def get_storage_root(addr: Address) -> Root: + if addr in new_storage_roots: + return new_storage_roots[addr] + if addr in storage_clears: + return EMPTY_TRIE_ROOT + return self._storage_root_cache.get(addr, EMPTY_TRIE_ROOT) + + for address, account in account_changes.items(): + mpt_set( + state_mpt, + address, + account, + get_storage_root=get_storage_root, + ) + + return mpt_root(state_mpt), [] diff --git a/src/ethereum/utils/ssz.py b/src/ethereum/utils/ssz.py new file mode 100644 index 00000000000..887517d0352 --- /dev/null +++ b/src/ethereum/utils/ssz.py @@ -0,0 +1,325 @@ +""" +Serialize specification dataclasses with SSZ while retaining Python types. +""" + +from dataclasses import dataclass, fields +from typing import ( + Annotated, + Any, + Dict, + Tuple, + Type, + TypeVar, + final, + get_args, + get_origin, + get_type_hints, +) + +from ethereum_types.bytes import FixedBytes +from ethereum_types.numeric import FixedUnsigned, Unsigned +from remerkleable import basic as rmk_basic +from remerkleable.byte_arrays import ByteList, ByteVector +from remerkleable.complex import Container +from remerkleable.complex import List as RmkList +from remerkleable.progressive import ( + ProgressiveByteList, + ProgressiveContainer, + ProgressiveList, +) + + +class _SszType: + pass + + +@final +@dataclass(frozen=True) +class _Uint(_SszType): + bits: int + + +@final +@dataclass(frozen=True) +class _ByteVector(_SszType): + length: int + + +@final +@dataclass(frozen=True) +class _ByteList(_SszType): + limit: int + + +@final +@dataclass(frozen=True) +class _List(_SszType): + element: _SszType + limit: int + + +@final +@dataclass(frozen=True) +class _ProgressiveList(_SszType): + element: _SszType + + +@final +@dataclass(frozen=True) +class _Container(_SszType): + model: Any + + +class _Bool(_SszType): + pass + + +class _ProgressiveByteList(_SszType): + pass + + +@final +@dataclass(frozen=True) +class _ListLimit: + limit: int + + +class _ProgressiveListMarker: + pass + + +def uint(bits: int) -> _Uint: + """Mark an unsigned integer field with its SSZ bit width.""" + if bits not in (8, 16, 32, 64, 128, 256): + raise ValueError(f"Unsupported SSZ integer width: {bits}") + return _Uint(bits) + + +def byte_vector(length: int) -> _ByteVector: + """Mark a byte field as a fixed-length SSZ byte vector.""" + return _ByteVector(length) + + +def byte_list(limit: int) -> _ByteList: + """Mark a byte field as a bounded SSZ byte list.""" + return _ByteList(limit) + + +def ssz_list(limit: int) -> _ListLimit: + """Mark a collection field as a bounded SSZ list.""" + return _ListLimit(limit) + + +def progressive_list() -> _ProgressiveListMarker: + """Mark a collection field as an SSZ progressive list.""" + return _ProgressiveListMarker() + + +def progressive_byte_list() -> _ProgressiveByteList: + """Mark a byte field as an SSZ progressive byte list.""" + return _ProgressiveByteList() + + +_C = TypeVar("_C", bound="SszContainer") + + +class SszContainer: + """Provide SSZ operations for a specification dataclass.""" + + def encode_bytes(self) -> bytes: + """Encode this dataclass as SSZ bytes.""" + return _to_view(self).encode_bytes() + + def hash_tree_root(self) -> bytes: + """Return the SSZ hash-tree root of this dataclass.""" + return bytes(_to_view(self).hash_tree_root()) + + @classmethod + def decode_bytes(cls: Type[_C], data: bytes) -> _C: + """Decode canonical SSZ bytes into this dataclass type.""" + view = _container_type(cls).decode_bytes(data) + # The underlying decoder can accept offset gaps and leave bytes + # unread. Require the exact encoding, including nested containers, + # before exposing the decoded value to validation or hashing. + if view.encode_bytes() != data: + raise ValueError("Non-canonical SSZ encoding") + return _from_view(cls, view) + + +class ProgressiveSszContainer(SszContainer): + """Identify a dataclass encoded as an SSZ progressive container.""" + + +def _annotated(annotation: Any) -> Tuple[Any, Any]: + if get_origin(annotation) is not Annotated: + return annotation, None + base, *metadata = get_args(annotation) + markers = [ + item + for item in metadata + if isinstance(item, (_SszType, _ListLimit, _ProgressiveListMarker)) + ] + if len(markers) != 1: + raise TypeError( + f"Annotated SSZ field requires exactly one marker: {annotation!r}" + ) + return base, markers[0] + + +def _collection_element(annotation: Any) -> Any: + base, _ = _annotated(annotation) + args = get_args(base) + if get_origin(base) is not tuple or ( + len(args) != 2 or args[1] is not Ellipsis + ): + raise TypeError(f"SSZ tuple must have one repeated type: {base!r}") + return args[0] + + +def _infer(annotation: Any) -> _SszType: + base, marker = _annotated(annotation) + if isinstance(marker, _ListLimit): + return _List(_infer(_collection_element(base)), marker.limit) + if isinstance(marker, _ProgressiveListMarker): + return _ProgressiveList(_infer(_collection_element(base))) + if isinstance(marker, _SszType): + if isinstance(marker, _Uint): + if not isinstance(base, type) or not issubclass(base, Unsigned): + raise TypeError(f"SSZ uint requires Unsigned: {base!r}") + elif isinstance( + marker, (_ByteVector, _ByteList, _ProgressiveByteList) + ): + if not isinstance(base, type) or not issubclass(base, bytes): + raise TypeError(f"SSZ byte type requires bytes: {base!r}") + return marker + + if base is bool: + return _Bool() + if isinstance(base, type): + if issubclass(base, FixedBytes): + return _ByteVector(base.LENGTH) + if issubclass(base, FixedUnsigned): + return _Uint(int(base.MAX_VALUE).bit_length()) + if issubclass(base, SszContainer): + return _Container(base) + raise TypeError(f"Cannot infer an SSZ type for {base!r}") + + +def _rmk_type(ssz_type: _SszType) -> Any: + if isinstance(ssz_type, _Uint): + return getattr(rmk_basic, f"uint{ssz_type.bits}") + if isinstance(ssz_type, _ByteVector): + return ByteVector[ssz_type.length] + if isinstance(ssz_type, _ByteList): + return ByteList[ssz_type.limit] + if isinstance(ssz_type, _List): + return RmkList[_rmk_type(ssz_type.element), ssz_type.limit] + if isinstance(ssz_type, _ProgressiveList): + return ProgressiveList[_rmk_type(ssz_type.element)] + if isinstance(ssz_type, _ProgressiveByteList): + return ProgressiveByteList + if isinstance(ssz_type, _Container): + return _container_type(ssz_type.model) + if isinstance(ssz_type, _Bool): + return rmk_basic.boolean + raise TypeError(f"Unsupported SSZ type: {ssz_type!r}") + + +_FIELD_TYPES: Dict[Any, Tuple[Tuple[str, Any], ...]] = {} + + +def _field_types(model: Any) -> Tuple[Tuple[str, Any], ...]: + if model in _FIELD_TYPES: + return _FIELD_TYPES[model] + hints = get_type_hints(model, include_extras=True) + result = tuple((field.name, hints[field.name]) for field in fields(model)) + _FIELD_TYPES[model] = result + return result + + +_CONTAINER_TYPES: Dict[Any, Type[Container]] = {} + + +def _container_type(model: Any) -> Type[Container]: + if model in _CONTAINER_TYPES: + return _CONTAINER_TYPES[model] + annotations = { + name: _rmk_type(_infer(annotation)) + for name, annotation in _field_types(model) + } + if issubclass(model, ProgressiveSszContainer): + base: Any = ProgressiveContainer(active_fields=[1] * len(annotations)) + else: + base = Container + result = type( + f"_{model.__name__}Ssz", + (base,), + {"__annotations__": annotations}, + ) + _CONTAINER_TYPES[model] = result + return result + + +def _to_ssz_value(annotation: Any, ssz_type: _SszType, value: Any) -> Any: + if isinstance(ssz_type, _Container): + return _to_view(value) + if isinstance(ssz_type, (_List, _ProgressiveList)): + element = _collection_element(annotation) + return [ + _to_ssz_value(element, ssz_type.element, item) for item in value + ] + if isinstance(ssz_type, _Uint): + return int(value) + if isinstance(ssz_type, (_ByteVector, _ByteList, _ProgressiveByteList)): + encoded = bytes(value) + if ( + isinstance(ssz_type, _ByteVector) + and len(encoded) != ssz_type.length + ): + raise ValueError( + f"Expected {ssz_type.length} bytes, got {len(encoded)}" + ) + return encoded + if isinstance(ssz_type, _Bool): + return bool(value) + raise TypeError(f"Unsupported SSZ type: {ssz_type!r}") + + +def _to_view(value: SszContainer) -> Container: + model = type(value) + values = { + name: _to_ssz_value( + annotation, _infer(annotation), getattr(value, name) + ) + for name, annotation in _field_types(model) + } + return _container_type(model)(**values) + + +def _from_ssz_value(annotation: Any, ssz_type: _SszType, value: Any) -> Any: + base, _ = _annotated(annotation) + if isinstance(ssz_type, _Container): + return _from_view(ssz_type.model, value) + if isinstance(ssz_type, (_List, _ProgressiveList)): + element = _collection_element(annotation) + decoded = [ + _from_ssz_value(element, ssz_type.element, item) for item in value + ] + return tuple(decoded) + if isinstance(ssz_type, _Uint): + return base(int(value)) + if isinstance(ssz_type, (_ByteVector, _ByteList, _ProgressiveByteList)): + return base(bytes(value)) + if isinstance(ssz_type, _Bool): + return bool(value) + raise TypeError(f"Unsupported SSZ type: {ssz_type!r}") + + +def _from_view(model: Type[_C], view: Container) -> _C: + values = { + name: _from_ssz_value( + annotation, _infer(annotation), getattr(view, name) + ) + for name, annotation in _field_types(model) + } + return model(**values) diff --git a/src/ethereum_spec_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/loaders/fork_loader.py index eebd406f7c7..b99a01af449 100644 --- a/src/ethereum_spec_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/loaders/fork_loader.py @@ -154,6 +154,48 @@ def has_hash_block_access_list(self) -> bool: return False return hasattr(module, "hash_block_access_list") + @property + def has_execution_witness(self) -> bool: + """Check if the fork has an `ExecutionWitness` type.""" + try: + module = self._module("stateless") + except ModuleNotFoundError: + return False + return hasattr(module, "ExecutionWitness") + + @property + def build_execution_witness(self) -> Any: + """Build function of the fork.""" + mod = self._module("stateless_host_exec_witness") + return mod.build_execution_witness + + @property + def build_stateless_input(self) -> Any: + """build_stateless_input function of the fork.""" + return self._module("stateless_host").build_stateless_input + + @property + def decode_execution_requests(self) -> Any: + """decode_execution_requests function of the fork.""" + return self._module( + "execution_engine.requests" + ).decode_execution_requests + + @property + def serialize_stateless_input(self) -> Any: + """serialize_stateless_input function of the fork.""" + return self._module("stateless_host").serialize_stateless_input + + @property + def deserialize_stateless_output(self) -> Any: + """deserialize_stateless_output function of the fork.""" + return self._module("stateless_host").deserialize_stateless_output + + @property + def run_stateless_guest(self) -> Any: + """run_stateless_guest function of the fork.""" + return self._module("stateless_guest").run_stateless_guest + @property def BlockAccessIndex(self) -> Any: """BlockAccessIndex type of the fork.""" @@ -206,6 +248,12 @@ def Block(self) -> Any: """Block class of the fork.""" return self._module("blocks").Block + @property + def block_rlp_size_limit(self) -> int | None: + """Return the maximum RLP-encoded block size, if defined.""" + limit = getattr(self._module("fork"), "MAX_RLP_BLOCK_SIZE", None) + return int(limit) if limit is not None else None + @property def decode_receipt(self) -> Any: """decode_receipt function of the fork.""" @@ -320,6 +368,20 @@ def BlockState(self) -> Any: """BlockState class of the fork.""" return self._module("state_tracker").BlockState + @property + def has_track_ancestor_access(self) -> bool: + """Check if the fork has ancestor tracking.""" + try: + module = self._module("state_tracker") + except ModuleNotFoundError: + return False + return hasattr(module, "track_ancestor_access") + + @property + def track_ancestor_access(self) -> Any: + """track_ancestor_access function of the fork.""" + return self._module("state_tracker").track_ancestor_access + @property def TransactionState(self) -> Any: """TransactionState class of the fork.""" diff --git a/tests/amsterdam/eip8025_optional_proofs/__init__.py b/tests/amsterdam/eip8025_optional_proofs/__init__.py new file mode 100644 index 00000000000..4438517433d --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/__init__.py @@ -0,0 +1 @@ +"""Tests for [EIP-8025: Optional Proofs](https://eips.ethereum.org/EIPS/eip-8025).""" diff --git a/tests/amsterdam/eip8025_optional_proofs/gas_helpers.py b/tests/amsterdam/eip8025_optional_proofs/gas_helpers.py new file mode 100644 index 00000000000..d8fe67c7711 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/gas_helpers.py @@ -0,0 +1,17 @@ +"""Gas helpers for Amsterdam EIP-8025 tests.""" + +from execution_testing import Fork, RecipientType + + +def empty_account_value_transfer_gas_limit(fork: Fork) -> int: + """Return the gas needed to transfer value to an empty account.""" + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + return intrinsic_gas + top_frame_state_gas diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py new file mode 100644 index 00000000000..617c841b4e8 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -0,0 +1,218 @@ +"""Helpers for `ExecutionWitness.state` tests.""" + +from collections.abc import Mapping, Sequence + +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import U256, Uint +from execution_testing import Account, Address, Alloc, Bytes, Storage +from execution_testing.forks import Amsterdam + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.amsterdam.incremental_mpt import ( + build_mpt, + mpt_get, + mpt_root, + mpt_set, +) +from ethereum.merkle_patricia_trie import EMPTY_TRIE_ROOT +from ethereum.state import ( + EMPTY_CODE_HASH, + Root, +) +from ethereum.state import ( + Account as StateAccount, +) +from ethereum.state import ( + Address as StateAddress, +) + + +def large_storage_value(slot: int) -> int: + """Return a 32-byte non-zero value so the leaf node is hashed.""" + return (1 << 255) + slot + + +def build_large_storage(slots: Sequence[int]) -> dict[int, int]: + """Build a slot->value mapping with large deterministic values.""" + return {slot: large_storage_value(slot) for slot in slots} + + +def as_storage(storage: Mapping[int, int]) -> Storage: + """Convert an int-keyed storage mapping into a `Storage`.""" + return Storage.model_validate(dict(storage.items())) + + +def _slot(slot: int) -> Bytes32: + """Convert an integer slot into a 32-byte storage key.""" + return Bytes32(slot.to_bytes(32, byteorder="big")) + + +def _storage_mpt_input(storage: Mapping[int, int]) -> dict[Bytes32, U256]: + """Convert int-based storage into the internal MPT key/value types.""" + return {_slot(slot): U256(value) for slot, value in storage.items()} + + +def _collect_storage_node_set( + storage: Mapping[int, int], + slots: Sequence[int], +) -> set[bytes]: + """Collect hashed witness nodes for the given storage proof paths.""" + storage_mpt = build_mpt( + _storage_mpt_input(storage), secured=True, default=U256(0) + ) + for slot in slots: + mpt_get(storage_mpt, _slot(slot)) + return set(storage_mpt.witness.accessed_nodes.values()) + + +def _nodes(nodes: set[bytes]) -> list[Bytes]: + """Return nodes as execution-testing bytes.""" + return [Bytes(node) for node in nodes] + + +def collect_storage_proof_nodes( + storage: Mapping[int, int], + slots: Sequence[int], +) -> list[Bytes]: + """Collect the pre-state proof nodes for the given storage slots.""" + return _nodes(_collect_storage_node_set(storage, slots)) + + +def collect_storage_delete_auxiliary_nodes( + storage: Mapping[int, int], + slot_to_delete: int, +) -> list[Bytes]: + """Collect nodes added only because a delete compressed the trie.""" + storage_mpt = build_mpt( + _storage_mpt_input(storage), secured=True, default=U256(0) + ) + mpt_get(storage_mpt, _slot(slot_to_delete)) + before = set(storage_mpt.witness.accessed_nodes.values()) + mpt_set(storage_mpt, _slot(slot_to_delete), U256(0)) + after = set(storage_mpt.witness.accessed_nodes.values()) + return _nodes(after - before) + + +def collect_storage_path_only_nodes( + storage: Mapping[int, int], + slot: int, + relative_to_slots: Sequence[int], +) -> list[Bytes]: + """Collect nodes unique to one proof path relative to others.""" + slot_nodes = _collect_storage_node_set(storage, [slot]) + reference_nodes = _collect_storage_node_set(storage, relative_to_slots) + return _nodes(slot_nodes - reference_nodes) + + +def collect_storage_post_state_only_nodes( + pre_storage: Mapping[int, int], + post_storage: Mapping[int, int], + slot: int, + pre_state_reference_slots: Sequence[int], +) -> list[Bytes]: + """Collect nodes that appear only on the post-state proof path.""" + post_state_nodes = _collect_storage_node_set(post_storage, [slot]) + pre_state_nodes = _collect_storage_node_set( + pre_storage, pre_state_reference_slots + ) + return _nodes(post_state_nodes - pre_state_nodes) + + +def find_account_with_shared_secured_nibble( + target: Address, + excluded: set[Address], +) -> Address: + """Return an occupied address that deepens one account absence proof.""" + target_nibble = keccak256(bytes(target))[0] >> 4 + for value in range(0x100, 0x1000): + address = Address(value) + if address in excluded: + continue + if (keccak256(bytes(address))[0] >> 4) == target_nibble: + return address + raise AssertionError("failed to find non-precompile sibling address") + + +def merge_with_amsterdam_pre_alloc(pre: Alloc) -> Alloc: + """Merge test-local accounts with Amsterdam's implicit pre-allocation.""" + return Alloc.merge( + Alloc.model_validate(Amsterdam.pre_allocation_blockchain()), + pre, + ) + + +def _storage_root_for_account(account: Account) -> Root: + """Compute the storage root for one execution-testing account.""" + if not account.storage.root: + return EMPTY_TRIE_ROOT + storage_mpt = build_mpt( + { + _slot(int(slot)): U256(int(value)) + for slot, value in account.storage.root.items() + }, + secured=True, + default=U256(0), + ) + return mpt_root(storage_mpt) + + +def _state_account(account: Account) -> StateAccount: + """Convert an execution-testing account into the spec account type.""" + code = bytes(account.code) + code_hash = EMPTY_CODE_HASH if len(code) == 0 else Hash32(keccak256(code)) + return StateAccount( + nonce=Uint(int(account.nonce)), + balance=U256(int(account.balance)), + code_hash=code_hash, + ) + + +def _collect_account_proof_node_rlps( + alloc: Alloc, + addresses: Sequence[StateAddress | bytes], +) -> set[bytes]: + """Collect witness node RLPs for hashed account proof-path nodes.""" + storage_roots: dict[StateAddress, Root] = {} + accounts: dict[StateAddress, StateAccount | None] = {} + + for address, account in alloc.items(): + state_address = StateAddress(bytes(address)) + if account is None: + accounts[state_address] = None + continue + storage_roots[state_address] = _storage_root_for_account(account) + accounts[state_address] = _state_account(account) + + def get_storage_root(address: StateAddress) -> Root: + return storage_roots.get(address, EMPTY_TRIE_ROOT) + + account_mpt = build_mpt( + accounts, + secured=True, + default=None, + get_storage_root=get_storage_root, + ) + for addr in addresses: + mpt_get(account_mpt, StateAddress(bytes(addr))) + return set(account_mpt.witness.accessed_nodes.values()) + + +def collect_account_proof_nodes( + alloc: Alloc, + addresses: Sequence[StateAddress | bytes], +) -> list[Bytes]: + """Collect account-trie proof nodes for the given addresses.""" + return _nodes(_collect_account_proof_node_rlps(alloc, addresses)) + + +def collect_account_path_only_nodes( + alloc: Alloc, + address: StateAddress | bytes, + relative_to_addresses: Sequence[StateAddress | bytes], +) -> list[Bytes]: + """Collect nodes unique to one account proof path relative to others.""" + address_nodes = _collect_account_proof_node_rlps(alloc, [address]) + reference_nodes = _collect_account_proof_node_rlps( + alloc, relative_to_addresses + ) + return _nodes(address_nodes - reference_nodes) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py new file mode 100644 index 00000000000..38b5fb1e3e9 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -0,0 +1,133 @@ +"""Stateless input byte validation tests.""" + +from typing import Callable + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Fork, + Transaction, +) + +from .gas_helpers import empty_account_value_transfer_gas_limit + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + +StatelessInputBytesModifier = Callable[[Bytes], Bytes] + + +def empty_input_bytes(input_bytes: Bytes) -> Bytes: + """Replace stateless input bytes with empty input.""" + del input_bytes + return Bytes(b"") + + +def incomplete_schema_id(input_bytes: Bytes) -> Bytes: + """Keep only one byte of the schema id.""" + return Bytes(input_bytes[:1]) + + +def unsupported_schema_revision(input_bytes: Bytes) -> Bytes: + """Replace the schema id with an unsupported Amsterdam revision.""" + return Bytes(b"\x15\x02" + input_bytes[2:]) + + +def unsupported_schema_fork(input_bytes: Bytes) -> Bytes: + """Replace the schema id with an unsupported fork.""" + return Bytes(b"\x16\x01" + input_bytes[2:]) + + +def missing_ssz_body(input_bytes: Bytes) -> Bytes: + """Keep only the schema id.""" + return Bytes(input_bytes[:2]) + + +def truncated_ssz_body(input_bytes: Bytes) -> Bytes: + """Drop the final SSZ body byte.""" + return Bytes(input_bytes[:-1]) + + +def trailing_garbage(input_bytes: Bytes) -> Bytes: + """Append extra bytes after the SSZ body.""" + return Bytes(input_bytes + b"\x00") + + +def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: + """ + Corrupt the first SSZ container offset. + + The stateless input starts with a 2-byte schema id, followed by the + encoded payload selected by that schema. For Amsterdam schema 0x1501, + the payload is an SSZ-encoded ``StatelessInput`` container. Its + first four SSZ bytes encode the offset to the first variable-size field. + Setting that offset to 1 makes it point inside the fixed-size section, + so the SSZ decoder must reject the input before stateless validation + can run. + """ + return Bytes(input_bytes[:2] + b"\x01\x00\x00\x00" + input_bytes[6:]) + + +def shifted_ssz_offsets(input_bytes: Bytes) -> Bytes: + """Shift every top-level offset and leave an extra byte at the end.""" + encoded = bytearray(input_bytes) + for offset in (2, 6, 18): + value = int.from_bytes(encoded[offset : offset + 4], "little") + encoded[offset : offset + 4] = (value + 1).to_bytes(4, "little") + encoded.append(0xFF) + return Bytes(bytes(encoded)) + + +@pytest.mark.parametrize( + "modifier", + [ + pytest.param(empty_input_bytes, id="empty_input_bytes"), + pytest.param(incomplete_schema_id, id="incomplete_schema_id"), + pytest.param( + unsupported_schema_revision, + id="unsupported_schema_revision", + ), + pytest.param(unsupported_schema_fork, id="unsupported_schema_fork"), + pytest.param(missing_ssz_body, id="missing_ssz_body"), + pytest.param(truncated_ssz_body, id="truncated_ssz_body"), + pytest.param(trailing_garbage, id="trailing_garbage"), + pytest.param(invalid_first_ssz_offset, id="invalid_first_ssz_offset"), + pytest.param(shifted_ssz_offsets, id="shifted_ssz_offsets"), + ], +) +def test_invalid_stateless_input_bytes_are_rejected( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, + modifier: StatelessInputBytesModifier, +) -> None: + """Invalid stateless input bytes fail guest validation.""" + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_bytes_modifier=modifier, + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_versioned_hashes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_versioned_hashes.py new file mode 100644 index 00000000000..6edff2846c5 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_versioned_hashes.py @@ -0,0 +1,298 @@ +"""Stateless input blob versioned-hash validation tests.""" + +from dataclasses import replace +from typing import Any, Callable, Tuple + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Fork, + Transaction, + add_kzg_version, +) + +from .gas_helpers import empty_account_value_transfer_gas_limit + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + +StatelessInputBytesModifier = Callable[[Bytes], Bytes] +VersionedHashesBuilder = Callable[[Tuple[Any, ...]], Tuple[Any, ...]] + +# Version byte prefixed to every blob versioned hash. +BLOB_COMMITMENT_VERSION_KZG = 1 + +# The canonical block below carries two blob transactions: the first +# declares two blobs, the second declares one. The declared versioned hash +# tuple is therefore three entries long, with the transaction boundary +# between index 1 and index 2. +FIRST_TRANSACTION_BLOB_HASHES = add_kzg_version( + [0, 1], BLOB_COMMITMENT_VERSION_KZG +) +SECOND_TRANSACTION_BLOB_HASHES = add_kzg_version( + [2], BLOB_COMMITMENT_VERSION_KZG +) +CANONICAL_BLOB_HASH_COUNT = len(FIRST_TRANSACTION_BLOB_HASHES) + len( + SECOND_TRANSACTION_BLOB_HASHES +) + +# A KZG-versioned hash no canonical blob transaction declares. +UNRELATED_BLOB_HASH = add_kzg_version([0xFF], BLOB_COMMITMENT_VERSION_KZG)[0] + + +def replace_versioned_hashes( + build_versioned_hashes: VersionedHashesBuilder, +) -> StatelessInputBytesModifier: + """ + Replace only the declared new payload request versioned hashes. + + Everything else the guest validates -- execution payload, block hash, + witness, chain config, and transaction public keys -- is preserved, and + the result is re-serialized as valid SSZ. A validation failure is + therefore attributable to the versioned hash cross-check rather than to + a decoding error or to some other validation step. + """ + + def modifier(input_bytes: Bytes) -> Bytes: + from ethereum_types.bytes import Bytes as AmsterdamBytes + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + serialize_stateless_input, + ) + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(input_bytes)) + ) + new_payload_request = stateless_input.new_payload_request + modified_input = replace( + stateless_input, + new_payload_request=replace( + new_payload_request, + versioned_hashes=build_versioned_hashes( + tuple(new_payload_request.versioned_hashes) + ), + ), + ) + return Bytes(bytes(serialize_stateless_input(modified_input))) + + return modifier + + +def assert_hash_count( + versioned_hashes: Tuple[Any, ...], + expected_count: int, +) -> None: + """ + Assert the canonical declared hash count before mutating it. + + Without this, a change that stops deriving the hashes from the block + would turn every mutation below into a silent no-op. + """ + if len(versioned_hashes) != expected_count: + raise AssertionError( + f"expected {expected_count} canonical versioned hashes, " + f"got {len(versioned_hashes)}" + ) + + +def swapped( + versioned_hashes: Tuple[Any, ...], + first: int, + second: int, +) -> Tuple[Any, ...]: + """Return the hashes with two entries exchanged.""" + swapped_hashes = list(versioned_hashes) + swapped_hashes[first], swapped_hashes[second] = ( + swapped_hashes[second], + swapped_hashes[first], + ) + return tuple(swapped_hashes) + + +def unchanged_hashes( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Rebuild the canonical hashes unchanged.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return versioned_hashes + + +def replaced_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Overwrite the first declared hash with an unrelated one.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return (UNRELATED_BLOB_HASH,) + versioned_hashes[1:] + + +def removed_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Drop the first declared hash.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return versioned_hashes[1:] + + +def extra_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Append a hash no blob transaction declares.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return versioned_hashes + (UNRELATED_BLOB_HASH,) + + +def cleared_hashes( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Declare no hashes at all despite the payload carrying blobs.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return () + + +def reordered_within_transaction( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Exchange the two hashes belonging to the first blob transaction.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return swapped(versioned_hashes, 0, 1) + + +def reordered_across_transactions( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Exchange hashes across the blob transaction boundary.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return swapped(versioned_hashes, 1, 2) + + +def sole_unrelated_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Declare one hash for a payload that carries no blob transactions.""" + assert_hash_count(versioned_hashes, 0) + return (UNRELATED_BLOB_HASH,) + + +@pytest.mark.parametrize( + "build_versioned_hashes,expected_validation_success", + [ + pytest.param(unchanged_hashes, True, id="unchanged_hashes"), + pytest.param(replaced_hash, False, id="replaced_hash"), + pytest.param(removed_hash, False, id="removed_hash"), + pytest.param(extra_hash, False, id="extra_hash"), + pytest.param(cleared_hashes, False, id="cleared_hashes"), + pytest.param( + reordered_within_transaction, + False, + id="reordered_within_transaction", + ), + pytest.param( + reordered_across_transactions, + False, + id="reordered_across_transactions", + ), + ], +) +def test_stateless_input_versioned_hashes( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, + build_versioned_hashes: VersionedHashesBuilder, + expected_validation_success: bool, +) -> None: + """ + Declared versioned hashes must match the payload's blob transactions. + + The guest recomputes the hashes from the blob transactions in the + payload and compares the ordered sequence against the hashes the + consensus layer declared. Only the unmodified sequence validates: a + changed, missing, extra, or reordered entry must be rejected, and the + reordering cases fail even though the declared multiset is unchanged. + """ + recipient = pre.fund_eoa(amount=0) + first_sender = pre.fund_eoa() + second_sender = pre.fund_eoa() + first_tx = Transaction( + ty=3, + sender=first_sender, + to=recipient, + max_fee_per_blob_gas=fork.min_base_fee_per_blob_gas(), + blob_versioned_hashes=FIRST_TRANSACTION_BLOB_HASHES, + ) + second_tx = Transaction( + ty=3, + sender=second_sender, + to=recipient, + max_fee_per_blob_gas=fork.min_base_fee_per_blob_gas(), + blob_versioned_hashes=SECOND_TRANSACTION_BLOB_HASHES, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[first_tx, second_tx], + # The rerun always goes through a modifier, including the + # unchanged case: a bytes modifier is what forces the + # filler to rerun the guest instead of trusting the + # validation flag the artifact builder may hardcode. + stateless_input_bytes_modifier=replace_versioned_hashes( + build_versioned_hashes + ), + expected_stateless_validation_success=( + expected_validation_success + ), + ) + ], + post={ + first_sender: Account(nonce=1), + second_sender: Account(nonce=1), + }, + ) + + +def test_stateless_input_versioned_hashes_without_blob_transactions( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A payload carrying no blob transactions must declare no hashes. + + This catches guests that skip the cross-check when the payload has + nothing to recompute the hashes from. + """ + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_bytes_modifier=replace_versioned_hashes( + sole_unrelated_hash + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py new file mode 100644 index 00000000000..69af99621ed --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py @@ -0,0 +1,731 @@ +"""Witness bytecode scenarios for EIP-7702 delegation.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Op, + Transaction, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.DELEGATECALL, id="delegatecall"), + pytest.param(Op.CALLCODE, id="callcode"), + pytest.param(Op.STATICCALL, id="staticcall"), + ], +) +def test_witness_codes_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Op, +) -> None: + """ + Call-type opcode targeting an EOA with pre-state delegation. + + Both the delegation marker code and the delegated contract's + bytecode appear in executionWitness.codes. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + + caller_code = call_opcode(address=delegated_eoa) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.CALLCODE, id="callcode"), + ], +) +def test_witness_codes_delegated_eoa_insufficient_balance( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Op, +) -> None: + """ + CALL/CALLCODE to a delegated EOA with value greater than caller balance. + + The call must fail and return 0, but delegation resolution still reads + both the marker code and the delegated bytecode into the witness before + the insufficient-balance early return. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(amount=0, delegation=delegate) + + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + call_opcode( + Op.GAS, + delegated_eoa, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + +def test_witness_codes_sender_delegation_marker_included( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Transaction sent from an EOA with pre-state delegation. + + The sender's delegation marker code appears in + executionWitness.codes because transaction validation reads + the sender code . + """ + delegate_code = Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_sender = pre.fund_eoa(delegation=delegate) + + recipient = pre.fund_eoa() + tx = Transaction( + sender=delegated_sender, + to=recipient, + gas_limit=500_000, + ) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(marker), + ], + ) + ), + ) + ], + post={ + delegated_sender: Account(nonce=2), + }, + ) + + +def test_witness_codes_top_level_tx_to_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Top-level transaction targeting a delegated EOA directly. + + Both the delegation marker and the delegated contract's code + appear in executionWitness.codes. This is a distinct path from + opcode-driven CALL tests because the delegation is resolved at + the top-level message-call. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=delegated_eoa, + gas_limit=500_000, + ) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_delegation_set_in_same_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Auth list sets delegation in tx1, then tx2 calls the EOA. + + The delegation marker is NOT in executionWitness.codes because + it was written in tx1. The delegated contract's + bytecode IS in codes because it is a pre-state read. + + Pre-state: + alice (plain EOA, no code) + delegate (contract with code) + + tx1 (type-4, auth list): + set_delegation(alice -> delegate) + => writes marker to alice (runtime creation of marker) + + tx2: + caller --CALL--> alice + => reads alice's marker (NOT from pre-state since created in tx1) + => reads delegate's code (pre-state => code_reads) + + Witness codes: + delegate_code IN codes (pre-state read) + marker NOT IN codes (written in tx1) + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + alice = pre.fund_eoa(amount=0) + + caller_code = Op.CALL(address=alice) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + relayer = pre.fund_eoa() + sender2 = pre.fund_eoa() + + marker = Spec7702.delegation_designation(delegate) + + tx1 = Transaction( + sender=relayer, + to=alice, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate, + nonce=0, + signer=alice, + ) + ], + ) + tx2 = Transaction( + sender=sender2, + to=caller, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1, tx2], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(delegate_code)), + ], + codes_absent=[ + Bytes(marker), + ], + ) + ), + ) + ], + post={ + alice: Account( + nonce=1, + code=marker, + ), + }, + ) + + +def test_witness_codes_redelegation_old_marker_included_new_marker_excluded( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Re-delegate an EOA that already had delegation in pre-state. + + The OLD marker IS in executionWitness.codes because + set_delegation() reads it via get_code() before overwriting. + The NEW marker is NOT in codes because it is written via + set_code(). + + Pre-state: + alice (delegated to delegate_old, marker in pre-state) + delegate_old, delegate_new (contracts with code) + + tx (type-4, auth list): + set_delegation(alice -> delegate_new) + => reads alice's old marker via get_code() (pre-state) + => writes new marker to alice via set_code() + + Witness codes: + old_marker IN codes (pre-state read) + new_marker NOT IN codes (written in this tx) + """ + delegate_old_code = Op.PUSH1(0x01) + Op.POP + Op.STOP + delegate_old = pre.deploy_contract(code=delegate_old_code) + + delegate_new_code = Op.PUSH1(0x02) + Op.POP + Op.STOP + delegate_new = pre.deploy_contract(code=delegate_new_code) + + alice = pre.fund_eoa(delegation=delegate_old) + + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Spec7702.delegation_designation(delegate_old) + new_marker = Spec7702.delegation_designation(delegate_new) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate_new, + nonce=1, + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(old_marker), + ], + codes_absent=[ + Bytes(new_marker), + ], + ) + ), + ) + ], + post={ + alice: Account( + nonce=2, + code=new_marker, + ), + }, + ) + + +@pytest.mark.parametrize( + "extcode_opcode", + [ + pytest.param("extcodesize", id="extcodesize"), + pytest.param("extcodecopy", id="extcodecopy"), + pytest.param("extcodehash", id="extcodehash"), + ], +) +def test_witness_codes_extcode_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + extcode_opcode: str, +) -> None: + """ + EXTCODE* opcode targeting a delegated EOA. + + Unlike CALL (which resolves delegation), EXTCODE* opcodes + operate on the account's own code: + + EXTCODESIZE/EXTCODECOPY: + Call get_code() on the account directly, returning the + 23-byte marker. Marker IS in witness, delegate code + is NOT. + + EXTCODEHASH: + Read code_hash from the account leaf — no get_code() + call. Neither marker nor delegate code appear in + witness. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + + if extcode_opcode == "extcodesize": + op = Op.EXTCODESIZE(delegated_eoa) + Op.POP + elif extcode_opcode == "extcodecopy": + op = Op.EXTCODECOPY(delegated_eoa, 0, 0, 23) + else: + op = Op.EXTCODEHASH(delegated_eoa) + Op.POP + + caller_code = op + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker = Spec7702.delegation_designation(delegate) + + if extcode_opcode in ("extcodesize", "extcodecopy"): + codes_present = [ + Bytes(bytes(caller_code)), + Bytes(marker), + ] + codes_absent = [Bytes(bytes(delegate_code))] + else: + codes_present = [Bytes(bytes(caller_code))] + codes_absent = [ + Bytes(marker), + Bytes(bytes(delegate_code)), + ] + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=codes_present, + codes_absent=codes_absent, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_delegation_chain( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Delegation pointing to another delegated account (chain). + + The EIP-7702 spec says "clients must retrieve only the first + code and then stop following the delegation chain." + + Pre-state: + alice --delegated--> bob --delegated--> charlie + + CALL alice: + calculate_delegation_cost() reads alice's marker + => get_code(alice) => marker_alice (pre-state) + Resolves to bob, then get_code(bob) => marker_bob + (pre-state, but NOT followed further) + + Witness codes: + marker_alice IN codes (pre-state read) + marker_bob IN codes (pre-state read) + charlie_code NOT IN codes (never reached) + """ + charlie_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + charlie = pre.deploy_contract(code=charlie_code) + + bob = pre.fund_eoa(delegation=charlie) + alice = pre.fund_eoa(delegation=bob) + + caller_code = Op.CALL(address=alice) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker_alice = Spec7702.delegation_designation(bob) + marker_bob = Spec7702.delegation_designation(charlie) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(marker_alice), + Bytes(marker_bob), + ], + codes_absent=[ + Bytes(bytes(charlie_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_reset_delegation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Reset delegation by setting address to zero. + + The EIP-7702 spec says setting address to 0x00..00 clears + the code. set_delegation() reads the authority's current + code via get_code() to check the existing delegation is + valid, so the old marker appears in witness. + + Pre-state: + alice (delegated to delegate) + + tx (type-4, auth list address=0x00..00): + set_delegation(alice -> 0x00..00) + => reads old marker via get_code() (pre-state) + => writes empty code via set_code() + + Witness codes: + old_marker IN codes (pre-state read) + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + alice = pre.fund_eoa(delegation=delegate) + + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Spec7702.delegation_designation(delegate) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=Address(0), + nonce=1, + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(old_marker)], + ) + ), + ) + ], + post={ + alice: Account( + nonce=2, + code=b"", + ), + }, + ) + + +def test_witness_codes_delegation_to_empty_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Delegation target has no code (empty account). + + The marker is read via get_code() and appears in witness. + When resolving delegation, the target's code is fetched but + has EMPTY_CODE_HASH, so get_code() returns early without + recording a code_reads entry. + + Pre-state: + alice --delegated--> empty_target (EOA, no code) + + tx to alice: + Reads alice's marker via get_code() (pre-state) + Resolves to empty_target, get_code(empty_target) + => EMPTY_CODE_HASH => returns early, no witness + + Witness codes: + marker IN codes (pre-state read) + """ + empty_target = pre.fund_eoa() + + alice = pre.fund_eoa(delegation=empty_target) + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=alice, + gas_limit=500_000, + ) + + marker = Spec7702.delegation_designation(empty_target) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(marker)], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_auth_nonce_mismatch( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Auth tuple rejected due to nonce mismatch. + + The authority nonce check happens after validating the current + authority code, so the marker must appear in the witness. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + alice = pre.fund_eoa(delegation=delegate) + + delegate_new_code = Op.PUSH1(0x99) + Op.POP + Op.STOP + delegate_new = pre.deploy_contract(code=delegate_new_code) + + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Spec7702.delegation_designation(delegate) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate_new, + nonce=99, # Just hardcode a wrong nonce to trigger the failure + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(old_marker)], + ) + ), + ) + ], + post={ + alice: Account( + nonce=1, + code=old_marker, + ), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py new file mode 100644 index 00000000000..53f2daf5b3b --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py @@ -0,0 +1,243 @@ +"""Witness bytecode collection for call variant opcodes.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Op, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.DELEGATECALL, id="delegatecall"), + pytest.param(Op.CALLCODE, id="callcode"), + pytest.param(Op.STATICCALL, id="staticcall"), + ], +) +def test_witness_codes_call_existing_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Op, +) -> None: + """ + Call an existing contract with each call variant. + + The target bytecode should appear in executionWitness.codes because + all call opcodes fetch code via get_code(). + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + if call_opcode in (Op.CALL, Op.CALLCODE): + caller_code = call_opcode(Op.GAS, target, 0, 0, 0, 0, 0) + else: + caller_code = call_opcode(Op.GAS, target, 0, 0, 0, 0) + caller_code += Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_nested_calls( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Nested calls A -> B -> C record all three bytecodes in witness codes. + + Each call depth fetches code via get_code(), so all accessed contract + bytecodes should appear in executionWitness.codes. + """ + code_c = Op.PUSH1(0x01) + Op.POP + Op.STOP + contract_c = pre.deploy_contract(code=code_c) + + code_b = Op.CALL(Op.GAS, contract_c, 0, 0, 0, 0, 0) + Op.STOP + contract_b = pre.deploy_contract(code=code_b) + + code_a = Op.CALL(Op.GAS, contract_b, 0, 0, 0, 0, 0) + Op.STOP + contract_a = pre.deploy_contract(code=code_a) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract_a, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(code_a)), + Bytes(bytes(code_b)), + Bytes(bytes(code_c)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_dedup_identical_bytecode( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Two pre-state contracts with identical bytecode, both CALLed. + + Only one copy of the bytecode should appear in executionWitness.codes + because get_witness_codes() deduplicates by code hash. + """ + shared_code = Op.SSTORE(0, 1) + Op.STOP + contract_a = pre.deploy_contract(code=shared_code) + contract_b = pre.deploy_contract(code=shared_code) + + caller_code = ( + Op.CALL(Op.GAS, contract_a, 0, 0, 0, 0, 0) + + Op.POP + + Op.CALL(Op.GAS, contract_b, 0, 0, 0, 0, 0) + + Op.POP + + Op.STOP + ) + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(shared_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract_a: Account(storage={0: 1}), + contract_b: Account(storage={0: 1}), + }, + ) + + +def test_witness_codes_reverted_transaction( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Transaction that fully reverts still records accessed code. + """ + target_code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + target = pre.deploy_contract(code=target_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=target, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(target_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + target: Account(storage={0: 0}), + }, + ) + + +def test_witness_codes_reverted_inner_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Inner CALL that reverts while outer transaction succeeds. + + The reverted callee's code must still be in executionWitness.codes. + """ + callee_code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + callee = pre.deploy_contract(code=callee_code) + + caller_code = ( + Op.CALL(Op.GAS, callee, 0, 0, 0, 0, 0) + + Op.POP + + Op.SSTORE(0, 1) + + Op.STOP + ) + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(callee_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + callee: Account(storage={0: 0}), # Check reverted storage change + caller: Account(storage={0: 1}), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py new file mode 100644 index 00000000000..18d48d4cf68 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py @@ -0,0 +1,565 @@ +"""Witness bytecode scenarios for contract creation.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Initcode, + Op, + Transaction, + compute_create_address, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_excludes_bytecode_created_in_same_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deploy a contract via CREATE with no prior code access. + + The deployed runtime code should not appear in the execution + witness. + """ + runtime_code = bytes.fromhex("deadbeef") + creator = pre.fund_eoa() + created_contract = compute_create_address(address=creator, nonce=0) + + create_tx = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[create_tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + creator: Account(nonce=1), + created_contract: Account( + nonce=1, + code=runtime_code, + ), + }, + ) + + +def test_witness_keeps_prestate_code_read_even_if_later_created_with_same_hash( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Tx1 reads pre-state code, tx2 deploys the same runtime code hash. + + The pre-state bytecode should appear in executionWitness.codes + because tx1 CALLs an existing pre-state contract with that code. + """ + runtime_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) + + existing_contract = pre.deploy_contract(code=runtime_code) + + reader = pre.fund_eoa() + creator = pre.fund_eoa() + created_contract = compute_create_address(address=creator, nonce=0) + + tx1_read_existing_code = Transaction( + sender=reader, + to=existing_contract, + gas_limit=200_000, + ) + tx2_create_same_code_hash = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[ + tx1_read_existing_code, + tx2_create_same_code_hash, + ], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + reader: Account(nonce=1), + creator: Account(nonce=1), + created_contract: Account( + nonce=1, + code=runtime_code, + ), + }, + ) + + +def test_witness_codes_create2_excludes_new_bytecode( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deploy a contract via CREATE2. + + The deployed runtime code should not appear in + executionWitness.codes because it had no pre-state match. + """ + runtime_code = bytes.fromhex("deadbeef") + salt = 0 + initcode = Initcode(deploy_code=runtime_code) + initcode_bytes = bytes(initcode) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE( + 0, + Op.CREATE2( + value=0, + offset=32 - len(initcode_bytes), + size=len(initcode_bytes), + salt=salt, + ), + ) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code) + sender = pre.fund_eoa() + + created = compute_create_address( + address=factory, + nonce=1, + salt=salt, + initcode=initcode_bytes, + opcode=Op.CREATE2, + ) + + tx = Transaction( + sender=sender, + to=factory, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(factory_code)], + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_failed_create_includes_factory( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Execute a CREATE whose initcode fails via INVALID. + + The factory contract's code should appear in executionWitness.codes + because it was read to execute the CREATE attempt, even though the + creation failed. No new code is deployed. + """ + failing_initcode = bytes(Op.INVALID) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(failing_initcode)) + + Op.SSTORE( + 0, + Op.CREATE( + offset=32 - len(failing_initcode), + size=len(failing_initcode), + ), + ) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code) + sender = pre.fund_eoa() + + tx = Transaction( + sender=sender, + to=factory, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(factory_code)], + ) + ), + ) + ], + post={ + factory: Account( + storage={0: 0}, + ), + }, + ) + + +def test_witness_codes_create_then_call_same_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Create a contract in tx1 then CALL it in tx2 of the same block. + + The created contract's code should not appear in + executionWitness.codes because it was written by tx1 thus known + at tx2 execution time. + """ + runtime_code = bytes(Op.STOP) + + creator = pre.fund_eoa() + created = compute_create_address(address=creator, nonce=0) + + caller_code = Op.CALL(address=created) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + caller2 = pre.fund_eoa() + + tx1_create = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + tx2_call = Transaction( + sender=caller2, + to=caller, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1_create, tx2_call], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(caller_code))], + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_create_same_hash_then_read( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Tx1 deploys a contract, tx2 calls a pre-state contract with same code. + + The pre-state contract's bytecode must not appear in + executionWitness.codes because the same code hash was already + written by tx1's CREATE. A stateless verifier observed + the bytecode from the CREATE transaction data, so including it + in the witness is redundant. + """ + runtime_code = bytes(Op.STOP) + + existing_contract = pre.deploy_contract(code=runtime_code) + + creator = pre.fund_eoa() + reader = pre.fund_eoa() + + tx1_create = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + tx2_read = Transaction( + sender=reader, + to=existing_contract, + gas_limit=200_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1_create, tx2_read], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + reader: Account(nonce=1), + creator: Account(nonce=1), + }, + ) + + +def test_witness_codes_create_then_call_same_tx( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Factory CREATEs a contract and then CALLs it in the same transaction. + + The newly created contract's code should not appear in + executionWitness.codes because it was read from tx-local code_writes + and has no pre-state match. + """ + runtime_code = bytes(Op.STOP) + initcode = Initcode(deploy_code=runtime_code) + initcode_bytes = bytes(initcode) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE( + 0, + Op.CREATE( + offset=32 - len(initcode_bytes), + size=len(initcode_bytes), + ), + ) + + Op.CALL(address=Op.SLOAD(0)) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code) + sender = pre.fund_eoa() + + created = compute_create_address(address=factory, nonce=1) + + tx = Transaction( + sender=sender, + to=factory, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(factory_code))], + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_initcode_calls_existing_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + CREATE where initcode CALLs a pre-state contract during deployment. + + The called contract's code should appear in executionWitness.codes. + The initcode itself should not appear because it comes from tx data + or memory and is never fetched through get_code(). + """ + callee_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) + callee = pre.deploy_contract(code=callee_code) + + runtime_code = bytes(Op.STOP) + + initcode_prefix = Op.CALL(address=callee) + Op.POP + initcode = Initcode( + deploy_code=runtime_code, + initcode_prefix=initcode_prefix, + ) + initcode_bytes = bytes(initcode) + + creator = pre.fund_eoa() + created = compute_create_address(address=creator, nonce=0) + + tx = Transaction( + sender=creator, + to=None, + data=initcode, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(callee_code)], + codes_absent=[Bytes(initcode_bytes)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_failed_create_after_initcode_read( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + CREATE where initcode CALLs a pre-state contract, then deployment fails. + + The called contract's code should appear in executionWitness.codes + because code_reads survive rollback (snapshots share the same set). + No new code is added since the deployment failed. + """ + callee_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) + callee = pre.deploy_contract(code=callee_code) + + # Initcode that calls callee then fails via INVALID opcode + initcode_body = Op.CALL(address=callee) + Op.POP + Op.INVALID + initcode = bytes(initcode_body) + + creator = pre.fund_eoa() + + tx = Transaction( + sender=creator, + to=None, + data=initcode, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(callee_code)], + ) + ), + ) + ], + post={ + creator: Account(nonce=1), + }, + ) + + +def test_witness_codes_reverted_create_same_hash_then_read( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Factory CREATEs bytecode A then REVERTs; tx2 calls pre-state contract + with also bytecode A. + + Said differently, bytecode A was observed by a contract creation execution + but since the creation fails it isn't tracked cross-tx boundary by the + state tracker. This means that tx2 access to the pre-state contract's + code hash doesn't find it thus falls back to fetching the bytecode + from pre-state and including it in the witness. + """ + runtime_code = bytes(Op.STOP) + + existing_contract = pre.deploy_contract(code=runtime_code) + + initcode = bytes(Initcode(deploy_code=runtime_code)) + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode)) + + Op.CREATE( + offset=32 - len(initcode), + size=len(initcode), + ) + # The runtime_code was observed by the CREATE execution, + # but since the CREATE fails, it isn't tracked cross-tx + # boundary by the state tracker. This means that tx2 + # access to the pre-state contract's code hash doesn't + # find it thus falls back to fetching the bytecode from + # pre-state and including it in the witness. + + Op.POP + + Op.REVERT(offset=0, size=0) + ) + factory = pre.deploy_contract(code=factory_code) + + sender1 = pre.fund_eoa() + sender2 = pre.fund_eoa() + + tx1_reverted_create = Transaction( + sender=sender1, + to=factory, + gas_limit=500_000, + ) + tx2_read = Transaction( + sender=sender2, + to=existing_contract, + gas_limit=200_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1_reverted_create, tx2_read], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(factory_code)), + Bytes(runtime_code), + ], + ) + ), + ) + ], + post={ + sender1: Account(nonce=1), + sender2: Account(nonce=1), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py new file mode 100644 index 00000000000..0ca23d3dd99 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py @@ -0,0 +1,86 @@ +"""Witness bytecode scenarios for precompiles and EOAs (negative cases).""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessCodesExpectation, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +@pytest.mark.with_all_precompiles() +def test_witness_codes_call_precompile( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, +) -> None: + """ + Send a transaction directly to a precompile. + + Precompile accounts have EMPTY_CODE_HASH, code tracking returns + early without recording a code read. The witness must contain + only system contract bytecodes — nothing for the precompile. + """ + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=precompile, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation() + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_call_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Send a value transfer directly to a plain EOA. + + EOAs with no delegations have EMPTY_CODE_HASH, so code tracking + returns early without recording a code read. The witness must + contain only system contract bytecodes — nothing for the EOA. + """ + eoa_target = pre.fund_eoa() + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=eoa_target, + value=1, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation() + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py new file mode 100644 index 00000000000..8f24e16ce02 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py @@ -0,0 +1,392 @@ +"""Witness bytecode scenarios for EXTCODE* opcodes.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Fork, + Op, + RecipientType, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_codes_extcodesize( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODESIZE on an existing contract without calling it. + + The target bytecode should appear in executionWitness.codes because + extcodesize calls get_code(), which records the code read. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodesize_empty_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODESIZE on an account with empty code (an EOA). + + The target has EMPTY_CODE_HASH, so get_code() returns early without + recording a code read. Nothing should be added to + executionWitness.codes for the target. + """ + eoa_target = pre.fund_eoa() + + caller_code = Op.EXTCODESIZE(eoa_target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(caller_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodecopy_empty_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODECOPY on an account with empty code (an EOA). + + The target has EMPTY_CODE_HASH, so get_code() returns early without + recording a code read. Nothing should be added to + executionWitness.codes for the target. + """ + eoa_target = pre.fund_eoa() + + caller_code = Op.EXTCODECOPY(eoa_target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(caller_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodecopy( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODECOPY on an existing contract without calling it. + + The target bytecode should appear in executionWitness.codes because + extcodecopy calls get_code(), which records the code read. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodecopy_zero_size( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODECOPY with size=0 on an existing contract. + + The target bytecode should appear in executionWitness.codes because + extcodecopy calls get_code() unconditionally before using size for + the memory copy. Even copying zero bytes still records the code read. + + TODO(zkevm): we will probably change this behavior since copying zero + bytes clearly doesn't need to read the code. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodehash_only( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODEHASH on an existing contract without any CALL, EXTCODESIZE, + or EXTCODECOPY. + + The target bytecode should NOT appear in executionWitness.codes + because EXTCODEHASH can read the value from the account leaf not + requiring doing a code access. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODEHASH(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + ], + codes_absent=[ + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +@pytest.mark.parametrize( + "gas_delta,expect_in_witness", + [ + pytest.param( + -1, + False, + id="oog", + ), + pytest.param( + 0, + True, + id="just_enough", + ), + ], +) +def test_witness_codes_extcodesize_cold_gas_boundary( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, + gas_delta: int, + expect_in_witness: bool, +) -> None: + """ + EXTCODESIZE at the exact gas boundary for cold account access. + + When gas is one short of covering PUSH20 + EXTCODESIZE-cold, the + opcode OOGs before reaching get_code() and the target code is NOT + recorded. With exactly enough gas the code read succeeds and the + target IS in the witness. The caller's code appears in both cases + because it was already read via get_code() when entering the call, + and code_reads survives transaction rollback. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + extcodesize_code = Op.EXTCODESIZE(target) + caller_code = extcodesize_code + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx_intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + recipient_type=RecipientType.CONTRACT, + return_cost_deducted_prior_execution=True, + ) + gas_limit = tx_intrinsic_gas + extcodesize_code.gas_cost(fork) + gas_delta + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=caller, + gas_limit=gas_limit, + ) + + codes_present = [Bytes(bytes(caller_code))] + codes_absent = [] + if expect_in_witness: + codes_present.append(Bytes(bytes(target_code))) + else: + codes_absent.append(Bytes(bytes(target_code))) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=codes_present, + codes_absent=codes_absent, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +@pytest.mark.with_all_precompiles() +@pytest.mark.parametrize( + "extcode_opcode", + [ + pytest.param("extcodesize", id="extcodesize"), + pytest.param("extcodecopy", id="extcodecopy"), + ], +) +def test_witness_codes_extcode_precompile( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, + extcode_opcode: str, +) -> None: + """ + Read code metadata of a precompile via EXTCODESIZE or EXTCODECOPY. + + Precompiles have EMPTY_CODE_HASH, so code tracking returns early + without recording a code read. The witness must contain only + the caller and system contract bytecodes — nothing for the + precompile. + """ + if extcode_opcode == "extcodesize": + op = Op.EXTCODESIZE(precompile) + Op.POP + else: + op = Op.EXTCODECOPY(precompile, 0, 0, 32) + + caller_code = op + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(caller_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py new file mode 100644 index 00000000000..c96a5370001 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py @@ -0,0 +1,353 @@ +"""Witness bytecode collection for SELFDESTRUCT.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Initcode, + Op, + Transaction, + compute_create_address, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_codes_selfdestruct( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Contract executing SELFDESTRUCT has its code in witness. + + The beneficiary is a contract whose code must NOT appear in the + witness because SELFDESTRUCT does not call get_code on the + beneficiary. + """ + sender = pre.fund_eoa() + + beneficiary_code = Op.PUSH1(0xAA) + Op.POP + Op.STOP + beneficiary = pre.deploy_contract(code=beneficiary_code) + + target_balance = 1 + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code, balance=target_balance) + + caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + codes_absent=[Bytes(bytes(beneficiary_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + beneficiary: Account(balance=target_balance), + target: Account(balance=0, code=target_code), + }, + ) + + +def test_witness_codes_selfdestruct_top_level_tx( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Top-level transaction to a selfdestructing contract. + + The target bytecode should appear in the witness, the beneficiary's + bytecode should not, and the beneficiary must receive the target's + balance to prove SELFDESTRUCT actually executed. + """ + sender = pre.fund_eoa() + + beneficiary_code = Op.PUSH1(0xBB) + Op.POP + Op.STOP + beneficiary = pre.deploy_contract(code=beneficiary_code) + + target_balance = 1 + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code, balance=target_balance) + + tx = Transaction(sender=sender, to=target, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(target_code)), + ], + codes_absent=[Bytes(bytes(beneficiary_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + beneficiary: Account(balance=target_balance), + target: Account(balance=0, code=target_code), + }, + ) + + +def test_witness_codes_create_then_selfdestruct_same_tx( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Factory CREATEs a contract then CALLs it; created contract SELFDESTRUCTs. + + The created contract was added to created_accounts by CREATE, so + SELFDESTRUCT actually deletes the account (EIP-6780). Its runtime + code should NOT appear in executionWitness.codes because get_code() + returned it from tx-local code_writes, never from pre-state. + The factory's code IS in the witness. + """ + runtime_code = bytes(Op.PUSH0 + Op.SELFDESTRUCT) + initcode = Initcode(deploy_code=runtime_code) + initcode_bytes = bytes(initcode) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE( + 0, + Op.CREATE( + offset=32 - len(initcode_bytes), + size=len(initcode_bytes), + ), + ) + + Op.CALL(address=Op.SLOAD(0)) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code, balance=10**18) + sender = pre.fund_eoa() + + created = compute_create_address(address=factory, nonce=1) + + tx = Transaction(sender=sender, to=factory, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(factory_code)), + ], + codes_absent=[ + Bytes(runtime_code), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + created: Account.NONEXISTENT, + factory: Account(storage={0: created}), + }, + ) + + +def test_witness_codes_selfdestruct_in_initcode( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Initcode that executes SELFDESTRUCT during contract creation. + + The initcode comes from tx data and must not appear in the witness. + The beneficiary's code must also stay out of the witness, while the + beneficiary balance change proves SELFDESTRUCT executed. + """ + creator = pre.fund_eoa() + + beneficiary_code = Op.PUSH1(0xCC) + Op.POP + Op.STOP + beneficiary = pre.deploy_contract(code=beneficiary_code) + + tx_value = 7 + initcode = bytes(Op.PUSH20(beneficiary) + Op.SELFDESTRUCT) + created = compute_create_address(address=creator, nonce=0) + + tx = Transaction( + sender=creator, + to=None, + data=initcode, + value=tx_value, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[ + Bytes(initcode), + Bytes(bytes(beneficiary_code)), + ], + ) + ), + ) + ], + post={ + creator: Account(nonce=1), + beneficiary: Account(balance=tx_value), + created: Account.NONEXISTENT, + }, + ) + + +def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + SELFDESTRUCT with a 7702 delegated EOA as beneficiary. + + SELFDESTRUCT does not call get_code() on the beneficiary, so the + delegation marker and the delegate's bytecode must NOT appear in + executionWitness.codes. + """ + sender = pre.fund_eoa() + + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + beneficiary_initial_balance = 1 + beneficiary = pre.fund_eoa( + amount=beneficiary_initial_balance, + delegation=delegate, + ) + marker = Spec7702.delegation_designation(delegate) + + target_balance = 1 + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code, balance=target_balance) + + caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + codes_absent=[ + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + beneficiary: Account( + balance=beneficiary_initial_balance + target_balance + ), + target: Account(balance=0, code=target_code), + }, + ) + + +@pytest.mark.parametrize( + "beneficiary_type", + [ + pytest.param("eoa", id="eoa"), + pytest.param("nonexistent", id="nonexistent"), + ], +) +def test_witness_codes_selfdestruct_beneficiary_no_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + beneficiary_type: str, +) -> None: + """ + SELFDESTRUCT where beneficiary has no code (EOA or nonexistent). + + Only system contract bytecodes, the caller's code, and the + target's code should appear in executionWitness.codes. Nothing + else should leak into the witness. + """ + sender = pre.fund_eoa() + + target_balance = 1 + beneficiary: Address + if beneficiary_type == "eoa": + beneficiary_initial_balance = 1 + beneficiary = pre.fund_eoa(amount=beneficiary_initial_balance) + else: + beneficiary_initial_balance = 0 + beneficiary = Address(0xDEAD) + + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code, balance=target_balance) + + caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + beneficiary: Account( + balance=beneficiary_initial_balance + target_balance + ), + target: Account(balance=0, code=target_code), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py new file mode 100644 index 00000000000..e0408a149c4 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py @@ -0,0 +1,39 @@ +"""Witness bytecode scenarios for system contracts.""" + +import pytest +from execution_testing import ( + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessCodesExpectation, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_codes_empty_block_has_system_contracts( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Verify an empty block contains only system contract bytecodes. + + System contract codes are automatically added to codes_present + by the testing framework, so an empty expectation is sufficient. + The exhaustiveness check ensures no extra codes appear. + """ + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation() + ), + ) + ], + post={}, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py new file mode 100644 index 00000000000..b5f344c96b7 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py @@ -0,0 +1,395 @@ +"""Witness header collection border-case tests.""" + +from copy import deepcopy + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTest, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, +) +from execution_testing.client_clis import TransitionTool +from execution_testing.fixtures import BlockchainFixture +from execution_testing.fixtures.blockchain import FixtureBlock +from execution_testing.forks import Amsterdam +from execution_testing.test_types.execution_witness.modifiers import ( + prepend_header, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_headers_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test witness headers for an empty block (no user transactions). + + The only ancestor tracking comes from the EIP-2935 system contract + which unconditionally records offset = 1 (parent header). + """ + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=1, + ) + ), + ), + ], + post={}, + ) + + +@pytest.mark.parametrize("offset", [1, 2, 5, 10]) +def test_witness_headers_blockhash_at_offset( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + offset: int, +) -> None: + """ + Test witness headers when BLOCKHASH queries a block at a given offset. + + offset = 1 matches the EIP-2935 baseline. + offset > 1 verifies BLOCKHASH extends oldest_ancestor_offset beyond + the system-contract baseline. + """ + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "queried_block_code", + [ + pytest.param(Op.NUMBER, id="current_block"), + pytest.param(Op.ADD(Op.NUMBER, 1), id="future_block"), + ], +) +def test_witness_headers_blockhash_out_of_range( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + queried_block_code: Op, +) -> None: + """ + Test witness headers when BLOCKHASH queries an out-of-range block. + + BLOCKHASH returns 0 for the current or future block numbers, so + track_ancestor_access is never called by the opcode. Only the + EIP-2935 system-contract offset = 1 remains. + """ + code = Op.BLOCKHASH(queried_block_code) + Op.POP + Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=1, + ) + ), + ), + ], + post={sender: Account(nonce=1)}, + ) + + +def test_witness_headers_blockhash_in_reverted_tx( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test witness headers survive a full transaction revert. + """ + offset = 5 + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.REVERT(0, 0) + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "offsets", + [ + pytest.param([2, 8], id="ascending"), + pytest.param([8, 2], id="descending"), + pytest.param([3, 3], id="same_twice"), + ], +) +def test_witness_headers_multiple_blockhash_max_wins( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + offsets: list[int], +) -> None: + """ + Test that the maximum BLOCKHASH offset wins. + + Multiple BLOCKHASH calls in one contract: the ascending and + descending cases prove order-independence. The same_twice case + confirms idempotent tracking. + """ + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offsets[0])) + Op.POP + for o in offsets[1:]: + code += Op.BLOCKHASH(Op.SUB(Op.NUMBER, o)) + Op.POP + code += Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + expected_count = max(offsets) + blocks = [Block(txs=[]) for _ in range(expected_count)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=expected_count, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_witness_headers_max_wins_across_multiple_transactions( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + The deepest BLOCKHASH across all transactions drives the witness. + """ + offset_small = 2 + offset_large = 5 + + contract_small = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset_small)) + Op.POP + Op.STOP + ) + contract_large = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset_large)) + Op.POP + Op.STOP + ) + + sender_small = pre.fund_eoa() + sender_large = pre.fund_eoa() + tx_small = Transaction( + sender=sender_small, + to=contract_small, + gas_limit=500_000, + ) + tx_large = Transaction( + sender=sender_large, + to=contract_large, + gas_limit=500_000, + ) + + blocks = [Block(txs=[]) for _ in range(offset_large)] + blocks.append( + Block( + txs=[tx_small, tx_large], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset_large, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={ + sender_small: Account(nonce=1), + sender_large: Account(nonce=1), + }, + ) + + +def test_witness_headers_blockhash_in_reverted_inner_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Header access in a reverted inner call should still be witnessed. + """ + offset = 5 + callee = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.REVERT(0, 0) + ) + caller = pre.deploy_contract( + code=Op.CALL(Op.GAS, callee, 0, 0, 0, 0, 0) + Op.POP + Op.STOP + ) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_witness_headers_extra_unused_older_ancestor( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + t8n: TransitionTool, +) -> None: + """ + A contiguous extra older ancestor should still validate. + """ + offset = 3 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + post = {sender: Account(nonce=1)} + + probe_fixture = ( + BlockchainTest( + fork=Amsterdam, + pre=deepcopy(pre), + blocks=[Block(txs=[])], + post={}, + ) + .generate(t8n=t8n, fixture_format=BlockchainFixture) + .fixture + ) + assert isinstance(probe_fixture, BlockchainFixture) + probe_block = probe_fixture.blocks[0] + assert isinstance(probe_block, FixtureBlock) + extra_header = probe_block.header.rlp + + blocks = [Block(txs=[]) for _ in range(offset + 1)] + blocks.append( + Block( + txs=[Transaction(sender=sender, to=contract, gas_limit=500_000)], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(prepend_header(extra_header)) + ), + expected_stateless_validation_success=True, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post=post, + ) + + +@pytest.mark.slow +@pytest.mark.parametrize( + "offset,expected_count", + [ + pytest.param(256, 256, id="max_valid"), + pytest.param(257, 1, id="first_invalid"), + ], +) +def test_witness_headers_blockhash_boundary( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + offset: int, + expected_count: int, +) -> None: + """ + Test witness headers at the exact boundary of the 256-block window. + + At offset = 256 the BLOCKHASH range check passes and all 256 + headers appear. At offset = 257 the check fails, BLOCKHASH + returns 0, no tracking occurs, and only the EIP-2935 parent + header remains. + """ + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=expected_count, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py new file mode 100644 index 00000000000..901a0d9447c --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py @@ -0,0 +1,215 @@ +"""Stateless input transaction public-key tests.""" + +import pytest +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import ( + Prehashed, + encode_dss_signature, +) +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Transaction, +) +from execution_testing.test_types.execution_witness.modifiers import ( + replace_public_key_at, +) +from spec256k1 import PublicKey + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_stateless_input_public_keys_are_constructed( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A public key is included for each payload transaction.""" + recipient = pre.fund_eoa() + sender_a = pre.fund_eoa() + sender_b = pre.fund_eoa() + tx_a = Transaction( + sender=sender_a, + to=recipient, + value=0, + gas_limit=500_000, + ) + tx_b = Transaction( + sender=sender_b, + to=recipient, + value=0, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx_a, tx_b], + # For accepted blocks, the filler verifies stateless input + # public keys against the recovered payload transaction keys. + expected_stateless_validation_success=True, + ) + ], + post={ + sender_a: Account(nonce=1), + sender_b: Account(nonce=1), + }, + ) + + +def test_stateless_input_invalid_public_key_is_rejected( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A wrong but SSZ-valid public key fails stateless validation.""" + recipient = pre.fund_eoa() + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=recipient, + value=0, + gas_limit=500_000, + ) + invalid_public_key = Bytes(b"\x04" + b"\x00" * 64) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_public_keys_modifier=( + replace_public_key_at(0, invalid_public_key) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_stateless_input_opposite_y_parity_public_key_is_rejected( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + An ECDSA-valid key from the other recovery candidate is rejected. + + This catches implementations that only verify the supplied key against + ``(r, s, message_hash)`` without also binding it to the transaction's + y-parity bit. + """ + recipient = pre.fund_eoa() + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=recipient, + value=0, + gas_limit=21_000, + max_fee_per_gas=10, + max_priority_fee_per_gas=0, + ).with_signature_and_sender() + invalid_public_key = _opposite_y_parity_public_key(tx) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_public_keys_modifier=( + replace_public_key_at(0, invalid_public_key) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def _recover_public_key( + tx: Transaction, + y_parity: int, + signing_hash: bytes, +) -> Bytes: + """Recover an uncompressed SEC1 public key for ``y_parity``.""" + signature = ( + int(tx.r).to_bytes(32, byteorder="big") + + int(tx.s).to_bytes(32, byteorder="big") + + bytes([y_parity]) + ) + public_key = PublicKey.from_signature_and_message( + signature, + signing_hash, + ) + return Bytes(public_key.format(compressed=False)) + + +def _opposite_y_parity_public_key(tx: Transaction) -> Bytes: + """Recover the other ECDSA-valid public key for a typed transaction.""" + signed_tx = tx.with_signature_and_sender() + if int(signed_tx.ty) == 0: + raise AssertionError("expected a typed transaction") + + y_parity = int(signed_tx.v) + if y_parity not in (0, 1): + raise AssertionError(f"expected y_parity 0 or 1, got {y_parity}") + + signing_hash = bytes(signed_tx.rlp_signing_bytes().keccak256()) + canonical_public_key = _recover_public_key( + signed_tx, + y_parity, + signing_hash, + ) + alternate_public_key = _recover_public_key( + signed_tx, + y_parity ^ 1, + signing_hash, + ) + if alternate_public_key == canonical_public_key: + raise AssertionError("alternate recovery id produced canonical key") + if _address_from_public_key(canonical_public_key) != signed_tx.sender: + raise AssertionError("canonical public key does not derive sender") + if _address_from_public_key(alternate_public_key) == signed_tx.sender: + raise AssertionError("alternate public key derives sender") + if not _signature_verifies(signed_tx, alternate_public_key, signing_hash): + raise AssertionError("alternate public key does not verify signature") + return alternate_public_key + + +def _address_from_public_key(public_key: Bytes) -> Address: + """Derive the sender address from an uncompressed SEC1 public key.""" + return Address(Bytes(public_key[1:]).keccak256()[12:]) + + +def _signature_verifies( + tx: Transaction, + public_key: Bytes, + signing_hash: bytes, +) -> bool: + """Return whether ``public_key`` verifies the transaction signature.""" + der_signature = encode_dss_signature(int(tx.r), int(tx.s)) + verifying_key = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256K1(), + bytes(public_key), + ) + try: + verifying_key.verify( + der_signature, + signing_hash, + ec.ECDSA(Prehashed(hashes.SHA256())), + ) + except InvalidSignature: + return False + return True diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py new file mode 100644 index 00000000000..a28e7a30945 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py @@ -0,0 +1,255 @@ +"""Witness state collection scenarios for storage deletes.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_delete_auxiliary_nodes, + collect_storage_path_only_nodes, + collect_storage_post_state_only_nodes, + collect_storage_proof_nodes, + large_storage_value, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_sstore_delete_branch_collapse_adds_auxiliary_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting slot 1 from the `{1, 2}` trie shape forces branch collapse. + + The witness should contain both the normal proof for slot 1 and the + auxiliary node needed to preserve the untouched sibling subtree. + """ + storage = build_large_storage([1, 2]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert proof_nodes + # Double check the auxiliary node after the deletion resulted in + # exactly one MPT node. + assert len(auxiliary_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes + auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2]}), + }, + ) + + +def test_witness_state_sstore_delete_without_collapse_omits_sibling_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting slot 1 from the `{1, 2, 3}` trie shape does not collapse. + + The untouched sibling paths should remain absent from the witness. + """ + storage = build_large_storage([1, 2, 3]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + slot_2_only_nodes = collect_storage_path_only_nodes(storage, 2, [1]) + slot_3_only_nodes = collect_storage_path_only_nodes(storage, 3, [1]) + assert proof_nodes + assert not auxiliary_nodes + assert slot_2_only_nodes + assert slot_3_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=slot_2_only_nodes + slot_3_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2], 3: storage[3]}), + }, + ) + + +def test_witness_state_sstore_delete_only_slot_keeps_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting the only slot should keep its pre-state proof and empty storage. + """ + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert proof_nodes + assert not auxiliary_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={}), + }, + ) + + +def test_witness_state_delete_with_new_dirty_sibling_omits_post_state_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A sibling created before branch collapse is dirty and should not leak. + + The witness still needs the pre-state delete proof for slot 1 and the + pre-state absence proof for slot 2, but it must not include the node + created only after slot 2 is inserted during execution. + """ + pre_storage = build_large_storage([1]) + post_storage = {2: large_storage_value(2)} + proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=2, + pre_state_reference_slots=[1, 2], + ) + assert proof_nodes + assert len(post_state_only_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_delete_with_modified_dirty_sibling_omits_post( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A pre-state sibling that becomes dirty before collapse should not leak. + + The delete still requires the pre-state proof material, but the updated + surviving child is dirty and must not be re-recorded as auxiliary. + """ + pre_storage = build_large_storage([1, 2]) + post_storage = {2: large_storage_value(9)} + proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=2, + pre_state_reference_slots=[1, 2], + ) + assert proof_nodes + assert len(post_state_only_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py new file mode 100644 index 00000000000..e0141ce82d6 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py @@ -0,0 +1,56 @@ +"""Witness state collection scenarios for structural invariants.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Fork, + Transaction, +) + +from .gas_helpers import empty_account_value_transfer_gas_limit + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_structural_invariants( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A simple transfer is enough to validate the shared state invariants. + + The expectation object always checks for duplicate entries and sorted + order even when no explicit state nodes are listed. + """ + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation() + ), + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py new file mode 100644 index 00000000000..178ffbe503c --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py @@ -0,0 +1,241 @@ +"""Witness state collection scenarios for state reads.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_account_proof_nodes, + collect_storage_path_only_nodes, + collect_storage_proof_nodes, + merge_with_amsterdam_pre_alloc, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_sload_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """SLOAD should include the pre-state proof for the loaded slot.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_reverted_sload_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A reverted SLOAD should still leave its proof nodes in witness state.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.REVERT(0, 0), + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_reverted_inner_sload_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A reverted inner-call SLOAD should still leave its proof nodes in witness. + """ + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + callee = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.REVERT(0, 0), + storage=as_storage(storage), + ) + caller = pre.deploy_contract( + code=Op.CALL(Op.GAS, callee, 0, 0, 0, 0, 0) + Op.POP + Op.STOP, + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + callee: Account(storage=storage), + }, + ) + + +def test_witness_state_failed_call_still_contains_target_account_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A failed CALL should still include the target account proof.""" + target = pre.fund_eoa() + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + Op.CALL( + Op.GAS, + target, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + sender = pre.fund_eoa() + full_alloc = merge_with_amsterdam_pre_alloc(pre) + proof_nodes = collect_account_proof_nodes(full_alloc, [target]) + assert proof_nodes + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + +def test_witness_state_sload_absent_slot_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + SLOAD of an absent slot should include the pre-state absence proof. + + Use a multi-slot trie so the absent-slot path is meaningfully different + from a single-leaf root. + """ + storage = build_large_storage([1, 2]) + absent_slot = 3 + proof_nodes = collect_storage_proof_nodes(storage, [absent_slot]) + slot_1_only_nodes = collect_storage_path_only_nodes( + storage, 1, [absent_slot] + ) + slot_2_only_nodes = collect_storage_path_only_nodes( + storage, 2, [absent_slot] + ) + existing_slot_only_nodes = slot_1_only_nodes + slot_2_only_nodes + assert proof_nodes + assert len(slot_1_only_nodes) == 1 + assert len(slot_2_only_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SLOAD(absent_slot) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=existing_slot_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py new file mode 100644 index 00000000000..4855e07ebdc --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py @@ -0,0 +1,185 @@ +"""Witness state collection scenarios for storage replay ordering.""" + +import pytest +from ethereum_types.bytes import Bytes32 +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Conditional, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from ethereum.crypto.hash import keccak256 + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_delete_auxiliary_nodes, + collect_storage_proof_nodes, + large_storage_value, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def _secured_storage_key(slot: int) -> bytes: + """Return the secured trie key used for a storage slot.""" + return keccak256(Bytes32(slot.to_bytes(32, byteorder="big"))) + + +def test_witness_state_delete_then_insert_uses_insert_before_delete_order( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EL post-state root calculation must insert before deleting. + + Deleting slot 1 from `{1, 2}` would normally require the clean sibling + auxiliary node for slot 2. Computing the correct post-state root avoids + that collapse by replaying the slot 3 insertion before the slot 1 + deletion. The chosen slots also catch buggy key-sorted replay: slot 1's + secured trie key sorts before slot 3's, so sorting still deletes too + early. + """ + delete_slot = 1 + preserved_slot = 2 + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + pre_storage = build_large_storage([delete_slot, preserved_slot]) + post_storage = { + preserved_slot: pre_storage[preserved_slot], + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes( + pre_storage, [delete_slot, insert_slot] + ) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes( + pre_storage, delete_slot + ) + assert proof_nodes + assert len(auxiliary_nodes) == 1 + assert not set(proof_nodes) & set(auxiliary_nodes) + # This slot choice also catches clients that sort secured trie keys: + # slot 1 sorts before slot 3, so a buggy key-sorted replay still + # deletes before it inserts. + assert _secured_storage_key(delete_slot) < _secured_storage_key( + insert_slot + ) + + contract = pre.deploy_contract( + code=Op.SSTORE(delete_slot, 0) + + Op.SSTORE(insert_slot, insert_value) + + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_block_diff_delete_insert_before_delete_order( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Reproduces the same post-state-root ordering case as the + single-transaction test, but across transactions. + + The block-level diff records slot 1 before slot 3 because the delete + transaction executes first. Even so, EL post-state root calculation + must still insert slot 3 before deleting slot 1. The chosen slots also + catch buggy key-sorted replay because slot 1's secured trie key sorts + before slot 3's. + """ + delete_slot = 1 + preserved_slot = 2 + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + pre_storage = build_large_storage([delete_slot, preserved_slot]) + post_storage = { + preserved_slot: pre_storage[preserved_slot], + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes( + pre_storage, [delete_slot, insert_slot] + ) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes( + pre_storage, delete_slot + ) + assert proof_nodes + assert len(auxiliary_nodes) == 1 + assert not set(proof_nodes) & set(auxiliary_nodes) + # This slot choice also catches clients that sort secured trie keys: + # slot 1 sorts before slot 3, so a buggy key-sorted replay still + # deletes before it inserts. + assert _secured_storage_key(delete_slot) < _secured_storage_key( + insert_slot + ) + + contract = pre.deploy_contract( + code=Conditional( + condition=Op.EQ(Op.CALLDATALOAD(0), 0), + if_true=Op.SSTORE(delete_slot, 0), + if_false=Op.SSTORE(insert_slot, insert_value), + ) + + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx_delete = Transaction( + sender=sender, + to=contract, + gas_limit=500_000, + nonce=0, + ) + tx_insert = Transaction( + sender=sender, + to=contract, + gas_limit=500_000, + data=(1).to_bytes(32, byteorder="big"), + nonce=1, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx_delete, tx_insert], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=2), + contract: Account(storage=post_storage), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py new file mode 100644 index 00000000000..38606068e6d --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py @@ -0,0 +1,206 @@ +"""Witness state collection scenarios for storage writes.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_post_state_only_nodes, + collect_storage_proof_nodes, + large_storage_value, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_sstore_without_explicit_read_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Dirty storage writes should still include the pre-state proof.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + new_value = large_storage_value(9) + contract = pre.deploy_contract( + code=Op.SSTORE(1, new_value) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={1: new_value}), + }, + ) + + +def test_witness_state_reverted_sstore_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A reverted SSTORE should still leave its proof nodes in witness. + """ + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + new_value = large_storage_value(9) + contract = pre.deploy_contract( + code=Op.SSTORE(1, new_value) + Op.REVERT(0, 0), + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_sstore_new_slot_omits_post_state_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Inserting a new slot should include the pre-state absence proof only. + + Nodes created solely by the post-state insertion must not leak into the + witness. + """ + pre_storage = build_large_storage([1, 2]) + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + post_storage = { + **pre_storage, + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=insert_slot, + pre_state_reference_slots=[insert_slot], + ) + assert proof_nodes + assert post_state_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_sstore_into_empty_storage_omits_post_state_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Empty pre-state storage should not require any storage proof nodes. + + The empty-trie RLP sentinel and nodes created solely by the insertion + are not pre-state material and must not appear in the witness. + """ + insert_slot = 1 + insert_value = large_storage_value(insert_slot) + pre_storage: dict[int, int] = {} + post_storage = {insert_slot: insert_value} + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=insert_slot, + pre_state_reference_slots=[insert_slot], + ) + assert not proof_nodes + assert post_state_only_nodes + empty_trie_sentinel = Bytes(b"\x80") + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_absent=post_state_only_nodes + + [empty_trie_sentinel], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_id.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_id.py new file mode 100644 index 00000000000..df38b076f33 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_id.py @@ -0,0 +1,97 @@ +"""Stateless chain-ID validation tests.""" + +from dataclasses import replace +from typing import Any, Callable + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Fork, + Transaction, +) + +from .gas_helpers import empty_account_value_transfer_gas_limit + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + +StatelessInputBytesModifier = Callable[[Bytes], Bytes] +ChainIdBuilder = Callable[[Any], Any] + + +def replace_chain_id( + build_chain_id: ChainIdBuilder, +) -> StatelessInputBytesModifier: + """Replace only the decoded stateless input chain ID.""" + + def modifier(input_bytes: Bytes) -> Bytes: + from ethereum_types.bytes import Bytes as AmsterdamBytes + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + serialize_stateless_input, + ) + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(input_bytes)) + ) + modified_input = replace( + stateless_input, + chain_id=build_chain_id(stateless_input), + ) + return Bytes(bytes(serialize_stateless_input(modified_input))) + + return modifier + + +def wrong_chain_id(stateless_input: Any) -> Any: + """Change chain_id from 1 to 2.""" + from ethereum_types.numeric import U64 + + if int(stateless_input.chain_id) != 1: + raise AssertionError( + f"expected canonical chain_id 1, got {stateless_input.chain_id}" + ) + return U64(2) + + +def test_validation_wrong_chain_id_legacy_signature( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A protected legacy signature for chain 1 fails under chain 2.""" + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction( + chain_id=1, + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_bytes_modifier=replace_chain_id( + wrong_chain_id + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_codes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_codes.py new file mode 100644 index 00000000000..5d98d0665d2 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_codes.py @@ -0,0 +1,459 @@ +"""Execution witness code validation tests.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Op, + Transaction, +) +from execution_testing.test_types.execution_witness.modifiers import ( + add_code, + remove_code, + remove_code_at, + reverse_codes, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_validation_codes_missing_current_frame_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the currently executing contract's code should fail.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + caller_code_bytes = Bytes(bytes(caller_code)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + caller_code_bytes, + Bytes(bytes(target_code)), + ], + ).modify(remove_code(caller_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_validation_codes_missing_external_code_read_target( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing externally read code should fail guest execution.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + target_code_bytes = Bytes(bytes(target_code)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + target_code_bytes, + ], + ).modify(remove_code(target_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_validation_codes_missing_implicit_system_contract_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Removing implicit system-contract code from an empty block should fail. + """ + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation().modify( + remove_code_at(0) + ) + ), + expected_stateless_validation_success=False, + ) + ], + post={}, + ) + + +def test_validation_codes_missing_7702_delegation_marker( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing a pre-state 7702 delegation marker should fail.""" + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=delegated_eoa, + gas_limit=500_000, + ) + + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + marker, + Bytes(bytes(delegate_code)), + ], + ).modify(remove_code(marker)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_validation_codes_missing_7702_delegated_target_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing delegated target code from a 7702 flow should fail.""" + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=delegated_eoa, + gas_limit=500_000, + ) + + delegate_code_bytes = Bytes(bytes(delegate_code)) + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + marker, + delegate_code_bytes, + ], + ).modify(remove_code(delegate_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_validation_codes_missing_sender_delegation_marker( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the sender's delegation marker should fail.""" + delegate = pre.deploy_contract(code=Op.STOP) + delegated_sender = pre.fund_eoa(delegation=delegate) + + recipient = pre.fund_eoa() + tx = Transaction( + sender=delegated_sender, + to=recipient, + gas_limit=500_000, + ) + + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[marker], + ).modify(remove_code(marker)) + ), + expected_stateless_validation_success=False, + ) + ], + post={delegated_sender: Account(nonce=2)}, + ) + + +def test_validation_codes_missing_redelegation_old_marker( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the old marker read during re-delegation should fail.""" + delegate_old = pre.deploy_contract(code=Op.PUSH1(0x01) + Op.POP + Op.STOP) + delegate_new = pre.deploy_contract(code=Op.PUSH1(0x02) + Op.POP + Op.STOP) + + alice = pre.fund_eoa(delegation=delegate_old) + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Bytes(Spec7702.delegation_designation(delegate_old)) + new_marker = Spec7702.delegation_designation(delegate_new) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate_new, + nonce=1, + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[old_marker], + codes_absent=[Bytes(new_marker)], + ).modify(remove_code(old_marker)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + alice: Account( + nonce=2, + code=new_marker, + ), + }, + ) + + +def test_validation_codes_missing_delegated_code_on_insufficient_balance_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing delegated code on an insufficient-balance CALL should fail.""" + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + delegated_eoa = pre.fund_eoa(amount=0, delegation=delegate) + + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + Op.CALL( + Op.GAS, + delegated_eoa, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + delegate_code_bytes = Bytes(bytes(delegate_code)) + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + marker, + delegate_code_bytes, + ], + ).modify(remove_code(delegate_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + +def test_validation_codes_missing_second_marker_in_delegation_chain( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the second marker in a delegation chain should fail.""" + charlie_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + charlie = pre.deploy_contract(code=charlie_code) + + bob = pre.fund_eoa(delegation=charlie) + alice = pre.fund_eoa(delegation=bob) + + caller_code = Op.CALL(address=alice) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker_alice = Bytes(Spec7702.delegation_designation(bob)) + marker_bob = Bytes(Spec7702.delegation_designation(charlie)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + marker_alice, + marker_bob, + ], + codes_absent=[ + Bytes(bytes(charlie_code)), + ], + ).modify(remove_code(marker_bob)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_validation_codes_extra_unused_bytecode( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Adding an unused bytecode preimage should still validate.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + unused_code = Bytes(bytes(Op.PUSH1(0x99) + Op.PUSH1(0x01) + Op.STOP)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ).modify(add_code(unused_code)) + ), + expected_stateless_validation_success=True, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_validation_codes_unsorted_but_complete( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Reordering complete witness codes should still validate.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ).modify(reverse_codes()) + ), + expected_stateless_validation_success=True, + ) + ], + post={sender: Account(nonce=1)}, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_headers.py new file mode 100644 index 00000000000..7c3c580d89d --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_headers.py @@ -0,0 +1,174 @@ +"""Execution witness header validation tests.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, +) +from execution_testing.test_types.execution_witness.modifiers import ( + clear_headers, + remove_header_at, + replace_header_at, + reverse_headers, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_validation_headers_missing_parent_header( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the parent header from the witness should fail.""" + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(-1)) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_validation_headers_missing_oldest_blockhash_ancestor( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the oldest required BLOCKHASH ancestor should fail.""" + offset = 5 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(0)) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_validation_headers_non_contiguous_chain( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Reordering headers into a non-contiguous chain should fail.""" + offset = 5 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(reverse_headers()) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_validation_headers_empty_block_missing_mandatory_parent( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the mandatory parent header from an empty block should fail.""" + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=1, + ).modify(clear_headers()) + ), + expected_stateless_validation_success=False, + ) + ], + post={}, + ) + + +def test_validation_headers_malformed_rlp_header( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Replacing a required header with malformed RLP should fail.""" + offset = 5 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(replace_header_at(-1, Bytes(b"\xff"))) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py new file mode 100644 index 00000000000..bb333d9fb1a --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -0,0 +1,429 @@ +"""Execution witness state validation tests.""" + +import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes as TrieBytes +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessStateExpectation, + Fork, + Op, + Transaction, +) +from execution_testing.forks import Amsterdam +from execution_testing.test_types.execution_witness.modifiers import ( + add_state_node, + remove_state_node, + reverse_state_nodes, +) + +from ethereum.forks.amsterdam.incremental_mpt import compact_to_nibbles + +from .gas_helpers import empty_account_value_transfer_gas_limit +from .state_helpers import ( + as_storage, + build_large_storage, + collect_account_path_only_nodes, + collect_account_proof_nodes, + collect_storage_delete_auxiliary_nodes, + collect_storage_proof_nodes, + find_account_with_shared_secured_nibble, + large_storage_value, + merge_with_amsterdam_pre_alloc, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def _required_node(nodes: list[Bytes]) -> Bytes: + """Pick one required trie node from a proof set.""" + assert nodes + return sorted(nodes)[0] + + +def _leaf_node(nodes: list[Bytes]) -> Bytes: + """Pick the unique leaf node from a proof set.""" + leaves: list[Bytes] = [] + for node in nodes: + decoded = rlp.decode(bytes(node)) + if not isinstance(decoded, list) or len(decoded) != 2: + continue + path_bytes = decoded[0] + assert isinstance(path_bytes, (bytes, bytearray)) + _, is_leaf = compact_to_nibbles(TrieBytes(path_bytes)) + if is_leaf: + leaves.append(node) + + assert len(leaves) == 1 + return leaves[0] + + +def test_validation_state_missing_storage_proof_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing a required storage proof node should fail the guest.""" + read_slot = 1 + write_slot = 2 + storage = build_large_storage([read_slot]) + proof_nodes = collect_storage_proof_nodes(storage, [read_slot]) + removed_node = _required_node(proof_nodes) + + contract = pre.deploy_contract( + code=Op.SSTORE(write_slot, Op.SLOAD(read_slot)) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + post_storage = storage | {write_slot: storage[read_slot]} + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_validation_state_missing_absent_slot_proof_leaf_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the absent-slot proof leaf should fail insertion.""" + # The contract will insert a value to a non-existent slot 1. + + # These slots produce a multi-node absence proof for slot 1: + # + # keccak(1) -> b1... + # keccak(24) -> b1... + # keccak(14) -> bx... (x != 1) + # + # ext("b") (root) + # | + # branch + # / \ + # [x] [1] + # | | + # leaf leaf + # (14) (24) + # ^ + # | + # absent slot 1 follows this edge, then diverges inside the leaf + # + # The proof for slot 1 is therefore `extension -> branch -> leaf`. + # Removing that leaf makes the absence proof invalid due to missing + # data. + pre_storage = build_large_storage([14, 24]) + insert_slot = 1 + insert_value = large_storage_value(insert_slot) + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + assert len(proof_nodes) == 3 + removed_node = _leaf_node(proof_nodes) + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account( + storage={**pre_storage, insert_slot: insert_value}, + ), + }, + ) + + +def test_validation_state_missing_delete_auxiliary_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the delete-collapse auxiliary node should fail.""" + storage = build_large_storage([1, 2]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert len(auxiliary_nodes) == 1 + removed_node = auxiliary_nodes[0] + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes + auxiliary_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2]}), + }, + ) + + +def test_validation_state_missing_sender_account_proof_leaf_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the sender account proof leaf should fail a transfer.""" + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=1) + full_alloc = merge_with_amsterdam_pre_alloc(pre) + proof_nodes = collect_account_proof_nodes(full_alloc, [sender, recipient]) + sender_only_nodes = collect_account_path_only_nodes( + full_alloc, + sender, + [recipient, *Amsterdam.execution_witness_implicit_code_addresses()], + ) + assert len(sender_only_nodes) == 1 + removed_node = _leaf_node(sender_only_nodes) + + tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=2), + }, + ) + + +def test_validation_state_missing_absent_account_proof_node( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing a recipient-only absent-account proof node should fail.""" + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + # Add one untouched sibling under the recipient's secured-trie prefix so + # the absence proof extends below the shared root node. + sibling = find_account_with_shared_secured_nibble( + recipient, + { + sender, + recipient, + *Amsterdam.execution_witness_implicit_code_addresses(), + }, + ) + pre.fund_address(sibling, 1) + full_alloc = merge_with_amsterdam_pre_alloc(pre) + recipient_only_nodes = sorted( + collect_account_path_only_nodes( + full_alloc, + recipient, + [sender, *Amsterdam.execution_witness_implicit_code_addresses()], + ) + ) + assert recipient_only_nodes + proof_nodes = collect_account_proof_nodes(full_alloc, [recipient]) + removed_node = _required_node(recipient_only_nodes) + + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) + + +def test_validation_state_missing_failed_call_target_account_proof_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the failed CALL target proof should fail witness replay.""" + target = pre.fund_eoa() + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + Op.CALL( + Op.GAS, + target, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + sender = pre.fund_eoa() + full_alloc = merge_with_amsterdam_pre_alloc(pre) + proof_nodes = collect_account_proof_nodes(full_alloc, [target]) + assert proof_nodes + removed_node = _leaf_node(proof_nodes) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + +def test_validation_state_extra_unused_trie_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Adding an unused state node should still validate.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(add_state_node(Bytes(b"\x81\x99"))) + ), + expected_stateless_validation_success=True, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_validation_state_unsorted_but_complete( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Reordering a complete state witness should still validate.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(reverse_state_nodes()) + ), + expected_stateless_validation_success=True, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) diff --git a/tests/json_loader/test_execution_requests.py b/tests/json_loader/test_execution_requests.py new file mode 100644 index 00000000000..767d3e14e7d --- /dev/null +++ b/tests/json_loader/test_execution_requests.py @@ -0,0 +1,145 @@ +"""Tests for Amsterdam typed execution request codecs.""" + +import pytest +from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 +from ethereum_types.numeric import U64 + +from ethereum.exceptions import InvalidBlock +from ethereum.forks.amsterdam.execution_engine.requests import ( + BuilderDepositRequest, + BuilderExitRequest, + ConsolidationRequest, + DepositRequest, + ExecutionRequests, + WithdrawalRequest, + decode_execution_requests, + encode_execution_requests, +) +from ethereum.state import Address + + +def _builder_deposit() -> BuilderDepositRequest: + return BuilderDepositRequest( + pubkey=Bytes48(b"\x11" * 48), + withdrawal_credentials=Bytes32(b"\x22" * 32), + amount=U64(0x0102030405060708), + signature=Bytes96(b"\x33" * 96), + ) + + +def _builder_exit() -> BuilderExitRequest: + return BuilderExitRequest( + source_address=Address(b"\x44" * 20), + pubkey=Bytes48(b"\x55" * 48), + ) + + +def _all_execution_requests() -> ExecutionRequests: + return ExecutionRequests( + deposits=( + DepositRequest( + pubkey=Bytes48(b"\x01" * 48), + withdrawal_credentials=Bytes32(b"\x02" * 32), + amount=U64(3), + signature=Bytes96(b"\x04" * 96), + index=U64(5), + ), + ), + withdrawals=( + WithdrawalRequest( + source_address=Address(b"\x06" * 20), + validator_pubkey=Bytes48(b"\x07" * 48), + amount=U64(8), + ), + ), + consolidations=( + ConsolidationRequest( + source_address=Address(b"\x09" * 20), + source_pubkey=Bytes48(b"\x0a" * 48), + target_pubkey=Bytes48(b"\x0b" * 48), + ), + ), + builder_deposits=(_builder_deposit(),), + builder_exits=(_builder_exit(),), + ) + + +def test_builder_deposit_encode_decode() -> None: + """Builder deposit amounts use little-endian wire encoding.""" + request = _builder_deposit() + requests = ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(request,), + builder_exits=(), + ) + + wire = encode_execution_requests(requests) + + assert wire == ( + Bytes( + b"\x03" + + bytes(request.pubkey) + + bytes(request.withdrawal_credentials) + + b"\x08\x07\x06\x05\x04\x03\x02\x01" + + bytes(request.signature) + ), + ) + assert decode_execution_requests(wire) == requests + + +def test_builder_exit_encode_decode() -> None: + """Builder exits encode the source address followed by the pubkey.""" + request = _builder_exit() + requests = ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(request,), + ) + + wire = encode_execution_requests(requests) + + assert wire == ( + Bytes(b"\x04" + bytes(request.source_address) + bytes(request.pubkey)), + ) + assert decode_execution_requests(wire) == requests + + +def test_all_execution_request_types_roundtrip_in_order() -> None: + """All five request types round trip in strict ascending order.""" + requests = _all_execution_requests() + + wire = encode_execution_requests(requests) + + assert tuple(blob[0] for blob in wire) == (0, 1, 2, 3, 4) + assert decode_execution_requests(wire) == requests + + +@pytest.mark.parametrize( + ("type_byte", "invalid_payload_size", "message"), + [ + pytest.param(b"\x03", 183, "builder deposit", id="builder-deposit"), + pytest.param(b"\x04", 67, "builder exit", id="builder-exit"), + ], +) +def test_decode_rejects_invalid_builder_request_payload_length( + type_byte: bytes, + invalid_payload_size: int, + message: str, +) -> None: + """Builder request payloads must contain whole wire records.""" + wire = (Bytes(type_byte + b"\x00" * invalid_payload_size),) + + with pytest.raises(InvalidBlock, match=message): + decode_execution_requests(wire) + + +def test_decode_rejects_non_ascending_builder_request_types() -> None: + """Builder request types cannot be duplicated or misordered.""" + wire = encode_execution_requests(_all_execution_requests()) + + with pytest.raises(InvalidBlock, match="strict ascending type order"): + decode_execution_requests((*wire[:3], wire[4], wire[3])) diff --git a/tests/json_loader/test_incremental_mpt.py b/tests/json_loader/test_incremental_mpt.py new file mode 100644 index 00000000000..c16aa070813 --- /dev/null +++ b/tests/json_loader/test_incremental_mpt.py @@ -0,0 +1,771 @@ +"""Tests for the incremental MPT witness decoding and HashedNode.""" + +from typing import Any + +import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes + +from ethereum.crypto.hash import keccak256 +from ethereum.forks.amsterdam.incremental_mpt import ( + HashedNode, + IncrementalMPT, + MutableBranchNode, + MutableLeafNode, + build_mpt, + compact_to_nibbles, + decode_witness_to_mpt, + mpt_get, + mpt_root, + mpt_set, +) +from ethereum.merkle_patricia_trie import ( + EMPTY_TRIE_ROOT, + Trie, + nibble_list_to_compact, + root, + trie_set, +) +from ethereum.state import Root + + +class TestCompactToNibbles: + """Test compact_to_nibbles.""" + + def test_even_leaf(self) -> None: + """Even-length leaf: flag nibble = 0x20.""" + nibbles = Bytes(b"\x01\x02\x03\x04") + compact = nibble_list_to_compact(nibbles, True) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is True + + def test_odd_leaf(self) -> None: + """Odd-length leaf: flag nibble = 0x3X.""" + nibbles = Bytes(b"\x01\x02\x03") + compact = nibble_list_to_compact(nibbles, True) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is True + + def test_even_extension(self) -> None: + """Even-length extension: flag nibble = 0x00.""" + nibbles = Bytes(b"\x0a\x0b") + compact = nibble_list_to_compact(nibbles, False) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is False + + def test_odd_extension(self) -> None: + """Odd-length extension: flag nibble = 0x1X.""" + nibbles = Bytes(b"\x0f") + compact = nibble_list_to_compact(nibbles, False) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is False + + def test_empty_even_leaf(self) -> None: + """Empty nibble list as even leaf.""" + nibbles = Bytes(b"") + compact = nibble_list_to_compact(nibbles, True) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is True + + @pytest.mark.parametrize( + "nibbles,is_leaf", + [ + pytest.param(Bytes(bytes(range(16))), True, id="all-nibbles-leaf"), + pytest.param( + Bytes(bytes(range(16))), + False, + id="all-nibbles-ext", + ), + pytest.param(Bytes(b"\x00"), True, id="zero-leaf"), + pytest.param(Bytes(b"\x0f" * 20), True, id="long-leaf"), + ], + ) + def test_roundtrip(self, nibbles: Bytes, is_leaf: bool) -> None: + """Roundtrip compact -> nibbles -> compact.""" + compact = nibble_list_to_compact(nibbles, is_leaf) + result, result_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert result_leaf == is_leaf + + +class TestHashedNode: + """Test HashedNode behavior in the MutableNode functions.""" + + def test_hashed_node_as_child_in_root_computation(self) -> None: + """A hashed node child contributes its stored hash to the root.""" + fake_hash = keccak256(b"some subtree data") + hashed_node = HashedNode(_hash=fake_hash) + + branch = MutableBranchNode( + children=[None] * 16, + value=b"", + _dirty=True, + ) + branch.children[0] = MutableLeafNode( + rest_of_key=Bytes(b"\x01\x02"), + value=b"hello", + _dirty=True, + ) + branch.children[1] = hashed_node + + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=branch, + _data={}, + ) + # Should not raise — hashed node's hash is used directly + result = mpt_root(mpt) + assert isinstance(result, bytes) + assert len(result) == 32 + + def test_insert_into_hashed_node_raises(self) -> None: + """Inserting into a HashedNode raises AssertionError.""" + hashed_node = HashedNode(_hash=b"\x00" * 32) + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=hashed_node, + _data={}, + ) + with pytest.raises(AssertionError, match="cannot be invalidated"): + mpt_set(mpt, b"\x01", b"value") + + def test_delete_from_hashed_node_raises(self) -> None: + """Deleting from a HashedNode raises AssertionError.""" + hashed_node = HashedNode(_hash=b"\x00" * 32) + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=hashed_node, + _data={}, + ) + with pytest.raises(AssertionError, match="cannot be invalidated"): + mpt_set(mpt, b"\x01", b"") + + def test_witness_traversal_on_hashed_node_raises(self) -> None: + """Witness traversal on a HashedNode raises AssertionError.""" + hashed_node = HashedNode(_hash=b"\x00" * 32) + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=hashed_node, + _data={}, + ) + with pytest.raises(AssertionError, match="cannot be witnessed"): + mpt_get(mpt, b"\x01") + + +def _build_trie_and_collect_nodes( + data: dict[Bytes, Bytes], secured: bool +) -> tuple[Root, dict[Bytes, Bytes]]: + """ + Build a standard trie, then build an IncrementalMPT and collect + all witness nodes by traversing every key. + + Return (root_hash, node_db). + """ + from ethereum.forks.amsterdam.incremental_mpt import ( + _encode_mutable_node, + ) + + # Compute the expected root via standard trie + std_trie: Trie[Bytes, Bytes] = Trie(secured=secured, default=b"") + for k, v in data.items(): + trie_set(std_trie, k, v) + expected_root = root(std_trie) + + # Build incremental MPT and collect witness nodes + inc_mpt = build_mpt(data, secured=secured, default=b"") + for k in data: + mpt_get(inc_mpt, k) + + node_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + + # Small root nodes (RLP < 32 bytes) are not recorded in the + # witness because they have no hash. Ensure the root is + # always present in node_db. + if inc_mpt.root_node is not None and expected_root not in node_db: + root_rlp = rlp.encode(_encode_mutable_node(inc_mpt.root_node)) + node_db[keccak256(root_rlp)] = root_rlp + + return expected_root, node_db + + +def _decode_root_from_rlp( + root_rlp: Bytes, + *, + secured: bool = False, +) -> IncrementalMPT[Bytes, Bytes]: + """Decode a single synthetic witness root from its RLP bytes.""" + root_hash = Root(keccak256(root_rlp)) + return decode_witness_to_mpt( + {Bytes(root_hash): root_rlp}, + root_hash, + secured=secured, + default=b"", + ) + + +class TestDecodeWitnessToMpt: + """Test decode_witness_to_mpt with synthetic witness data.""" + + def test_empty_trie(self) -> None: + """Decoding an empty trie returns an empty MPT.""" + node_db: dict[Bytes, Bytes] = {} + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, EMPTY_TRIE_ROOT, secured=False, default=b"" + ) + assert mpt.root_node is None + assert mpt_root(mpt) == EMPTY_TRIE_ROOT + + def test_single_entry(self) -> None: + """Decode a trie with a single key-value pair.""" + data = {b"key1": b"value1"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + +class TestMalformedWitnessNodes: + """Test malformed witness node decoding failures.""" + + def test_malformed_rlp_bytes(self) -> None: + """Malformed RLP should fail before node-shape validation.""" + with pytest.raises(rlp.DecodingError): + _decode_root_from_rlp(Bytes(b"\xc1")) + + def test_nonempty_byte_string_node(self) -> None: + """A decoded byte string node must be the empty node only.""" + with pytest.raises(AssertionError, match="Expected empty node"): + _decode_root_from_rlp(Bytes(rlp.encode(b"\x01"))) + + def test_invalid_rlp_node_length(self) -> None: + """Only 2-item and 17-item node lists are valid.""" + with pytest.raises(AssertionError, match="Invalid RLP node length: 3"): + _decode_root_from_rlp( + Bytes(rlp.encode([b"\x01", b"\x02", b"\x03"])) + ) + + def test_extension_with_empty_child_ref(self) -> None: + """Extension nodes must point to a branch child.""" + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b"\x01"), False), b""]) + ) + + with pytest.raises( + AssertionError, match="ExtensionNode child must be a BranchNode" + ): + _decode_root_from_rlp(root_rlp) + + def test_extension_pointing_to_leaf(self) -> None: + """Extensions may not point directly to leaf nodes.""" + leaf = [nibble_list_to_compact(Bytes(b"\x02"), True), b"value"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b"\x01"), False), leaf]) + ) + + with pytest.raises( + AssertionError, match="ExtensionNode child must be a BranchNode" + ): + _decode_root_from_rlp(root_rlp) + + def test_extension_pointing_to_extension(self) -> None: + """Extensions may not point directly to other extensions.""" + branch: list[Any] = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x03"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x04"), True), b"right"] + inner_extension: list[Any] = [ + nibble_list_to_compact(Bytes(b"\x02"), False), + branch, + ] + root_rlp = Bytes( + rlp.encode( + [ + nibble_list_to_compact(Bytes(b"\x01"), False), + inner_extension, + ] + ) + ) + + with pytest.raises( + AssertionError, match="ExtensionNode child must be a BranchNode" + ): + _decode_root_from_rlp(root_rlp) + + def test_extension_child_raw_nonzero_non_hash_bytes(self) -> None: + """Non-empty byte refs inside extensions must be 32-byte hashes.""" + root_rlp = Bytes( + rlp.encode( + [nibble_list_to_compact(Bytes(b"\x01"), False), b"\x01"] + ) + ) + + with pytest.raises( + AssertionError, match="Unexpected child ref length" + ): + _decode_root_from_rlp(root_rlp) + + def test_branch_with_zero_occupied_entries(self) -> None: + """A branch node must encode at least two occupied entries.""" + with pytest.raises( + AssertionError, + match="BranchNode must have at least 2 occupied entries", + ): + _decode_root_from_rlp(Bytes(rlp.encode([b""] * 17))) + + def test_branch_with_single_occupied_entry(self) -> None: + """A branch node with only one child is non-canonical.""" + branch: list[Any] = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"value"] + + with pytest.raises( + AssertionError, + match="BranchNode must have at least 2 occupied entries", + ): + _decode_root_from_rlp(Bytes(rlp.encode(branch))) + + def test_extension_with_empty_path(self) -> None: + """Tries must reject empty extension segments.""" + branch: list[Any] = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b""), False), branch]) + ) + + with pytest.raises( + AssertionError, + match="ExtensionNode must have a non-empty path", + ): + _decode_root_from_rlp(root_rlp) + + +class TestDecodeWitnessToMptMore: + """Additional decode_witness_to_mpt roundtrip coverage.""" + + def test_multiple_entries(self) -> None: + """Decode a trie with multiple entries and verify root.""" + data = { + b"do": b"verb", + b"dog": b"puppy", + b"doge": b"coin", + b"horse": b"stallion", + } + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + def test_secured_trie(self) -> None: + """Decode a secured (hashed-key) trie.""" + data = { + b"account1": b"data1", + b"account2": b"data2", + b"account3": b"data3", + } + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=True + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=True, default=b"" + ) + assert mpt_root(mpt) == expected_root + + def test_decode_then_modify(self) -> None: + """Decode from witness, modify a key, verify new root.""" + data = {b"aa": b"val_a", b"ab": b"val_b", b"ba": b"val_c"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + # Modify a key + mpt_set(mpt, b"aa", b"new_val") + + # Build expected trie with the modification + data_modified = dict(data) + data_modified[b"aa"] = b"new_val" + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_modified.items(): + trie_set(std_trie, k, v) + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + def test_decode_then_delete(self) -> None: + """Decode from witness, delete a key, verify new root.""" + data = {b"aa": b"val_a", b"ab": b"val_b"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + + # Delete a key + mpt_set(mpt, b"aa", b"") + + # Build expected trie with the deletion + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + trie_set(std_trie, b"ab", b"val_b") + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + def test_decode_then_insert(self) -> None: + """Decode from witness, insert a new key, verify root.""" + data = {b"aa": b"val_a", b"ab": b"val_b"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + + # Insert a new key + mpt_set(mpt, b"ac", b"val_c") + + # Build expected trie with the insertion + data_with_insert = dict(data) + data_with_insert[b"ac"] = b"val_c" + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_with_insert.items(): + trie_set(std_trie, k, v) + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + +class TestPartialWitness: + """Test decode_witness_to_mpt with incomplete witness data.""" + + def test_partial_witness_preserves_root(self) -> None: + """ + Build a trie, collect witness for only some keys. + Decode from partial witness. Root should still match + because hashed nodes preserve hashes of unvisited subtrees. + """ + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + # Build the full trie to get the root + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data.items(): + trie_set(std_trie, k, v) + expected_root = root(std_trie) + + # Build incremental MPT but only access some keys + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + # Intentionally NOT accessing b"ba" and b"bb" + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + + # Also need the root node itself + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, expected_root, secured=False, default=b"" + ) + + # Root should match even with hashed nodes for b"ba"/b"bb" + assert mpt_root(decoded_mpt) == expected_root + + def test_partial_witness_modify_known_path(self) -> None: + """ + Decode from partial witness, modify a key on a known path. + The hashed node subtrees should remain intact. + """ + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + # Full trie for expected roots + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data.items(): + trie_set(std_trie, k, v) + + # Build incremental MPT, access only "a*" keys + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, root_hash, secured=False, default=b"" + ) + + # Modify a known key + mpt_set(decoded_mpt, b"aa", b"new_a") + + # Build expected root with modification + data_mod = dict(data) + data_mod[b"aa"] = b"new_a" + mod_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_mod.items(): + trie_set(mod_trie, k, v) + expected_mod_root = root(mod_trie) + + assert mpt_root(decoded_mpt) == expected_mod_root + + def test_partial_witness_insert_into_hashed_node_fails(self) -> None: + """ + Decode from partial witness, try to modify a key in the + hashed node region. Should raise AssertionError. + """ + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, root_hash, secured=False, default=b"" + ) + + # Try to insert into the hashed node subtree + with pytest.raises(AssertionError, match="cannot be invalidated"): + mpt_set(decoded_mpt, b"ba", b"new_c") + + def test_partial_witness_delete_collapses_to_hashed_node(self) -> None: + """ + Delete a key whose sibling is a HashedNode. + + When a branch has two children and one is deleted, the branch + must collapse. If the remaining child is a HashedNode (from a + partial witness), _collapse_branch cannot merge the nibble into + it because its structure is unknown. This currently raises + AssertionError. + """ + fake_hash = keccak256(b"some large subtree") + hashed_child = HashedNode(_hash=fake_hash) + + # Branch with a leaf at nibble 0 and a HashedNode at nibble 1. + # Key b"\x01" -> nibbles [0, 1]: child index 0, rest_of_key [1]. + branch = MutableBranchNode( + children=[None] * 16, + value=b"", + ) + branch.children[0] = MutableLeafNode( + rest_of_key=Bytes(b"\x01"), + value=b"hello", + ) + branch.children[1] = hashed_child + + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=branch, + _data={Bytes(b"\x01"): b"hello"}, + ) + + # Deleting the leaf at nibble 0 leaves only the HashedNode, + # triggering a branch collapse. _collapse_branch calls + # _record_witness on the remaining child, which fails + # because HashedNode cannot be witnessed. + with pytest.raises( + AssertionError, match="HashedNode cannot be witnessed" + ): + mpt_set(mpt, Bytes(b"\x01"), b"") + + +def _encode_root_for_db( + node: object, +) -> object: + """Encode a MutableNode root into its RLP-encodable form.""" + from ethereum.forks.amsterdam.incremental_mpt import ( + _encode_mutable_node, + ) + + return _encode_mutable_node(node) # type: ignore[arg-type] + + +class TestBuildVsDecode: + """ + Verify that decode_witness_to_mpt produces tries equivalent to + build_mpt by checking root hashes after identical mutations. + """ + + @pytest.mark.parametrize( + "data", + [ + pytest.param({b"a": b"1"}, id="single"), + pytest.param({b"a": b"1", b"b": b"2"}, id="two-keys"), + pytest.param( + { + b"do": b"verb", + b"dog": b"puppy", + b"doge": b"coin", + b"horse": b"stallion", + }, + id="ethereum-example", + ), + pytest.param( + {bytes([i]): bytes([i]) for i in range(20)}, + id="many-keys", + ), + ], + ) + def test_roots_match_after_mutation( + self, data: dict[Bytes, Bytes] + ) -> None: + """Build and decode produce same root after same mutations.""" + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + # Decode + decoded: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(decoded) == expected_root + + # Build fresh + built = build_mpt(data, secured=False, default=b"") + assert mpt_root(built) == expected_root + + # Now mutate both identically — add a new key + new_key = b"\xff" + new_val = b"new" + mpt_set(decoded, new_key, new_val) + mpt_set(built, new_key, new_val) + + assert mpt_root(decoded) == mpt_root(built) + + +class TestDecodeEdgeCases: + """Test decode_witness_to_mpt edge cases.""" + + def test_branch_with_value(self) -> None: + """ + Decode a trie where a key terminates at a branch node. + + Keys "ab" and "abc" coexist, so "ab" occupies the branch + value slot (index 16) rather than a leaf child. + """ + data = {b"ab": b"short", b"abc": b"longer"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + def test_secured_trie_modify_and_delete(self) -> None: + """Modify and delete keys in a secured (hashed-key) trie.""" + data = { + b"account1": b"data1", + b"account2": b"data2", + b"account3": b"data3", + } + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=True + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=True, default=b"" + ) + + # Modify one key, delete another + mpt_set(mpt, b"account1", b"updated") + mpt_set(mpt, b"account3", b"") + + # Build expected trie with same changes + std_trie: Trie[Bytes, Bytes] = Trie(secured=True, default=b"") + trie_set(std_trie, b"account1", b"updated") + trie_set(std_trie, b"account2", b"data2") + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + def test_partial_witness_delete_known_path(self) -> None: + """Delete a key on a known path while hashed nodes exist elsewhere.""" + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + # Build incremental MPT, access only "a*" keys + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, root_hash, secured=False, default=b"" + ) + + # Delete a known key + mpt_set(decoded_mpt, b"aa", b"") + + # Build expected root with the deletion + data_del = dict(data) + del data_del[b"aa"] + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_del.items(): + trie_set(std_trie, k, v) + expected_del_root = root(std_trie) + + assert mpt_root(decoded_mpt) == expected_del_root diff --git a/tests/json_loader/test_ssz.py b/tests/json_loader/test_ssz.py new file mode 100644 index 00000000000..57dc9b7b9a2 --- /dev/null +++ b/tests/json_loader/test_ssz.py @@ -0,0 +1,139 @@ +"""Tests for dataclass-native SSZ serialization.""" + +from dataclasses import dataclass +from typing import Annotated, Tuple + +import pytest +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import U16, U64, Uint + +from ethereum.utils.ssz import ( + ProgressiveSszContainer, + SszContainer, + byte_list, + progressive_list, + ssz_list, + uint, +) + + +@dataclass(frozen=True) +class _Item(SszContainer): + key: Bytes32 + value: U16 + + +@dataclass(frozen=True) +class _ProgressiveItems(ProgressiveSszContainer): + items: Annotated[Tuple[_Item, ...], progressive_list()] + + +@dataclass(frozen=True) +class _Envelope(SszContainer): + count: Annotated[Uint, uint(64)] + payload: Annotated[bytes, byte_list(16)] + fixed_items: Annotated[Tuple[_Item, ...], ssz_list(2)] + progressive_items: _ProgressiveItems + + +@dataclass(frozen=True) +class _ByteLists(SszContainer): + bounded: Annotated[ + Tuple[Annotated[bytes, byte_list(16)], ...], ssz_list(2) + ] + progressive: Annotated[ + Tuple[Annotated[bytes, byte_list(16)], ...], progressive_list() + ] + + +def test_nested_containers_roundtrip() -> None: + """Restore Python types after standard and progressive SSZ decoding.""" + item = _Item(key=Bytes32(b"\x11" * 32), value=U16(3)) + original = _Envelope( + count=Uint(1), + payload=b"payload", + fixed_items=(item,), + progressive_items=_ProgressiveItems(items=(item, item)), + ) + + encoded = original.encode_bytes() + recovered = _Envelope.decode_bytes(encoded) + + assert recovered == original + assert type(recovered.count) is Uint + assert type(recovered.fixed_items) is tuple + assert type(recovered.progressive_items.items) is tuple + assert len(original.hash_tree_root()) == 32 + + +def test_collection_limits_are_enforced() -> None: + """Reject dataclass values that exceed their declared SSZ limits.""" + item = _Item(key=Bytes32(b"\x22" * 32), value=U16(4)) + too_many_items = _Envelope( + count=Uint(3), + payload=b"payload", + fixed_items=(item, item, item), + progressive_items=_ProgressiveItems(items=()), + ) + + with pytest.raises(Exception, match="too many list inputs"): + too_many_items.encode_bytes() + + +def test_fixed_width_integer_roundtrip() -> None: + """Decode an explicitly sized integer to its specification type.""" + original = _Envelope( + count=Uint(2**63), + payload=b"", + fixed_items=(), + progressive_items=_ProgressiveItems(items=()), + ) + + recovered = _Envelope.decode_bytes(original.encode_bytes()) + + assert recovered.count == Uint(2**63) + assert type(recovered.count) is Uint + assert U64(recovered.count) == U64(2**63) + + +@pytest.mark.parametrize("nested", [False, True]) +def test_container_offset_gap_rejected(nested: bool) -> None: + """Reject offset gaps in both standard and progressive containers.""" + original = _Envelope( + count=Uint(0), + payload=b"", + fixed_items=(), + progressive_items=_ProgressiveItems(items=()), + ) + encoded = bytearray(original.encode_bytes()) + offsets: tuple[int, ...] + if nested: + # The final field is a progressive container with one offset. + offsets = (int.from_bytes(encoded[16:20], "little"),) + else: + offsets = (8, 12, 16) + for offset in offsets: + value = int.from_bytes(encoded[offset : offset + 4], "little") + encoded[offset : offset + 4] = (value + 1).to_bytes(4, "little") + encoded.append(0xFF) + + with pytest.raises(ValueError, match="Non-canonical SSZ encoding"): + _Envelope.decode_bytes(bytes(encoded)) + + +@pytest.mark.parametrize("progressive", [False, True]) +@pytest.mark.parametrize("trailing", [b"", b"ignored"]) +def test_nonempty_list_with_zero_first_offset_rejected( + progressive: bool, trailing: bytes +) -> None: + """An empty variable-element list must have a zero-byte encoding.""" + invalid_list = b"\x00" * 4 + trailing + second_offset = 8 if progressive else 8 + len(invalid_list) + encoded = ( + (8).to_bytes(4, "little") + + second_offset.to_bytes(4, "little") + + invalid_list + ) + + with pytest.raises(ValueError): + _ByteLists.decode_bytes(encoded) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py new file mode 100644 index 00000000000..997fe0ec560 --- /dev/null +++ b/tests/json_loader/test_stateless_guest.py @@ -0,0 +1,690 @@ +"""Tests for stateless_guest serialization roundtrip.""" + +import random +from hashlib import sha256 +from typing import Tuple + +import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 +from ethereum_types.numeric import U16, U64, U256, Uint + +from ethereum.crypto.hash import Hash32 +from ethereum.forks.amsterdam.block_access_lists import BlockAccessList +from ethereum.forks.amsterdam.blocks import Block, Header +from ethereum.forks.amsterdam.execution_engine.requests import ( + BuilderDepositRequest, + BuilderExitRequest, + DepositRequest, + ExecutionRequests, +) +from ethereum.forks.amsterdam.execution_engine.types import ( + ExecutionPayload, + NewPayloadRequest, +) +from ethereum.forks.amsterdam.execution_engine.validation_helpers import ( + _payload_block, +) +from ethereum.forks.amsterdam.fork_types import Bloom +from ethereum.forks.amsterdam.stateless import ( + MAX_BYTES_PER_CODE, + MAX_BYTES_PER_HEADER, + MAX_BYTES_PER_WITNESS_NODE, + MAX_WITNESS_HEADERS, + STATELESS_INPUT_SCHEMA_FORK_INDEX, + STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_BYTES, + STATELESS_INPUT_SCHEMA_REVISION, + ExecutionWitness, + ProtocolFork, + StatelessInput, + StatelessValidationResult, + compute_new_payload_request_root, + verify_stateless_new_payload, +) +from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + run_stateless_guest, + serialize_stateless_output, +) +from ethereum.forks.amsterdam.stateless_host import ( + build_stateless_input, + deserialize_stateless_output, + serialize_stateless_input, +) +from ethereum.forks.amsterdam.transactions import LegacyTransaction +from ethereum.state import Address, Root + +_RNG = random.Random(0xDEADBEEF) + + +def test_stateless_input_schema_id_identifies_amsterdam_revision() -> None: + """Amsterdam stateless input schema id is fork_index || revision.""" + assert STATELESS_INPUT_SCHEMA_FORK_INDEX is ProtocolFork.Amsterdam + assert STATELESS_INPUT_SCHEMA_FORK_INDEX == 0x15 + assert STATELESS_INPUT_SCHEMA_REVISION == 0x01 + assert STATELESS_INPUT_SCHEMA_ID == 0x1501 + assert STATELESS_INPUT_SCHEMA_ID_BYTES == b"\x15\x01" + + +def _rb(n: int) -> bytes: + """Return ``n`` pseudo-random bytes.""" + return bytes(_RNG.getrandbits(8) for _ in range(n)) + + +def _make_payload() -> ExecutionPayload: + return ExecutionPayload( + parent_hash=Hash32(_rb(32)), + fee_recipient=Address(_rb(20)), + state_root=Root(_rb(32)), + receipts_root=Root(_rb(32)), + logs_bloom=Bloom(_rb(256)), + prev_randao=Bytes32(_rb(32)), + block_number=Uint(_RNG.randint(1, 2**32)), + gas_limit=Uint(30_000_000), + gas_used=Uint(_RNG.randint(0, 20_000_000)), + timestamp=U256(_RNG.randint(1, 2**32)), + extra_data=Bytes(_rb(32)), + base_fee_per_gas=Uint(_RNG.randint(1, 10**9)), + block_hash=Hash32(_rb(32)), + transactions=(Bytes(_rb(64)), Bytes(_rb(128))), + withdrawals=(), + blob_gas_used=U64(_RNG.randint(0, 2**17)), + excess_blob_gas=U64(_RNG.randint(0, 2**17)), + block_access_list=Bytes(_rb(16)), + slot_number=U64(_RNG.randint(0, 2**32)), + ) + + +def _make_header() -> Header: + return Header( + parent_hash=Hash32(_rb(32)), + ommers_hash=Hash32(_rb(32)), + coinbase=Address(_rb(20)), + state_root=Root(_rb(32)), + transactions_root=Root(_rb(32)), + receipt_root=Root(_rb(32)), + bloom=Bloom(_rb(256)), + difficulty=Uint(0), + number=Uint(_RNG.randint(1, 2**32)), + gas_limit=Uint(30_000_000), + gas_used=Uint(_RNG.randint(0, 20_000_000)), + timestamp=U256(_RNG.randint(1, 2**32)), + extra_data=Bytes(_rb(32)), + prev_randao=Bytes32(_rb(32)), + nonce=Bytes8(_rb(8)), + base_fee_per_gas=Uint(_RNG.randint(1, 10**9)), + withdrawals_root=Root(_rb(32)), + blob_gas_used=U64(_RNG.randint(0, 2**17)), + excess_blob_gas=U64(_RNG.randint(0, 2**17)), + parent_beacon_block_root=Root(_rb(32)), + requests_hash=Hash32(_rb(32)), + block_access_list_hash=Hash32(_rb(32)), + slot_number=U64(_RNG.randint(0, 2**32)), + ) + + +def _make_block() -> Block: + return Block( + header=_make_header(), + transactions=(), + ommers=(), + withdrawals=(), + ) + + +def _make_deposit_request() -> DepositRequest: + return DepositRequest( + pubkey=Bytes48(_rb(48)), + withdrawal_credentials=Bytes32(_rb(32)), + amount=U64(_RNG.randint(0, 2**64 - 1)), + signature=Bytes96(_rb(96)), + index=U64(_RNG.randint(0, 2**64 - 1)), + ) + + +def _make_builder_deposit_request() -> BuilderDepositRequest: + return BuilderDepositRequest( + pubkey=Bytes48(_rb(48)), + withdrawal_credentials=Bytes32(_rb(32)), + amount=U64(_RNG.randint(0, 2**64 - 1)), + signature=Bytes96(_rb(96)), + ) + + +def _make_builder_exit_request() -> BuilderExitRequest: + return BuilderExitRequest( + source_address=Address(_rb(20)), + pubkey=Bytes48(_rb(48)), + ) + + +def _make_stateless_input() -> StatelessInput: + versioned_hashes: Tuple[Hash32, ...] = (Hash32(_rb(32)), Hash32(_rb(32))) + return StatelessInput( + new_payload_request=NewPayloadRequest( + execution_payload=_make_payload(), + versioned_hashes=versioned_hashes, + parent_beacon_block_root=Root(_rb(32)), + execution_requests=ExecutionRequests( + deposits=( + _make_deposit_request(), + _make_deposit_request(), + ), + withdrawals=(), + consolidations=(), + builder_deposits=(_make_builder_deposit_request(),), + builder_exits=(_make_builder_exit_request(),), + ), + ), + witness=ExecutionWitness( + state=(Bytes(_rb(64)), Bytes(_rb(64)), Bytes(_rb(32))), + codes=(Bytes(_rb(48)), Bytes(_rb(96))), + headers=(Bytes(_rb(512)), Bytes(_rb(512))), + ), + chain_id=U64(1), + public_keys=(Bytes(_rb(65)), Bytes(_rb(65))), + ) + + +def _make_stateless_output() -> StatelessValidationResult: + return StatelessValidationResult( + new_payload_request_root=Hash32(_rb(32)), + successful_validation=True, + chain_id=U64(1), + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), + ) + + +def _make_known_stateless_values() -> tuple[ + StatelessInput, StatelessValidationResult +]: + """Return order-independent values used by SSZ known-answer tests.""" + state = _RNG.getstate() + try: + _RNG.seed(0xDEADBEEF) + return _make_stateless_input(), _make_stateless_output() + finally: + _RNG.setstate(state) + + +class TestBuildStatelessInput: + """Test host-side StatelessInput construction.""" + + def test_includes_chain_id(self) -> None: + """Include the configured chain identifier.""" + block_access_list: BlockAccessList = [] + stateless_input = build_stateless_input( + _make_block(), + execution_witness=ExecutionWitness( + state=(), + codes=(), + headers=(), + ), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(), + ), + block_access_list=block_access_list, + chain_id=U64(123), + ) + assert stateless_input.chain_id == U64(123) + + @pytest.mark.parametrize( + ("v", "r", "s"), + [ + pytest.param(27, 0, 1, id="invalid-signature"), + pytest.param(39, 1, 1, id="wrong-chain-id"), + ], + ) + def test_rejected_transaction_omits_public_key( + self, v: int, r: int, s: int + ) -> None: + """Keep rejected transactions without requiring a public key.""" + tx = LegacyTransaction( + nonce=U256(0), + gas_price=Uint(1), + gas=Uint(21_000), + to=Address(b"\x00" * 20), + value=U256(0), + data=Bytes(b""), + v=U256(v), + r=U256(r), + s=U256(s), + ) + block = Block( + header=_make_header(), + transactions=(tx,), + ommers=(), + withdrawals=(), + ) + stateless_input = build_stateless_input( + block, + execution_witness=ExecutionWitness( + state=(), + codes=(), + headers=(), + ), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(), + ), + block_access_list=[], + chain_id=U64(1), + ) + + payload = stateless_input.new_payload_request.execution_payload + assert payload.transactions == (Bytes(rlp.encode(tx)),) + assert stateless_input.public_keys == () + + def test_payload_round_trip_preserves_legacy_transaction_rlp(self) -> None: + """Preserve canonical legacy transaction RLP through the payload.""" + tx = LegacyTransaction( + nonce=U256(0), + gas_price=Uint(1), + gas=Uint(21_000), + to=Address(b"\x00" * 20), + value=U256(0), + data=Bytes(b"\x00" * 200_000), + v=U256(27), + r=U256(0), + s=U256(1), + ) + block = Block( + header=_make_header(), + transactions=(tx,), + ommers=(), + withdrawals=(), + ) + execution_requests = ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(), + ) + stateless_input = build_stateless_input( + block, + execution_witness=ExecutionWitness( + state=(), + codes=(), + headers=(), + ), + execution_requests=execution_requests, + block_access_list=[], + chain_id=U64(1), + ) + + rebuilt_block = _payload_block( + stateless_input.new_payload_request.execution_payload, + block.header.parent_beacon_block_root, + execution_requests, + ) + + assert rebuilt_block.transactions == (tx,) + assert rlp.encode(rebuilt_block.transactions) == rlp.encode( + block.transactions + ) + + +class TestSerializeStatelessInput: + """Test serialize_stateless_input.""" + + def test_roundtrip(self) -> None: + """Encoding then decoding recovers the original StatelessInput.""" + original = _make_stateless_input() + encoded = serialize_stateless_input(original) + assert encoded[:2] == STATELESS_INPUT_SCHEMA_ID_BYTES + recovered = deserialize_stateless_input(encoded) + assert recovered == original + + def test_known_encoding_and_request_root(self) -> None: + """Retain the schema bytes and payload request hash-tree root.""" + original, _ = _make_known_stateless_values() + encoded = serialize_stateless_input(original) + + assert len(encoded) == 3072 + assert sha256(encoded).hexdigest() == ( + "b7d516d24d8bde7426cae58f22b04fd7e824353eccc30bba9592487bcf4e55ec" + ) + assert compute_new_payload_request_root(original) == Hash32( + bytes.fromhex( + "71d67022e2df6fc9b757f8c4614f1da2" + "0c993efe8595a715829093c431b3f4eb" + ) + ) + + def test_empty_witness(self) -> None: + """Works with an empty witness.""" + original = StatelessInput( + new_payload_request=NewPayloadRequest( + execution_payload=_make_payload(), + versioned_hashes=(), + parent_beacon_block_root=Root(_rb(32)), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(), + ), + ), + witness=ExecutionWitness(state=(), codes=(), headers=()), + chain_id=U64(1), + public_keys=(), + ) + encoded = serialize_stateless_input(original) + assert encoded[:2] == STATELESS_INPUT_SCHEMA_ID_BYTES + recovered = deserialize_stateless_input(encoded) + assert recovered == original + + def test_rejects_non_65_byte_public_key(self) -> None: + """Public keys must be 65-byte uncompressed SEC1 points.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=original.witness, + chain_id=original.chain_id, + public_keys=(Bytes(_rb(64)), Bytes(_rb(65))), + ) + + with pytest.raises(ValueError): + serialize_stateless_input(invalid) + + @pytest.mark.parametrize( + "witness", + [ + pytest.param( + ExecutionWitness( + state=(Bytes(b"\0" * (MAX_BYTES_PER_WITNESS_NODE + 1)),), + codes=(), + headers=(), + ), + id="state-node", + ), + pytest.param( + ExecutionWitness( + state=(), + codes=(Bytes(b"\0" * (MAX_BYTES_PER_CODE + 1)),), + headers=(), + ), + id="code", + ), + pytest.param( + ExecutionWitness( + state=(), + codes=(), + headers=(Bytes(b"\0" * (MAX_BYTES_PER_HEADER + 1)),), + ), + id="header", + ), + ], + ) + def test_rejects_oversized_witness_item( + self, + witness: ExecutionWitness, + ) -> None: + """Retain the structural byte limit for each witness item.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=witness, + chain_id=original.chain_id, + public_keys=original.public_keys, + ) + + with pytest.raises(Exception, match="cannot be more than limit"): + serialize_stateless_input(invalid) + + def test_rejects_more_than_256_headers(self) -> None: + """Retain the protocol-backed witness header count limit.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=ExecutionWitness( + state=(), + codes=(), + headers=tuple(Bytes() for _ in range(MAX_WITNESS_HEADERS + 1)), + ), + chain_id=original.chain_id, + public_keys=original.public_keys, + ) + + with pytest.raises(Exception, match="too many list inputs: 257"): + serialize_stateless_input(invalid) + + +class TestDeserializeStatelessInput: + """Test deserialize_stateless_input.""" + + def test_roundtrip(self) -> None: + """Encoding then decoding recovers the original StatelessInput.""" + original = _make_stateless_input() + encoded = serialize_stateless_input(original) + recovered = deserialize_stateless_input(encoded) + assert recovered == original + payload = recovered.new_payload_request.execution_payload + assert type(payload.block_number) is Uint + assert type(payload.timestamp) is U256 + assert type(payload.transactions) is tuple + assert type(recovered.witness.state) is tuple + assert type(recovered.public_keys) is tuple + + def test_empty_witness(self) -> None: + """Works with an empty witness.""" + original = StatelessInput( + new_payload_request=NewPayloadRequest( + execution_payload=_make_payload(), + versioned_hashes=(), + parent_beacon_block_root=Root(_rb(32)), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(), + ), + ), + witness=ExecutionWitness(state=(), codes=(), headers=()), + chain_id=U64(1), + public_keys=(), + ) + encoded = serialize_stateless_input(original) + recovered = deserialize_stateless_input(encoded) + assert recovered == original + + def test_empty_input_rejected(self) -> None: + """Reject input that does not contain a schema id.""" + with pytest.raises(ValueError, match="missing schema id"): + deserialize_stateless_input(Bytes(b"")) + + def test_one_byte_input_rejected(self) -> None: + """Reject input that does not contain a full schema id.""" + with pytest.raises(ValueError, match="missing schema id"): + deserialize_stateless_input(Bytes(b"\x15")) + + def test_unsupported_schema_revision_rejected(self) -> None: + """Reject an unsupported Amsterdam schema revision.""" + encoded = serialize_stateless_input(_make_stateless_input()) + with pytest.raises(ValueError, match="0x1502"): + deserialize_stateless_input(Bytes(b"\x15\x02" + encoded[2:])) + + def test_unsupported_schema_fork_rejected(self) -> None: + """Reject an unsupported stateless input schema fork.""" + encoded = serialize_stateless_input(_make_stateless_input()) + with pytest.raises(ValueError, match="0x1601"): + deserialize_stateless_input(Bytes(b"\x16\x01" + encoded[2:])) + + def test_legacy_raw_ssz_input_rejected(self) -> None: + """Reject unprefixed SSZ input bytes.""" + original = _make_stateless_input() + raw_ssz = Bytes(original.encode_bytes()) + assert raw_ssz[:2] != STATELESS_INPUT_SCHEMA_ID_BYTES + with pytest.raises(ValueError, match="Unsupported stateless input"): + deserialize_stateless_input(raw_ssz) + + +class TestSerializeStatelessOutput: + """Test serialize_stateless_output.""" + + def test_roundtrip(self) -> None: + """Encoding then decoding recovers the original result.""" + original = _make_stateless_output() + encoded = serialize_stateless_output(original) + assert encoded[-2:] == STATELESS_INPUT_SCHEMA_ID.to_bytes(2, "little") + recovered = deserialize_stateless_output(encoded) + assert recovered == original + assert recovered.chain_id == U64(1) + assert recovered.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + + def test_known_encoding(self) -> None: + """Retain the fixed SSZ output encoding.""" + _, original = _make_known_stateless_values() + assert serialize_stateless_output(original).hex() == ( + "0d663a7de3d811fbc797f605a65d2745df3a97d9f1994dfb685ff66d2917009f" + "0101000000000000000115" + ) + + def test_failed_validation(self) -> None: + """Preserve the input schema when later validation fails.""" + original = StatelessValidationResult( + new_payload_request_root=Hash32(_rb(32)), + successful_validation=False, + chain_id=U64(1), + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), + ) + encoded = serialize_stateless_output(original) + recovered = deserialize_stateless_output(encoded) + assert recovered == original + assert recovered.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + + +class TestRunStatelessGuest: + """Test stateless guest input and output handling.""" + + def test_request_commitment_failure_returns_sentinel( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Failure to compute the public commitment must fail closed.""" + stateless_input = _make_stateless_input() + input_bytes = serialize_stateless_input(stateless_input) + + def fail_hash_tree_root(self: NewPayloadRequest) -> bytes: + raise RuntimeError("Request commitment unavailable") + + monkeypatch.setattr( + NewPayloadRequest, "hash_tree_root", fail_hash_tree_root + ) + result = deserialize_stateless_output(run_stateless_guest(input_bytes)) + + assert result == StatelessValidationResult( + new_payload_request_root=Hash32(b"\0" * 32), + successful_validation=False, + chain_id=U64(0), + schema_id=U16(0), + ) + + def test_noncanonical_ssz_returns_sentinel_failure(self) -> None: + """Reject shifted container offsets that leave trailing data unread.""" + stateless_input = _make_stateless_input() + encoded = bytearray(serialize_stateless_input(stateless_input)) + # After the schema prefix: request offset, witness offset, chain ID, + # and public-key offset. Shift all three offsets by one byte. + for offset in (2, 6, 18): + value = int.from_bytes(encoded[offset : offset + 4], "little") + encoded[offset : offset + 4] = (value + 1).to_bytes(4, "little") + encoded.append(0xFF) + + result = deserialize_stateless_output( + run_stateless_guest(Bytes(encoded)) + ) + + assert result == StatelessValidationResult( + new_payload_request_root=Hash32(b"\0" * 32), + successful_validation=False, + chain_id=U64(0), + schema_id=U16(0), + ) + + def test_invalid_input_bytes_return_failed_validation(self) -> None: + """Malformed input returns a failed result with sentinel fields.""" + encoded = run_stateless_guest(Bytes(b"")) + result = deserialize_stateless_output(encoded) + + assert result.new_payload_request_root == Hash32(b"\0" * 32) + assert not result.successful_validation + assert result.chain_id == U64(0) + assert result.schema_id == U16(0) + + def test_decodable_input_reports_schema_on_validation_failure( + self, + ) -> None: + """Decoded input reports its schema after execution failure.""" + stateless_input = _make_stateless_input() + encoded = run_stateless_guest( + serialize_stateless_input(stateless_input) + ) + result = deserialize_stateless_output(encoded) + + assert not result.successful_validation + assert result.chain_id == stateless_input.chain_id + assert result.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + + +class TestComputeNewPayloadRequestRoot: + """Test compute_new_payload_request_root.""" + + def test_hash_tree_root_is_deterministic(self) -> None: + """Same input produces the same root.""" + si = _make_stateless_input() + root_a = compute_new_payload_request_root(si) + root_b = compute_new_payload_request_root(si) + assert root_a == root_b + + def test_hash_tree_root_is_32_bytes(self) -> None: + """Root is always 32 bytes.""" + si = _make_stateless_input() + root = compute_new_payload_request_root(si) + assert len(root) == 32 + + +class TestTransactionPublicKeys: + """Test stateless transaction public-key validation.""" + + def test_too_few_public_keys_fail_validation(self) -> None: + """Stateless validation should fail with too few public keys.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=original.witness, + chain_id=original.chain_id, + public_keys=(original.public_keys[0],), + ) + + result = verify_stateless_new_payload(invalid) + assert not result.successful_validation + assert result.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + + def test_too_many_public_keys_fail_validation(self) -> None: + """Stateless validation should fail with too many public keys.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=original.witness, + chain_id=original.chain_id, + public_keys=( + original.public_keys[0], + original.public_keys[1], + Bytes(_rb(65)), + ), + ) + + result = verify_stateless_new_payload(invalid) + assert not result.successful_validation + assert result.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) diff --git a/tests/json_loader/test_witness_state.py b/tests/json_loader/test_witness_state.py new file mode 100644 index 00000000000..b76d82c8335 --- /dev/null +++ b/tests/json_loader/test_witness_state.py @@ -0,0 +1,352 @@ +"""Tests for WitnessState.""" + +from typing import Any, Optional + +import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.amsterdam.fork_types import encode_account +from ethereum.forks.amsterdam.incremental_mpt import ( + IncrementalMPT, + build_mpt, + mpt_get, + mpt_root, +) +from ethereum.forks.amsterdam.witness_state import ( + WitnessState, + build_code_db, + build_node_db, +) +from ethereum.merkle_patricia_trie import ( + EMPTY_TRIE_ROOT, + bytes_to_nibble_list, + nibble_list_to_compact, +) +from ethereum.state import EMPTY_CODE_HASH, Account, Address, Root + +_ADDR1 = Address(b"\x01" * 20) +_ADDR2 = Address(b"\x02" * 20) + +_CODE = b"\x60\x00\x56" +_CODE_HASH = Hash32(keccak256(_CODE)) + +_SLOT1 = Bytes32(b"\x00" * 31 + b"\x01") +_SLOT2 = Bytes32(b"\x00" * 31 + b"\x02") + + +def _acct(balance: int = 0, nonce: int = 1) -> Account: + return Account( + nonce=Uint(nonce), balance=U256(balance), code_hash=EMPTY_CODE_HASH + ) + + +def _build_witness( + accounts: dict[Address, Optional[Account]], + storage: dict[Address, dict[Bytes32, U256]], +) -> tuple[Root, dict[Bytes, Bytes]]: + """Build a (state_root, node_db) witness from account and storage data.""" + storage_mpts: dict[Address, IncrementalMPT[Bytes32, U256]] = {} + for addr, slots in storage.items(): + storage_mpt: IncrementalMPT[Bytes32, U256] = build_mpt( + slots, secured=True, default=U256(0) + ) + for key in slots: + mpt_get(storage_mpt, key) + storage_mpts[addr] = storage_mpt + + def get_storage_root(addr: Address) -> Root: + if addr in storage_mpts: + return mpt_root(storage_mpts[addr]) + return EMPTY_TRIE_ROOT + + account_mpt: IncrementalMPT[Address, Optional[Account]] = build_mpt( + accounts, secured=True, default=None, get_storage_root=get_storage_root + ) + for addr in accounts: + mpt_get(account_mpt, addr) + + state_root = mpt_root(account_mpt) + node_db: dict[Bytes, Bytes] = dict(account_mpt.witness.accessed_nodes) + for storage_mpt in storage_mpts.values(): + node_db.update(storage_mpt.witness.accessed_nodes) + + return state_root, node_db + + +def _make_ws( + accounts: dict[Address, Optional[Account]], + storage: dict[Address, dict[Bytes32, U256]] | None = None, + code_db: dict[Hash32, Bytes] | None = None, +) -> WitnessState: + """Build a WitnessState from account and storage data.""" + state_root, node_db = _build_witness(accounts, storage or {}) + return WitnessState( + _node_db=node_db, _state_root=state_root, _code_db=code_db or {} + ) + + +def _root_witness(root_rlp: Bytes) -> tuple[Root, dict[Bytes, Bytes]]: + """Build a synthetic witness DB keyed by one root node.""" + root_hash = Root(keccak256(root_rlp)) + return root_hash, {Bytes(root_hash): root_rlp} + + +def _single_account_state_witness( + *, + address: Address = _ADDR1, + storage_root: Root = EMPTY_TRIE_ROOT, +) -> tuple[Root, dict[Bytes, Bytes]]: + """Build a valid one-account state witness with a custom storage root.""" + account_leaf = [ + nibble_list_to_compact(bytes_to_nibble_list(keccak256(address)), True), + encode_account(_acct(), storage_root), + ] + return _root_witness(Bytes(rlp.encode(account_leaf))) + + +class TestBuildNodeDb: + """Test build_node_db.""" + + def test_empty(self) -> None: + """Empty input produces empty mapping.""" + assert build_node_db(()) == {} + + def test_single_entry(self) -> None: + """Each entry is keyed by its keccak256 hash.""" + data = b"some_rlp_node_data_long_enough_to_be_realistic" + db = build_node_db((data,)) + assert db == {keccak256(data): data} + + def test_multiple_entries(self) -> None: + """Multiple entries all appear in the mapping.""" + a, b = b"node_aaa", b"node_bbb" + db = build_node_db((a, b)) + assert db[keccak256(a)] == a + assert db[keccak256(b)] == b + + +class TestBuildCodeDb: + """Test build_code_db.""" + + def test_empty(self) -> None: + """Empty input produces empty mapping.""" + assert build_code_db(()) == {} + + def test_single_entry(self) -> None: + """Entry is keyed by code hash.""" + db = build_code_db((_CODE,)) + assert db == {_CODE_HASH: _CODE} + + def test_multiple_entries(self) -> None: + """Multiple bytecodes all appear.""" + code2 = b"\x60\x01\x56" + db = build_code_db((_CODE, code2)) + assert db[keccak256(_CODE)] == _CODE + assert db[keccak256(code2)] == code2 + + +class TestGetAccountOptional: + """Test WitnessState.get_account_optional.""" + + def test_existing_account(self) -> None: + """Returns the account stored in the trie.""" + witness_state = _make_ws({_ADDR1: _acct(balance=1000, nonce=5)}) + result = witness_state.get_account_optional(_ADDR1) + assert result is not None + assert result.nonce == Uint(5) + assert result.balance == U256(1000) + assert result.code_hash == EMPTY_CODE_HASH + + def test_missing_account(self) -> None: + """Returns None for an address not in the trie.""" + witness_state = _make_ws({_ADDR1: _acct()}) + assert witness_state.get_account_optional(_ADDR2) is None + + def test_multiple_accounts(self) -> None: + """Correctly distinguishes between multiple accounts.""" + witness_state = _make_ws( + {_ADDR1: _acct(balance=100), _ADDR2: _acct(balance=200)} + ) + r1 = witness_state.get_account_optional(_ADDR1) + r2 = witness_state.get_account_optional(_ADDR2) + assert r1 is not None and r1.balance == U256(100) + assert r2 is not None and r2.balance == U256(200) + + +class TestGetStorage: + """Test WitnessState.get_storage.""" + + def test_existing_slot(self) -> None: + """Returns the storage value for a known slot.""" + witness_state = _make_ws( + {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}} + ) + assert witness_state.get_storage(_ADDR1, _SLOT1) == U256(42) + + def test_missing_slot(self) -> None: + """Returns U256(0) for a slot not in the trie.""" + witness_state = _make_ws( + {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}} + ) + assert witness_state.get_storage(_ADDR1, _SLOT2) == U256(0) + + def test_no_storage_account(self) -> None: + """Returns U256(0) for an account with no storage.""" + witness_state = _make_ws({_ADDR1: _acct(balance=100)}) + assert witness_state.get_storage(_ADDR1, _SLOT1) == U256(0) + + def test_multiple_slots(self) -> None: + """Correctly distinguishes between multiple storage slots.""" + witness_state = _make_ws( + {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(10), _SLOT2: U256(20)}} + ) + assert witness_state.get_storage(_ADDR1, _SLOT1) == U256(10) + assert witness_state.get_storage(_ADDR1, _SLOT2) == U256(20) + + +class TestGetCode: + """Test WitnessState.get_code.""" + + def test_empty_code_hash(self) -> None: + """EMPTY_CODE_HASH always returns b'' without a lookup.""" + witness_state = _make_ws({}) + assert witness_state.get_code(EMPTY_CODE_HASH) == b"" + + def test_known_code(self) -> None: + """Returns the bytecode for a known code hash.""" + witness_state = _make_ws({}, code_db=build_code_db((_CODE,))) + assert witness_state.get_code(_CODE_HASH) == _CODE + + +class TestComputeStateRoot: + """Test WitnessState.compute_state_root_and_trie_changes.""" + + def test_account_balance_change(self) -> None: + """Changing an account balance produces the correct new state root.""" + witness_state = _make_ws({_ADDR1: _acct(balance=100)}) + new_acct = _acct(balance=200) + new_root, _ = witness_state.compute_state_root_and_trie_changes( + {_ADDR1: new_acct}, {} + ) + expected_root, _ = _build_witness({_ADDR1: new_acct}, {}) + assert new_root == expected_root + + def test_storage_slot_change(self) -> None: + """Changing a storage slot produces the correct new state root.""" + acct = _acct() + witness_state = _make_ws({_ADDR1: acct}, {_ADDR1: {_SLOT1: U256(10)}}) + new_root, _ = witness_state.compute_state_root_and_trie_changes( + {}, {_ADDR1: {_SLOT1: U256(99)}} + ) + expected_root, _ = _build_witness( + {_ADDR1: acct}, {_ADDR1: {_SLOT1: U256(99)}} + ) + assert new_root == expected_root + + def test_no_changes_preserves_root(self) -> None: + """Empty diffs leave the state root unchanged.""" + state_root, node_db = _build_witness({_ADDR1: _acct(balance=100)}, {}) + witness_state = WitnessState( + _node_db=node_db, _state_root=state_root, _code_db={} + ) + new_root, _ = witness_state.compute_state_root_and_trie_changes({}, {}) + assert new_root == state_root + + +class TestCanonicalSecureTrieValidation: + """Test state/storage-trie canonicality checks in WitnessState.""" + + def test_account_trie_rejects_zero_length_extension_path(self) -> None: + """Account tries must reject empty extension segments.""" + branch: list[Any] = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b""), False), branch]) + ) + state_root, node_db = _root_witness(root_rlp) + witness_state = WitnessState( + _node_db=node_db, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="ExtensionNode must have a non-empty path", + ): + witness_state.get_account_optional(_ADDR1) + + def test_storage_trie_rejects_zero_length_extension_path(self) -> None: + """Storage tries must reject empty extension segments.""" + branch: list[Any] = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b""), False), branch]) + ) + storage_root, storage_node_db = _root_witness(root_rlp) + state_root, state_node_db = _single_account_state_witness( + storage_root=storage_root + ) + witness_state = WitnessState( + _node_db={**state_node_db, **storage_node_db}, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="ExtensionNode must have a non-empty path", + ): + witness_state.get_storage(_ADDR1, _SLOT1) + + def test_account_trie_rejects_unresolved_hashed_node(self) -> None: + """Secured account lookups must not silently pass unresolved hashes.""" + fake_hash = Bytes(b"\x11" * 32) + key_nibbles = bytes_to_nibble_list(keccak256(_ADDR1)) + root_rlp = Bytes( + rlp.encode( + [nibble_list_to_compact(key_nibbles[:1], False), fake_hash] + ) + ) + state_root, node_db = _root_witness(root_rlp) + witness_state = WitnessState( + _node_db=node_db, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="Encountered unresolved HashedNode during witness lookup", + ): + witness_state.get_account_optional(_ADDR1) + + def test_storage_trie_rejects_unresolved_hashed_node(self) -> None: + """Secured storage lookups must not silently pass unresolved hashes.""" + fake_hash = Bytes(b"\x22" * 32) + key_nibbles = bytes_to_nibble_list(keccak256(_SLOT1)) + root_rlp = Bytes( + rlp.encode( + [nibble_list_to_compact(key_nibbles[:1], False), fake_hash] + ) + ) + storage_root, storage_node_db = _root_witness(root_rlp) + state_root, state_node_db = _single_account_state_witness( + storage_root=storage_root + ) + witness_state = WitnessState( + _node_db={**state_node_db, **storage_node_db}, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="Encountered unresolved HashedNode during witness lookup", + ): + witness_state.get_storage(_ADDR1, _SLOT1) diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index 26e759e453c..62e1e457dd4 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -301,7 +301,17 @@ def test_withdrawals_root( blockchain_test(pre=pre, post={}, blocks=blocks) -@pytest.mark.parametrize("test_case", ["single_block", "multiple_blocks"]) +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + "single_block", + marks=pytest.mark.skip_stateless_validation, + id="single_block", + ), + pytest.param("multiple_blocks", id="multiple_blocks"), + ], +) class TestMultipleWithdrawalsSameAddress: """ Test that multiple withdrawals can be sent to the same address. @@ -377,6 +387,7 @@ def test_multiple_withdrawals_same_address( blockchain_test(pre=pre, post=post, blocks=blocks) +@pytest.mark.skip_stateless_validation def test_many_withdrawals( blockchain_test: BlockchainTestFiller, pre: Alloc, @@ -709,6 +720,7 @@ def test_zero_amount( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_large_amount( blockchain_test: BlockchainTestFiller, @@ -750,6 +762,7 @@ def test_large_amount( blockchain_test(pre=pre, post=post, blocks=blocks) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize("amount", [0, 1]) @pytest.mark.with_all_precompiles diff --git a/uv.lock b/uv.lock index cab3b4ab404..aa4cdc88fa2 100644 --- a/uv.lock +++ b/uv.lock @@ -840,6 +840,7 @@ name = "ethereum-execution" source = { editable = "." } dependencies = [ { name = "cryptography" }, + { name = "eth-remerkleable" }, { name = "ethereum-rlp" }, { name = "ethereum-types" }, { name = "libcst" }, @@ -952,6 +953,7 @@ test = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = ">=45.0.1,<46" }, + { name = "eth-remerkleable", specifier = ">=0.1.29,<0.2" }, { name = "ethash", marker = "extra == 'optimized'", specifier = ">=1.1.0,<2" }, { name = "ethereum-rlp", specifier = ">=0.1.6,<0.2" }, { name = "ethereum-types", specifier = ">=0.4.1,<0.5" }, diff --git a/vulture_whitelist.py b/vulture_whitelist.py index ebdffee5ead..1df2a7527d1 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -10,6 +10,15 @@ from ethereum.ethash import * from ethereum.fork_criteria import Unscheduled +from ethereum.forks.amsterdam.execution_engine.types import ( + BlobsBundle, + GetPayloadResponse, + PayloadAttributes, +) +from ethereum.forks.amsterdam.stateless import ( + NewPayloadRequestHeader, + ProtocolFork, +) from ethereum.trace import EvmTracer from ethereum.utils.hexadecimal import hex_to_bytes256 from ethereum_optimized.state_db import State @@ -167,6 +176,38 @@ test_suite_name # hive test suite name fixture genesis_header # genesis header fixture +# src/ethereum/forks/amsterdam/execution_engine/types.py - Engine API fields +PayloadAttributes.suggested_fee_recipient +BlobsBundle.commitments +BlobsBundle.proofs +BlobsBundle.blobs +GetPayloadResponse.block_value +GetPayloadResponse.blobs_bundle + +# src/ethereum/forks/amsterdam/stateless.py - stateless public API scaffolding +NewPayloadRequestHeader +NewPayloadRequestHeader.execution_payload_header +ProtocolFork.Frontier +ProtocolFork.Homestead +ProtocolFork.DAOFork +ProtocolFork.TangerineWhistle +ProtocolFork.SpuriousDragon +ProtocolFork.Byzantium +ProtocolFork.StPetersburg +ProtocolFork.Istanbul +ProtocolFork.MuirGlacier +ProtocolFork.Berlin +ProtocolFork.London +ProtocolFork.ArrowGlacier +ProtocolFork.GrayGlacier +ProtocolFork.Paris +ProtocolFork.Shanghai +ProtocolFork.Cancun +ProtocolFork.Prague +ProtocolFork.Osaka +ProtocolFork.BPO1 +ProtocolFork.BPO2 + # packages/testing/src/execution_testing/evm_tools/t8n/evm_trace/ # eip3155.py - EIP-3155 trace output field names, serialized to JSON gasCost