diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index e966f7f6b7a..9f8cbfa260e 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -9,3 +9,8 @@ monad: # (triggered by tarball output) fails to proceed on no tests processed # in 1st phase (exit code 5) fill-params: --suppress-no-test-exit-code -m blockchain_test --from=MONAD_EIGHT --until=MONAD_NEXT --chain-id=143 -k "not invalid_header" + +monad_runloop: + evm-type: eels + # Like `monad`, but `--monad-runloop` and eestnet chain id `30143` + fill-params: --suppress-no-test-exit-code -m blockchain_test --from=MONAD_EIGHT --until=MONAD_NEXT --chain-id=30143 --monad-runloop -k "not invalid_header" diff --git a/.github/workflows/consume_release.yaml b/.github/workflows/consume_release.yaml new file mode 100644 index 00000000000..97e89a1fbcf --- /dev/null +++ b/.github/workflows/consume_release.yaml @@ -0,0 +1,186 @@ +# Manually run a filled fixtures release against a pinned monad stack via +# `consume direct` + the eest-runner runloop client. + +name: Consume Release (monad runloop) + +on: + workflow_dispatch: + inputs: + release: + description: "Fixtures source: release spec (e.g. monad_runloop@v1.1.0-rc1), a fixtures.tar.gz URL, or latest" + required: true + default: "monad_runloop@latest" + harness_ref: + description: "monad-eest-rust-harness ref (branch/tag/SHA)" + required: false + default: "execute-with-eestnet" + monad_bft_repo: + description: "Override monad-bft from this owner/repo (e.g. category-labs/monad-bft); empty = keep the harness's pinned commit" + required: false + default: "" + monad_bft_ref: + description: "Ref/SHA in monad_bft_repo (default branch if empty); applies only when monad_bft_repo is set" + required: false + default: "" + monad_execution_repo: + description: "Override monad-execution from this owner/repo; empty = keep the harness's pinned commit" + required: false + default: "" + monad_execution_ref: + description: "Ref/SHA in monad_execution_repo (default branch if empty); applies only when monad_execution_repo is set" + required: false + default: "" + filter: + description: "Optional -k expression to consume a subset" + required: false + default: "" + +permissions: + contents: read + +jobs: + consume: + runs-on: ubuntu-24.04 + timeout-minutes: 1440 # hosted runners still cap at 6h; raise the runner for a full release + steps: + - name: Validate inputs + shell: bash + env: + BFT_REPO: ${{ inputs.monad_bft_repo }} + BFT_REF: ${{ inputs.monad_bft_ref }} + EXEC_REPO: ${{ inputs.monad_execution_repo }} + EXEC_REF: ${{ inputs.monad_execution_ref }} + run: | + set -euo pipefail + fail=0 + if [ -n "$BFT_REF" ] && [ -z "$BFT_REPO" ]; then + echo "::error::monad_bft_ref is set but monad_bft_repo is empty; set monad_bft_repo to override monad-bft." + fail=1 + fi + if [ -n "$EXEC_REF" ] && [ -z "$EXEC_REPO" ]; then + echo "::error::monad_execution_ref is set but monad_execution_repo is empty; set monad_execution_repo to override monad-execution." + fail=1 + fi + [ "$fail" -eq 0 ] + + - name: Checkout consumer (execution-specs) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Checkout monad-eest-rust-harness (build input) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: monad-exp/monad-eest-rust-harness + ref: ${{ inputs.harness_ref }} + path: monad-eest-rust-harness + submodules: recursive + fetch-depth: 0 + token: ${{ secrets.MONAD_STACK_TOKEN }} + + # Optional submodule overrides. Set a *_repo to pull that submodule from + # a different fork at *_ref (default branch if ref is empty); leave it + # empty to keep the harness's pinned commit. actions/checkout handles + # auth, so no manual credential setup is needed. The monad-bft override + # recurses (so monad-execution lands at the new bft's pin); the + # monad-execution override runs after, so it is not reset. + - name: Override monad-bft + if: ${{ inputs.monad_bft_repo != '' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ inputs.monad_bft_repo }} + ref: ${{ inputs.monad_bft_ref }} + path: monad-eest-rust-harness/monad-bft + submodules: recursive + fetch-depth: 0 + token: ${{ secrets.MONAD_STACK_TOKEN }} + + - name: Override monad-execution + if: ${{ inputs.monad_execution_repo != '' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ inputs.monad_execution_repo }} + ref: ${{ inputs.monad_execution_ref }} + path: monad-eest-rust-harness/monad-bft/monad-execution + fetch-depth: 0 + token: ${{ secrets.MONAD_STACK_TOKEN }} + + - name: Log pinned stack + id: stack + working-directory: monad-eest-rust-harness + shell: bash + run: | + bft=$(git -C monad-bft rev-parse HEAD) + exec=$(git -C monad-bft/monad-execution rev-parse HEAD) + echo "monad-bft: $bft" + echo "monad-execution: $exec" + echo "bft=$bft" >> "$GITHUB_OUTPUT" + echo "exec=$exec" >> "$GITHUB_OUTPUT" + + - name: Build the monad-builder image + working-directory: monad-eest-rust-harness + shell: bash + run: docker build -t monad-builder:latest - < monad-bft/docker/builder/Dockerfile + + - name: Build the eest-runner stack (build.sh) + working-directory: monad-eest-rust-harness + shell: bash + run: ./build.sh + + - name: Setup uv + uses: ./.github/actions/setup-uv + + - name: Install EEST + shell: bash + run: uv sync --no-progress + + - name: Provision hugepages + shell: bash + run: sudo sysctl -w vm.nr_hugepages=3072 + + - name: Consume release + shell: bash + env: + RELEASE: ${{ inputs.release }} + FILTER: ${{ inputs.filter }} + run: | + set -o pipefail + FILTER_ARG=() + if [ -n "$FILTER" ]; then + FILTER_ARG=(-k "$FILTER") + fi + uv run consume direct \ + --input "$RELEASE" \ + --bin "$GITHUB_WORKSPACE/monad-eest-rust-harness/bin/eest-runner" \ + "${FILTER_ARG[@]}" -v | tee consume.log + + - name: Summary + if: always() + shell: bash + env: + RELEASE: ${{ inputs.release }} + FILTER: ${{ inputs.filter || '(none)' }} + HARNESS_REF: ${{ inputs.harness_ref }} + BFT_SHA: ${{ steps.stack.outputs.bft }} + EXEC_SHA: ${{ steps.stack.outputs.exec }} + run: | + { + echo "## consume \`${RELEASE:-(unknown)}\`" + echo "" + echo "| input | value |" + echo "|---|---|" + echo "| harness_ref | \`${HARNESS_REF}\` |" + echo "| monad-bft | \`${BFT_SHA:-(unknown)}\` |" + echo "| monad-execution | \`${EXEC_SHA:-(unknown)}\` |" + echo "| filter | \`${FILTER:-(none)}\` |" + echo "" + echo '```' + cat consume.log 2>/dev/null || echo "(no consume.log produced)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload log + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: consume-report + path: consume.log + if-no-files-found: warn diff --git a/MONAD_RUNLOOP_TESTING.md b/MONAD_RUNLOOP_TESTING.md new file mode 100644 index 00000000000..a0b79d3fff9 --- /dev/null +++ b/MONAD_RUNLOOP_TESTING.md @@ -0,0 +1,132 @@ +# Running EEST fixtures on the monad runloop + +Executes blockchain fixtures (including those generated from state +tests) against the monad execution client's `runloop`, using the +consensus ledger directory as the block source. Each `postState` +account is compared (balance/nonce/code/storage) against the +executed result. + +## Repos + +| Repo / branch | Role | +|---|---| +| `monad-exp/monad-eest-rust-harness` @ `execute-with-eestnet` | `eest-runner` harness: builds consensus blocks from a fixture and runs them on the runloop | +| `monad-bft` @ `execute-with-eestnet` (submodule of the above) | consensus block types + ledger writer; pins monad-execution below | +| `monad` @ `execute-with-eestnet` (submodule of monad-bft) | execution client with the `EestNet` chain (id 30143, per-fixture revision schedule, runtime genesis) and the extended `monad_runloop_*` FFI | +| this repo @ `execute-with-eestnet` | `MonadFixtureConsumer` (`packages/testing/.../client_clis/clis/monad.py`) wired into `consume direct` | + +## One-time setup + +Requirements: docker, ~10 GB disk for the builder image and build +artifacts, ~6 GB RAM for hugepages. + +```sh +git clone --branch execute-with-eestnet \ + https://github.com/monad-exp/monad-eest-rust-harness.git +cd monad-eest-rust-harness +git submodule update --init --recursive + +# Toolchain image (gcc-15 + rust), from monad-bft's Dockerfile: +curl -fsSL https://raw.githubusercontent.com/category-labs/monad-bft/master/docker/builder/Dockerfile \ + | docker build -t monad-builder:latest - + +./build.sh # builds + syncs binaries/libs into install/ +bin/eest-runner --version +``` + +In this repo: + +```sh +uv sync +``` + +Always rebuild with `./build.sh`; it syncs `libmonad_execution.so` +alongside the binary (copying only the binary leaves a stale library +that fails silently). + +## Fill + consume + +```sh +uv run fill --clean -m blockchain_test \ + --fork MONAD_NINE --chain-id 30143 --monad-runloop \ + --output ../fixtures_eestnet + +uv run consume direct --input ../fixtures_eestnet \ + --bin ../monad-eest-rust-harness/bin/eest-runner +``` + +- `--monad-runloop` stamps monad blocks with the consensus-derived + header fields the runloop produces (prev_randao from the round-0 BLS + signature, 32-byte extra_data, the proposal gas limit, zero + requests_hash). +- `--chain-id 30143` is EestNet's chain id. +- Block timestamps need no special handling: the consumer derives the + monad revision schedule from the fixture's `network` + (`FORK_REVISION_SCHEDULES` in `clis/monad.py`) and the harness + injects it into the chain. +- `consume` parallelism is CPU-bound: one runloop peaks near 4 cores (~386% CPU + across ~14 threads), so budget ~5 vCPUs per worker (`-n N` needs + roughly `5 * N` cores). + +## Behavior and known limits + +- Fixtures containing expected-invalid blocks are skipped: the ledger + machine validates blocks at proposal time and cannot write invalid + ones. +- The runloop runs in a privileged container (io_uring) and needs + `vm.nr_hugepages >= 2048` on the host; the `bin/` wrappers + re-provision this automatically (it resets on host reboot). + +## Fork-transition flow (MIP-8 dual-db) + +MONAD_NINE storage is slot-encoded; MONAD_NEXT (MIP-8) is +page-encoded. A transition fixture (e.g. +`MONAD_NINEToMONAD_NEXTAtTime15k`) crosses that boundary mid-run, so +the run keeps both encodings live in one triedb: a slot-encoded +primary timeline and a page-encoded secondary timeline. + +- **Consumer** (`clis/monad.py`): loads the fixture, maps its + `network` to a `(revision, from_timestamp)` schedule, skips + `expectException` blocks, and provisions the db. Revisions spanning + the fork activate the secondary timeline via `monad-mpt + --activate-secondary`. +- **Harness** (`eest-runner`): fakes consensus with a Ledger + simulator (`propose` then `finalize` per block) that writes BFT + headers/bodies to `--ledger-dir` — the same artifacts monad-bft + persists on a real network — then drives the runloop over the FFI + and writes `output.json`. +- **Execution** (`runloop_monad`): reads each block from the ledger + dir and dispatches on `get_monad_revision(timestamp)` via + `SWITCH_MONAD_TRAITS` — block 1 runs MONAD_NINE, block 2 MONAD_NEXT. +- **Dual-write** (`commit_block`): both timelines get the same + `StateDeltas` every block. Slot is canonical before the fork, page + after; the canonical root flips to page at the first MONAD_NEXT + block, matching the fixture's final `blockHeader.stateRoot`. +- **Assertions**: `state_root` and every `postState` account + (balance, nonce, code, per-slot storage) match `output`, with no + unexpected non-zero slots. + +### Correspondence to production + +eest-runner runs the same execution core as a production node, with +two differences: the block source and the revision schedule. + +| Concern | eest-runner | Production node | +|---|---|---| +| Block source | Ledger sim writes headers/bodies to `--ledger-dir` | `monad-bft` `MonadBlockFileLedger` persists consensus-committed blocks to the ledger dir | +| Revision schedule | runtime, from fixture (`EestNet::get_monad_revision`) | compiled-in timestamps (`MonadMainnet::get_monad_revision`) | +| Execution loop | `runloop_monad` | the same `runloop_monad` | +| Per-block dispatch | `SWITCH_MONAD_TRAITS` on `get_monad_revision(timestamp)` | the same | +| Block execution | `execute_block` | the same | +| Commit | `commit_block` with dual-write | the same function; dual-write engages whenever a secondary timeline is active | +| State root / db | `mpt::Db` + `TrieDb`, optional secondary | the same | + +eest-only shims: the C ABI in `runloop_interface_monad.cpp`, the `EestNet` +chain (runtime schedule), `MonadRunloopDbCache` / `set_balance` (balance +overrides, unused here), genesis loading from the fixture, and the Rust +Ledger simulator that fabricates the BFT blocks consensus would deliver. +The production fork migration uses the same dual-db mechanism: an operator +runs `monad-mpt --activate-secondary --state-machine monad` on the live +slot-encoded db before the fork, the node opens the secondary timeline and +dual-writes both encodings across the boundary, and the authoritative root +flips from slot to page at the first MONAD_NEXT block. diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py index 556fa7fe2c3..9d3a3ba12a7 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py @@ -19,6 +19,7 @@ ) SUPPORTED_REPOS = [ + "monad-developers/execution-specs", "ethereum/execution-spec-tests", "ethereum/execution-specs", "ethereum/tests", 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 fb7fac5f239..d446fbef525 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 @@ -70,7 +70,10 @@ ) from execution_testing.specs import BaseTest from execution_testing.specs.base import FillResult, OpMode -from execution_testing.test_types import EnvironmentDefaults +from execution_testing.test_types import ( + EnvironmentDefaults, + MonadRunloopDefaults, +) from execution_testing.test_types.chain_config_types import ( DEFAULT_CHAIN_ID, ChainConfigDefaults, @@ -573,6 +576,18 @@ def pytest_addoption(parser: pytest.Parser) -> None: f"(Default: {EnvironmentDefaults.gas_limit})" ), ) + test_group.addoption( + "--monad-runloop", + action="store_true", + dest="monad_runloop", + default=False, + help=( + "Stamp monad blocks with the consensus-derived header fields " + "the production runloop produces (prev_randao, extra_data, " + "gas_limit, requests_hash), so filled block hashes and EIP-2935 " + "history storage match the monad `eest-runner` consumer." + ), + ) test_group.addoption( "--generate-pre-alloc-groups", action="store_true", @@ -694,6 +709,17 @@ def pytest_configure(config: pytest.Config) -> None: if option_was_explicitly_set(config, "--block-gas-limit"): EnvironmentDefaults.gas_limit = config.getoption("block_gas_limit") + # Stamp monad blocks with runloop-conformant header fields if requested. + if config.getoption("monad_runloop"): + MonadRunloopDefaults.enabled = True + # The runloop builds every block at the monad proposal gas limit, so + # make it the default block gas limit too: tests that read + # env.gas_limit (the GASLIMIT opcode, tx-gas-vs-block-limit checks) + # then build expectations that match execution. An explicit + # --block-gas-limit still wins. + if not option_was_explicitly_set(config, "--block-gas-limit"): + EnvironmentDefaults.gas_limit = MonadRunloopDefaults.gas_limit + # Initialize fixture output configuration config.fixture_output = FixtureOutput.from_config( # type: ignore[attr-defined] config @@ -1827,6 +1853,11 @@ def pytest_collection_modifyitems( if marker.name == "fill": for mark in marker.args: item.add_marker(mark) + if marker.name == "monad_runloop" and config.getoption( + "monad_runloop", False + ): + for mark in marker.args: + item.add_marker(mark) if "yul" in item.fixturenames: # type: ignore item.add_marker(pytest.mark.yul_test) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py index d827d96927a..2651faaac6b 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/execute_fill.py @@ -188,6 +188,12 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "execute: Markers to be added in execute mode only.", ) + config.addinivalue_line( + "markers", + "monad_runloop: Markers to be added only when filling with " + "--monad-runloop (e.g. skips for tests that need a test-controlled " + "block gas limit the runloop overrides).", + ) config.addinivalue_line( "markers", "exception_test: Negative tests that include an invalid block or " diff --git a/packages/testing/src/execution_testing/client_clis/__init__.py b/packages/testing/src/execution_testing/client_clis/__init__.py index 2a9e603a118..85ee33f52e0 100644 --- a/packages/testing/src/execution_testing/client_clis/__init__.py +++ b/packages/testing/src/execution_testing/client_clis/__init__.py @@ -32,6 +32,7 @@ ) from .clis.execution_specs import ExecutionSpecsTransitionTool from .clis.geth import GethFixtureConsumer, GethTransitionTool +from .clis.monad import MonadFixtureConsumer from .clis.nethermind import Nethtest, NethtestFixtureConsumer from .clis.nimbus import NimbusTransitionTool from .ethereum_cli import CLINotFoundInPathError, UnknownCLIError @@ -74,6 +75,7 @@ "GethFixtureConsumer", "GethTransitionTool", "LazyAlloc", + "MonadFixtureConsumer", "Nethtest", "NethtestFixtureConsumer", "NimbusTransitionTool", diff --git a/packages/testing/src/execution_testing/client_clis/clis/monad.py b/packages/testing/src/execution_testing/client_clis/clis/monad.py new file mode 100644 index 00000000000..7fa29612b21 --- /dev/null +++ b/packages/testing/src/execution_testing/client_clis/clis/monad.py @@ -0,0 +1,394 @@ +""" +Monad `eest-runner` fixture consumer. + +Executes blockchain fixtures against the production monad runloop via +the `eest-runner` harness binary (built from monad-bft + monad +execution, EestNet chain). The fixture is digested into a simple input +document (genesis allocation + per-block timestamp/base fee/beneficiary +and raw signed transactions); the harness drives the consensus ledger +and the runloop, then emits the resulting post-state, which is compared +against the fixture's `postState`. +""" + +import ctypes +import json +import os +import re +import signal +import subprocess +import tempfile +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +from execution_testing.fixtures import BlockchainFixture, FixtureFormat +from execution_testing.test_types import Transaction + +from ..fixture_consumer_tool import FixtureConsumerTool + +# Monad revision schedule per fixture `network`, as +# (monad_revision, activation timestamp) pairs. Non-transition +# fixtures run a single revision from genesis; transition fixtures +# activate at the EEST transition fork's boundary. +FORK_REVISION_SCHEDULES = { + "MONAD_EIGHT": [(8, 0)], + "MONAD_NINE": [(9, 0)], + "MONAD_NEXT": [(10, 0)], + "MONAD_EIGHTToMONAD_NINEAtTime15k": [(8, 0), (9, 15_000)], + "MONAD_NINEToMONAD_NEXTAtTime15k": [(9, 0), (10, 15_000)], +} + + +_LIBC: Optional[ctypes.CDLL] +try: + _LIBC = ctypes.CDLL("libc.so.6", use_errno=True) +except OSError: + _LIBC = None + + +def _set_pdeathsig() -> None: + """ + Have the kernel SIGTERM this child when its parent dies. + + Runs in the child between fork and exec. `PR_SET_PDEATHSIG` (1) + fires even if pytest is SIGKILLed, so the docker client receives a + signal it can forward to stop the container instead of leaving the + runloop orphaned. + """ + if _LIBC is not None: + _LIBC.prctl(1, signal.SIGTERM) + + +def _hex32(value: str) -> str: + """Normalize a hex quantity to a 0x-prefixed 64-nibble word.""" + return f"0x{int(value, 16):064x}" + + +def _digest_genesis_alloc(pre: Dict[str, Any]) -> Dict[str, Any]: + """Convert a fixture `pre` alloc into the harness genesis alloc.""" + alloc: Dict[str, Any] = {} + for address, account in pre.items(): + entry: Dict[str, Any] = { + "wei_balance": account.get("balance", "0x0"), + "nonce": account.get("nonce", "0x0"), + } + code = account.get("code", "0x") + if code and code != "0x": + entry["code"] = code + storage = { + _hex32(slot): _hex32(value) + for slot, value in account.get("storage", {}).items() + if int(value, 16) != 0 + } + if storage: + entry["storage"] = storage + alloc[address] = entry + return alloc + + +def _digest_blocks(fixture: Dict[str, Any]) -> list: + """Convert fixture blocks into the harness block list.""" + blocks = [] + for block in fixture["blocks"]: + header = block["blockHeader"] + txs = [] + for tx_json in block.get("transactions", []): + for auth in tx_json.get("authorizationList", []): + # Fixtures serialize both `v` and `yParity`; the + # Transaction model accepts only one of them. + if "v" in auth: + auth.pop("yParity", None) + tx = Transaction.model_validate(tx_json) + txs.append(tx.rlp().hex()) + blocks.append( + { + "timestamp": int(header["timestamp"], 16), + "base_fee": int(header["baseFeePerGas"], 16), + "beneficiary": header["coinbase"], + "txs": txs, + } + ) + return blocks + + +def _compare_account( + address: str, expected: Dict[str, Any], actual: Optional[Dict[str, Any]] +) -> list: + """Compare one expected post-state account; return mismatch strings.""" + mismatches = [] + if actual is None: + actual = {} + for field in ("balance", "nonce"): + expected_value = int(expected.get(field, "0x0"), 16) + actual_value = int(actual.get(field, "0x0"), 16) + if expected_value != actual_value: + mismatches.append( + f"{address}: {field} expected {hex(expected_value)}, " + f"got {hex(actual_value)}" + ) + expected_code = expected.get("code", "0x") or "0x" + actual_code = actual.get("code", "0x") or "0x" + if expected_code.lower() != actual_code.lower(): + mismatches.append( + f"{address}: code expected {expected_code}, got {actual_code}" + ) + actual_storage = { + int(slot, 16): int(value, 16) + for slot, value in actual.get("storage", {}).items() + } + expected_storage = { + int(slot, 16): int(value, 16) + for slot, value in expected.get("storage", {}).items() + } + for slot, expected_value in expected_storage.items(): + actual_value = actual_storage.get(slot, 0) + if expected_value != actual_value: + mismatches.append( + f"{address}: storage[{hex(slot)}] expected " + f"{hex(expected_value)}, got {hex(actual_value)}" + ) + for slot, actual_value in actual_storage.items(): + if slot not in expected_storage and actual_value != 0: + mismatches.append( + f"{address}: unexpected storage[{hex(slot)}] = " + f"{hex(actual_value)}" + ) + return mismatches + + +class MonadFixtureConsumer( + FixtureConsumerTool, + fixture_formats=[BlockchainFixture], +): + """Monad's `eest-runner` fixture consumer for blockchain tests.""" + + default_binary = Path("eest-runner") + detect_binary_pattern = re.compile(r"^eest-runner\b") + version_flag: str = "--version" + + def __init__( + self, + binary: Optional[Path] = None, + trace: bool = False, + ): + """Initialize the MonadFixtureConsumer.""" + super().__init__(binary=binary) + self.trace = trace + + def _init_triedb( + self, db_path: Path, schedule: List[Tuple[int, int]] + ) -> None: + """ + Create and format a fresh triedb file via `monad-mpt`. + + The storage encoding depends on the fixture's revision schedule. + MIP-8 (MONAD_NEXT, revision >= 10) uses a page-encoded triedb; older + revisions use slot encoding. A fixture that crosses the boundary + (e.g. a MONAD_NINE->MONAD_NEXT transition) needs both: a slot-encoded + primary plus an activated page-encoded secondary timeline, so the + runloop can dual-write across the fork. + """ + monad_mpt = self.binary.parent / "monad-mpt" + revisions = [revision for revision, _ in schedule] + uses_page = any(revision >= 10 for revision in revisions) + uses_slot = any(revision < 10 for revision in revisions) + + with open(db_path, "wb") as f: + f.truncate(2 * 1024**3) + + # Shrunk chunk capacity / history ring keep per-test time at ~2s + # (the production defaults dominate runtime). `monad` is the + # page-encoded state machine, `ethereum` the slot-encoded one. + primary = "monad" if uses_page and not uses_slot else "ethereum" + subprocess.run( + [ + str(monad_mpt), + "--storage", + str(db_path), + "--create", + "--chunk-capacity", + "26", + "--root-offsets-chunk-count", + "2", + "--state-machine", + primary, + ], + capture_output=True, + text=True, + check=True, + ) + + if uses_page and uses_slot: + # Transition fixture: add a page-encoded secondary timeline. + subprocess.run( + [ + str(monad_mpt), + "--storage", + str(db_path), + "--activate-secondary", + "--state-machine", + "monad", + ], + capture_output=True, + text=True, + check=True, + ) + + def _run_harness( + self, + input_path: Path, + output_path: Path, + ledger_dir: Path, + db_path: Path, + ) -> Tuple[str, str, int]: + """ + Run `eest-runner`, tearing down its container when we exit. + + A killed docker client does not stop its container, so a + dockerized runloop would otherwise keep spinning past pytest. + Two teardown paths cover this: an explicit `docker kill` on the + named container when this call is interrupted, and a parent-death + signal so the client is also stopped if pytest dies without + unwinding (SIGTERM/SIGHUP, or SIGKILL). + """ + container_name = f"eest-runner-{uuid.uuid4().hex}" + process = subprocess.Popen( + [ + str(self.binary), + "--input", + str(input_path), + "--output", + str(output_path), + "--ledger-dir", + str(ledger_dir), + "--db", + str(db_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + preexec_fn=_set_pdeathsig, + env={ + **os.environ, + "EEST_RUNNER_CONTAINER_NAME": container_name, + }, + ) + try: + stdout, stderr = process.communicate() + except BaseException: + try: + subprocess.run( + ["docker", "kill", container_name], + capture_output=True, + ) + except Exception: + pass + process.kill() + process.wait() + raise + return stdout, stderr, process.returncode + + def consume_fixture( + self, + fixture_format: FixtureFormat, + fixture_path: Path, + fixture_name: Optional[str] = None, + debug_output_path: Optional[Path] = None, + ) -> None: + """Execute a blockchain fixture on the monad runloop and verify.""" + assert fixture_format == BlockchainFixture + + with open(fixture_path) as f: + fixtures = json.load(f) + if fixture_name is None: + assert len(fixtures) == 1, "fixture_name required" + fixture_name = next(iter(fixtures)) + fixture = fixtures[fixture_name] + + network = fixture["network"] + assert network in FORK_REVISION_SCHEDULES, ( + f"no monad revision schedule for network {network}" + ) + if any("expectException" in block for block in fixture["blocks"]): + pytest.skip("invalid blocks cannot be proposed through the ledger") + input_doc = { + "genesis_alloc": _digest_genesis_alloc(fixture["pre"]), + "genesis_rlp": fixture["genesisRLP"], + "revision_schedule": [ + {"revision": revision, "from_timestamp": from_timestamp} + for revision, from_timestamp in FORK_REVISION_SCHEDULES[ + network + ] + ], + "blocks": _digest_blocks(fixture), + } + + with tempfile.TemporaryDirectory(prefix="eest-runner-") as tmp: + tmp_path = Path(tmp) + input_path = tmp_path / "input.json" + output_path = tmp_path / "output.json" + ledger_dir = tmp_path / "ledger" + ledger_dir.mkdir() + db_path = tmp_path / "triedb" + self._init_triedb(db_path, FORK_REVISION_SCHEDULES[network]) + + input_path.write_text(json.dumps(input_doc, indent=2)) + + stdout, stderr, returncode = self._run_harness( + input_path, output_path, ledger_dir, db_path + ) + + if debug_output_path is not None: + debug_output_path.mkdir(parents=True, exist_ok=True) + (debug_output_path / "input.json").write_text( + input_path.read_text() + ) + (debug_output_path / "stdout.txt").write_text(stdout) + (debug_output_path / "stderr.txt").write_text(stderr) + if output_path.exists(): + (debug_output_path / "output.json").write_text( + output_path.read_text() + ) + + if returncode != 0: + raise Exception( + f"eest-runner failed (exit {returncode}):\n" + f"stdout:\n{stdout}\nstderr:\n{stderr}" + ) + + output = json.loads(output_path.read_text()) + + actual_post = { + address.lower(): account + for address, account in output["post_state"].items() + } + post_state = fixture.get("postState") + assert post_state is not None, ( + "fixture has no postState (hash-only fixtures not supported)" + ) + + mismatches = [] + for address, expected in post_state.items(): + actual = actual_post.get(address.lower()) + mismatches.extend(_compare_account(address, expected, actual)) + + # Assert the final state root, the last executed block's root. Under + # monad's synchronous execution it equals the fixture's last block + # `stateRoot`; invalid-block fixtures are skipped above, so every + # block carries a header. + expected_state_root = fixture["blocks"][-1]["blockHeader"]["stateRoot"] + actual_state_root = output["state_root"] + if int(expected_state_root, 16) != int(actual_state_root, 16): + mismatches.append( + f"state_root expected {expected_state_root}, " + f"got {actual_state_root}" + ) + + if mismatches: + raise Exception( + "post-state mismatch on the monad runloop:\n" + + "\n".join(mismatches) + ) diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 1041e1157a3..a35793a41ce 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -41,7 +41,6 @@ MONAD_NINEToMONAD_NEXTAtTime15k, OsakaToBPO1AtTime15k, ParisToShanghaiAtTime15k, - PragueToMONAD_EIGHTAtTime15k, PragueToOsakaAtTime15k, ShanghaiToCancunAtTime15k, ) @@ -122,7 +121,6 @@ "PragueToOsakaAtTime15k", "Osaka", "OsakaToBPO1AtTime15k", - "PragueToMONAD_EIGHTAtTime15k", "MONAD_EIGHT", "MONAD_EIGHTToMONAD_NINEAtTime15k", "MONAD_NINE", diff --git a/packages/testing/src/execution_testing/forks/forks/transition.py b/packages/testing/src/execution_testing/forks/forks/transition.py index 6500b48b788..e63edafc18d 100644 --- a/packages/testing/src/execution_testing/forks/forks/transition.py +++ b/packages/testing/src/execution_testing/forks/forks/transition.py @@ -56,13 +56,6 @@ class PragueToOsakaAtTime15k(TransitionBaseClass): pass -@transition_fork(to_fork=MONAD_EIGHT, from_fork=Prague, at_timestamp=15_000) -class PragueToMONAD_EIGHTAtTime15k(TransitionBaseClass): # noqa: N801 - """Prague to MONAD_EIGHT transition at Timestamp 15k.""" - - pass - - @transition_fork( to_fork=MONAD_NINE, from_fork=MONAD_EIGHT, at_timestamp=15_000 ) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 853d10bb1af..d3c86e001ef 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -83,10 +83,11 @@ FixtureTransactionReceipt, ) from execution_testing.fixtures.post_verifications import PostVerifications -from execution_testing.forks import Fork, TransitionFork +from execution_testing.forks import MONAD_EIGHT, Fork, TransitionFork from execution_testing.test_types import ( Alloc, Environment, + MonadRunloopDefaults, Removable, Requests, TestPhase, @@ -773,6 +774,22 @@ def make_genesis( ) -> Tuple[Alloc, FixtureBlock]: """Create a genesis block from the blockchain test definition.""" env = self.get_genesis_environment() + + # Keep the genesis gas limit at monad's proposal_gas_limit so the + # whole chain has a constant gas limit; otherwise the jump from the + # EEST default to block 1's stamped limit fails the EIP-1559 gas + # limit bound check during fill. Resolve the fork at genesis so + # transition forks are covered too. + genesis_fork = self.fork.fork_at( + block_number=env.number, timestamp=env.timestamp + ) + if ( + MonadRunloopDefaults.enabled + and isinstance(genesis_fork, type) + and issubclass(genesis_fork, MONAD_EIGHT) + ): + env = env.copy(gas_limit=MonadRunloopDefaults.gas_limit) + assert env.withdrawals is None or len(env.withdrawals) == 0, ( "withdrawals must be empty at genesis" ) @@ -825,6 +842,25 @@ def generate_block_data( block_number=env.number, timestamp=env.timestamp ) env = env.set_fork_requirements(fork) + + # When filling with --monad-runloop, monad blocks must carry the + # consensus-derived header fields the production runloop produces. + # gas_limit and prev_randao feed execution (gas, PREVRANDAO) and the + # EIP-2935 block-hash chain, so they are set on the env before t8n; + # extra_data and requests_hash are stamped onto the header below. + # Gate on the per-block resolved fork so transition forks (whose + # `self.fork` is the transition class) are covered too. + monad_runloop = ( + MonadRunloopDefaults.enabled + and isinstance(fork, type) + and issubclass(fork, MONAD_EIGHT) + ) + if monad_runloop: + env = env.copy( + gas_limit=MonadRunloopDefaults.gas_limit, + prev_randao=MonadRunloopDefaults.prev_randao, + ) + txs = [tx.with_signature_and_sender() for tx in block.txs] if failing_tx_count := len([tx for tx in txs if tx.error]) > 0: @@ -935,6 +971,12 @@ def generate_block_data( ) requests_list = block.requests + if monad_runloop: + # monad stamps a 32-zero-byte extra_data and requests_hash, + # rather than empty extra_data and the EIP-7685 requests root. + header.extra_data = Bytes(b"\x00" * 32) + header.requests_hash = Hash(0) + # Decode BAL from RLP bytes provided by the transition tool. t8n_bal_rlp = transition_tool_output.result.block_access_list t8n_bal: BlockAccessList | None = None diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index b9029ceb3ad..976edd477ef 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -17,6 +17,7 @@ from .block_types import ( Environment, EnvironmentDefaults, + MonadRunloopDefaults, Withdrawal, ) from .chain_config_types import ChainConfig, ChainConfigDefaults @@ -72,6 +73,7 @@ "DepositRequest", "Environment", "EnvironmentDefaults", + "MonadRunloopDefaults", "EOA", "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 56d367e2e68..b9c8a93a792 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -37,6 +37,29 @@ class EnvironmentDefaults: gas_limit: int = DEFAULT_BLOCK_GAS_LIMIT +@dataclass +class MonadRunloopDefaults: + """ + Consensus-derived header values the monad runloop produces. + + When `fill` runs with `--monad-runloop`, monad blocks are stamped + with these so filled block hashes and EIP-2935 history storage match + the production runloop (which the `eest-runner` consumer executes). + They are the only header fields that differ from the synchronous EEST + defaults; the execution-result roots already agree. + """ + + enabled: bool = False + # prev_randao = blake3(rlp(BLS round-signature over Round(0))) with the + # harness's all-zero proposer key. Constant because the harness never + # advances the round (Round(0) for every block). + prev_randao: int = ( + 0x15B2551B9CE2307DCFF661B7DEE9CC4D09F304F03E7887B0E948A1C29F0E5826 + ) + # proposal_gas_limit of the active monad chain revision (V_0_11_0). + gas_limit: int = 200_000_000 + + class WithdrawalGeneric(CamelModel, Generic[NumberBoundTypeVar]): """ Withdrawal generic type, used as a parent class for `Withdrawal` and diff --git a/tests/frontier/validation/test_transaction.py b/tests/frontier/validation/test_transaction.py index 1b665a4e073..237b4186ce1 100644 --- a/tests/frontier/validation/test_transaction.py +++ b/tests/frontier/validation/test_transaction.py @@ -21,6 +21,13 @@ from execution_testing.test_types.transaction_types import TransactionDefaults +@pytest.mark.monad_runloop( + pytest.mark.skip( + reason="Requires a test-controlled block gas limit; the monad " + "runloop fixes every block to the proposal gas limit, so the " + "over-limit tx no longer exceeds it." + ) +) @pytest.mark.exception_test @pytest.mark.eels_base_coverage def test_tx_gas_limit( diff --git a/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py b/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py index 3ed92bd2ac1..450e8f62f37 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py @@ -23,7 +23,7 @@ REFERENCE_SPEC_VERSION = ref_spec_mip4.version -@pytest.mark.valid_at_transition_to("MONAD_NINE", subsequent_forks=True) +@pytest.mark.valid_at_transition_to("MONAD_NINE") def test_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, diff --git a/tests/osaka/eip7951_p256verify_precompiles/test_p256verify_before_fork.py b/tests/osaka/eip7951_p256verify_precompiles/test_p256verify_before_fork.py index 6e1e1f08317..7bc643d49c5 100644 --- a/tests/osaka/eip7951_p256verify_precompiles/test_p256verify_before_fork.py +++ b/tests/osaka/eip7951_p256verify_precompiles/test_p256verify_before_fork.py @@ -22,6 +22,10 @@ REFERENCE_SPEC_GIT_PATH = ref_spec_7951.git_path REFERENCE_SPEC_VERSION = ref_spec_7951.version +# NOTE: the PragueToMONAD_EIGHT transition fork this targets was dropped as +# unused, so this marker currently resolves to no forks and the test +# generates nothing. P256VERIFY at MONAD_EIGHT+ is covered by the +# non-transition p256verify tests. pytestmark = pytest.mark.valid_at_transition_to("MONAD_EIGHT") diff --git a/whitelist.txt b/whitelist.txt index 958faf89571..4bb6a579cc1 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -322,6 +322,7 @@ copytree cp CPUs CPython +crate create create2 create3